├── .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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
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 |
--------------------------------------------------------------------------------