├── .all-contributorsrc ├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md ├── PULL_REQUEST_TEMPLATE.md ├── release-drafter.yml └── workflows │ ├── deploy.yml │ ├── draft-release.yml │ └── tests.yml ├── .gitignore ├── .gitmodules ├── .prettierrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── .webpack ├── common.ts ├── development.ts └── production.ts ├── .xo-config.json ├── LICENSE ├── README.md ├── assets └── logo.png ├── codecov.yml ├── package.json ├── package.nls.json ├── src ├── main.ts ├── migrations.ts ├── models │ ├── index.ts │ ├── languagePack.ts │ ├── migrations.ts │ ├── profile.ts │ ├── settings.ts │ ├── syncer.ts │ ├── syncers.ts │ └── webview │ │ ├── section.ts │ │ ├── selectOption.ts │ │ ├── setting.ts │ │ └── settingType.ts ├── services │ ├── __mocks__ │ │ └── localize.ts │ ├── announcer.ts │ ├── customFiles.ts │ ├── environment.ts │ ├── extensions.ts │ ├── factory.ts │ ├── fs.ts │ ├── index.ts │ ├── init.ts │ ├── localize.ts │ ├── logger.ts │ ├── migrator.ts │ ├── oauth.ts │ ├── pragma.ts │ ├── profile.ts │ ├── settings.ts │ ├── watcher.ts │ └── webview.ts ├── state.ts ├── syncers │ ├── file.ts │ ├── index.ts │ └── repo.ts ├── tests │ ├── __mocks__ │ │ └── vscode.ts │ ├── getCleanupPath.ts │ ├── services │ │ ├── __snapshots__ │ │ │ ├── extensions.test.ts.snap │ │ │ ├── localize.test.ts.snap │ │ │ └── pragma.test.ts.snap │ │ ├── customFiles.test.ts │ │ ├── extensions.test.ts │ │ ├── factory.test.ts │ │ ├── fs.test.ts │ │ ├── init.test.ts │ │ ├── localize.test.ts │ │ ├── migrator.test.ts │ │ ├── pragma.test.ts │ │ ├── profile.test.ts │ │ └── settings.test.ts │ ├── syncers │ │ ├── file.test.ts │ │ └── repo.test.ts │ ├── teardown.ts │ └── utilities │ │ ├── checkGit.test.ts │ │ └── confirm.test.ts ├── typings │ └── main.d.ts └── utilities │ ├── __mocks__ │ └── confirm.ts │ ├── checkGit.ts │ ├── confirm.ts │ ├── index.ts │ ├── merge.ts │ ├── sleep.ts │ └── stringifyPretty.ts ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "vscode-syncify", 3 | "projectOwner": "arnohovhannisyan", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "clawoflight", 15 | "name": "Bennett Piater", 16 | "avatar_url": "https://avatars3.githubusercontent.com/u/1181744?v=4", 17 | "profile": "http://bennett.piater.name", 18 | "contributions": [ 19 | "code" 20 | ] 21 | }, 22 | { 23 | "login": "shanalikhan", 24 | "name": "Shan Khan", 25 | "avatar_url": "https://avatars0.githubusercontent.com/u/8774556?v=4", 26 | "profile": "http://shanalikhan.github.io", 27 | "contributions": [ 28 | "ideas" 29 | ] 30 | }, 31 | { 32 | "login": "dxman", 33 | "name": "dxman", 34 | "avatar_url": "https://avatars2.githubusercontent.com/u/10678981?v=4", 35 | "profile": "https://github.com/dxman", 36 | "contributions": [ 37 | "userTesting", 38 | "bug" 39 | ] 40 | }, 41 | { 42 | "login": "everito", 43 | "name": "everito", 44 | "avatar_url": "https://avatars3.githubusercontent.com/u/31976784?v=4", 45 | "profile": "https://github.com/everito", 46 | "contributions": [ 47 | "userTesting", 48 | "bug" 49 | ] 50 | }, 51 | { 52 | "login": "allenyllee", 53 | "name": "Allen.YL", 54 | "avatar_url": "https://avatars3.githubusercontent.com/u/3991134?v=4", 55 | "profile": "https://allenyllee.gitlab.io/", 56 | "contributions": [ 57 | "userTesting" 58 | ] 59 | }, 60 | { 61 | "login": "frankhommers", 62 | "name": "Frank Hommers", 63 | "avatar_url": "https://avatars2.githubusercontent.com/u/7355878?v=4", 64 | "profile": "http://frank.hommers.nl/", 65 | "contributions": [ 66 | "bug" 67 | ] 68 | }, 69 | { 70 | "login": "nawordar", 71 | "name": "Cezary Drożak", 72 | "avatar_url": "https://avatars2.githubusercontent.com/u/26769700?v=4", 73 | "profile": "https://github.com/nawordar", 74 | "contributions": [ 75 | "ideas" 76 | ] 77 | } 78 | ], 79 | "contributorsPerLine": 7, 80 | "skipCi": true 81 | } 82 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [{*,.*}] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [{*.yml,yarn.lock}] 12 | indent_style = space 13 | 14 | [{*.md}] 15 | trim_trailing_whitespace = false 16 | indent_style = space 17 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at arnohovhannisyan0@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Syncify 2 | 3 | Welcome, and thank you for your interest in contributing to Syncify! 4 | 5 | There are many ways in which you can contribute, beyond writing code. The goal of this document is to provide a high-level overview of how you can get involved. 6 | 7 | ## Asking Questions 8 | 9 | Have a question? Rather than opening an issue, please ask away on [Spectrum](https://spectrum.chat/vscode-syncify). 10 | 11 | ## Reporting Issues 12 | 13 | Have you identified a reproducible problem? Have a feature request? We want to hear about it! Here's how you can make reporting your issue as effective as possible. 14 | 15 | ### Look For an Existing Issue 16 | 17 | Before you create a new issue, please do a search in [open issues](https://github.com/arnohovhannisyan/vscode-syncify/issues) to see if the issue or feature request has already been filed. 18 | 19 | Be sure to scan through the [most popular](https://github.com/arnohovhannisyan/vscode-syncify/issues?q=is%3Aopen+is%3Aissue+label%3Afeature-request+sort%3Areactions-%2B1-desc) feature requests. 20 | 21 | If you find your issue already exists, make relevant comments and add your reaction. Use a reaction in place of a "+1" comment: 22 | 23 | - 👍 - upvote 24 | - 👎 - downvote 25 | 26 | If you cannot find an existing issue that describes your bug or feature, create a new issue using the guidelines below. 27 | 28 | ### Writing Good Bug Reports and Feature Requests 29 | 30 | File a single issue per problem and feature request. Do not enumerate multiple bugs or feature requests in the same issue. 31 | 32 | Do not add your issue as a comment to an existing issue unless it's for the identical input. Many issues look similar, but have different causes. 33 | 34 | The more information you can provide, the more likely someone will be successful at reproducing the issue and finding a fix. 35 | 36 | Please include the following with each issue: 37 | 38 | - Version of Syncify and your operating system 39 | 40 | - Reproducible steps (1... 2... 3...) that cause the issue 41 | 42 | - What you expected to see, versus what you actually saw 43 | 44 | - Images, animations, or a link to a video showing the issue occurring 45 | 46 | - A code snippet that demonstrates the issue or a link to your Syncify settings repository 47 | 48 | - Errors from the Dev Tools Console (open from the menu: Help > Toggle Developer Tools) 49 | 50 | # Thank You! 51 | 52 | Your contributions to open source, large or small, make great projects like this possible. Thank you for taking the time to contribute. 53 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [] 4 | open_collective: arnohovhannisyan 5 | issuehunt: arnohovhannisyan 6 | custom: ["https://paypal.me/jetbr33ze"] 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug to help improve Syncify 4 | title: "" 5 | labels: bug 6 | assignees: arnohovhannisyan 7 | --- 8 | 9 | ### Syncify Version and Operating System 10 | 11 | I am using Syncify `vX.X.X` on Windows/Mac/Linux/Other 12 | 13 | 14 | 15 | ### Describe the bug 16 | 17 | A clear and concise description of what the bug is. 18 | 19 | ### To Reproduce 20 | 21 | Steps to reproduce the behavior: 22 | 23 | 1. Do '...' 24 | 25 | ### Expected behavior 26 | 27 | A clear and concise description of what you expected to happen. 28 | 29 | ### Screenshots 30 | 31 | If applicable, add screenshots to help explain your problem. 32 | 33 | ### Additional context 34 | 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: arnohovhannisyan 7 | --- 8 | 9 | ### Is your feature request related to a problem? 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ### Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | ### Describe alternatives you've considered 18 | 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | ### Additional context 22 | 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What does this PR do? 2 | 3 | 4 | 5 | This PR ... 6 | 7 | ### Changes 8 | 9 | 10 | 11 | - Change "..." 12 | 13 | ### Does this PR fix an issue? 14 | 15 | 16 | 17 | 18 | This PR fixes ... 19 | 20 | ### Tests 21 | 22 | 23 | 24 | This PR was tested ... 25 | 26 | ### Screenshots 27 | 28 | 29 | 30 | ### Merge Requirements 31 | 32 | - [ ] This PR requires updates to the Syncify documentation 33 | - [ ] The documentation has been updated accordingly 34 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$NEXT_PATCH_VERSION" 2 | 3 | tag-template: "v$NEXT_PATCH_VERSION" 4 | 5 | categories: 6 | - title: Breaking Changes 7 | label: breaking 8 | 9 | - title: Enhancements 10 | label: enhancement 11 | 12 | - title: Bug Fixes 13 | label: fix 14 | 15 | - title: Documentation 16 | label: documentation 17 | 18 | - title: Housekeeping 19 | label: housekeeping 20 | 21 | change-template: "- $TITLE (#$NUMBER) @$AUTHOR" 22 | 23 | no-changes-template: "- No changes" 24 | 25 | template: | 26 | $CHANGES 27 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - name: Checkout Submodules 16 | run: git submodule update --init --recursive 17 | 18 | - name: Setup Node 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ">=12" 22 | 23 | - name: Install Dependencies 24 | run: yarn install 25 | 26 | - name: Setup Environment 27 | run: | 28 | echo "::set-env name=RELEASE_TAG::${GITHUB_REF//refs\/tags\/}" 29 | echo "::set-env name=RAW_VERSION::${GITHUB_REF//refs\/tags\/v}" 30 | 31 | - name: Increment Version 32 | run: | 33 | git config user.email "actions@github.com" 34 | git config user.name "github-actions" 35 | 36 | yarn version --new-version $RAW_VERSION --no-git-tag-version 37 | 38 | git add package.json 39 | git commit -m "Increment version to $RELEASE_TAG" 40 | 41 | - name: Push Version 42 | uses: ad-m/github-push-action@master 43 | with: 44 | github_token: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Build 47 | run: yarn vsce package --yarn 48 | 49 | - name: Generate Checksum 50 | run: sha256sum syncify-*.vsix > syncify-${RAW_VERSION}.vsix.sha256 51 | 52 | - name: Publish to VSCode Marketplace 53 | run: yarn vsce publish --yarn -p ${{ secrets.VS_TOKEN }} 54 | 55 | - name: Publish to Open VSX Registry 56 | run: yarn ovsx publish syncify-${RAW_VERSION}.vsix -p ${{ secrets.OVSX_TOKEN }} 57 | 58 | - name: Publish to GitHub Releases 59 | uses: AButler/upload-release-assets@v2.0 60 | with: 61 | files: syncify-*.vsix* 62 | repo-token: ${{ secrets.GITHUB_TOKEN }} 63 | release-tag: ${{ env.RELEASE_TAG }} 64 | -------------------------------------------------------------------------------- /.github/workflows/draft-release.yml: -------------------------------------------------------------------------------- 1 | name: Draft Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | draft-release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Draft Release 14 | uses: release-drafter/release-drafter@v5 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: Lint 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - name: Setup Node 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ">=12" 19 | 20 | - name: Install Dependencies 21 | run: yarn install 22 | 23 | - name: Lint 24 | run: yarn lint 25 | 26 | test: 27 | name: Test (${{ matrix.os }}) 28 | 29 | runs-on: ${{ matrix.os }}-latest 30 | 31 | strategy: 32 | matrix: 33 | os: [Ubuntu, Windows, macOS] 34 | 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v2 38 | 39 | - name: Checkout Submodules 40 | run: git submodule update --init --recursive 41 | 42 | - name: Setup Node 43 | uses: actions/setup-node@v1 44 | with: 45 | node-version: ">=12" 46 | 47 | - name: Install Dependencies 48 | run: yarn install 49 | 50 | - name: Configure Git 51 | run: | 52 | git config --global user.email "actions@github.com" 53 | git config --global user.name "GitHub Actions" 54 | 55 | - name: Test 56 | run: yarn test 57 | 58 | coverage: 59 | name: Coverage 60 | 61 | needs: [lint, test] 62 | 63 | runs-on: ubuntu-latest 64 | 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@v2 68 | 69 | - name: Checkout Submodules 70 | run: git submodule update --init --recursive 71 | 72 | - name: Setup Node 73 | uses: actions/setup-node@v1 74 | with: 75 | node-version: ">=12" 76 | 77 | - name: Install Dependencies 78 | run: yarn install 79 | 80 | - name: Configure Git 81 | run: | 82 | git config --global user.email "actions@github.com" 83 | git config --global user.name "GitHub Actions" 84 | 85 | - name: Calculate Coverage 86 | run: yarn test --coverage 87 | 88 | - name: Upload Coverage 89 | uses: codecov/codecov-action@v1 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | 3 | .vsce 4 | dist 5 | node_modules 6 | coverage 7 | 8 | # Files 9 | 10 | *.vsix 11 | *.log 12 | assets/settings.schema.json -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "assets/ui"] 2 | path = assets/ui 3 | url = https://github.com/arnohovhannisyan/syncify-webviews 4 | branch = dist 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "semi": true, 4 | "useTabs": true, 5 | "arrowParens": "always", 6 | "bracketSpacing": true, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "runtimeExecutable": "${execPath}", 9 | "args": [ 10 | "--extensionDevelopmentPath=${workspaceFolder}", 11 | "--user-data-dir=/tmp/vscode-syncify/user-data", 12 | "--extensions-dir=/tmp/vscode-syncify/extensions" 13 | ], 14 | "outFiles": ["${workspaceFolder}/dist/**/*.js"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "dist": false 5 | }, 6 | "files.associations": { 7 | ".all-contributorsrc": "json", 8 | ".lintstagedrc": "json", 9 | ".huskyrc": "json" 10 | }, 11 | "search.exclude": { 12 | "dist": true 13 | }, 14 | "typescript.tsc.autoDetect": "off", 15 | "npm.autoDetect": "off", 16 | "typescript.preferences.importModuleSpecifier": "non-relative", 17 | "typescript.tsdk": "node_modules/typescript/lib", 18 | "xo.enable": true 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Watch", 6 | "type": "shell", 7 | "command": "rm -rf /tmp/vscode-syncify && mkdir -p /tmp/vscode-syncify/{extensions,user-data} && yarn start", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | } 12 | }, 13 | { 14 | "label": "Test", 15 | "type": "shell", 16 | "command": "yarn test" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | 3 | .vscode 4 | .github 5 | .webpack 6 | src 7 | node_modules 8 | coverage 9 | 10 | # Files 11 | 12 | .all-contributorsrc 13 | .xo-config.json 14 | .huskyrc 15 | .lintstagedrc 16 | .prettierrc 17 | codecov.yml 18 | .gitignore 19 | .gitmodules 20 | tsconfig.json 21 | assets/ui/.git 22 | assets/ui/index.html 23 | *.map 24 | *.log -------------------------------------------------------------------------------- /.webpack/common.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { TsConfigPathsPlugin } from "awesome-typescript-loader"; 3 | import { Configuration } from "webpack"; 4 | import { CleanWebpackPlugin } from "clean-webpack-plugin"; 5 | 6 | const config: Configuration = { 7 | stats: { 8 | warningsFilter: /(Critical dependency: the request of a dependency is an expression|Can't resolve 'original-fs')/, 9 | }, 10 | target: "node", 11 | entry: "./src/main.ts", 12 | output: { 13 | filename: "main.js", 14 | path: resolve(__dirname, "../dist"), 15 | libraryTarget: "commonjs2", 16 | devtoolModuleFilenameTemplate: "file:///[absolute-resource-path]", 17 | }, 18 | resolve: { 19 | extensions: [".ts", ".js"], 20 | plugins: [new TsConfigPathsPlugin()], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.ts$/, 26 | exclude: /node_modules/, 27 | loader: "ts-loader", 28 | }, 29 | { 30 | test: /\.html$/, 31 | exclude: /node_modules/, 32 | use: "raw-loader", 33 | }, 34 | ], 35 | }, 36 | plugins: [new CleanWebpackPlugin()], 37 | externals: { 38 | vscode: "commonjs vscode", 39 | "vscode-fsevents": "commonjs vscode-fsevents", 40 | }, 41 | }; 42 | 43 | export default config; 44 | -------------------------------------------------------------------------------- /.webpack/development.ts: -------------------------------------------------------------------------------- 1 | import common from "./common"; 2 | import { merge } from "webpack-merge"; 3 | import webpack from "webpack"; 4 | 5 | const config = merge(common, { 6 | mode: "development", 7 | devtool: "source-map", 8 | plugins: [ 9 | new webpack.SourceMapDevToolPlugin({ 10 | exclude: /node_modules/, 11 | test: /\.ts($|\?)/i, 12 | }), 13 | ], 14 | }); 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /.webpack/production.ts: -------------------------------------------------------------------------------- 1 | import common from "./common"; 2 | import { merge } from "webpack-merge"; 3 | import TerserPlugin from "terser-webpack-plugin"; 4 | 5 | const config = merge(common, { 6 | mode: "production", 7 | optimization: { 8 | minimizer: [ 9 | new TerserPlugin({ 10 | cache: false, 11 | sourceMap: true, 12 | extractComments: true, 13 | terserOptions: { 14 | ecma: 2017, 15 | mangle: false, 16 | keep_classnames: true, 17 | keep_fnames: true, 18 | }, 19 | }), 20 | ], 21 | }, 22 | }); 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /.xo-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier": true, 3 | "extends": ["plugin:jest/recommended", "plugin:jest/style"], 4 | "rules": { 5 | "unicorn/filename-case": [1, { "case": "camelCase" }], 6 | "@typescript-eslint/prefer-readonly-parameter-types": 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Arno Hovhannisyan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ![Syncify][img:banner] 2 | 3 |

4 | 5 | Tests 6 | 7 | 8 | Version 9 | 10 | 11 | Issues 12 | 13 | 14 | License 15 | 16 | 17 | Deps 18 | 19 | 20 | Coverage 21 | 22 |

23 | 24 | Syncify can sync your VSCode settings and extensions across all your devices using multiple methods. 25 | 26 | ## Documentation 27 | 28 | The documentation can be found [here][link:docs]. 29 | 30 | ## Need Help? 31 | 32 | - Join the [Spectrum][link:spectrum] community 33 | - Join the [Discord][link:discord] server 34 | 35 | ## Advantages over Settings Sync 36 | 37 | - No annoying popups 38 | - Quick setup with GitHub, GitLab, or BitBucket 39 | - Merge conflict resolution supported 40 | - Multiple profiles with support for hot-switching (currently only supported on `Git` method) 41 | - Simpler settings with intellisense 42 | - Debug logging for quickly resolving issues 43 | - Multiple sync methods 44 | - `repo` — sync using a git repository 45 | - `file` — sync to local folder, even when offline (can be used with Dropbox, zipped and emailed, etc) 46 | - `Sync` command that automatically uploads or downloads if there are changes locally or remotely (only supported on `repo` method) 47 | - More intuitive custom file registration and importing 48 | - Notification for extension installation/uninstallation progress 49 | - Status bar button to cancel auto-upload 50 | - Descriptive errors with possible causes and solutions 51 | - Support for syncing custom extensions that are not from the VSCode Marketplace 52 | 53 | ## Contributors 54 | 55 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |

Bennett Piater

💻

Shan Khan

🤔

dxman

📓 🐛

everito

📓 🐛

Allen.YL

📓

Frank Hommers

🐛

Cezary Drożak

🤔
71 | 72 | 73 | 74 | 75 | 76 | 77 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 78 | 79 | [link:docs]: https://arnohovhannisyan.space/vscode-syncify 80 | [link:spectrum]: https://spectrum.chat/vscode-syncify 81 | [link:discord]: https://discord.gg/DwFKj57 82 | [img:banner]: https://arnohovhannisyan.space/vscode-syncify/img/banner.jpg 83 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/auxves/vscode-syncify/ef925ad71d8ee0007c747dc4ecb2477eace42f51/assets/logo.png -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 65..100 3 | 4 | status: 5 | project: off 6 | patch: off 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncify", 3 | "displayName": "Syncify", 4 | "publisher": "arnohovhannisyan", 5 | "description": "A reliable way of syncing your VSCode settings and extensions", 6 | "license": "MIT", 7 | "version": "4.0.5", 8 | "main": "./dist/main.js", 9 | "scripts": { 10 | "start": "webpack --config .webpack/development.ts --watch", 11 | "build": "webpack --config .webpack/production.ts", 12 | "test": "jest", 13 | "lint": "xo 'src/**/*'", 14 | "schema": "typescript-json-schema tsconfig.json ISettings -o assets/settings.schema.json --noExtraProps", 15 | "vscode:prepublish": "yarn schema && yarn build" 16 | }, 17 | "dependencies": { 18 | "express": "^4.17.1", 19 | "fast-glob": "^3.2.4", 20 | "fs-extra": "^9.0.1", 21 | "got": "^11.5.0", 22 | "jsonc-pragma": "^1.0.7", 23 | "lodash": "^4.17.19", 24 | "semver": "^7.3.2", 25 | "simple-git": "^2.12.0", 26 | "vscode-chokidar": "^2.1.7" 27 | }, 28 | "devDependencies": { 29 | "@types/express": "^4.17.7", 30 | "@types/fs-extra": "^9.0.1", 31 | "@types/jest": "^26.0.4", 32 | "@types/lodash": "^4.14.157", 33 | "@types/node": "^14.0.22", 34 | "@types/semver": "^7.3.1", 35 | "@types/terser-webpack-plugin": "^3.0.0", 36 | "@types/vscode": "1.47.0", 37 | "@types/webpack": "^4.41.21", 38 | "@types/webpack-merge": "^4.1.5", 39 | "awesome-typescript-loader": "^5.2.1", 40 | "clean-webpack-plugin": "^3.0.0", 41 | "codecov": "^3.7.0", 42 | "eslint-plugin-jest": "^23.18.0", 43 | "eslint-plugin-prettier": "^3.1.4", 44 | "jest": "^26.1.0", 45 | "jest-raw-loader": "^1.0.1", 46 | "ovsx": "^0.1.0-next.9b4e999", 47 | "prettier": "2.0.5", 48 | "raw-loader": "^4.0.1", 49 | "shx": "^0.3.2", 50 | "terser-webpack-plugin": "^3.0.6", 51 | "ts-jest": "^26.1.1", 52 | "ts-loader": "^8.0.0", 53 | "ts-node": "^8.10.2", 54 | "typescript": "^3.9.6", 55 | "typescript-json-schema": "^0.42.0", 56 | "utility-types": "^3.10.0", 57 | "vsce": "^1.77.0", 58 | "webpack": "^4.43.0", 59 | "webpack-cli": "^3.3.12", 60 | "webpack-merge": "^5.0.9", 61 | "xo": "^0.32.1" 62 | }, 63 | "engines": { 64 | "vscode": "^1.47.0" 65 | }, 66 | "icon": "assets/logo.png", 67 | "homepage": "https://arnohovhannisyan.space/vscode-syncify", 68 | "repository": { 69 | "type": "git", 70 | "url": "https://github.com/arnohovhannisyan/vscode-syncify" 71 | }, 72 | "bugs": { 73 | "url": "https://github.com/arnohovhannisyan/vscode-syncify/issues", 74 | "email": "arnohovhannisyan0@gmail.com" 75 | }, 76 | "extensionKind": [ 77 | "ui" 78 | ], 79 | "categories": [ 80 | "Other" 81 | ], 82 | "keywords": [ 83 | "sync", 84 | "vscode-sync", 85 | "settings-sync", 86 | "syncify", 87 | "vscode-syncify" 88 | ], 89 | "activationEvents": [ 90 | "*" 91 | ], 92 | "contributes": { 93 | "commands": [ 94 | { 95 | "command": "syncify.sync", 96 | "title": "%(command) sync%" 97 | }, 98 | { 99 | "command": "syncify.upload", 100 | "title": "%(command) upload%" 101 | }, 102 | { 103 | "command": "syncify.download", 104 | "title": "%(command) download%" 105 | }, 106 | { 107 | "command": "syncify.reset", 108 | "title": "%(command) reset%" 109 | }, 110 | { 111 | "command": "syncify.openSettings", 112 | "title": "%(command) openSettings%" 113 | }, 114 | { 115 | "command": "syncify.reinitialize", 116 | "title": "%(command) reinitialize%" 117 | }, 118 | { 119 | "command": "syncify.registerCustomFile", 120 | "title": "%(command) registerCustomFile%" 121 | }, 122 | { 123 | "command": "syncify.importCustomFile", 124 | "title": "%(command) importCustomFile%" 125 | }, 126 | { 127 | "command": "syncify.switchProfile", 128 | "title": "%(command) switchProfile%" 129 | } 130 | ], 131 | "jsonValidation": [ 132 | { 133 | "fileMatch": "arnohovhannisyan.syncify/settings.json", 134 | "url": "./assets/settings.schema.json" 135 | } 136 | ], 137 | "menus": { 138 | "explorer/context": [ 139 | { 140 | "command": "syncify.registerCustomFile", 141 | "when": "!explorerResourceIsFolder" 142 | }, 143 | { 144 | "command": "syncify.importCustomFile", 145 | "when": "explorerResourceIsFolder" 146 | } 147 | ] 148 | } 149 | }, 150 | "jest": { 151 | "preset": "ts-jest", 152 | "testEnvironment": "node", 153 | "roots": [ 154 | "/src/tests/" 155 | ], 156 | "moduleNameMapper": { 157 | "^~/(.*)": "/src/$1" 158 | }, 159 | "transform": { 160 | "\\.html$": "jest-raw-loader" 161 | }, 162 | "globalTeardown": "/src/tests/teardown.ts" 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /package.nls.json: -------------------------------------------------------------------------------- 1 | { 2 | "(command) cancelUpload": "Cancel Upload", 3 | "(command) download": "Syncify: Download", 4 | "(command) importCustomFile": "Syncify: Import Custom File", 5 | "(command) openSettings": "Syncify: Open Settings", 6 | "(command) registerCustomFile": "Syncify: Register Custom File", 7 | "(command) reinitialize": "Syncify: Reinitialize", 8 | "(command) reset": "Syncify: Reset", 9 | "(command) switchProfile": "Syncify: Switch Profile", 10 | "(command) sync": "Syncify: Sync", 11 | "(command) upload": "Syncify: Upload", 12 | "(confirm) settings -> reset": "Syncify: Are you sure you want to reset?", 13 | "(error) default": "Oops! An error has occurred. Click `Show Details` for more information.", 14 | "(info) announcementAvailable": "Syncify: Urgent Announcement Available", 15 | "(info) customFiles -> noFilesAvailable": "Syncify: No Files Available", 16 | "(info) customFiles -> registered": "Syncify: Registered `{0}`", 17 | "(info) extensions -> installed": "Syncify: Installed {0}", 18 | "(info) extensions -> uninstalled": "Syncify: Uninstalled {0}", 19 | "(info) repo -> noRemoteBranches": "Syncify: No Remote Branches", 20 | "(info) repo -> remoteChanges": "Syncify: Newer Remote Changes", 21 | "(info) repo -> remoteUpToDate": "Syncify: Remote already up to date", 22 | "(info) repo -> switchedProfile": "Syncify: Switched to profile `{0}`. Do you want to download?", 23 | "(info) repo -> upToDate": "Syncify: Already up to date", 24 | "(info) reset -> complete": "Syncify: Settings have been reset", 25 | "(info) sync -> downloaded": "Syncify: Downloaded", 26 | "(info) sync -> downloading": "Syncify: Downloading", 27 | "(info) sync -> needToReload": "Syncify: Would you like to reload to finish the procedure?", 28 | "(info) sync -> nothingToDo": "Syncify: Nothing To Do", 29 | "(info) sync -> uploaded": "Syncify: Uploaded", 30 | "(info) sync -> uploading": "Syncify: Uploading", 31 | "(info) watcher -> initiating": "Syncify: Uploading in {0} seconds", 32 | "(label) customFiles -> selectFile": "Select File", 33 | "(label) dismiss": "Dismiss", 34 | "(label) no": "No", 35 | "(label) open": "Open", 36 | "(label) showDetails": "Show Details", 37 | "(label) yes": "Yes", 38 | "(prompt) customFiles -> import -> file -> placeholder": "Select the file to import", 39 | "(prompt) customFiles -> import -> file -> name": "Enter the name you want to import the file as", 40 | "(prompt) customFiles -> import -> folder -> placeholder": "Select the workspace to import the file into", 41 | "(prompt) customFiles -> register -> exists": "A custom file with that name already exists. Do you want to overwrite it?", 42 | "(prompt) customFiles -> register -> name": "Enter the name you want to register the file as", 43 | "(prompt) profile -> switch -> placeholder": "Select the profile to switch to", 44 | "(prompt) webview -> landingPage -> nologin": "Enter the URL of the repository you want to download", 45 | "(setting) autoUploadDelay -> name": "Auto Upload Delay", 46 | "(setting) autoUploadDelay -> placeholder": "Enter Auto Upload Delay", 47 | "(setting) file.path -> name": "File Export Path", 48 | "(setting) file.path -> placeholder": "Enter File Export Path", 49 | "(setting) forceDownload -> name": "Force Download", 50 | "(setting) forceUpload -> name": "Force Upload", 51 | "(setting) hostname -> name": "Hostname", 52 | "(setting) hostname -> placeholder": "Enter Hostname", 53 | "(setting) ignoredItems -> name": "Ignored Items", 54 | "(setting) ignoredItems -> placeholder": "Enter ignored items (one every line)", 55 | "(setting) repo.currentProfile -> name": "Current Profile", 56 | "(setting) repo.profiles -> name": "Profiles", 57 | "(setting) repo.profiles.properties.branch -> name": "Branch", 58 | "(setting) repo.profiles.properties.branch -> placeholder": "Enter Branch", 59 | "(setting) repo.profiles.properties.name -> name": "Name", 60 | "(setting) repo.profiles.properties.name -> placeholder": "Enter Name", 61 | "(setting) repo.url -> name": "Repo URL", 62 | "(setting) repo.url -> placeholder": "Enter Repo URL", 63 | "(setting) syncOnStartup -> name": "Sync On Startup", 64 | "(setting) syncer -> name": "Syncer", 65 | "(setting) watchSettings -> name": "Watch Settings" 66 | } 67 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode"; 2 | import { init, initLocalization, migrate } from "~/services"; 3 | import state from "~/state"; 4 | import migrations from "~/migrations"; 5 | 6 | export async function activate(context: ExtensionContext): Promise { 7 | state.context = context; 8 | 9 | await initLocalization(); 10 | 11 | await migrate(migrations); 12 | 13 | await init(); 14 | } 15 | -------------------------------------------------------------------------------- /src/migrations.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from "~/models"; 2 | import { showAnnouncement } from "~/services"; 3 | 4 | const migrations = new Map([ 5 | [ 6 | "4.0.0", 7 | (previousVersion) => { 8 | if (previousVersion === "0.0.0") return; 9 | 10 | const url = 11 | "https://arnohovhannisyan.space/vscode-syncify/blog/2020/03/15/breaking-changes-in-v4"; 12 | 13 | showAnnouncement(url); 14 | }, 15 | ], 16 | ]); 17 | 18 | export default migrations; 19 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/models/syncers"; 2 | export * from "~/models/languagePack"; 3 | export * from "~/models/profile"; 4 | export * from "~/models/migrations"; 5 | export * from "~/models/settings"; 6 | export * from "~/models/syncer"; 7 | export * from "~/models/webview/settingType"; 8 | export * from "~/models/webview/section"; 9 | export * from "~/models/webview/setting"; 10 | export * from "~/models/webview/selectOption"; 11 | -------------------------------------------------------------------------------- /src/models/languagePack.ts: -------------------------------------------------------------------------------- 1 | export type LanguagePack = { 2 | [key: string]: string | undefined; 3 | }; 4 | -------------------------------------------------------------------------------- /src/models/migrations.ts: -------------------------------------------------------------------------------- 1 | export type Migration = (previousVersion: string) => void | Promise; 2 | 3 | export type Migrations = { 4 | [key: string]: Migration; 5 | }; 6 | -------------------------------------------------------------------------------- /src/models/profile.ts: -------------------------------------------------------------------------------- 1 | export type Profile = { 2 | /** 3 | * The Git branch used to store the settings 4 | */ 5 | branch: string; 6 | 7 | /** 8 | * The name of the profile 9 | */ 10 | name: string; 11 | }; 12 | -------------------------------------------------------------------------------- /src/models/settings.ts: -------------------------------------------------------------------------------- 1 | import { Profile, Syncers } from "~/models"; 2 | 3 | export type ISettings = { 4 | /** 5 | * The method used to sync your settings. 6 | */ 7 | syncer: Syncers; 8 | 9 | /** 10 | * Settings relating to the `Repo` syncer. 11 | */ 12 | repo: { 13 | /** 14 | * The path to the Git repository. 15 | */ 16 | url: string; 17 | 18 | /** 19 | * Profiles can be used to sync different settings for different occasions. 20 | */ 21 | profiles: Profile[]; 22 | 23 | /** 24 | * The profile currently being used. 25 | */ 26 | currentProfile: string; 27 | }; 28 | 29 | /** 30 | * Settings relating to the `File` syncer. 31 | */ 32 | file: { 33 | /** 34 | * The path to the export folder. 35 | */ 36 | path: string; 37 | }; 38 | 39 | /** 40 | * Items that will not be uploaded, formatted as an array of glob strings. If a path matches a listed glob, it will be ignored. 41 | * 42 | * See https://en.wikipedia.org/wiki/Glob_%28programming%29 for more information about globs. 43 | */ 44 | ignoredItems: string[]; 45 | 46 | /** 47 | * The amount of time to wait before automatically uploading. This is only used when `#watchSettings` is on. 48 | * 49 | * @minimum 0 50 | */ 51 | autoUploadDelay: number; 52 | 53 | /** 54 | * Controls whether or not Syncify will watch for local changes. If true, an upload will occur when settings have changed or an extension has been installed/uninstalled. 55 | */ 56 | watchSettings: boolean; 57 | 58 | /** 59 | * Controls whether or not Syncify should run the `Sync` command when opening the editor. 60 | */ 61 | syncOnStartup: boolean; 62 | 63 | /** 64 | * Hostnames are used by `Sync Pragmas` to differentiate between different computers. 65 | */ 66 | hostname: string; 67 | 68 | /** 69 | * Controls whether or not local settings will be forcefully uploaded, even if remote settings are up to date. 70 | */ 71 | forceUpload: boolean; 72 | 73 | /** 74 | * Controls whether or not remote settings will be forcefully downloaded, even if local settings are up to date. 75 | */ 76 | forceDownload: boolean; 77 | }; 78 | 79 | export const defaultSettings: ISettings = { 80 | syncer: Syncers.Repo, 81 | repo: { 82 | url: "", 83 | profiles: [ 84 | { 85 | branch: "master", 86 | name: "main", 87 | }, 88 | ], 89 | currentProfile: "main", 90 | }, 91 | file: { 92 | path: "", 93 | }, 94 | ignoredItems: [ 95 | "**/workspaceStorage", 96 | "**/globalStorage/state.vscdb*", 97 | "**/globalStorage/arnohovhannisyan.syncify", 98 | "**/.git", 99 | ], 100 | autoUploadDelay: 20, 101 | watchSettings: false, 102 | syncOnStartup: false, 103 | hostname: "", 104 | forceDownload: false, 105 | forceUpload: false, 106 | }; 107 | -------------------------------------------------------------------------------- /src/models/syncer.ts: -------------------------------------------------------------------------------- 1 | export type Syncer = { 2 | sync: () => Promise; 3 | upload: () => Promise; 4 | download: () => Promise; 5 | isConfigured: () => Promise; 6 | }; 7 | -------------------------------------------------------------------------------- /src/models/syncers.ts: -------------------------------------------------------------------------------- 1 | export enum Syncers { 2 | Repo = "repo", 3 | File = "file", 4 | } 5 | -------------------------------------------------------------------------------- /src/models/webview/section.ts: -------------------------------------------------------------------------------- 1 | import { WebviewSetting } from "~/models"; 2 | 3 | export type WebviewSection = { 4 | name: string; 5 | settings: WebviewSetting[]; 6 | }; 7 | -------------------------------------------------------------------------------- /src/models/webview/selectOption.ts: -------------------------------------------------------------------------------- 1 | export type SelectOption = { 2 | name: string; 3 | value: string; 4 | }; 5 | -------------------------------------------------------------------------------- /src/models/webview/setting.ts: -------------------------------------------------------------------------------- 1 | import { SelectOption, UISettingType } from "~/models"; 2 | 3 | type Checkbox = { 4 | name: string; 5 | type: UISettingType.Checkbox; 6 | correspondingSetting: string; 7 | }; 8 | 9 | type TextInput = { 10 | name: string; 11 | type: UISettingType.TextInput; 12 | correspondingSetting: string; 13 | placeholder: string; 14 | }; 15 | 16 | type TextArea = { 17 | name: string; 18 | type: UISettingType.TextArea; 19 | correspondingSetting: string; 20 | placeholder: string; 21 | }; 22 | 23 | type NumberInput = { 24 | name: string; 25 | type: UISettingType.NumberInput; 26 | correspondingSetting: string; 27 | placeholder: string; 28 | min?: number; 29 | max?: number; 30 | }; 31 | 32 | type Select = { 33 | name: string; 34 | type: UISettingType.Select; 35 | correspondingSetting: string; 36 | options: SelectOption[]; 37 | }; 38 | 39 | type ObjectArray = { 40 | name: string; 41 | type: UISettingType.ObjectArray; 42 | correspondingSetting: string; 43 | schema: WebviewSetting[]; 44 | newTemplate: Record; 45 | }; 46 | 47 | export type WebviewSetting = 48 | | Checkbox 49 | | Select 50 | | TextArea 51 | | TextInput 52 | | NumberInput 53 | | ObjectArray; 54 | -------------------------------------------------------------------------------- /src/models/webview/settingType.ts: -------------------------------------------------------------------------------- 1 | export enum UISettingType { 2 | TextInput = "string", 3 | NumberInput = "number", 4 | Checkbox = "boolean", 5 | TextArea = "string[]", 6 | Select = "enum", 7 | ObjectArray = "object[]", 8 | } 9 | -------------------------------------------------------------------------------- /src/services/__mocks__/localize.ts: -------------------------------------------------------------------------------- 1 | export const localize = (key: string): string => key; 2 | -------------------------------------------------------------------------------- /src/services/announcer.ts: -------------------------------------------------------------------------------- 1 | import { window, commands, env, Uri } from "vscode"; 2 | import { localize } from "~/services"; 3 | 4 | export function showAnnouncement(url: string): void { 5 | const message = window.setStatusBarMessage( 6 | localize("(info) announcementAvailable"), 7 | ); 8 | 9 | const openBtn = window.createStatusBarItem(1); 10 | const dismissBtn = window.createStatusBarItem(1); 11 | 12 | const dispose = (): void => { 13 | openDisposable.dispose(); 14 | dismissDisposable.dispose(); 15 | openBtn.dispose(); 16 | dismissBtn.dispose(); 17 | message.dispose(); 18 | }; 19 | 20 | const openDisposable = commands.registerCommand( 21 | "syncify.openAnnouncement", 22 | () => { 23 | dispose(); 24 | 25 | return env.openExternal(Uri.parse(url)); 26 | }, 27 | ); 28 | 29 | const dismissDisposable = commands.registerCommand( 30 | "syncify.dismissAnnouncement", 31 | dispose, 32 | ); 33 | 34 | openBtn.command = "syncify.openAnnouncement"; 35 | openBtn.text = `$(check) ${localize("(label) open")}`; 36 | openBtn.show(); 37 | 38 | dismissBtn.command = "syncify.dismissAnnouncement"; 39 | dismissBtn.text = `$(x) ${localize("(label) dismiss")}`; 40 | dismissBtn.show(); 41 | } 42 | -------------------------------------------------------------------------------- /src/services/customFiles.ts: -------------------------------------------------------------------------------- 1 | import { basename, resolve } from "path"; 2 | import { QuickPickItem, Uri, window, workspace } from "vscode"; 3 | import { Environment, FS, localize, Logger } from "~/services"; 4 | 5 | export namespace CustomFiles { 6 | export const importFile = async (uri?: Uri): Promise => { 7 | try { 8 | const folderExists = await FS.exists(Environment.customFilesFolder); 9 | 10 | if (!folderExists) { 11 | await FS.mkdir(Environment.customFilesFolder); 12 | } 13 | 14 | const allFiles = await FS.listFiles(Environment.customFilesFolder); 15 | 16 | if (allFiles.length === 0) { 17 | await window.showInformationMessage( 18 | localize("(info) customFiles -> noFilesAvailable"), 19 | ); 20 | return; 21 | } 22 | 23 | const folder = await (async () => { 24 | if (uri) return uri.fsPath; 25 | 26 | if (!workspace.workspaceFolders) return; 27 | 28 | if (workspace.workspaceFolders.length === 1) { 29 | return workspace.workspaceFolders[0].uri.fsPath; 30 | } 31 | 32 | const result = await window.showQuickPick( 33 | workspace.workspaceFolders.map((f) => ({ 34 | label: f.name, 35 | description: f.uri.fsPath, 36 | })), 37 | { 38 | placeHolder: localize( 39 | "(prompt) customFiles -> import -> folder -> placeholder", 40 | ), 41 | }, 42 | ); 43 | 44 | const selectedWorkspace = workspace.workspaceFolders.find( 45 | (f) => f.uri.fsPath === result?.description, 46 | ); 47 | 48 | if (!selectedWorkspace) return; 49 | 50 | return selectedWorkspace.uri.fsPath; 51 | })(); 52 | 53 | if (!folder) return; 54 | 55 | const selectedFile = await window.showQuickPick( 56 | allFiles.map((f) => basename(f)), 57 | { 58 | placeHolder: localize( 59 | "(prompt) customFiles -> import -> file -> placeholder", 60 | ), 61 | }, 62 | ); 63 | 64 | if (!selectedFile) return; 65 | 66 | const filepath = resolve(Environment.customFilesFolder, selectedFile); 67 | 68 | const filename = await (async () => { 69 | const newName = await window.showInputBox({ 70 | prompt: localize("(prompt) customFiles -> import -> file -> name"), 71 | value: selectedFile, 72 | }); 73 | 74 | if (newName?.length) return newName; 75 | 76 | return selectedFile; 77 | })(); 78 | 79 | const contents = await FS.readBuffer(filepath); 80 | await FS.write(resolve(folder, filename), contents); 81 | } catch (error) { 82 | Logger.error(error); 83 | } 84 | }; 85 | 86 | export const registerFile = async (uri?: Uri): Promise => { 87 | try { 88 | const folderExists = await FS.exists(Environment.customFilesFolder); 89 | 90 | if (!folderExists) { 91 | await FS.mkdir(Environment.customFilesFolder); 92 | } 93 | 94 | const filepath = uri 95 | ? uri.fsPath 96 | : await (async () => { 97 | const result = await window.showOpenDialog({ 98 | canSelectMany: false, 99 | openLabel: localize("(label) customFiles -> selectFile"), 100 | }); 101 | 102 | if (!result) return; 103 | 104 | return result[0].fsPath; 105 | })(); 106 | 107 | if (!filepath) return; 108 | 109 | const filename = await (async () => { 110 | const original = basename(filepath); 111 | 112 | const newName = await window.showInputBox({ 113 | prompt: localize("(prompt) customFiles -> register -> name"), 114 | value: original, 115 | }); 116 | 117 | if (newName?.length) return newName; 118 | 119 | return original; 120 | })(); 121 | 122 | const newPath = resolve(Environment.customFilesFolder, filename); 123 | 124 | if (await FS.exists(newPath)) { 125 | const result = await window.showWarningMessage( 126 | localize("(prompt) customFiles -> register -> exists"), 127 | localize("(label) no"), 128 | localize("(label) yes"), 129 | ); 130 | 131 | if (result !== localize("(label) yes")) return; 132 | } 133 | 134 | const contents = await FS.readBuffer(filepath); 135 | 136 | await FS.write(newPath, contents); 137 | 138 | await window.showInformationMessage( 139 | localize("(info) customFiles -> registered", filename), 140 | ); 141 | } catch (error) { 142 | Logger.error(error); 143 | } 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /src/services/environment.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import pkg from "~/../package.json"; 3 | import state from "~/state"; 4 | 5 | export const Environment = { 6 | pkg, 7 | 8 | get userFolder() { 9 | const path = process.env.VSCODE_PORTABLE 10 | ? resolve(process.env.VSCODE_PORTABLE, "user-data") 11 | : resolve(Environment.globalStoragePath, "../../.."); 12 | 13 | return resolve(path, "User"); 14 | }, 15 | 16 | get repoFolder() { 17 | return resolve(Environment.globalStoragePath, "repo"); 18 | }, 19 | 20 | get settings() { 21 | return resolve(Environment.globalStoragePath, "settings.json"); 22 | }, 23 | 24 | get customFilesFolder() { 25 | return resolve(Environment.userFolder, "customFiles"); 26 | }, 27 | 28 | get vsixFolder() { 29 | return resolve(Environment.userFolder, "vsix"); 30 | }, 31 | 32 | get conflictsFolder() { 33 | return resolve(Environment.globalStoragePath, "conflicts"); 34 | }, 35 | 36 | get globalStoragePath() { 37 | return state.context?.globalStoragePath ?? ""; 38 | }, 39 | 40 | get extensionPath() { 41 | return state.context?.extensionPath ?? ""; 42 | }, 43 | 44 | get os() { 45 | if (process.platform === "win32") return "windows"; 46 | if (process.platform === "darwin") return "mac"; 47 | return "linux"; 48 | }, 49 | 50 | version: pkg.version, 51 | 52 | extensionId: `${pkg.publisher}.${pkg.name}`, 53 | 54 | oauthClientIds: { 55 | github: "0b56a3589b5582d11832", 56 | gitlab: "32c563edb04c312c7959fd1c4863e883878ed4af1f39d6d788c9758d4916a0db", 57 | bitbucket: "zhkr5tYsZsUfN9KkDn", 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /src/services/extensions.ts: -------------------------------------------------------------------------------- 1 | import { basename } from "path"; 2 | import { commands, extensions, ProgressLocation, Uri, window } from "vscode"; 3 | import { Environment, FS, localize } from "~/services"; 4 | 5 | export namespace Extensions { 6 | export const install = async (...ids: string[]): Promise => { 7 | const vsixFiles = await FS.listFiles(Environment.vsixFolder, []); 8 | 9 | await window.withProgress( 10 | { 11 | location: ProgressLocation.Notification, 12 | }, 13 | async (progress) => { 14 | const increment = 100 / ids.length; 15 | 16 | return Promise.all( 17 | ids.map(async (ext) => { 18 | const matchingVsix = `${ext}.vsix`; 19 | 20 | const vsix = vsixFiles.find( 21 | (file) => basename(file) === matchingVsix, 22 | ); 23 | 24 | await commands.executeCommand( 25 | "workbench.extensions.installExtension", 26 | vsix ? Uri.file(vsix) : ext, 27 | ); 28 | 29 | progress.report({ 30 | increment, 31 | message: localize("(info) extensions -> installed", ext), 32 | }); 33 | }), 34 | ); 35 | }, 36 | ); 37 | }; 38 | 39 | export const uninstall = async (...ids: string[]): Promise => { 40 | await window.withProgress( 41 | { 42 | location: ProgressLocation.Notification, 43 | }, 44 | async (progress) => { 45 | const increment = 100 / ids.length; 46 | 47 | return Promise.all( 48 | ids.map(async (ext) => { 49 | await commands.executeCommand( 50 | "workbench.extensions.uninstallExtension", 51 | ext, 52 | ); 53 | 54 | progress.report({ 55 | increment, 56 | message: localize("(info) extensions -> uninstalled", ext), 57 | }); 58 | }), 59 | ); 60 | }, 61 | ); 62 | }; 63 | 64 | export const get = (): string[] => { 65 | return extensions.all 66 | .filter((ext) => !ext.packageJSON.isBuiltin) 67 | .map((ext) => ext.id); 68 | }; 69 | 70 | export const getMissing = (downloadedExtensions: string[]): string[] => { 71 | const installed = get(); 72 | return downloadedExtensions.filter((ext) => !installed.includes(ext)); 73 | }; 74 | 75 | export const getUnneeded = (downloadedExtensions: string[]): string[] => { 76 | return get().filter((ext) => !downloadedExtensions.includes(ext)); 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/services/factory.ts: -------------------------------------------------------------------------------- 1 | import { Syncer, Syncers } from "~/models"; 2 | import { FileSyncer, RepoSyncer } from "~/syncers"; 3 | 4 | export namespace Factory { 5 | export const generate = (syncer: Syncers): Syncer => { 6 | return new syncers[syncer](); 7 | }; 8 | 9 | const syncers = { 10 | [Syncers.Repo]: RepoSyncer, 11 | [Syncers.File]: FileSyncer, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/services/fs.ts: -------------------------------------------------------------------------------- 1 | import glob from "fast-glob"; 2 | import fse from "fs-extra"; 3 | import { normalize } from "path"; 4 | import { Settings } from "~/services"; 5 | 6 | export namespace FS { 7 | export const exists = async (path: string): Promise => { 8 | return fse.pathExists(path); 9 | }; 10 | 11 | export const mkdir = async (path: string): Promise => { 12 | return fse.ensureDir(path); 13 | }; 14 | 15 | export const copy = async (src: string, dest: string): Promise => { 16 | return fse.copy(src, dest, { 17 | overwrite: true, 18 | recursive: true, 19 | preserveTimestamps: true, 20 | }); 21 | }; 22 | 23 | export const read = async (path: string): Promise => { 24 | return fse.readFile(path, "utf-8"); 25 | }; 26 | 27 | export const readBuffer = async (path: string): Promise => { 28 | return fse.readFile(path); 29 | }; 30 | 31 | export const write = async (path: string, data: any): Promise => { 32 | return fse.writeFile(path, data); 33 | }; 34 | 35 | export const remove = async (...paths: string[]): Promise => { 36 | await Promise.all(paths.map(async (path) => fse.remove(path))); 37 | }; 38 | 39 | export const listFiles = async ( 40 | path: string, 41 | ignoredItems?: string[], 42 | ): Promise => { 43 | const files = await glob("**/*", { 44 | dot: true, 45 | ignore: ignoredItems ?? (await Settings.get((s) => s.ignoredItems)), 46 | absolute: true, 47 | cwd: path, 48 | }); 49 | 50 | return files.map((f) => normalize(f)); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/services/environment"; 2 | export * from "~/services/localize"; 3 | export * from "~/services/customFiles"; 4 | export * from "~/services/announcer"; 5 | export * from "~/services/extensions"; 6 | export * from "~/services/factory"; 7 | export * from "~/services/fs"; 8 | export * from "~/services/oauth"; 9 | export * from "~/services/init"; 10 | export * from "~/services/logger"; 11 | export * from "~/services/pragma"; 12 | export * from "~/services/settings"; 13 | export * from "~/services/watcher"; 14 | export * from "~/services/webview"; 15 | export * from "~/services/profile"; 16 | export * from "~/services/migrator"; 17 | -------------------------------------------------------------------------------- /src/services/init.ts: -------------------------------------------------------------------------------- 1 | import { commands } from "vscode"; 2 | import { CustomFiles, Factory, Profile, Settings, Watcher } from "~/services"; 3 | import state from "~/state"; 4 | 5 | export async function init(): Promise { 6 | const settings = await Settings.get(); 7 | 8 | const syncer = Factory.generate(settings.syncer); 9 | 10 | Watcher.stop(); 11 | 12 | Watcher.init(settings.ignoredItems); 13 | 14 | if (settings.watchSettings) Watcher.start(); 15 | 16 | state.context?.subscriptions.forEach((d) => d.dispose()); 17 | 18 | const cmds = { 19 | sync: syncer.sync.bind(syncer), 20 | upload: syncer.upload.bind(syncer), 21 | download: syncer.download.bind(syncer), 22 | reset: Settings.reset, 23 | openSettings: Settings.open, 24 | reinitialize: init, 25 | importCustomFile: CustomFiles.importFile, 26 | registerCustomFile: CustomFiles.registerFile, 27 | switchProfile: Profile.switchProfile, 28 | }; 29 | 30 | state.context?.subscriptions.push( 31 | ...Object.entries(cmds).map(([name, fn]) => 32 | commands.registerCommand(`syncify.${name}`, fn), 33 | ), 34 | ); 35 | 36 | if (settings.syncOnStartup) await commands.executeCommand("syncify.sync"); 37 | } 38 | -------------------------------------------------------------------------------- /src/services/localize.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { LanguagePack } from "~/models"; 3 | import { Environment, FS, Logger } from "~/services"; 4 | 5 | let pack: LanguagePack = {}; 6 | 7 | export async function initLocalization(lang?: string): Promise { 8 | pack = await (async (): Promise => { 9 | try { 10 | const language = ((): string => { 11 | if (lang) return lang; 12 | 13 | if (process.env.VSCODE_NLS_CONFIG) { 14 | return JSON.parse(process.env.VSCODE_NLS_CONFIG).locale; 15 | } 16 | 17 | return "en-us"; 18 | })(); 19 | 20 | const languagePackPath = resolve( 21 | Environment.extensionPath, 22 | `package.nls.${language}.json`, 23 | ); 24 | 25 | const languageExists = await FS.exists(languagePackPath); 26 | 27 | const defaultPack = JSON.parse( 28 | await FS.read(resolve(Environment.extensionPath, "package.nls.json")), 29 | ); 30 | 31 | if (!languageExists || language === "en-us") { 32 | return defaultPack; 33 | } 34 | 35 | return { ...defaultPack, ...JSON.parse(await FS.read(languagePackPath)) }; 36 | } catch (error) { 37 | Logger.error(error); 38 | return {}; 39 | } 40 | })(); 41 | } 42 | 43 | const formatRegex = /{(\d+?)}/g; 44 | 45 | export function localize(key: string, ...args: string[]): string { 46 | return pack[key]?.replace(formatRegex, (_, index) => args[index]) ?? key; 47 | } 48 | -------------------------------------------------------------------------------- /src/services/logger.ts: -------------------------------------------------------------------------------- 1 | import { window } from "vscode"; 2 | import { localize, Webview } from "~/services"; 3 | 4 | export namespace Logger { 5 | const output = window.createOutputChannel("Syncify"); 6 | 7 | export const error = (err: Error): void => { 8 | output.appendLine(`[error] ${err.message.trim()}`); 9 | 10 | window 11 | .showErrorMessage( 12 | localize("(error) default"), 13 | localize("(label) showDetails"), 14 | ) 15 | .then((result) => result && Webview.openErrorPage(err), error); 16 | }; 17 | 18 | const debugMapper = (value: unknown): unknown => { 19 | return Array.isArray(value) ? JSON.stringify(value, undefined, 2) : value; 20 | }; 21 | 22 | export const debug = (...args: any[]): void => { 23 | output.appendLine( 24 | `[debug] ${args 25 | .map((a) => debugMapper(a)) 26 | .join(" ") 27 | .trim()}`, 28 | ); 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/services/migrator.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from "~/models"; 2 | import state from "~/state"; 3 | import { Environment, Logger } from "~/services"; 4 | import { validRange, satisfies } from "semver"; 5 | 6 | function shouldMigrate(candidate: string, previous: string): boolean { 7 | if (validRange(candidate)) { 8 | if (satisfies(previous, candidate)) return false; 9 | return satisfies(Environment.version, candidate); 10 | } 11 | 12 | return false; 13 | } 14 | 15 | export async function migrate( 16 | migrations: Map, 17 | ): Promise { 18 | const globalState = state.context?.globalState; 19 | 20 | if (!globalState) return; 21 | 22 | const previous = globalState.get("version") ?? "0.0.0"; 23 | 24 | if (previous !== Environment.version) { 25 | await globalState.update("version", Environment.version); 26 | } 27 | 28 | const newerVersions = Array.from(migrations.keys()).filter((candidate) => 29 | shouldMigrate(candidate, previous), 30 | ); 31 | 32 | if (newerVersions.length === 0) return; 33 | 34 | try { 35 | for await (const version of newerVersions) { 36 | await migrations.get(version)!(previous); 37 | } 38 | } catch (error) { 39 | Logger.error(error); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/services/oauth.ts: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | import express, { Request } from "express"; 3 | import { URLSearchParams } from "url"; 4 | import { Environment, Logger, Webview } from "~/services"; 5 | 6 | type Provider = "github" | "gitlab" | "bitbucket"; 7 | 8 | export namespace OAuth { 9 | export const listen = async ( 10 | port: number, 11 | provider: Provider, 12 | ): Promise => { 13 | try { 14 | const app = express().use( 15 | express.json(), 16 | express.urlencoded({ extended: false }), 17 | ); 18 | 19 | const server = app.listen(port); 20 | 21 | app.get("/implicit", async (request) => { 22 | const token = request.query.token as string; 23 | const user = await getUser(token, provider); 24 | 25 | if (!user) return; 26 | 27 | Webview.openRepositoryCreationPage({ token, user, provider }); 28 | }); 29 | 30 | app.get("/callback", async (request, response) => { 31 | try { 32 | const data = await handleRequest(request, provider); 33 | 34 | response.send(` 35 | 36 | 37 | 38 | 39 |

Success! You may now close tab.

40 | 41 | 42 | 43 | 44 | `); 45 | 46 | server.close(); 47 | 48 | if (!data) return; 49 | 50 | const { user, token } = data; 51 | 52 | if (!user || !token) return; 53 | 54 | Webview.openRepositoryCreationPage({ token, user, provider }); 55 | } catch (error) { 56 | Logger.error(error); 57 | } 58 | }); 59 | } catch (error) { 60 | Logger.error(error); 61 | } 62 | }; 63 | 64 | const getUser = async ( 65 | token: string, 66 | provider: Provider, 67 | ): Promise => { 68 | try { 69 | const urls = { 70 | github: `https://api.github.com/user`, 71 | gitlab: `https://gitlab.com/api/v4/user`, 72 | bitbucket: `https://api.bitbucket.org/2.0/user`, 73 | }; 74 | 75 | const authHeader = { 76 | github: `token ${token}`, 77 | gitlab: `Bearer ${token}`, 78 | bitbucket: `Bearer ${token}`, 79 | }; 80 | 81 | const data = await got(urls[provider], { 82 | headers: { Authorization: authHeader[provider] }, 83 | }).json(); 84 | 85 | switch (provider) { 86 | case "github": 87 | return data.login; 88 | case "gitlab": 89 | case "bitbucket": 90 | return data.username; 91 | default: 92 | return ""; 93 | } 94 | } catch (error) { 95 | Logger.error(error); 96 | return ""; 97 | } 98 | }; 99 | 100 | const getToken = async (code: string): Promise => { 101 | try { 102 | const data = await got 103 | .post(`https://github.com/login/oauth/access_token`, { 104 | json: { 105 | code, 106 | client_id: Environment.oauthClientIds.github, 107 | client_secret: "3ac123310971a75f0a26e979ce0030467fc32682", 108 | }, 109 | }) 110 | .text(); 111 | 112 | return new URLSearchParams(data).get("access_token")!; 113 | } catch (error) { 114 | Logger.error(error); 115 | return ""; 116 | } 117 | }; 118 | 119 | const handleRequest = async ( 120 | request: Request, 121 | provider: Provider, 122 | ): Promise<{ token?: string; user?: string } | undefined> => { 123 | if (provider !== "github") return; 124 | 125 | const token = await getToken(request.query.code as string); 126 | 127 | if (!token) return; 128 | 129 | const user = await getUser(token, provider); 130 | 131 | return { token, user }; 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /src/services/pragma.ts: -------------------------------------------------------------------------------- 1 | import { uncomment, comment } from "jsonc-pragma"; 2 | import { Environment } from "~/services"; 3 | 4 | export namespace Pragma { 5 | export const incoming = (content: string, hostname?: string): string => { 6 | return uncomment(content, (section) => { 7 | if (section.name !== "sync") return false; 8 | 9 | const checks: boolean[] = []; 10 | 11 | const { host, os, env } = section.args; 12 | 13 | if (host) checks.push(host === hostname); 14 | if (os) checks.push(os === Environment.os); 15 | if (env) checks.push(Boolean(process.env[env])); 16 | 17 | return checks.every(Boolean); 18 | }); 19 | }; 20 | 21 | export const outgoing = (content: string): string => { 22 | return comment(content, (section) => section.name === "sync"); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/services/profile.ts: -------------------------------------------------------------------------------- 1 | import { commands, QuickPickItem, window } from "vscode"; 2 | import { localize, Logger, Settings } from "~/services"; 3 | 4 | export namespace Profile { 5 | export const switchProfile = async (profile?: string): Promise => { 6 | const { profiles, currentProfile } = await Settings.get((s) => s.repo); 7 | 8 | const newProfile = await (async () => { 9 | if (profile) { 10 | return profiles.find((p) => p.name === profile); 11 | } 12 | 13 | const selected = await window.showQuickPick( 14 | profiles.map((p) => ({ 15 | label: p.name === currentProfile ? `${p.name} [current]` : p.name, 16 | description: p.branch, 17 | })), 18 | { 19 | placeHolder: localize("(prompt) profile -> switch -> placeholder"), 20 | }, 21 | ); 22 | 23 | if (!selected) return; 24 | 25 | return profiles.find((p) => p.name === selected.label); 26 | })(); 27 | 28 | if (!newProfile) return; 29 | 30 | Logger.debug(`Switching to profile:`, newProfile.name); 31 | 32 | await Settings.set({ 33 | repo: { 34 | currentProfile: newProfile.name, 35 | }, 36 | }); 37 | 38 | const result = await window.showInformationMessage( 39 | localize("(info) repo -> switchedProfile", newProfile.name), 40 | localize("(label) yes"), 41 | ); 42 | 43 | if (result) await commands.executeCommand("syncify.download"); 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/services/settings.ts: -------------------------------------------------------------------------------- 1 | import cloneDeep from "lodash/cloneDeep"; 2 | import { DeepPartial } from "utility-types"; 3 | import { commands, ViewColumn, window, workspace } from "vscode"; 4 | import { defaultSettings, ISettings } from "~/models"; 5 | import { 6 | Environment, 7 | FS, 8 | localize, 9 | Logger, 10 | Watcher, 11 | Webview, 12 | } from "~/services"; 13 | import { confirm, merge, stringifyPretty } from "~/utilities"; 14 | 15 | export namespace Settings { 16 | type SettingsGet = { 17 | (): Promise; 18 | (selector: (s: ISettings) => T): Promise; 19 | }; 20 | 21 | export const get: SettingsGet = async ( 22 | selector?: (s: ISettings) => T, 23 | ): Promise => { 24 | const exists = await FS.exists(Environment.settings); 25 | 26 | if (!exists) { 27 | await FS.mkdir(Environment.globalStoragePath); 28 | await FS.write(Environment.settings, stringifyPretty(defaultSettings)); 29 | 30 | if (selector) return cloneDeep(selector(defaultSettings)); 31 | 32 | return cloneDeep(defaultSettings); 33 | } 34 | 35 | try { 36 | const contents = await FS.read(Environment.settings); 37 | const settings = JSON.parse(contents); 38 | 39 | const merged = merge(defaultSettings, settings); 40 | 41 | if (selector) return cloneDeep(selector(merged)); 42 | 43 | return cloneDeep(merged); 44 | } catch (error) { 45 | Logger.error(error); 46 | 47 | if (selector) return cloneDeep(selector(defaultSettings)); 48 | 49 | return cloneDeep(defaultSettings); 50 | } 51 | }; 52 | 53 | export const set = async ( 54 | settings: DeepPartial, 55 | ): Promise => { 56 | const exists = await FS.exists(Environment.globalStoragePath); 57 | if (!exists) await FS.mkdir(Environment.globalStoragePath); 58 | 59 | const currentSettings = await get(); 60 | 61 | await FS.write( 62 | Environment.settings, 63 | stringifyPretty(merge(currentSettings, settings)), 64 | ); 65 | 66 | await commands.executeCommand("syncify.reinitialize"); 67 | }; 68 | 69 | export const open = async (): Promise => { 70 | Webview.openSettingsPage(await get()); 71 | }; 72 | 73 | export const openFile = async (): Promise => { 74 | await window.showTextDocument( 75 | await workspace.openTextDocument(Environment.settings), 76 | ViewColumn.One, 77 | true, 78 | ); 79 | }; 80 | 81 | export const reset = async (): Promise => { 82 | const userIsSure = await confirm("settings -> reset"); 83 | 84 | if (!userIsSure) return; 85 | 86 | Watcher.stop(); 87 | 88 | await FS.remove(Environment.globalStoragePath); 89 | 90 | await commands.executeCommand("syncify.reinitialize"); 91 | 92 | await window.showInformationMessage(localize("(info) reset -> complete")); 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /src/services/watcher.ts: -------------------------------------------------------------------------------- 1 | import { relative } from "path"; 2 | import { commands, Disposable, extensions, window } from "vscode"; 3 | import chokidar, { FSWatcher } from "vscode-chokidar"; 4 | import { Environment, localize, Logger, Settings } from "~/services"; 5 | import { sleep } from "~/utilities"; 6 | 7 | export namespace Watcher { 8 | let disposable: Disposable | undefined; 9 | let watcher: FSWatcher | undefined; 10 | 11 | export const init = (ignoredItems: string[]): void => { 12 | if (watcher) watcher.close(); 13 | 14 | watcher = chokidar.watch([], { 15 | ignored: ignoredItems, 16 | }); 17 | }; 18 | 19 | export const start = (): void => { 20 | if (!watcher) return; 21 | 22 | stop(); 23 | 24 | watcher.add(Environment.userFolder); 25 | watcher.on("change", async (path) => { 26 | Logger.debug(`File change: ${relative(Environment.userFolder, path)}`); 27 | 28 | return upload(); 29 | }); 30 | 31 | disposable = extensions.onDidChange(async () => { 32 | Logger.debug("Extension installed/uninstalled"); 33 | 34 | return upload(); 35 | }); 36 | }; 37 | 38 | export const stop = (): void => { 39 | if (watcher) watcher.close(); 40 | 41 | if (disposable) { 42 | disposable.dispose(); 43 | disposable = undefined; 44 | } 45 | }; 46 | 47 | const upload = async (): Promise => { 48 | if (!window.state.focused) return; 49 | 50 | const cmds = await commands.getCommands(); 51 | 52 | if (cmds.includes("syncify.cancelUpload")) return; 53 | 54 | const delay = await Settings.get((s) => s.autoUploadDelay); 55 | 56 | let shouldUpload = true; 57 | 58 | const message = window.setStatusBarMessage( 59 | localize("(info) watcher -> initiating", delay.toString()), 60 | 5000, 61 | ); 62 | 63 | const btn = window.createStatusBarItem(1); 64 | 65 | const disposable = commands.registerCommand("syncify.cancelUpload", () => { 66 | shouldUpload = false; 67 | disposable.dispose(); 68 | btn.dispose(); 69 | message.dispose(); 70 | }); 71 | 72 | btn.command = "syncify.cancelUpload"; 73 | btn.text = `$(x) ${localize("(command) cancelUpload")}`; 74 | btn.show(); 75 | 76 | await sleep(delay * 1000); 77 | 78 | disposable.dispose(); 79 | btn.dispose(); 80 | 81 | if (shouldUpload) await commands.executeCommand("syncify.upload"); 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /src/services/webview.ts: -------------------------------------------------------------------------------- 1 | import set from "lodash/set"; 2 | import { resolve } from "path"; 3 | import { 4 | commands, 5 | env, 6 | Uri, 7 | ViewColumn, 8 | WebviewOptions, 9 | WebviewPanel, 10 | WebviewPanelOptions, 11 | window, 12 | } from "vscode"; 13 | import { ISettings, WebviewSection, Syncers, UISettingType } from "~/models"; 14 | import { 15 | Environment, 16 | FS, 17 | localize, 18 | OAuth, 19 | Settings, 20 | Watcher, 21 | } from "~/services"; 22 | import { merge } from "~/utilities"; 23 | 24 | // eslint-disable-next-line import/extensions 25 | import webviewContent from "~/../assets/ui/index.html"; 26 | 27 | export namespace Webview { 28 | export const openSettingsPage = (settings: ISettings): WebviewPanel => { 29 | return createPanel({ 30 | data: { 31 | settings, 32 | sections: generateSections(settings), 33 | }, 34 | id: "settings", 35 | title: "Syncify Settings", 36 | onMessage: async (message) => { 37 | if (message === "edit") return Settings.openFile(); 38 | 39 | const curSettings = await Settings.get(); 40 | 41 | return Settings.set(set(curSettings, message.setting, message.value)); 42 | }, 43 | }); 44 | }; 45 | 46 | export const openErrorPage = (error: Error): WebviewPanel => { 47 | return createPanel({ 48 | data: error.message, 49 | id: "error", 50 | title: "Syncify Error", 51 | }); 52 | }; 53 | 54 | export const openLandingPage = (): WebviewPanel => { 55 | return createPanel({ 56 | id: "landing", 57 | title: "Welcome to Syncify", 58 | onMessage: async (message: string) => { 59 | const settings = await Settings.get(); 60 | 61 | switch (message) { 62 | case "gitlab": 63 | case "bitbucket": 64 | case "github": { 65 | const clientIds = Environment.oauthClientIds; 66 | 67 | const authUrls = { 68 | github: `https://github.com/login/oauth/authorize?scope=repo%20read:user&client_id=${clientIds.github}`, 69 | gitlab: `https://gitlab.com/oauth/authorize?client_id=${clientIds.gitlab}&redirect_uri=http://localhost:37468/callback&response_type=token&scope=api+read_repository+read_user+write_repository`, 70 | bitbucket: `https://bitbucket.org/site/oauth2/authorize?client_id=${clientIds.bitbucket}&response_type=token`, 71 | }; 72 | 73 | await OAuth.listen(37468, message); 74 | 75 | return env.openExternal(Uri.parse(authUrls[message])); 76 | } 77 | 78 | case "settings": 79 | return openSettingsPage(settings); 80 | 81 | case "nologin": { 82 | const result = await window.showInputBox({ 83 | placeHolder: localize( 84 | "(prompt) webview -> landingPage -> nologin", 85 | ), 86 | }); 87 | 88 | if (!result) return; 89 | 90 | const currentSettings = await Settings.get(); 91 | 92 | Watcher.stop(); 93 | 94 | await FS.remove(Environment.globalStoragePath); 95 | 96 | await Settings.set({ repo: { url: result } }); 97 | 98 | await commands.executeCommand("syncify.download"); 99 | 100 | Watcher.stop(); 101 | 102 | await FS.remove(Environment.globalStoragePath); 103 | 104 | return Settings.set(currentSettings); 105 | } 106 | 107 | default: 108 | break; 109 | } 110 | }, 111 | }); 112 | }; 113 | 114 | export const openRepositoryCreationPage = (options: { 115 | token: string; 116 | user: string; 117 | provider: string; 118 | }): WebviewPanel => { 119 | return createPanel({ 120 | id: "repo", 121 | title: "Configure Repository", 122 | data: options, 123 | onMessage: async (message) => { 124 | if (message.close && pages.repo) return pages.repo.dispose(); 125 | 126 | await Settings.set({ 127 | repo: { 128 | url: message, 129 | }, 130 | }); 131 | }, 132 | }); 133 | }; 134 | 135 | const pages = { 136 | landing: undefined as WebviewPanel | undefined, 137 | repo: undefined as WebviewPanel | undefined, 138 | settings: undefined as WebviewPanel | undefined, 139 | error: undefined as WebviewPanel | undefined, 140 | }; 141 | 142 | const createPanel = (options: { 143 | id: keyof typeof pages; 144 | data?: any; 145 | title: string; 146 | viewColumn?: ViewColumn; 147 | options?: WebviewPanelOptions & WebviewOptions; 148 | onMessage?: (message: any) => any; 149 | }): WebviewPanel => { 150 | const { id, data = "" } = options; 151 | 152 | const page = pages[id]; 153 | 154 | const pwdUri = Uri.file(resolve(Environment.extensionPath, "assets/ui")); 155 | 156 | if (page) { 157 | page.webview.html = generateContent( 158 | page.webview.asWebviewUri(pwdUri).toString(), 159 | page.webview.cspSource, 160 | id, 161 | data, 162 | ); 163 | 164 | page.reveal(); 165 | return page; 166 | } 167 | 168 | const defaultOptions = { 169 | retainContextWhenHidden: true, 170 | enableScripts: true, 171 | }; 172 | 173 | const panel = window.createWebviewPanel( 174 | id, 175 | options.title, 176 | options.viewColumn ?? ViewColumn.One, 177 | merge(defaultOptions, options.options ?? {}), 178 | ); 179 | 180 | panel.webview.html = generateContent( 181 | panel.webview.asWebviewUri(pwdUri).toString(), 182 | panel.webview.cspSource, 183 | id, 184 | data, 185 | ); 186 | 187 | if (options.onMessage) panel.webview.onDidReceiveMessage(options.onMessage); 188 | 189 | panel.onDidDispose(() => { 190 | pages[id] = undefined; 191 | }); 192 | 193 | pages[id] = panel; 194 | return panel; 195 | }; 196 | 197 | const generateContent = ( 198 | pwd: string, 199 | csp: string, 200 | id: string, 201 | data: any, 202 | ): string => { 203 | return webviewContent 204 | .replace(/@PWD/g, pwd) 205 | .replace(/@CSP/g, csp) 206 | .replace(/@PAGE/g, id) 207 | .replace(/@DATA/g, encodeURIComponent(JSON.stringify(data))); 208 | }; 209 | 210 | const generateSections = (settings: ISettings): WebviewSection[] => { 211 | return [ 212 | { 213 | name: "General", 214 | settings: [ 215 | { 216 | name: localize("(setting) syncer -> name"), 217 | correspondingSetting: "syncer", 218 | type: UISettingType.Select, 219 | options: Object.entries(Syncers).map(([key, value]) => ({ 220 | value, 221 | name: key, 222 | })), 223 | }, 224 | { 225 | name: localize("(setting) hostname -> name"), 226 | placeholder: localize("(setting) hostname -> placeholder"), 227 | correspondingSetting: "hostname", 228 | type: UISettingType.TextInput, 229 | }, 230 | { 231 | name: localize("(setting) ignoredItems -> name"), 232 | placeholder: localize("(setting) ignoredItems -> placeholder"), 233 | correspondingSetting: "ignoredItems", 234 | type: UISettingType.TextArea, 235 | }, 236 | { 237 | name: localize("(setting) autoUploadDelay -> name"), 238 | placeholder: localize("(setting) autoUploadDelay -> placeholder"), 239 | correspondingSetting: "autoUploadDelay", 240 | type: UISettingType.NumberInput, 241 | min: 0, 242 | }, 243 | { 244 | name: localize("(setting) watchSettings -> name"), 245 | correspondingSetting: "watchSettings", 246 | type: UISettingType.Checkbox, 247 | }, 248 | { 249 | name: localize("(setting) syncOnStartup -> name"), 250 | correspondingSetting: "syncOnStartup", 251 | type: UISettingType.Checkbox, 252 | }, 253 | { 254 | name: localize("(setting) forceUpload -> name"), 255 | correspondingSetting: "forceUpload", 256 | type: UISettingType.Checkbox, 257 | }, 258 | { 259 | name: localize("(setting) forceDownload -> name"), 260 | correspondingSetting: "forceDownload", 261 | type: UISettingType.Checkbox, 262 | }, 263 | ], 264 | }, 265 | { 266 | name: "Repo Syncer", 267 | settings: [ 268 | { 269 | name: localize("(setting) repo.url -> name"), 270 | placeholder: localize("(setting) repo.url -> placeholder"), 271 | correspondingSetting: "repo.url", 272 | type: UISettingType.TextInput, 273 | }, 274 | { 275 | name: localize("(setting) repo.currentProfile -> name"), 276 | correspondingSetting: "repo.currentProfile", 277 | type: UISettingType.Select, 278 | options: settings.repo.profiles.map((p) => ({ 279 | name: `${p.name} [branch: ${p.branch}]`, 280 | value: p.name, 281 | })), 282 | }, 283 | { 284 | name: localize("(setting) repo.profiles -> name"), 285 | correspondingSetting: "repo.profiles", 286 | type: UISettingType.ObjectArray, 287 | newTemplate: { 288 | branch: "", 289 | name: "", 290 | }, 291 | schema: [ 292 | { 293 | name: localize( 294 | "(setting) repo.profiles.properties.name -> name", 295 | ), 296 | placeholder: localize( 297 | "(setting) repo.profiles.properties.name -> placeholder", 298 | ), 299 | correspondingSetting: "name", 300 | type: UISettingType.TextInput, 301 | }, 302 | { 303 | name: localize( 304 | "(setting) repo.profiles.properties.branch -> name", 305 | ), 306 | placeholder: localize( 307 | "(setting) repo.profiles.properties.branch -> placeholder", 308 | ), 309 | correspondingSetting: "branch", 310 | type: UISettingType.TextInput, 311 | }, 312 | ], 313 | }, 314 | ], 315 | }, 316 | { 317 | name: "File Syncer", 318 | settings: [ 319 | { 320 | name: localize("(setting) file.path -> name"), 321 | placeholder: localize("(setting) file.path -> placeholder"), 322 | correspondingSetting: "file.path", 323 | type: UISettingType.TextInput, 324 | }, 325 | ], 326 | }, 327 | ]; 328 | }; 329 | } 330 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode"; 2 | 3 | type State = { 4 | context?: ExtensionContext; 5 | }; 6 | 7 | const state: State = {}; 8 | 9 | export default state; 10 | -------------------------------------------------------------------------------- /src/syncers/file.ts: -------------------------------------------------------------------------------- 1 | import { dirname, relative, resolve } from "path"; 2 | import { commands, extensions, ProgressLocation, window } from "vscode"; 3 | import { ISettings, Syncer } from "~/models"; 4 | import { 5 | Environment, 6 | Extensions, 7 | FS, 8 | localize, 9 | Logger, 10 | Pragma, 11 | Settings, 12 | Watcher, 13 | Webview, 14 | } from "~/services"; 15 | import { sleep, stringifyPretty } from "~/utilities"; 16 | 17 | export class FileSyncer implements Syncer { 18 | async sync(): Promise { 19 | await window.showInformationMessage( 20 | "Syncify: Sync is not available for File Syncer yet", 21 | ); 22 | } 23 | 24 | async upload(): Promise { 25 | const settings = await Settings.get(); 26 | Watcher.stop(); 27 | 28 | await window.withProgress( 29 | { location: ProgressLocation.Window }, 30 | async (progress) => { 31 | try { 32 | const configured = await this.isConfigured(); 33 | if (!configured) { 34 | Webview.openLandingPage(); 35 | return; 36 | } 37 | 38 | progress.report({ message: localize("(info) sync -> uploading") }); 39 | 40 | const installedExtensions = Extensions.get(); 41 | 42 | await FS.write( 43 | resolve(settings.file.path, "extensions.json"), 44 | stringifyPretty(installedExtensions), 45 | ); 46 | 47 | await this.copyFilesToPath(settings); 48 | 49 | progress.report({ increment: 100 }); 50 | 51 | await sleep(10); 52 | 53 | window.setStatusBarMessage(localize("(info) sync -> uploaded"), 2000); 54 | } catch (error) { 55 | Logger.error(error); 56 | } 57 | }, 58 | ); 59 | 60 | if (settings.watchSettings) Watcher.start(); 61 | } 62 | 63 | async download(): Promise { 64 | const settings = await Settings.get(); 65 | Watcher.stop(); 66 | 67 | await window.withProgress( 68 | { location: ProgressLocation.Window }, 69 | async (progress) => { 70 | try { 71 | const configured = await this.isConfigured(); 72 | if (!configured) { 73 | Webview.openLandingPage(); 74 | return; 75 | } 76 | 77 | progress.report({ message: localize("(info) sync -> downloading") }); 78 | 79 | await this.copyFilesFromPath(settings); 80 | 81 | const extensionsFromFile = await (async () => { 82 | const path = resolve(settings.file.path, "extensions.json"); 83 | 84 | const extensionsExist = await FS.exists(path); 85 | 86 | if (!extensionsExist) return []; 87 | 88 | return JSON.parse(await FS.read(path)); 89 | })(); 90 | 91 | Logger.debug( 92 | "Extensions parsed from downloaded file:", 93 | extensionsFromFile, 94 | ); 95 | 96 | await Extensions.install( 97 | ...Extensions.getMissing(extensionsFromFile), 98 | ); 99 | 100 | const toDelete = Extensions.getUnneeded(extensionsFromFile); 101 | 102 | if (toDelete.length !== 0) { 103 | const needToReload = toDelete.some( 104 | (name) => extensions.getExtension(name)?.isActive ?? false, 105 | ); 106 | 107 | Logger.debug("Need to reload:", needToReload); 108 | 109 | await Extensions.uninstall(...toDelete); 110 | 111 | if (needToReload) { 112 | const result = await window.showInformationMessage( 113 | localize("(info) sync -> needToReload"), 114 | localize("(label) yes"), 115 | ); 116 | 117 | if (result) { 118 | await commands.executeCommand("workbench.action.reloadWindow"); 119 | } 120 | } 121 | } 122 | 123 | progress.report({ increment: 100 }); 124 | 125 | await sleep(10); 126 | 127 | window.setStatusBarMessage( 128 | localize("(info) sync -> downloaded"), 129 | 2000, 130 | ); 131 | } catch (error) { 132 | Logger.error(error); 133 | } 134 | }, 135 | ); 136 | 137 | if (settings.watchSettings) Watcher.start(); 138 | } 139 | 140 | async isConfigured(): Promise { 141 | const path = await Settings.get((s) => s.file.path); 142 | 143 | if (!path) return false; 144 | 145 | await FS.mkdir(path); 146 | 147 | return true; 148 | } 149 | 150 | private async copyFilesToPath(settings: ISettings): Promise { 151 | try { 152 | const files = await FS.listFiles(Environment.userFolder); 153 | 154 | Logger.debug( 155 | "Files to copy to folder:", 156 | files.map((f) => relative(Environment.userFolder, f)), 157 | ); 158 | 159 | await Promise.all( 160 | files.map(async (file) => { 161 | const newPath = resolve( 162 | settings.file.path, 163 | relative(Environment.userFolder, file), 164 | ); 165 | 166 | await FS.mkdir(dirname(newPath)); 167 | 168 | if (file.endsWith(".json")) { 169 | return FS.write(newPath, Pragma.outgoing(await FS.read(file))); 170 | } 171 | 172 | return FS.copy(file, newPath); 173 | }), 174 | ); 175 | } catch (error) { 176 | Logger.error(error); 177 | } 178 | } 179 | 180 | private async copyFilesFromPath(settings: ISettings): Promise { 181 | try { 182 | const files = await FS.listFiles(settings.file.path); 183 | 184 | Logger.debug( 185 | "Files to copy from folder:", 186 | files.map((f) => relative(settings.file.path, f)), 187 | ); 188 | 189 | await Promise.all( 190 | files.map(async (file) => { 191 | const newPath = resolve( 192 | Environment.userFolder, 193 | relative(settings.file.path, file), 194 | ); 195 | 196 | await FS.mkdir(dirname(newPath)); 197 | 198 | if (file.endsWith(".json")) { 199 | const currentContents = await (async () => { 200 | if (await FS.exists(newPath)) return FS.read(newPath); 201 | 202 | return "{}"; 203 | })(); 204 | 205 | const afterPragma = Pragma.incoming( 206 | await FS.read(file), 207 | settings.hostname, 208 | ); 209 | 210 | if (currentContents !== afterPragma) { 211 | return FS.write(newPath, afterPragma); 212 | } 213 | 214 | return; 215 | } 216 | 217 | return FS.copy(file, newPath); 218 | }), 219 | ); 220 | } catch (error) { 221 | Logger.error(error); 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/syncers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/syncers/file"; 2 | export * from "~/syncers/repo"; 3 | -------------------------------------------------------------------------------- /src/syncers/repo.ts: -------------------------------------------------------------------------------- 1 | import { basename, dirname, relative, resolve } from "path"; 2 | import createSimpleGit, { SimpleGit } from "simple-git/promise"; 3 | import { 4 | commands, 5 | extensions, 6 | ProgressLocation, 7 | ViewColumn, 8 | window, 9 | workspace, 10 | } from "vscode"; 11 | import { Profile, ISettings, Syncer } from "~/models"; 12 | import { 13 | Environment, 14 | Extensions, 15 | FS, 16 | localize, 17 | Logger, 18 | Pragma, 19 | Settings, 20 | Watcher, 21 | Webview, 22 | } from "~/services"; 23 | import { checkGit, sleep, stringifyPretty } from "~/utilities"; 24 | 25 | export class RepoSyncer implements Syncer { 26 | private readonly git: SimpleGit = createSimpleGit().silent(true); 27 | 28 | async init(): Promise { 29 | try { 30 | await FS.mkdir(Environment.repoFolder); 31 | 32 | await this.git.cwd(Environment.repoFolder); 33 | 34 | await this.git.init(); 35 | 36 | const remotes = await this.git.getRemotes(true); 37 | const origin = remotes.find((remote) => remote.name === "origin"); 38 | 39 | const repoUrl = await Settings.get((s) => s.repo.url); 40 | 41 | if (!origin) { 42 | Logger.debug(`Adding new remote "origin" at "${repoUrl}"`); 43 | 44 | await this.git.addRemote("origin", repoUrl); 45 | } else if (origin.refs.push !== repoUrl) { 46 | Logger.debug( 47 | `Wrong remote url for "origin", removing and adding new origin at "${repoUrl}"`, 48 | ); 49 | 50 | await this.git.removeRemote("origin"); 51 | await this.git.addRemote("origin", repoUrl); 52 | } 53 | } catch (error) { 54 | Logger.error(error); 55 | } 56 | } 57 | 58 | async sync(): Promise { 59 | try { 60 | if (!(await this.isConfigured())) { 61 | Webview.openLandingPage(); 62 | return; 63 | } 64 | 65 | await this.init(); 66 | 67 | const [profile, settings] = await Promise.all([ 68 | this.getProfile(), 69 | Settings.get(), 70 | this.git.fetch(), 71 | this.copyFilesToRepo(), 72 | ]); 73 | 74 | const status = await this.getStatus(settings, profile); 75 | 76 | Logger.debug(`Current git status: ${status}`); 77 | 78 | const diff = await this.git.diff(); 79 | 80 | if (diff && status !== "behind") return await this.upload(); 81 | 82 | if (status === "behind") return await this.download(); 83 | 84 | window.setStatusBarMessage(localize("(info) sync -> nothingToDo"), 2000); 85 | } catch (error) { 86 | Logger.error(error); 87 | } 88 | } 89 | 90 | async upload(): Promise { 91 | const settings = await Settings.get(); 92 | Watcher.stop(); 93 | 94 | await window.withProgress( 95 | { location: ProgressLocation.Window }, 96 | async (progress) => { 97 | try { 98 | if (!(await this.isConfigured())) { 99 | Webview.openLandingPage(); 100 | return; 101 | } 102 | 103 | await this.init(); 104 | 105 | progress.report({ message: localize("(info) sync -> uploading") }); 106 | 107 | const profile = await this.getProfile(); 108 | 109 | await this.git.fetch(); 110 | 111 | const status = await this.getStatus(settings, profile); 112 | 113 | Logger.debug(`Current git status: ${status}`); 114 | 115 | if (status === "behind" && !settings.forceUpload) { 116 | progress.report({ increment: 100 }); 117 | 118 | await sleep(10); 119 | 120 | return window.setStatusBarMessage( 121 | localize("(info) repo -> remoteChanges"), 122 | 2000, 123 | ); 124 | } 125 | 126 | const branchExists = await this.localBranchExists(profile.branch); 127 | 128 | if (!branchExists) { 129 | Logger.debug( 130 | `Branch "${profile.branch}" does not exist, creating new branch...`, 131 | ); 132 | 133 | await this.git.checkout(["-b", profile.branch]); 134 | } 135 | 136 | await this.copyFilesToRepo(); 137 | await this.cleanUpRepo(); 138 | 139 | const installedExtensions = Extensions.get(); 140 | 141 | Logger.debug("Installed extensions:", installedExtensions); 142 | 143 | await FS.write( 144 | resolve(Environment.repoFolder, "extensions.json"), 145 | stringifyPretty(installedExtensions), 146 | ); 147 | 148 | const currentChanges = await this.git.diff(); 149 | 150 | if (!currentChanges && !settings.forceUpload && branchExists) { 151 | progress.report({ increment: 100 }); 152 | 153 | await sleep(10); 154 | 155 | return window.setStatusBarMessage( 156 | localize("(info) repo -> remoteUpToDate"), 157 | 2000, 158 | ); 159 | } 160 | 161 | await this.git.add("."); 162 | await this.git.commit(`Update [${new Date().toLocaleString()}]`); 163 | await this.git.push("origin", profile.branch); 164 | 165 | progress.report({ increment: 100 }); 166 | 167 | await sleep(10); 168 | 169 | window.setStatusBarMessage(localize("(info) sync -> uploaded"), 2000); 170 | } catch (error) { 171 | Logger.error(error); 172 | } 173 | }, 174 | ); 175 | 176 | if (settings.watchSettings) Watcher.start(); 177 | } 178 | 179 | async download(): Promise { 180 | const settings = await Settings.get(); 181 | Watcher.stop(); 182 | 183 | await window.withProgress( 184 | { location: ProgressLocation.Window }, 185 | async (progress) => { 186 | try { 187 | if (!(await this.isConfigured())) { 188 | Webview.openLandingPage(); 189 | return; 190 | } 191 | 192 | await this.init(); 193 | 194 | progress.report({ 195 | message: localize("(info) sync -> downloading"), 196 | }); 197 | 198 | const profile = await this.getProfile(); 199 | 200 | await this.git.fetch(); 201 | 202 | const remoteBranches = await this.git.branch(["-r"]); 203 | 204 | Logger.debug("Remote branches:", remoteBranches.all); 205 | 206 | if (remoteBranches.all.length === 0) { 207 | progress.report({ increment: 100 }); 208 | 209 | await sleep(10); 210 | 211 | return window.setStatusBarMessage( 212 | localize("(info) repo -> noRemoteBranches"), 213 | 2000, 214 | ); 215 | } 216 | 217 | const diff = await this.git.diff([`origin/${profile.branch}`]); 218 | 219 | if (!diff && !settings.forceDownload) { 220 | progress.report({ increment: 100 }); 221 | 222 | await sleep(10); 223 | 224 | return window.setStatusBarMessage( 225 | localize("(info) repo -> upToDate"), 226 | 2000, 227 | ); 228 | } 229 | 230 | await this.copyFilesToRepo(); 231 | 232 | const installedExtensions = Extensions.get(); 233 | 234 | await FS.write( 235 | resolve(Environment.repoFolder, "extensions.json"), 236 | stringifyPretty(installedExtensions), 237 | ); 238 | 239 | const branches = await this.git.branchLocal(); 240 | 241 | Logger.debug("Local branches:", branches.all); 242 | 243 | await this.git.fetch(); 244 | 245 | if (!branches.current) { 246 | Logger.debug(`First download, checking out ${profile.branch}`); 247 | 248 | await this.git.clean("f"); 249 | await this.git.checkout(["-f", profile.branch]); 250 | } else if (!branches.all.includes(profile.branch)) { 251 | Logger.debug( 252 | `Checking out remote branch "origin/${profile.branch}"`, 253 | ); 254 | 255 | await this.git.clean("f"); 256 | await this.git.checkout([ 257 | "-f", 258 | "-b", 259 | profile.branch, 260 | `origin/${profile.branch}`, 261 | ]); 262 | } else if (branches.current !== profile.branch) { 263 | Logger.debug(`Branch exists, switching to ${profile.branch}`); 264 | 265 | if (await checkGit("2.23.0")) { 266 | Logger.debug(`Git version is >=2.23.0, using git-switch`); 267 | 268 | await this.git.raw(["switch", "-f", profile.branch]); 269 | } else { 270 | Logger.debug(`Git version is <2.23.0, not using git-switch`); 271 | 272 | await this.git.reset(["--hard", "HEAD"]); 273 | await this.git.checkout(["-f", profile.branch]); 274 | } 275 | } 276 | 277 | await this.git.stash(); 278 | 279 | await this.git.pull("origin", profile.branch); 280 | 281 | const stashList = await this.git.stashList(); 282 | 283 | if (stashList.total > 0) { 284 | Logger.debug("Reapplying local changes"); 285 | 286 | await this.git.stash(["pop"]); 287 | } 288 | 289 | await this.copyFilesFromRepo(settings); 290 | await this.cleanUpUser(); 291 | 292 | const extensionsFromFile = JSON.parse( 293 | await FS.read(resolve(Environment.userFolder, "extensions.json")), 294 | ); 295 | 296 | Logger.debug( 297 | "Extensions parsed from downloaded file:", 298 | extensionsFromFile, 299 | ); 300 | 301 | await Extensions.install( 302 | ...Extensions.getMissing(extensionsFromFile), 303 | ); 304 | 305 | const toDelete = Extensions.getUnneeded(extensionsFromFile); 306 | 307 | Logger.debug("Extensions to delete:", toDelete); 308 | 309 | if (toDelete.length !== 0) { 310 | const needToReload = toDelete.some( 311 | (name) => extensions.getExtension(name)?.isActive ?? false, 312 | ); 313 | 314 | Logger.debug("Need to reload:", needToReload); 315 | 316 | await Extensions.uninstall(...toDelete); 317 | 318 | if (needToReload) { 319 | const yes = localize("(label) yes"); 320 | const result = await window.showInformationMessage( 321 | localize("(info) sync -> needToReload"), 322 | yes, 323 | ); 324 | 325 | if (result === yes) { 326 | await commands.executeCommand("workbench.action.reloadWindow"); 327 | } 328 | } 329 | } 330 | 331 | progress.report({ increment: 100 }); 332 | 333 | await sleep(10); 334 | 335 | window.setStatusBarMessage( 336 | localize("(info) sync -> downloaded"), 337 | 2000, 338 | ); 339 | } catch (error) { 340 | Logger.error(error); 341 | } 342 | }, 343 | ); 344 | 345 | if (settings.watchSettings) Watcher.start(); 346 | } 347 | 348 | async isConfigured(): Promise { 349 | const { currentProfile, profiles, url } = await Settings.get((s) => s.repo); 350 | 351 | return ( 352 | Boolean(url) && 353 | Boolean(currentProfile) && 354 | Boolean(profiles.find(({ name }) => name === currentProfile)) 355 | ); 356 | } 357 | 358 | private async getProfile(): Promise { 359 | const { currentProfile, profiles } = await Settings.get((s) => s.repo); 360 | 361 | return profiles.find(({ name }) => name === currentProfile) ?? profiles[0]; 362 | } 363 | 364 | private async copyFilesToRepo(): Promise { 365 | try { 366 | const files = await FS.listFiles(Environment.userFolder); 367 | 368 | Logger.debug( 369 | "Files to copy to repo:", 370 | files.map((f) => relative(Environment.userFolder, f)), 371 | ); 372 | 373 | await Promise.all( 374 | files.map(async (file) => { 375 | const newPath = resolve( 376 | Environment.repoFolder, 377 | relative(Environment.userFolder, file), 378 | ); 379 | 380 | await FS.mkdir(dirname(newPath)); 381 | 382 | if (file.endsWith(".json")) { 383 | return FS.write(newPath, Pragma.outgoing(await FS.read(file))); 384 | } 385 | 386 | return FS.copy(file, newPath); 387 | }), 388 | ); 389 | } catch (error) { 390 | Logger.error(error); 391 | } 392 | } 393 | 394 | private async copyFilesFromRepo(settings: ISettings): Promise { 395 | try { 396 | const files = await FS.listFiles( 397 | Environment.repoFolder, 398 | settings.ignoredItems.filter( 399 | (i) => !i.includes(Environment.extensionId), 400 | ), 401 | ); 402 | 403 | Logger.debug( 404 | "Files to copy from repo:", 405 | files.map((f) => relative(Environment.repoFolder, f)), 406 | ); 407 | 408 | await Promise.all( 409 | files.map(async (file) => { 410 | let contents = await FS.readBuffer(file); 411 | 412 | const hasConflict = (c: string): boolean => { 413 | const regexes = [/^<{7}$/, /^={7}$/, /^>{7}$/]; 414 | 415 | return !c 416 | .split("\n") 417 | .every((v) => regexes.every((r) => !r.test(v))); 418 | }; 419 | 420 | if (hasConflict(contents.toString())) { 421 | await FS.mkdir(Environment.conflictsFolder); 422 | 423 | const temporaryPath = resolve( 424 | Environment.conflictsFolder, 425 | `${Math.random()}-${basename(file)}`, 426 | ); 427 | 428 | await FS.copy(file, temporaryPath); 429 | 430 | const doc = await workspace.openTextDocument(temporaryPath); 431 | 432 | await window.showTextDocument(doc, ViewColumn.One, true); 433 | 434 | await new Promise((resolve) => { 435 | const d = workspace.onDidSaveTextDocument((document) => { 436 | if ( 437 | document.fileName === doc.fileName && 438 | !hasConflict(document.getText()) 439 | ) { 440 | d.dispose(); 441 | resolve(); 442 | return commands.executeCommand( 443 | "workbench.action.closeActiveEditor", 444 | ); 445 | } 446 | }); 447 | }); 448 | 449 | contents = await FS.readBuffer(temporaryPath); 450 | 451 | await FS.remove(temporaryPath); 452 | } 453 | 454 | const newPath = resolve( 455 | Environment.userFolder, 456 | relative(Environment.repoFolder, file), 457 | ); 458 | 459 | await FS.mkdir(dirname(newPath)); 460 | 461 | if (file.endsWith(".json")) { 462 | const currentContents = await (async () => { 463 | if (await FS.exists(newPath)) return FS.read(newPath); 464 | 465 | return "{}"; 466 | })(); 467 | 468 | const afterPragma = Pragma.incoming( 469 | contents.toString(), 470 | settings.hostname, 471 | ); 472 | 473 | if (currentContents !== afterPragma) { 474 | return FS.write(newPath, afterPragma); 475 | } 476 | 477 | return; 478 | } 479 | 480 | return FS.write(newPath, contents); 481 | }), 482 | ); 483 | } catch (error) { 484 | Logger.error(error); 485 | } 486 | } 487 | 488 | private async cleanUpRepo(): Promise { 489 | try { 490 | const [repoFiles, userFiles] = await Promise.all([ 491 | FS.listFiles(Environment.repoFolder), 492 | FS.listFiles(Environment.userFolder), 493 | ]); 494 | 495 | Logger.debug( 496 | "Files in the repo folder:", 497 | repoFiles.map((f) => relative(Environment.repoFolder, f)), 498 | ); 499 | 500 | Logger.debug( 501 | "Files in the user folder:", 502 | userFiles.map((f) => relative(Environment.userFolder, f)), 503 | ); 504 | 505 | const unneeded = repoFiles.filter((f) => { 506 | const correspondingFile = resolve( 507 | Environment.userFolder, 508 | relative(Environment.repoFolder, f), 509 | ); 510 | return !userFiles.includes(correspondingFile); 511 | }); 512 | 513 | Logger.debug("Unneeded files:", unneeded); 514 | 515 | await FS.remove(...unneeded); 516 | } catch (error) { 517 | Logger.error(error); 518 | } 519 | } 520 | 521 | private async cleanUpUser(): Promise { 522 | try { 523 | const [repoFiles, userFiles] = await Promise.all([ 524 | FS.listFiles(Environment.repoFolder), 525 | FS.listFiles(Environment.userFolder), 526 | ]); 527 | 528 | Logger.debug( 529 | "Files in the repo folder:", 530 | repoFiles.map((f) => relative(Environment.repoFolder, f)), 531 | ); 532 | 533 | Logger.debug( 534 | "Files in the user folder:", 535 | userFiles.map((f) => relative(Environment.userFolder, f)), 536 | ); 537 | 538 | const unneeded = userFiles.filter((f) => { 539 | const correspondingFile = resolve( 540 | Environment.repoFolder, 541 | relative(Environment.userFolder, f), 542 | ); 543 | return !repoFiles.includes(correspondingFile); 544 | }); 545 | 546 | Logger.debug("Unneeded files:", unneeded); 547 | 548 | await FS.remove(...unneeded); 549 | } catch (error) { 550 | Logger.error(error); 551 | } 552 | } 553 | 554 | private async getStatus( 555 | settings: ISettings, 556 | profile: Profile, 557 | ): Promise<"ahead" | "behind" | "up-to-date"> { 558 | const { branch } = profile; 559 | const { url } = settings.repo; 560 | 561 | const lsRemote = await this.git.listRemote(["--heads", url, branch]); 562 | const localExists = await this.localBranchExists(branch); 563 | 564 | if (!lsRemote) return "ahead"; 565 | if (!localExists) return "behind"; 566 | 567 | const mergeBase = await this.git.raw([ 568 | `merge-base`, 569 | branch, 570 | `origin/${branch}`, 571 | ]); 572 | 573 | const revLocal = await this.git.raw([`rev-parse`, branch]); 574 | const revRemote = await this.git.raw([`rev-parse`, `origin/${branch}`]); 575 | 576 | if (revLocal === revRemote) return "up-to-date"; 577 | 578 | if (mergeBase === revRemote) return "ahead"; 579 | 580 | if (mergeBase === revLocal) return "behind"; 581 | 582 | return "up-to-date"; 583 | } 584 | 585 | private async localBranchExists(branch: string): Promise { 586 | const localBranches = await this.git.branchLocal(); 587 | return localBranches.all.includes(branch); 588 | } 589 | } 590 | -------------------------------------------------------------------------------- /src/tests/__mocks__/vscode.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | 3 | export const window = { 4 | createOutputChannel: () => ({ appendLine: () => undefined }), 5 | setStatusBarMessage: () => undefined, 6 | withProgress: (_: any, fn: any) => fn({ report: () => undefined }), 7 | showInformationMessage: () => undefined, 8 | showWarningMessage: () => undefined, 9 | showErrorMessage: () => undefined, 10 | showInputBox: () => undefined, 11 | }; 12 | 13 | export const extensions = { 14 | all: [ 15 | { 16 | id: "arnohovhannisyan.syncify", 17 | packageJSON: { isBuiltin: false }, 18 | }, 19 | ], 20 | getExtension: () => ({ 21 | extensionPath: resolve("."), 22 | packageJSON: { 23 | version: "", 24 | }, 25 | }), 26 | onDidChange: () => undefined, 27 | }; 28 | 29 | export const commands = { 30 | registerCommand: () => ({ dispose: () => undefined }), 31 | executeCommand: () => undefined, 32 | getCommands: () => [], 33 | }; 34 | 35 | export enum ProgressLocation { 36 | Notification = 1, 37 | } 38 | 39 | export const Uri = { 40 | file: () => "file:///", 41 | }; 42 | -------------------------------------------------------------------------------- /src/tests/getCleanupPath.ts: -------------------------------------------------------------------------------- 1 | import { tmpdir } from "os"; 2 | import { resolve } from "path"; 3 | 4 | export function getCleanupPath(type: string): string { 5 | return resolve(tmpdir(), "vscode-syncify-tests", type); 6 | } 7 | -------------------------------------------------------------------------------- /src/tests/services/__snapshots__/extensions.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`missing extensions 1`] = ` 4 | Array [ 5 | "publisher2.extension2", 6 | "publisher3.extension3", 7 | ] 8 | `; 9 | 10 | exports[`unneeded extensions 1`] = ` 11 | Array [ 12 | "publisher2.extension2", 13 | "publisher3.extension3", 14 | ] 15 | `; 16 | -------------------------------------------------------------------------------- /src/tests/services/__snapshots__/localize.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`basic functionality 1`] = `"Syncify: Installed 5"`; 4 | 5 | exports[`basic functionality 2`] = `"Syncify: Uninstalled 10"`; 6 | -------------------------------------------------------------------------------- /src/tests/services/__snapshots__/pragma.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`incoming env 1`] = ` 4 | "{ 5 | // @sync env=ENV_TEST_KEY 6 | // \\"key\\": \\"value\\" 7 | }" 8 | `; 9 | 10 | exports[`incoming env 2`] = ` 11 | "{ 12 | // @sync env=ENV_TEST_KEY 13 | \\"key\\": \\"value\\" 14 | }" 15 | `; 16 | 17 | exports[`incoming host 1`] = ` 18 | "{ 19 | // @sync host=pc 20 | \\"key\\": \\"value\\" 21 | }" 22 | `; 23 | 24 | exports[`incoming host 2`] = ` 25 | "{ 26 | // @sync host=invalid 27 | // \\"key\\": \\"value\\" 28 | }" 29 | `; 30 | 31 | exports[`incoming operating system 1`] = ` 32 | "{ 33 | // @sync os=windows 34 | \\"key\\": \\"value\\" 35 | }" 36 | `; 37 | 38 | exports[`incoming operating system 2`] = ` 39 | "{ 40 | // @sync os=mac 41 | // \\"key\\": \\"value\\" 42 | }" 43 | `; 44 | 45 | exports[`incoming operating system 3`] = ` 46 | "{ 47 | // @sync os=linux 48 | // \\"key\\": \\"value\\" 49 | }" 50 | `; 51 | 52 | exports[`outgoing 1`] = ` 53 | "{ 54 | // @sync ... 55 | // \\"key\\": \\"value\\", 56 | 57 | // @invalid 58 | \\"key2\\": \\"value2\\" 59 | }" 60 | `; 61 | -------------------------------------------------------------------------------- /src/tests/services/customFiles.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { Uri } from "vscode"; 3 | import { CustomFiles, Environment, FS } from "~/services"; 4 | import { getCleanupPath } from "~/tests/getCleanupPath"; 5 | import { stringifyPretty } from "~/utilities"; 6 | 7 | jest.mock("~/services/localize.ts"); 8 | 9 | const cleanupPath = getCleanupPath("services/customFiles"); 10 | 11 | const pathToSource = resolve(cleanupPath, "source"); 12 | const pathToRegistered = resolve(cleanupPath, "registered"); 13 | 14 | const paths = [pathToSource, pathToRegistered]; 15 | 16 | jest 17 | .spyOn(Environment, "customFilesFolder", "get") 18 | .mockReturnValue(pathToRegistered); 19 | 20 | beforeEach(async () => Promise.all(paths.map(async (p) => FS.mkdir(p)))); 21 | 22 | afterEach(async () => FS.remove(cleanupPath)); 23 | 24 | test("register", async () => { 25 | const testPath = resolve(pathToSource, "test.json"); 26 | 27 | const data = stringifyPretty({ test: true }); 28 | await FS.write(testPath, data); 29 | 30 | const uri = { 31 | fsPath: testPath, 32 | }; 33 | 34 | await CustomFiles.registerFile(uri as Uri); 35 | 36 | const exists = await FS.exists(resolve(pathToRegistered, "test.json")); 37 | expect(exists).toBeTruthy(); 38 | }); 39 | -------------------------------------------------------------------------------- /src/tests/services/extensions.test.ts: -------------------------------------------------------------------------------- 1 | import { extensions, Uri, commands } from "vscode"; 2 | import { Extensions, Environment, FS } from "~/services"; 3 | import { getCleanupPath } from "~/tests/getCleanupPath"; 4 | import { resolve } from "path"; 5 | 6 | function setExtensions(exts: string[]): void { 7 | (extensions.all as any) = exts.map((ext) => ({ 8 | id: ext, 9 | packageJSON: { isBuiltin: false }, 10 | extensionPath: "", 11 | isActive: false, 12 | exports: undefined, 13 | activate: async () => Promise.resolve(), 14 | })); 15 | } 16 | 17 | const cleanupPath = getCleanupPath("services/extensions"); 18 | 19 | const pathToVsix = resolve(cleanupPath, "vsix"); 20 | 21 | jest.spyOn(Environment, "vsixFolder", "get").mockReturnValue(pathToVsix); 22 | 23 | test("missing extensions", () => { 24 | setExtensions(["publisher1.extension1"]); 25 | 26 | const missing = Extensions.getMissing([ 27 | "publisher1.extension1", 28 | "publisher2.extension2", 29 | "publisher3.extension3", 30 | ]); 31 | 32 | expect(missing).toMatchSnapshot(); 33 | }); 34 | 35 | test("unneeded extensions", () => { 36 | setExtensions([ 37 | "publisher1.extension1", 38 | "publisher2.extension2", 39 | "publisher3.extension3", 40 | ]); 41 | 42 | const unneeded = Extensions.getUnneeded(["publisher1.extension1"]); 43 | 44 | expect(unneeded).toMatchSnapshot(); 45 | }); 46 | 47 | describe("install", () => { 48 | test("marketplace", async () => { 49 | const spy = jest.spyOn(commands, "executeCommand"); 50 | 51 | await Extensions.install("test.extension"); 52 | 53 | expect(spy).toHaveBeenCalledWith( 54 | "workbench.extensions.installExtension", 55 | "test.extension", 56 | ); 57 | 58 | spy.mockRestore(); 59 | }); 60 | 61 | test("vsix", async () => { 62 | await FS.mkdir(pathToVsix); 63 | 64 | const spy = jest.spyOn(commands, "executeCommand"); 65 | 66 | await FS.write(resolve(pathToVsix, "test.extension.vsix"), "test"); 67 | 68 | await Extensions.install("test.extension"); 69 | 70 | expect(spy).toHaveBeenCalledWith( 71 | "workbench.extensions.installExtension", 72 | Uri.file(""), 73 | ); 74 | 75 | spy.mockRestore(); 76 | 77 | await FS.remove(pathToVsix); 78 | }); 79 | }); 80 | 81 | test("uninstall", async () => { 82 | const spy = jest.spyOn(commands, "executeCommand"); 83 | 84 | await Extensions.uninstall("test.extension"); 85 | 86 | expect(spy).toHaveBeenCalledWith( 87 | "workbench.extensions.uninstallExtension", 88 | "test.extension", 89 | ); 90 | 91 | spy.mockRestore(); 92 | }); 93 | -------------------------------------------------------------------------------- /src/tests/services/factory.test.ts: -------------------------------------------------------------------------------- 1 | import { Syncers } from "~/models"; 2 | import { Factory } from "~/services"; 3 | import { FileSyncer, RepoSyncer } from "~/syncers"; 4 | 5 | jest.mock("~/services/localize.ts"); 6 | 7 | test("repo syncer", () => { 8 | expect(Factory.generate(Syncers.Repo)).toBeInstanceOf(RepoSyncer); 9 | }); 10 | 11 | test("file syncer", () => { 12 | expect(Factory.generate(Syncers.File)).toBeInstanceOf(FileSyncer); 13 | }); 14 | -------------------------------------------------------------------------------- /src/tests/services/fs.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { FS } from "~/services"; 3 | import { getCleanupPath } from "~/tests/getCleanupPath"; 4 | 5 | jest.mock("~/services/localize.ts"); 6 | 7 | const cleanupPath = getCleanupPath("services/fs"); 8 | 9 | const pathToTest = resolve(cleanupPath, "test"); 10 | 11 | const paths = [pathToTest]; 12 | 13 | beforeEach(async () => Promise.all(paths.map(async (p) => FS.mkdir(p)))); 14 | 15 | afterEach(async () => FS.remove(cleanupPath)); 16 | 17 | test("regular files", async () => { 18 | const filepath = resolve(pathToTest, "file"); 19 | await FS.write(filepath, "test"); 20 | 21 | const files = await FS.listFiles(pathToTest, ["**/file"]); 22 | 23 | expect(files.includes(filepath)).toBeFalsy(); 24 | }); 25 | 26 | test("ignored files", async () => { 27 | const filepath = resolve(pathToTest, "file"); 28 | await FS.write(filepath, "test"); 29 | 30 | const files = await FS.listFiles(pathToTest, ["**/fole"]); 31 | 32 | expect(files.includes(filepath)).toBeTruthy(); 33 | }); 34 | -------------------------------------------------------------------------------- /src/tests/services/init.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { commands } from "vscode"; 3 | import { Environment, FS, init, Settings, Watcher } from "~/services"; 4 | import state from "~/state"; 5 | import { getCleanupPath } from "~/tests/getCleanupPath"; 6 | 7 | const cleanupPath = getCleanupPath("services/init"); 8 | 9 | const pathToUser = resolve(cleanupPath, "user"); 10 | const pathToGlobalStoragePath = resolve(cleanupPath, "user"); 11 | 12 | jest 13 | .spyOn(Environment, "globalStoragePath", "get") 14 | .mockReturnValue(pathToGlobalStoragePath); 15 | 16 | const paths = [pathToUser, pathToGlobalStoragePath]; 17 | 18 | beforeEach(async () => { 19 | (state.context as any) = { 20 | subscriptions: [], 21 | }; 22 | 23 | return Promise.all(paths.map(async (p) => FS.mkdir(p))); 24 | }); 25 | 26 | afterEach(async () => { 27 | state.context = undefined; 28 | 29 | return FS.remove(cleanupPath); 30 | }); 31 | 32 | jest.mock("~/services/localize.ts"); 33 | 34 | test("command registration", async () => { 35 | const spy = jest.spyOn(commands, "registerCommand"); 36 | 37 | await init(); 38 | 39 | expect(spy).toHaveBeenCalled(); 40 | 41 | spy.mockRestore(); 42 | }); 43 | 44 | test("command disposal", async () => { 45 | const fn = jest.fn(); 46 | 47 | (state.context as any) = { 48 | subscriptions: [{ dispose: fn }], 49 | }; 50 | 51 | await init(); 52 | 53 | expect(fn).toHaveBeenCalled(); 54 | }); 55 | 56 | test("watch settings", async () => { 57 | await Settings.set({ watchSettings: true }); 58 | 59 | const spy = jest.spyOn(Watcher, "start"); 60 | 61 | await init(); 62 | 63 | expect(spy).toHaveBeenCalled(); 64 | 65 | spy.mockRestore(); 66 | }); 67 | 68 | test("sync on startup", async () => { 69 | await Settings.set({ syncOnStartup: true }); 70 | 71 | const spy = jest.spyOn(commands, "executeCommand"); 72 | 73 | await init(); 74 | 75 | expect(spy).toHaveBeenCalledWith("syncify.sync"); 76 | 77 | spy.mockRestore(); 78 | }); 79 | -------------------------------------------------------------------------------- /src/tests/services/localize.test.ts: -------------------------------------------------------------------------------- 1 | import { initLocalization, localize, FS, Environment } from "~/services"; 2 | import { resolve } from "path"; 3 | import { getCleanupPath } from "~/tests/getCleanupPath"; 4 | import { stringifyPretty } from "~/utilities"; 5 | 6 | test("language detection", async () => { 7 | const spy = jest.spyOn(FS, "exists"); 8 | 9 | { 10 | const spy = jest.spyOn(FS, "exists"); 11 | 12 | process.env.VSCODE_NLS_CONFIG = JSON.stringify({ locale: "fake-locale" }); 13 | 14 | await initLocalization(); 15 | 16 | delete process.env.VSCODE_NLS_CONFIG; 17 | 18 | const expected = resolve( 19 | Environment.extensionPath, 20 | `package.nls.fake-locale.json`, 21 | ); 22 | 23 | expect(spy).toHaveBeenCalledWith(expected); 24 | 25 | spy.mockClear(); 26 | } 27 | 28 | { 29 | await initLocalization(); 30 | 31 | const expected = resolve( 32 | Environment.extensionPath, 33 | `package.nls.en-us.json`, 34 | ); 35 | 36 | expect(spy).toHaveBeenCalledWith(expected); 37 | 38 | spy.mockClear(); 39 | } 40 | 41 | spy.mockRestore(); 42 | }); 43 | 44 | test("returns requested language pack", async () => { 45 | const cleanupPath = getCleanupPath("services/localize"); 46 | 47 | const spy = jest 48 | .spyOn(Environment, "extensionPath", "get") 49 | .mockReturnValueOnce(cleanupPath); 50 | 51 | await FS.mkdir(cleanupPath); 52 | 53 | await FS.write( 54 | resolve(cleanupPath, "package.nls.lang.json"), 55 | stringifyPretty({ 56 | key: "value", 57 | }), 58 | ); 59 | 60 | await initLocalization("lang"); 61 | 62 | expect(localize("key")).toBe("value"); 63 | 64 | await FS.remove(cleanupPath); 65 | 66 | spy.mockRestore(); 67 | }); 68 | 69 | test("basic functionality", async () => { 70 | await initLocalization("en-us"); 71 | 72 | expect(localize("(info) extensions -> installed", "5")).toMatchSnapshot(); 73 | expect(localize("(info) extensions -> uninstalled", "10")).toMatchSnapshot(); 74 | }); 75 | 76 | test("invalid key", async () => { 77 | await initLocalization("en-us"); 78 | 79 | expect(localize("")).toBe(""); 80 | 81 | const rand = Math.random().toString(); 82 | 83 | expect(localize(rand)).toBe(rand); 84 | }); 85 | -------------------------------------------------------------------------------- /src/tests/services/migrator.test.ts: -------------------------------------------------------------------------------- 1 | import { migrate, Environment } from "~/services"; 2 | import state from "~/state"; 3 | 4 | function setPreviousVersion(version: string): void { 5 | (state.context as any) = {}; 6 | (state.context!.globalState as any) = { 7 | get: () => version, 8 | update: () => undefined, 9 | }; 10 | } 11 | 12 | function setVersion(version: string): void { 13 | Environment.version = version; 14 | } 15 | 16 | test("basic functionality", async () => { 17 | setPreviousVersion("1.0.0"); 18 | setVersion("1.1.0"); 19 | 20 | const fn = jest.fn(); 21 | 22 | await migrate(new Map([["1.1.0", fn]])); 23 | 24 | expect(fn).toHaveBeenCalled(); 25 | }); 26 | 27 | test("range", async () => { 28 | { 29 | setPreviousVersion("1.0.0"); 30 | setVersion("1.2.0"); 31 | 32 | const fn = jest.fn(); 33 | 34 | await migrate(new Map([[">= 1.1.0", fn]])); 35 | 36 | expect(fn).toHaveBeenCalled(); 37 | } 38 | 39 | { 40 | setPreviousVersion("1.1.0"); 41 | setVersion("1.2.0"); 42 | 43 | const fn = jest.fn(); 44 | 45 | await migrate(new Map([[">= 1.1.0", fn]])); 46 | 47 | expect(fn).not.toHaveBeenCalled(); 48 | } 49 | }); 50 | 51 | test("skip", async () => { 52 | setPreviousVersion("1.1.0"); 53 | setVersion("1.2.0"); 54 | 55 | const fn = jest.fn(); 56 | 57 | await migrate(new Map([["1.1.0", fn]])); 58 | 59 | expect(fn).not.toHaveBeenCalled(); 60 | }); 61 | 62 | test("invalid", async () => { 63 | setPreviousVersion("1.1.0"); 64 | setVersion("1.2.0"); 65 | 66 | const fn = jest.fn(); 67 | 68 | await migrate(new Map([["hello", fn]])); 69 | 70 | expect(fn).not.toHaveBeenCalled(); 71 | }); 72 | 73 | test("no context", async () => { 74 | state.context = undefined; 75 | 76 | const fn = jest.fn(); 77 | 78 | await migrate(new Map([["0.0.0", fn]])); 79 | 80 | expect(fn).not.toHaveBeenCalled(); 81 | }); 82 | -------------------------------------------------------------------------------- /src/tests/services/pragma.test.ts: -------------------------------------------------------------------------------- 1 | import { Pragma, Environment } from "~/services"; 2 | 3 | test("outgoing", () => { 4 | const initial = `{ 5 | // @sync ... 6 | "key": "value", 7 | 8 | // @invalid 9 | "key2": "value2" 10 | }`; 11 | 12 | const result = Pragma.outgoing(initial); 13 | 14 | expect(result).toMatchSnapshot(); 15 | }); 16 | 17 | describe("incoming", () => { 18 | test("operating system", () => { 19 | const spy = jest.spyOn(Environment, "os", "get"); 20 | 21 | const testOS = (os: string): void => { 22 | spy.mockReturnValueOnce("windows"); 23 | 24 | const initial = `{ 25 | // @sync os=${os} 26 | // "key": "value" 27 | }`; 28 | 29 | const result = Pragma.incoming(initial); 30 | 31 | expect(result).toMatchSnapshot(); 32 | }; 33 | 34 | testOS("windows"); 35 | testOS("mac"); 36 | testOS("linux"); 37 | 38 | spy.mockRestore(); 39 | }); 40 | 41 | test("host", () => { 42 | const validHostname = "pc"; 43 | 44 | const testHostname = (hostname: string): void => { 45 | const initial = `{ 46 | // @sync host=${hostname} 47 | // "key": "value" 48 | }`; 49 | 50 | const result = Pragma.incoming(initial, validHostname); 51 | 52 | expect(result).toMatchSnapshot(); 53 | }; 54 | 55 | testHostname(validHostname); 56 | testHostname("invalid"); 57 | }); 58 | 59 | test("env", () => { 60 | const testEnv = (value?: string): void => { 61 | if (value) { 62 | process.env.ENV_TEST_KEY = value; 63 | } 64 | 65 | const initial = `{ 66 | // @sync env=ENV_TEST_KEY 67 | // "key": "value" 68 | }`; 69 | 70 | const result = Pragma.incoming(initial); 71 | 72 | expect(result).toMatchSnapshot(); 73 | 74 | delete process.env.ENV_TEST_KEY; 75 | }; 76 | 77 | testEnv(); 78 | testEnv("value"); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/tests/services/profile.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { Environment, FS, Profile, Settings } from "~/services"; 3 | import { getCleanupPath } from "~/tests/getCleanupPath"; 4 | 5 | jest.mock("~/services/localize.ts"); 6 | 7 | const cleanupPath = getCleanupPath("services/profile"); 8 | 9 | const pathToTest = resolve(cleanupPath, "test"); 10 | 11 | const paths = [pathToTest]; 12 | 13 | jest 14 | .spyOn(Environment, "settings", "get") 15 | .mockReturnValue(resolve(pathToTest, "settings.json")); 16 | 17 | beforeEach(async () => Promise.all(paths.map(async (p) => FS.mkdir(p)))); 18 | 19 | afterEach(async () => FS.remove(cleanupPath)); 20 | 21 | test("switch", async () => { 22 | await Settings.set({ 23 | repo: { 24 | profiles: [ 25 | { 26 | name: "1", 27 | branch: "1", 28 | }, 29 | { 30 | name: "2", 31 | branch: "2", 32 | }, 33 | ], 34 | currentProfile: "1", 35 | }, 36 | }); 37 | 38 | await Profile.switchProfile("2"); 39 | 40 | const currentProfile = await Settings.get((s) => s.repo.currentProfile); 41 | 42 | expect(currentProfile).toBe("2"); 43 | }); 44 | -------------------------------------------------------------------------------- /src/tests/services/settings.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defaultSettings, ISettings } from "~/models"; 3 | import { Environment, FS, Settings } from "~/services"; 4 | import { getCleanupPath } from "~/tests/getCleanupPath"; 5 | 6 | jest.mock("~/services/localize.ts"); 7 | jest.mock("~/utilities/confirm.ts"); 8 | 9 | const cleanupPath = getCleanupPath("services/settings"); 10 | 11 | const pathToTest = resolve(cleanupPath, "test"); 12 | 13 | const paths = [pathToTest]; 14 | 15 | const pathToSettings = resolve(pathToTest, "settings.json"); 16 | 17 | jest.spyOn(Environment, "settings", "get").mockReturnValue(pathToSettings); 18 | jest.spyOn(Environment, "globalStoragePath", "get").mockReturnValue(pathToTest); 19 | 20 | beforeEach(async () => Promise.all(paths.map(async (p) => FS.mkdir(p)))); 21 | 22 | afterEach(async () => FS.remove(cleanupPath)); 23 | 24 | test("set", async () => { 25 | await Settings.set({ watchSettings: true }); 26 | 27 | const fetched: ISettings = JSON.parse(await FS.read(Environment.settings)); 28 | 29 | expect(fetched.watchSettings).toBeTruthy(); 30 | }); 31 | 32 | test("get", async () => { 33 | const newSettings: ISettings = { 34 | ...defaultSettings, 35 | watchSettings: true, 36 | }; 37 | 38 | await FS.write(Environment.settings, JSON.stringify(newSettings)); 39 | 40 | const watchSettings = await Settings.get((s) => s.watchSettings); 41 | 42 | expect(watchSettings).toBeTruthy(); 43 | }); 44 | 45 | test("immutability", async () => { 46 | const settings = await Settings.get(); 47 | 48 | expect(settings).toStrictEqual(defaultSettings); 49 | expect(settings).not.toBe(defaultSettings); 50 | }); 51 | 52 | test("reset", async () => { 53 | await Settings.set({ watchSettings: true }); 54 | 55 | await Settings.reset(); 56 | 57 | const exists = await FS.exists(Environment.settings); 58 | 59 | expect(exists).toBeFalsy(); 60 | }); 61 | -------------------------------------------------------------------------------- /src/tests/syncers/file.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { Syncers } from "~/models"; 3 | import { Environment, FS, Settings } from "~/services"; 4 | import { FileSyncer } from "~/syncers"; 5 | import { getCleanupPath } from "~/tests/getCleanupPath"; 6 | import { stringifyPretty } from "~/utilities"; 7 | 8 | jest.mock("~/services/localize.ts"); 9 | 10 | const cleanupPath = getCleanupPath("syncers/file"); 11 | 12 | const pathToExport = resolve(cleanupPath, "export"); 13 | const pathToUser = resolve(cleanupPath, "user"); 14 | const pathToGlobalStoragePath = resolve(cleanupPath, "globalStoragePath"); 15 | 16 | const paths = [pathToExport, pathToUser, pathToGlobalStoragePath]; 17 | 18 | const pathToSettings = resolve(pathToUser, "settings.json"); 19 | const pathToExportSettings = resolve(pathToExport, "settings.json"); 20 | 21 | jest.spyOn(Environment, "userFolder", "get").mockReturnValue(pathToUser); 22 | 23 | jest 24 | .spyOn(Environment, "globalStoragePath", "get") 25 | .mockReturnValue(pathToGlobalStoragePath); 26 | 27 | const currentSettings = { 28 | syncer: Syncers.File, 29 | file: { 30 | path: pathToExport, 31 | }, 32 | }; 33 | 34 | beforeEach(async () => Promise.all(paths.map(async (p) => FS.mkdir(p)))); 35 | 36 | afterEach(async () => FS.remove(cleanupPath)); 37 | 38 | describe("upload", () => { 39 | test("basic functionality", async () => { 40 | await Settings.set(currentSettings); 41 | 42 | const userData = stringifyPretty({ 43 | "test.key": true, 44 | }); 45 | 46 | await FS.write(pathToSettings, userData); 47 | 48 | const fileSyncer = new FileSyncer(); 49 | await fileSyncer.upload(); 50 | 51 | const uploadedData = await FS.read(pathToExportSettings); 52 | expect(uploadedData).toBe(userData); 53 | }); 54 | 55 | test("binary files", async () => { 56 | await Settings.set(currentSettings); 57 | 58 | const buffer = Buffer.alloc(2).fill(1); 59 | 60 | await FS.write(resolve(pathToUser, "buffer"), buffer); 61 | 62 | const fileSyncer = new FileSyncer(); 63 | await fileSyncer.upload(); 64 | 65 | const uploadedBuffer = await FS.readBuffer(resolve(pathToExport, "buffer")); 66 | 67 | expect(Buffer.compare(buffer, uploadedBuffer)).toBe(0); 68 | }); 69 | }); 70 | 71 | describe("download", () => { 72 | test("basic functionality", async () => { 73 | await Settings.set(currentSettings); 74 | 75 | const settings = stringifyPretty({ 76 | "test.key": true, 77 | }); 78 | 79 | const extensions = stringifyPretty(["1", "2", "3"]); 80 | 81 | await FS.write(pathToExportSettings, settings); 82 | await FS.write(resolve(pathToExport, "extensions.json"), extensions); 83 | 84 | const fileSyncer = new FileSyncer(); 85 | await fileSyncer.download(); 86 | 87 | const downloadedSettings = await FS.read(pathToSettings); 88 | 89 | const downloadedExtensions = await FS.read( 90 | resolve(pathToUser, "extensions.json"), 91 | ); 92 | 93 | expect(downloadedSettings).toBe(settings); 94 | expect(downloadedExtensions).toBe(extensions); 95 | }); 96 | 97 | test("binary files", async () => { 98 | await Settings.set(currentSettings); 99 | 100 | const buffer = Buffer.alloc(2).fill(1); 101 | 102 | await FS.write(resolve(pathToExport, "buffer"), buffer); 103 | 104 | const fileSyncer = new FileSyncer(); 105 | await fileSyncer.download(); 106 | 107 | const downloadedBuffer = await FS.readBuffer(resolve(pathToUser, "buffer")); 108 | 109 | expect(Buffer.compare(buffer, downloadedBuffer)).toBe(0); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/tests/syncers/repo.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import createSimpleGit from "simple-git/promise"; 3 | import { window } from "vscode"; 4 | import { Environment, FS, localize, Profile, Settings } from "~/services"; 5 | import { RepoSyncer } from "~/syncers"; 6 | import { getCleanupPath } from "~/tests/getCleanupPath"; 7 | import { merge, stringifyPretty } from "~/utilities"; 8 | 9 | jest.mock("~/services/localize.ts"); 10 | 11 | const cleanupPath = getCleanupPath("syncers/repo"); 12 | 13 | const pathToRemote = resolve(cleanupPath, "remote"); 14 | const pathToRepo = resolve(cleanupPath, "repo"); 15 | const pathToTemporaryRepo = resolve(cleanupPath, "tmpRepo"); 16 | const pathToUser = resolve(cleanupPath, "user"); 17 | const pathToGlobalStoragePath = resolve(cleanupPath, "globalStoragePath"); 18 | 19 | const paths = [ 20 | pathToRemote, 21 | pathToRepo, 22 | pathToTemporaryRepo, 23 | pathToUser, 24 | pathToGlobalStoragePath, 25 | ]; 26 | 27 | const pathToSettings = resolve(pathToUser, "settings.json"); 28 | 29 | jest.spyOn(Environment, "userFolder", "get").mockReturnValue(pathToUser); 30 | jest.spyOn(Environment, "repoFolder", "get").mockReturnValue(pathToRepo); 31 | 32 | jest 33 | .spyOn(Environment, "globalStoragePath", "get") 34 | .mockReturnValue(pathToGlobalStoragePath); 35 | 36 | jest.setTimeout(15000); 37 | 38 | const currentSettings = { 39 | repo: { 40 | url: pathToRemote, 41 | }, 42 | }; 43 | 44 | beforeEach(async () => { 45 | await Promise.all(paths.map(async (p) => FS.mkdir(p))); 46 | return createSimpleGit(pathToRemote).init(true); 47 | }); 48 | 49 | afterEach(async () => FS.remove(cleanupPath)); 50 | 51 | describe("upload", () => { 52 | test("basic functionality", async () => { 53 | await Settings.set(currentSettings); 54 | 55 | const userData = stringifyPretty({ 56 | "test.key": true, 57 | }); 58 | 59 | await FS.write(pathToSettings, userData); 60 | 61 | const repoSyncer = new RepoSyncer(); 62 | await repoSyncer.upload(); 63 | 64 | const uploadedData = await FS.read(pathToSettings); 65 | expect(uploadedData).toBe(userData); 66 | }); 67 | 68 | test("behind remote", async () => { 69 | await Settings.set(currentSettings); 70 | 71 | const userData = stringifyPretty({ 72 | "test.key": true, 73 | }); 74 | 75 | await FS.write(pathToSettings, userData); 76 | 77 | const repoSyncer = new RepoSyncer(); 78 | await repoSyncer.upload(); 79 | 80 | const expected = stringifyPretty({ 81 | "test.key": false, 82 | }); 83 | 84 | const git = createSimpleGit(pathToTemporaryRepo); 85 | await git.init(); 86 | await git.addRemote("origin", pathToRemote); 87 | await git.pull("origin", "master", { "--force": null }); 88 | 89 | await FS.write(resolve(pathToTemporaryRepo, "settings.json"), expected); 90 | 91 | await git.add("."); 92 | await git.commit("Testing"); 93 | await git.push("origin", "master", { "--force": null }); 94 | 95 | await repoSyncer.upload(); 96 | 97 | await git.pull("origin", "master", { "--force": null }); 98 | 99 | const syncedData = await FS.read( 100 | resolve(pathToTemporaryRepo, "settings.json"), 101 | ); 102 | expect(syncedData).toBe(expected); 103 | }); 104 | 105 | test("ignore items", async () => { 106 | await Settings.set(currentSettings); 107 | 108 | const expected = stringifyPretty({ 109 | "test.key": true, 110 | }); 111 | 112 | await FS.write(pathToSettings, expected); 113 | 114 | const repoSyncer = new RepoSyncer(); 115 | await repoSyncer.upload(); 116 | 117 | const exists = await FS.exists(resolve(pathToRepo, "workspaceStorage")); 118 | expect(exists).toBeFalsy(); 119 | 120 | const settingsData = await FS.read(pathToSettings); 121 | expect(settingsData).toBe(expected); 122 | }); 123 | 124 | test("up to date", async () => { 125 | await Settings.set(currentSettings); 126 | 127 | const userData = stringifyPretty({ 128 | "test.key": true, 129 | }); 130 | 131 | await FS.write(pathToSettings, userData); 132 | 133 | const repoSyncer = new RepoSyncer(); 134 | await repoSyncer.upload(); 135 | 136 | const spy = jest.spyOn(window, "setStatusBarMessage"); 137 | 138 | await repoSyncer.upload(); 139 | 140 | expect(spy).toHaveBeenCalledWith( 141 | localize("(info) repo -> remoteUpToDate"), 142 | 2000, 143 | ); 144 | 145 | spy.mockRestore(); 146 | }); 147 | 148 | test("binary files", async () => { 149 | await Settings.set(currentSettings); 150 | 151 | const pathToBuffer = resolve(pathToUser, "buffer"); 152 | 153 | const buffer = Buffer.alloc(2).fill(1); 154 | 155 | await FS.write(pathToBuffer, buffer); 156 | 157 | const repoSyncer = new RepoSyncer(); 158 | await repoSyncer.upload(); 159 | 160 | const git = createSimpleGit(pathToTemporaryRepo); 161 | await git.init(); 162 | await git.addRemote("origin", pathToRemote); 163 | await git.pull("origin", "master", { "--force": null }); 164 | 165 | const downloadedBuffer = await FS.readBuffer( 166 | resolve(pathToTemporaryRepo, "buffer"), 167 | ); 168 | 169 | expect(Buffer.compare(buffer, downloadedBuffer)).toBe(0); 170 | }); 171 | }); 172 | 173 | describe("download", () => { 174 | test("basic functionality", async () => { 175 | await Settings.set(currentSettings); 176 | 177 | const userData = stringifyPretty({ 178 | "test.key": true, 179 | }); 180 | 181 | await FS.write(pathToSettings, userData); 182 | 183 | const repoSyncer = new RepoSyncer(); 184 | await repoSyncer.upload(); 185 | 186 | const expected = stringifyPretty({ 187 | "test.key": false, 188 | }); 189 | 190 | const git = createSimpleGit(pathToTemporaryRepo); 191 | await git.init(); 192 | await git.addRemote("origin", pathToRemote); 193 | await git.pull("origin", "master", { "--force": null }); 194 | 195 | await FS.write(resolve(pathToTemporaryRepo, "settings.json"), expected); 196 | 197 | await git.add("."); 198 | await git.commit("Testing"); 199 | await git.push("origin", "master", { "--force": null }); 200 | 201 | await repoSyncer.download(); 202 | 203 | const downloadedData = await FS.read(pathToSettings); 204 | 205 | expect(downloadedData.replace(/\r\n/g, "\n")).toBe(expected); 206 | }); 207 | 208 | test("create branch if first download", async () => { 209 | await Settings.set(currentSettings); 210 | 211 | const userData = stringifyPretty({ 212 | "test.key": true, 213 | }); 214 | 215 | await FS.write(pathToSettings, userData); 216 | 217 | await new RepoSyncer().upload(); 218 | 219 | await FS.remove(pathToRepo); 220 | 221 | await new RepoSyncer().download(); 222 | 223 | const downloadedData = await FS.read(pathToSettings); 224 | 225 | expect(downloadedData.replace(/\r\n/g, "\n")).toBe(userData); 226 | }); 227 | 228 | test("ahead of remote", async () => { 229 | await Settings.set(currentSettings); 230 | 231 | const userData = stringifyPretty({ 232 | "test.key": true, 233 | }); 234 | 235 | await FS.write(pathToSettings, userData); 236 | 237 | await new RepoSyncer().download(); 238 | 239 | const currentData = await FS.read(pathToSettings); 240 | expect(currentData).toBe(userData); 241 | }); 242 | 243 | test("switch profiles", async () => { 244 | await Settings.set( 245 | merge(currentSettings, { 246 | repo: { 247 | profiles: [ 248 | { branch: "test1", name: "test1" }, 249 | { branch: "test2", name: "test2" }, 250 | ], 251 | currentProfile: "test1", 252 | }, 253 | }), 254 | ); 255 | 256 | const userData = stringifyPretty({ 257 | "test.key": 1, 258 | }); 259 | 260 | await FS.write(pathToSettings, userData); 261 | 262 | const repoSyncer = new RepoSyncer(); 263 | await repoSyncer.upload(); 264 | 265 | await Profile.switchProfile("test2"); 266 | 267 | const newUserData = stringifyPretty({ 268 | "test.key": 2, 269 | }); 270 | 271 | await FS.write(pathToSettings, newUserData); 272 | 273 | await repoSyncer.upload(); 274 | await Profile.switchProfile("test1"); 275 | await repoSyncer.download(); 276 | 277 | const downloadedData = await FS.read(pathToSettings); 278 | 279 | expect(downloadedData.replace(/\r\n/g, "\n")).toBe(userData); 280 | }); 281 | 282 | test("download new profile", async () => { 283 | await Settings.set( 284 | merge(currentSettings, { 285 | repo: { 286 | profiles: [ 287 | { branch: "test1", name: "test1" }, 288 | { branch: "test2", name: "test2" }, 289 | ], 290 | currentProfile: "test1", 291 | }, 292 | }), 293 | ); 294 | 295 | const userData = stringifyPretty({ 296 | "test.key": 1, 297 | }); 298 | 299 | await FS.write(pathToSettings, userData); 300 | 301 | const repoSyncer = new RepoSyncer(); 302 | await repoSyncer.upload(); 303 | 304 | const newUserData = stringifyPretty({ 305 | "test.key": 2, 306 | }); 307 | 308 | const git = createSimpleGit(pathToTemporaryRepo); 309 | await git.init(); 310 | await git.addRemote("origin", pathToRemote); 311 | await git.pull("origin", "test1", { "--force": null }); 312 | 313 | await git.checkout(["-b", "test2"]); 314 | 315 | await FS.write(resolve(pathToTemporaryRepo, "settings.json"), newUserData); 316 | 317 | await git.add("."); 318 | await git.commit("Testing"); 319 | await git.push("origin", "test2"); 320 | 321 | await Profile.switchProfile("test2"); 322 | 323 | await repoSyncer.download(); 324 | 325 | const downloadedData = await FS.read(pathToSettings); 326 | 327 | expect(downloadedData.replace(/\r\n/g, "\n")).toBe(newUserData); 328 | }); 329 | 330 | test("up to date", async () => { 331 | await Settings.set(currentSettings); 332 | 333 | const userData = stringifyPretty({ 334 | "test.key": true, 335 | }); 336 | 337 | await FS.write(pathToSettings, userData); 338 | 339 | const repoSyncer = new RepoSyncer(); 340 | await repoSyncer.upload(); 341 | 342 | const spy = jest.spyOn(window, "setStatusBarMessage"); 343 | 344 | await repoSyncer.download(); 345 | 346 | expect(spy).toHaveBeenCalledWith(localize("(info) repo -> upToDate"), 2000); 347 | 348 | spy.mockRestore(); 349 | }); 350 | 351 | test("merge if dirty", async () => { 352 | await Settings.set(currentSettings); 353 | 354 | const userData = stringifyPretty({ 355 | "test.key": true, 356 | }); 357 | 358 | await FS.write(pathToSettings, userData); 359 | 360 | const repoSyncer = new RepoSyncer(); 361 | await repoSyncer.upload(); 362 | 363 | const expected = stringifyPretty({ 364 | "test.key": false, 365 | }); 366 | 367 | const git = createSimpleGit(pathToTemporaryRepo); 368 | await git.init(); 369 | await git.addRemote("origin", pathToRemote); 370 | await git.pull("origin", "master", { "--force": null }); 371 | 372 | await FS.write(resolve(pathToTemporaryRepo, "settings.json"), expected); 373 | 374 | await git.add("."); 375 | await git.commit("Testing"); 376 | await git.push("origin", "master", { "--force": null }); 377 | 378 | const keybindings = stringifyPretty([1, 2, 3]); 379 | const pathToKeybindings = resolve(pathToUser, "keybindings.json"); 380 | 381 | await FS.write(pathToKeybindings, keybindings); 382 | 383 | await repoSyncer.download(); 384 | 385 | const downloadedData = await FS.read(pathToSettings); 386 | expect(downloadedData.replace(/\r\n/g, "\n")).toBe(expected); 387 | 388 | const keybindingsData = await FS.read(pathToKeybindings); 389 | expect(keybindingsData.replace(/\r\n/g, "\n")).toBe(keybindings); 390 | }); 391 | 392 | test("binary files", async () => { 393 | await Settings.set(currentSettings); 394 | 395 | const pathToBuffer = resolve(pathToUser, "buffer"); 396 | 397 | await FS.write(pathToBuffer, Buffer.alloc(2).fill(0)); 398 | 399 | const repoSyncer = new RepoSyncer(); 400 | await repoSyncer.upload(); 401 | 402 | const git = createSimpleGit(pathToTemporaryRepo); 403 | await git.init(); 404 | await git.addRemote("origin", pathToRemote); 405 | await git.pull("origin", "master", { "--force": null }); 406 | 407 | const buffer = Buffer.alloc(2).fill(1); 408 | 409 | await FS.write(resolve(pathToTemporaryRepo, "buffer"), buffer); 410 | 411 | await git.add("."); 412 | await git.commit("Testing"); 413 | await git.push("origin", "master", { "--force": null }); 414 | 415 | await repoSyncer.download(); 416 | 417 | const downloadedBuffer = await FS.readBuffer(pathToBuffer); 418 | 419 | expect(Buffer.compare(buffer, downloadedBuffer)).toBe(0); 420 | }); 421 | }); 422 | 423 | describe("sync", () => { 424 | test("dirty and not behind", async () => { 425 | await Settings.set(currentSettings); 426 | 427 | const userData = stringifyPretty({ 428 | "test.key": true, 429 | }); 430 | 431 | await FS.write(pathToSettings, userData); 432 | 433 | const repoSyncer = new RepoSyncer(); 434 | await repoSyncer.upload(); 435 | 436 | const newUserData = stringifyPretty({ 437 | "test.key": false, 438 | }); 439 | 440 | await FS.write(pathToSettings, newUserData); 441 | 442 | const spy = jest.spyOn(repoSyncer, "upload"); 443 | 444 | await repoSyncer.sync(); 445 | 446 | expect(spy).toHaveBeenCalled(); 447 | 448 | spy.mockRestore(); 449 | }); 450 | 451 | test("clean and not behind", async () => { 452 | await Settings.set(currentSettings); 453 | 454 | const userData = stringifyPretty({ 455 | "test.key": true, 456 | }); 457 | 458 | await FS.write(pathToSettings, userData); 459 | 460 | const repoSyncer = new RepoSyncer(); 461 | await repoSyncer.upload(); 462 | 463 | const uploadSpy = jest.spyOn(repoSyncer, "upload"); 464 | const downloadSpy = jest.spyOn(repoSyncer, "download"); 465 | 466 | await repoSyncer.sync(); 467 | 468 | expect(uploadSpy).not.toHaveBeenCalled(); 469 | expect(downloadSpy).not.toHaveBeenCalled(); 470 | 471 | uploadSpy.mockRestore(); 472 | downloadSpy.mockRestore(); 473 | }); 474 | 475 | test("behind remote", async () => { 476 | await Settings.set(currentSettings); 477 | 478 | const userData = stringifyPretty({ 479 | "test.key": true, 480 | }); 481 | 482 | await FS.write(pathToSettings, userData); 483 | 484 | const repoSyncer = new RepoSyncer(); 485 | await repoSyncer.upload(); 486 | 487 | const expected = stringifyPretty({ 488 | "test.key": false, 489 | }); 490 | 491 | const git = createSimpleGit(pathToTemporaryRepo); 492 | await git.init(); 493 | await git.addRemote("origin", pathToRemote); 494 | await git.pull("origin", "master", { "--force": null }); 495 | 496 | await FS.write(resolve(pathToTemporaryRepo, "settings.json"), expected); 497 | 498 | await git.add("."); 499 | await git.commit("Testing"); 500 | await git.push("origin", "master", { "--force": null }); 501 | 502 | await repoSyncer.sync(); 503 | 504 | const syncedData = await FS.read(pathToSettings); 505 | expect(syncedData.replace(/\r\n/g, "\n")).toBe(expected); 506 | }); 507 | }); 508 | 509 | describe("init", () => { 510 | test("basic functionality", async () => { 511 | await Settings.set(currentSettings); 512 | 513 | await new RepoSyncer().init(); 514 | 515 | const git = createSimpleGit(pathToRepo); 516 | 517 | expect(await git.checkIsRepo()).toBeTruthy(); 518 | 519 | const remotes = await git.getRemotes(true); 520 | 521 | expect(remotes[0].name).toBe("origin"); 522 | expect(remotes[0].refs.push).toBe(pathToRemote); 523 | }); 524 | 525 | test("update remote if not correct", async () => { 526 | const git = createSimpleGit(pathToRepo); 527 | 528 | await git.init(); 529 | await git.addRemote("origin", "test"); 530 | 531 | await Settings.set(currentSettings); 532 | 533 | await new RepoSyncer().init(); 534 | 535 | const remotes = await git.getRemotes(true); 536 | 537 | expect(remotes[0].name).toBe("origin"); 538 | expect(remotes[0].refs.push).toBe(pathToRemote); 539 | }); 540 | }); 541 | -------------------------------------------------------------------------------- /src/tests/teardown.ts: -------------------------------------------------------------------------------- 1 | import { remove } from "fs-extra"; 2 | import { tmpdir } from "os"; 3 | import { resolve } from "path"; 4 | 5 | export = async () => remove(resolve(tmpdir(), "vscode-syncify-tests")); 6 | -------------------------------------------------------------------------------- /src/tests/utilities/checkGit.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("simple-git/promise"); 2 | 3 | import { checkGit } from "~/utilities"; 4 | import simplegit from "simple-git/promise"; 5 | 6 | test("invalid version", async () => { 7 | (simplegit as any).mockImplementationOnce(() => ({ 8 | raw: () => "invalid version", 9 | })); 10 | 11 | const result = await checkGit("1.0.0"); 12 | 13 | expect(result).toBeFalsy(); 14 | }); 15 | -------------------------------------------------------------------------------- /src/tests/utilities/confirm.test.ts: -------------------------------------------------------------------------------- 1 | import { confirm } from "~/utilities"; 2 | import { window } from "vscode"; 3 | 4 | jest.mock("~/services/localize.ts"); 5 | 6 | test("yes", async () => { 7 | const spy = jest.spyOn(window, "showWarningMessage"); 8 | 9 | spy.mockImplementationOnce(() => "(label) yes" as any); 10 | 11 | const result = await confirm("test"); 12 | 13 | expect(result).toBeTruthy(); 14 | 15 | spy.mockRestore(); 16 | }); 17 | 18 | test("no", async () => { 19 | const spy = jest.spyOn(window, "showWarningMessage"); 20 | 21 | { 22 | spy.mockImplementationOnce(() => "(label) no" as any); 23 | 24 | const result = await confirm("test"); 25 | 26 | expect(result).toBeFalsy(); 27 | } 28 | 29 | { 30 | spy.mockImplementationOnce(() => undefined as any); 31 | 32 | const result = await confirm("test"); 33 | 34 | expect(result).toBeFalsy(); 35 | } 36 | 37 | spy.mockRestore(); 38 | }); 39 | -------------------------------------------------------------------------------- /src/typings/main.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /src/utilities/__mocks__/confirm.ts: -------------------------------------------------------------------------------- 1 | export const confirm = (): true => true; 2 | -------------------------------------------------------------------------------- /src/utilities/checkGit.ts: -------------------------------------------------------------------------------- 1 | import { gte } from "semver"; 2 | import { tmpdir } from "os"; 3 | import createGit from "simple-git/promise"; 4 | 5 | export async function checkGit(required: string): Promise { 6 | const git = createGit(tmpdir()); 7 | const versionString = await git.raw(["--version"]); 8 | const version = versionString 9 | .trim() 10 | .split(" ") 11 | .find((s) => /^\d+?\.\d+?\.\d+?$/.test(s)); 12 | 13 | if (!version) return false; 14 | 15 | return gte(version, required); 16 | } 17 | -------------------------------------------------------------------------------- /src/utilities/confirm.ts: -------------------------------------------------------------------------------- 1 | import { window } from "vscode"; 2 | import { localize } from "~/services"; 3 | 4 | export async function confirm(id: string): Promise { 5 | const response = await window.showWarningMessage( 6 | localize(`(confirm) ${id}`), 7 | localize("(label) yes"), 8 | localize("(label) no"), 9 | ); 10 | 11 | return response === localize("(label) yes"); 12 | } 13 | -------------------------------------------------------------------------------- /src/utilities/index.ts: -------------------------------------------------------------------------------- 1 | export * from "~/utilities/confirm"; 2 | export * from "~/utilities/sleep"; 3 | export * from "~/utilities/merge"; 4 | export * from "~/utilities/checkGit"; 5 | export * from "~/utilities/stringifyPretty"; 6 | -------------------------------------------------------------------------------- /src/utilities/merge.ts: -------------------------------------------------------------------------------- 1 | import cloneDeep from "lodash/cloneDeep"; 2 | import mergeWith from "lodash/mergeWith"; 3 | 4 | export function merge(object: T, source: J): T & J { 5 | return mergeWith(cloneDeep(object), source, (leftValue, rightValue) => { 6 | if (Array.isArray(leftValue) && Array.isArray(rightValue)) { 7 | return rightValue; 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/utilities/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number): Promise { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /src/utilities/stringifyPretty.ts: -------------------------------------------------------------------------------- 1 | export function stringifyPretty(object: any): string { 2 | return JSON.stringify(object, undefined, "\t"); 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "paths": { 5 | "~/*": ["./*"] 6 | }, 7 | "module": "CommonJS", 8 | "target": "ES5", 9 | "lib": ["ESNext"], 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "sourceMap": true, 13 | "rootDirs": ["src", "assets"], 14 | "strict": true 15 | }, 16 | "include": ["src/**/*.ts"], 17 | "exclude": ["node_modules", "**/__mocks__/**/*"] 18 | } 19 | --------------------------------------------------------------------------------