diff --git a/.eslintrc.base.json b/.eslintrc.base.json index 5427199841601..e28ab89c5af53 100644 --- a/.eslintrc.base.json +++ b/.eslintrc.base.json @@ -5,10 +5,8 @@ "extends": [ "eslint:recommended", "plugin:@typescript-eslint/strict-type-checked", - "plugin:@typescript-eslint/stylistic-type-checked", "plugin:import/recommended", - "plugin:import/typescript", - "prettier" + "plugin:import/typescript" ], "parser": "@typescript-eslint/parser", "parserOptions": { @@ -24,25 +22,17 @@ "root": true, "rules": { "anti-trojan-source/no-bidi": "error", - "arrow-parens": ["off"], - "brace-style": ["off", "stroustrup"], - "consistent-return": "off", - "curly": ["error", "multi-line", "consistent"], - "eol-last": "error", - "linebreak-style": ["error", "unix"], - "new-parens": "error", - "no-console": "off", "no-constant-condition": ["warn", { "checkLoops": false }], "no-constant-binary-expression": "error", "no-caller": "error", "no-debugger": "off", - "no-dupe-class-members": "off", "no-else-return": "warn", "no-empty": ["warn", { "allowEmptyCatch": true }], "no-eval": "error", "no-ex-assign": "warn", "no-extend-native": "error", "no-extra-bind": "error", + "no-extra-semi": "off", "no-floating-decimal": "error", "no-implicit-coercion": "error", "no-implied-eval": "error", @@ -50,7 +40,7 @@ "no-lone-blocks": "error", "no-lonely-if": "error", "no-loop-func": "error", - "no-multi-spaces": "error", + "no-mixed-spaces-and-tabs": "off", "no-restricted-globals": ["error", "process"], "no-restricted-imports": [ "error", @@ -98,8 +88,18 @@ "message": "Use @env/ instead" }, { - "group": ["src/**/*"], + "group": ["src/*"], "message": "Use relative paths instead" + }, + { + "group": ["react-dom"], + "importNames": ["Container"], + "message": "Use our Container instead" + }, + { + "group": ["vscode"], + "importNames": ["CancellationError"], + "message": "Use our CancellationError instead" } ] } @@ -134,14 +134,11 @@ ], "prefer-numeric-literals": "error", "prefer-object-spread": "error", - "prefer-promise-reject-errors": ["error", { "allowEmptyReject": true }], + "prefer-promise-reject-errors": "off", "prefer-rest-params": "error", "prefer-spread": "error", "prefer-template": "error", - "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], "require-atomic-updates": "off", - "semi": ["error", "always"], - "semi-style": ["error", "last"], "sort-imports": [ "error", { @@ -153,15 +150,22 @@ ], "yoda": "error", "import/consistent-type-specifier-style": ["error", "prefer-top-level"], - "import/extensions": ["error", "never"], + "import/default": "off", + "import/extensions": "off", + "import/named": "off", + "import/namespace": "off", "import/newline-after-import": "warn", "import/no-absolute-path": "error", "import/no-cycle": "off", + "import/no-deprecated": "off", "import/no-default-export": "error", "import/no-duplicates": ["error", { "prefer-inline": false }], "import/no-dynamic-require": "error", + "import/no-named-as-default": "off", + "import/no-named-as-default-member": "off", "import/no-self-import": "error", - "import/no-unresolved": ["warn", { "ignore": ["vscode", "@env"] }], + "import/no-unused-modules": "off", + "import/no-unresolved": "off", "import/no-useless-path-segments": "error", "import/order": [ "warn", @@ -212,7 +216,6 @@ } } ], - "@typescript-eslint/class-literal-property-style": "off", "@typescript-eslint/consistent-type-assertions": [ "error", { @@ -220,9 +223,7 @@ "objectLiteralTypeAssertions": "allow-as-parameter" } ], - "@typescript-eslint/consistent-type-definitions": "off", "@typescript-eslint/consistent-type-imports": ["error", { "disallowTypeAnnotations": false }], - "@typescript-eslint/dot-notation": "off", "@typescript-eslint/naming-convention": [ "error", { @@ -273,7 +274,6 @@ "error", { "ignoreArrowShorthand": true, "ignoreVoidOperator": true } ], - "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-inferrable-types": ["warn", { "ignoreParameters": true, "ignoreProperties": true }], "@typescript-eslint/no-invalid-void-type": "off", // Seems to error on `void` return types @@ -285,6 +285,7 @@ "@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-enum-comparison": "off", "@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/no-unused-expressions": ["warn", { "allowShortCircuit": true }], "@typescript-eslint/no-unused-vars": [ @@ -296,13 +297,12 @@ "varsIgnorePattern": "^_$" } ], - "@typescript-eslint/no-unsafe-enum-comparison": "off", "@typescript-eslint/no-use-before-define": ["error", { "functions": false, "classes": false }], "@typescript-eslint/prefer-for-of": "warn", "@typescript-eslint/prefer-includes": "warn", "@typescript-eslint/prefer-literal-enum-member": ["warn", { "allowBitwiseExpressions": true }], - "@typescript-eslint/prefer-nullish-coalescing": "off", // warn "@typescript-eslint/prefer-optional-chain": "warn", + "@typescript-eslint/prefer-promise-reject-errors": ["error", { "allowEmptyReject": true }], "@typescript-eslint/prefer-reduce-type-parameter": "warn", "@typescript-eslint/restrict-template-expressions": [ "error", @@ -326,7 +326,22 @@ { "files": ["src/env/node/**/*"], "rules": { - "no-restricted-imports": "off" + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["src/*"], + "message": "Use relative paths instead" + }, + { + "group": ["react-dom"], + "importNames": ["Container"], + "message": "Use our Container instead" + } + ] + } + ] } } ] diff --git a/.fantasticonrc.js b/.fantasticonrc.js index 5a3c9de2247ac..f1cc3725ce234 100644 --- a/.fantasticonrc.js +++ b/.fantasticonrc.js @@ -1,6 +1,6 @@ //@ts-check -/** @type { import('fantasticon').RunnerOptions} } */ +/** @type {import('@twbs/fantasticon').RunnerOptions} */ const config = { name: 'glicons', prefix: 'glicon', diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 58466c32b141a..28f97a2c07331 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -8,3 +8,4 @@ d790e9db047769de079f6838c3578f3a47bf5930 9c2df377d3e1842ed09eea5bb99be00edee9ca9c 444bf829156b3170c8b4b5156dcf10b06db83779 4dba4612670c0a942e3daa3e6a34a57aebe257ae +fbccf2428fd671378202de43ff99deff66168a13 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000..cf739af0c04b8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 4 +updates: + - package-ecosystem: "" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/datadog-synthetics.yml b/.github/workflows/datadog-synthetics.yml new file mode 100644 index 0000000000000..f7cb68704b65b --- /dev/null +++ b/.github/workflows/datadog-synthetics.yml @@ -0,0 +1,38 @@ +# This workflow will trigger Datadog Synthetic tests within your Datadog organisation +# For more information on running Synthetic tests within your GitHub workflows see: https://docs.datadoghq.com/synthetics/cicd_integrations/github_actions/ + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# To get started: + +# 1. Add your Datadog API (DD_API_KEY) and Application Key (DD_APP_KEY) as secrets to your GitHub repository. For more information, see: https://docs.datadoghq.com/account_management/api-app-keys/. +# 2. Start using the action within your workflow + +name: Run Datadog Synthetic tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + # Run Synthetic tests within your GitHub workflow. + # For additional configuration options visit the action within the marketplace: https://github.com/marketplace/actions/datadog-synthetics-ci + - name: Run Datadog Synthetic tests + uses: DataDog/synthetics-ci-github-action@2b56dc0cca9daa14ab69c0d1d6844296de8f941e + with: + api_key: ${{secrets.DD_API_KEY}} + app_key: ${{secrets.DD_APP_KEY}} + test_search_query: 'tag:e2e-tests' #Modify this tag to suit your tagging strategy + + diff --git a/.prettierrc b/.prettierrc index 5a24bc35e38c2..df7b60158484f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -14,10 +14,6 @@ { "files": "*.md", "options": { "tabWidth": 2 } - }, - { - "files": "*.svg", - "options": { "parser": "html" } } ] } diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 957f8030e8763..98e7c6fe26d84 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,11 @@ { // See http://go.microsoft.com/fwlink/?LinkId=827846 // for the documentation about the extensions.json format - "recommendations": ["dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher", "esbenp.prettier-vscode"] + "recommendations": [ + "dbaeumer.vscode-eslint", + "amodio.tsl-problem-matcher", + "esbenp.prettier-vscode", + "joyceerhl.github-graphql-nb", + "ms-vscode.vscode-github-issue-notebooks" + ] } diff --git a/.vscode/queries.github-graphql-nb b/.vscode/queries.github-graphql-nb index ae770667e2c38..5df1e21ccf488 100644 --- a/.vscode/queries.github-graphql-nb +++ b/.vscode/queries.github-graphql-nb @@ -1 +1 @@ -{"cells":[{"code":"### Get Default Branch & Tip","kind":"markdown"},{"code":"query getDefaultBranchAndTip(\n\t$owner: String!\n\t$repo: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tdefaultBranchRef {\n\t\t\tname\n\t\t\ttarget { oid }\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Branches","kind":"markdown"},{"code":"query getBranches(\n\t$owner: String!\n\t$repo: String!\n\t$branchQuery: String\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\trefs(query: $branchQuery, refPrefix: \"refs/heads/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t\ttarget {\n\t\t\t\t\toid\n\t\t\t\t\tcommitUrl\n\t\t\t\t\t...on Commit {\n\t\t\t\t\t\tauthoredDate\n\t\t\t\t\t\tcommittedDate\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Blame","kind":"markdown"},{"code":"query getBlame(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n) {\n\tviewer { name }\n\trepository(owner: $owner, name: $repo) {\n\t\tobject(expression: $ref) {\n\t\t\t...on Commit {\n\t\t\t\tblame(path: $path) {\n\t\t\t\t\tranges {\n\t\t\t\t\t\tstartingLine\n\t\t\t\t\t\tendingLine\n\t\t\t\t\t\tage\n\t\t\t\t\t\tcommit {\n\t\t\t\t\t\t\toid\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\t\t\t\tmessage\n\t\t\t\t\t\t\tadditions\n\t\t\t\t\t\t\tchangedFiles\n\t\t\t\t\t\t\tdeletions\n\t\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\t\tavatarUrl\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcommitter {\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"gitkraken\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"0b8f151bd0458340b7779a64884c97754c3cedb8\",\n\t\"path\": \"package.json\"\n}","kind":"code"},{"code":"### Get Commit for File","kind":"markdown"},{"code":"query getCommitForFile(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tref(qualifiedName: $ref) {\n\t\t\ttarget {\n\t\t\t\t... on Commit {\n\t\t\t\t\thistory(first: 1, path: $path) {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\toid\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\t\t\t\tmessage\n\t\t\t\t\t\t\tadditions\n\t\t\t\t\t\t\tchangedFiles\n\t\t\t\t\t\t\tdeletions\n\t\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcommitter { date }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\",\n\t\"path\": \"src/extension.ts\"\n}","kind":"code"},{"code":"### Get Current User","kind":"markdown"},{"code":"query getCurrentUser(\n\t$owner: String!\n\t$repo: String!\n) {\n\tviewer { name }\n\trepository(name: $repo owner: $owner) {\n\t\tviewerPermission\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Commit","kind":"markdown"},{"code":"query getCommit(\n\t$owner: String!\n\t$repo: String!\n\t$ref: GitObjectID!\n) {\n\trepository(name: $repo owner: $owner) {\n\t\tobject(oid: $ref) {\n\t\t\t...on Commit {\n\t\t\t\toid\n\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\tmessage\n\t\t\t\tadditions\n\t\t\t\tchangedFiles\n\t\t\t\tdeletions\n\t\t\t\tauthor {\n\t\t\t\t\tdate\n\t\t\t\t\temail\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcommitter { date }\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"54f28933055124d6ba3808a787f6947c929f9db0\"\n}","kind":"code"},{"code":"### Get Commits","kind":"markdown"},{"code":"query getCommits(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String\n\t$author: CommitAuthor\n\t$after: String\n\t$before: String\n\t$limit: Int = 100\n\t$since: GitTimestamp\n\t$until: GitTimestamp\n) {\n\tviewer { name }\n\trepository(name: $repo, owner: $owner) {\n\t\tobject(expression: $ref) {\n\t\t\t... on Commit {\n\t\t\t\thistory(first: $limit, author: $author, path: $path, after: $after, before: $before, since: $since, until: $until) {\n\t\t\t\t\tpageInfo {\n\t\t\t\t\t\tstartCursor\n\t\t\t\t\t\tendCursor\n\t\t\t\t\t\thasNextPage\n\t\t\t\t\t\thasPreviousPage\n\t\t\t\t\t}\n\t\t\t\t\tnodes {\n\t\t\t\t\t\t... on Commit {\n\t\t\t\t\t\t\toid\n\t\t\t\t\t\t\tmessage\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\t\t\t\tadditions\n\t\t\t\t\t\t\tchangedFiles\n\t\t\t\t\t\t\tdeletions\n\t\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\t\tavatarUrl\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcommitter {\n\t\t\t\t\t\t\t\t date\n\t\t\t\t\t\t\t\t email\n\t\t\t\t\t\t\t\t name\n\t\t\t\t\t\t\t }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"HEAD\",\n\t\"-path\": \"src/extension.ts\",\n\t\"since\": \"2022-02-07T00:00:00Z\"\n}","kind":"code"},{"code":"### Get Tags","kind":"markdown"},{"code":"query getTags(\n\t$owner: String!\n\t$repo: String!\n\t$tagQuery: String\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\trefs(query: $tagQuery, refPrefix: \"refs/tags/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t\ttarget {\n\t\t\t\t\toid\n\t\t\t\t\tcommitUrl\n\t\t\t\t\t...on Commit {\n\t\t\t\t\t\tauthoredDate\n\t\t\t\t\t\tcommittedDate\n\t\t\t\t\t\tmessage\n\t\t\t\t\t}\n\t\t\t\t\t...on Tag {\n\t\t\t\t\t\tmessage\n\t\t\t\t\t\ttagger { date }\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get collaborators ","kind":"markdown"},{"code":"query getCollaborators (\n\t$owner: String!\n\t$repo: String!\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tcollaborators(affiliation: ALL, first: $limit, after: $cursor) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Resolve reference","kind":"markdown"},{"code":"query resolveReference(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tobject(expression: $ref) {\n\t\t\t... on Commit {\n\t\t\t\thistory(first: 1, path: $path) {\n\t\t\t\t\tnodes { oid }\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"d790e9db047769de079f6838c3578f3a47bf5930^\",\n\t\"path\": \"CODE_OF_CONDUCT.md\"\n}","kind":"code"},{"code":"### Get branches that contain commit","kind":"markdown"},{"code":"query getCommitBranches(\n\t$owner: String!\n\t$repo: String!\n\t$since: GitTimestamp!\n\t$until: GitTimestamp!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\trefs(first: 20, refPrefix: \"refs/heads/\", orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t\ttarget {\n\t\t\t\t\t... on Commit {\n\t\t\t\t\t\thistory(first: 3, since: $since until: $until) {\n\t\t\t\t\t\t\tnodes { oid }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"since\": \"2022-01-06T01:07:46-04:00\",\n\t\"until\": \"2022-01-06T01:07:46-05:00\"\n}","kind":"code"},{"code":"query getCommitBranch(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$since: GitTimestamp!\n\t$until: GitTimestamp!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tref(qualifiedName: $ref) {\n\t\t\ttarget {\n\t\t\t\t... on Commit {\n\t\t\t\t\thistory(first: 3, since: $since until: $until) {\n\t\t\t\t\t\tnodes { oid }\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\",\n\t\"since\": \"2022-01-06T01:07:46-04:00\",\n\t\"until\": \"2022-01-06T01:07:46-05:00\"\n}","kind":"code"},{"code":"### Get commit count for branch (ref)","kind":"markdown"},{"code":"query getCommitCount(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tref(qualifiedName: $ref) {\n\t\t\ttarget {\n\t\t\t\t... on Commit {\n\t\t\t\t\thistory(first: 1) {\n\t\t\t\t\t\ttotalCount\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\"\n}","kind":"code"},{"code":"### Get commit refs (sha)","kind":"markdown"},{"code":"query getCommitRefs(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String\n\t$since: GitTimestamp\n\t$until: GitTimestamp\n\t$limit: Int = 1\n) {\n\tviewer { name }\n\trepository(name: $repo, owner: $owner) {\n\t\tref(qualifiedName: $ref) {\n\t\t\thistory(first: $limit, path: $path, since: $since, until: $until) {\n\t\t\t\tnodes { oid, message }\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\",\n\t\"path\": \"extension.ts\",\n\t\"limit\": 2\n}","kind":"code"},{"code":"### Get next file commit","kind":"markdown"},{"code":"query getCommitDate(\n\t$owner: String!\n\t$repo: String!\n\t$ref: GitObjectID!\n) {\n\trepository(name: $repo owner: $owner) {\n\t\tobject(oid: $ref) {\n\t\t\t...on Commit {\n\t\t\t\tcommitter { date }\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"a03ff942c40665c451bd4c7768f46e3e5f00e97c\"\n}","kind":"code"},{"code":"query getNextCommitCursor(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n\t$since: GitTimestamp!\n) {\n\trepository(name: $repo owner: $owner) {\n\t\tobject(expression: $ref) {\n\t\t\t... on Commit {\n\t\t\t\thistory(first:1, path: $path, since: $since) {\n\t\t\t\t\ttotalCount\n\t\t\t\t\tpageInfo { startCursor }\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"b062e960b6ee5ca7ac081dd84d9217bd4b2051e0\",\n \"path\": \"src/extension.ts\",\n \"since\": \"2021-11-03T02:46:29-04:00\"\n}","kind":"code"},{"code":"query getNextCommit(\n\t$owner: String!\n\t$repo: String!\n $ref: String!\n $path: String!\n\t$before: String!\n) {\trepository(name: $repo owner: $owner) {\n object(expression: $ref) {\n ... on Commit {\n history(last:4, path: $path, before: $before) {\n totalCount\n pageInfo {\n startCursor\n }\n nodes {\n oid\n message\n committedDate\n }\n }\n }\n }\n }\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"496c35eaeff2c33d3f1256a25d83198ace6aa6b0\",\n \"path\": \"src/extension.ts\",\n \"before\": \"496c35eaeff2c33d3f1256a25d83198ace6aa6b0 4\"\n}","kind":"code"},{"code":"### Get Pull Request for Branch","kind":"markdown"},{"code":"query getPullRequestForBranch(\n\t$owner: String!\n\t$repo: String!\n\t$branch: String!\n\t$limit: Int!\n\t$include: [PullRequestState!]\n\t$avatarSize: Int\n) {\n\trepository(name: $repo, owner: $owner) {\n\t\trefs(query: $branch, refPrefix: \"refs/heads/\", first: 1) {\n\t\t\tnodes {\n\t\t\t\tassociatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpermalink\n\t\t\t\t\t\tnumber\n\t\t\t\t\t\ttitle\n\t\t\t\t\t\tstate\n\t\t\t\t\t\tupdatedAt\n\t\t\t\t\t\tclosedAt\n\t\t\t\t\t\tmergedAt\n\t\t\t\t\t\trepository {\n\t\t\t\t\t\t\tisFork\n\t\t\t\t\t\t\towner {\n\t\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"gitkraken\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"branch\": \"main\",\n\t\"limit\": 1\n}","kind":"code"},{"code":"### Get My Assigned Pull Requests","kind":"markdown"},{"code":"query getMyAssignedPullRequests($assigned: String!) {\n search(first: 100, query: $assigned, type: ISSUE) {\n nodes {\n ... on PullRequest {\n assignees(first: 100) {\n nodes {\n login\n avatarUrl\n url\n }\n }\n author {\n login\n avatarUrl\n url\n }\n baseRefName\n baseRefOid\n baseRepository {\n name\n owner {\n login\n }\n url\n }\n checksUrl\n isDraft\n isCrossRepository\n isReadByViewer\n headRefName\n headRefOid\n headRepository {\n name\n owner {\n login\n }\n url\n }\n permalink\n number\n title\n state\n additions\n deletions\n updatedAt\n closedAt\n mergeable\n mergedAt\n mergedBy {\n login\n }\n repository {\n isFork\n owner {\n login\n }\n }\n reviewDecision\n reviewRequests(first: 100) {\n nodes {\n asCodeOwner\n requestedReviewer {\n ... on User {\n login\n avatarUrl\n url\n }\n }\n }\n }\n totalCommentsCount\n }\n }\n }\n}\n\nvariables {\n \"assigned\": \"assignee:@me is:pr is:open archived:false repo:gitkraken/vscode-gitlens\"\n}","kind":"code"},{"code":"### Get My Assigned Issues","kind":"markdown"},{"code":"query MyQuery($assigned: String!) {\n search(first: 2, query: $assigned, type: ISSUE) {\n nodes {\n ... on Issue {\n assignees(first: 100) {\n nodes {\n login\n url\n avatarUrl\n }\n }\n author {\n login\n avatarUrl\n url\n }\n comments {\n totalCount\n }\n number\n title\n url\n createdAt\n closedAt\n closed\n updatedAt\n labels(first: 20) {\n nodes {\n color\n name\n }\n }\n reactions(content: THUMBS_UP) {\n totalCount\n }\n repository {\n name\n owner {\n login\n }\n }\n }\n }\n }\n}\n\nvariables {\n \"assigned\": \"assignee:@me type:issue is:open archived:false repo:gitkraken/vscode-gitlens\"\n}","kind":"code"}]} \ No newline at end of file +{"cells":[{"code":"### Get Default Branch & Tip","kind":"markdown"},{"code":"query getDefaultBranchAndTip(\r\n\t$owner: String!\r\n\t$repo: String!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tdefaultBranchRef {\r\n\t\t\tname\r\n\t\t\ttarget { oid }\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get Branches","kind":"markdown"},{"code":"query getBranches(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$branchQuery: String\r\n\t$cursor: String\r\n\t$limit: Int = 100\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\trefs(query: $branchQuery, refPrefix: \"refs/heads/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\r\n\t\t\tpageInfo {\r\n\t\t\t\tendCursor\r\n\t\t\t\thasNextPage\r\n\t\t\t}\r\n\t\t\tnodes {\r\n\t\t\t\tname\r\n\t\t\t\ttarget {\r\n\t\t\t\t\toid\r\n\t\t\t\t\tcommitUrl\r\n\t\t\t\t\t...on Commit {\r\n\t\t\t\t\t\tauthoredDate\r\n\t\t\t\t\t\tcommittedDate\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get Blame","kind":"markdown"},{"code":"query getBlame(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String!\r\n) {\r\n\tviewer { name }\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tobject(expression: $ref) {\r\n\t\t\t...on Commit {\r\n\t\t\t\tblame(path: $path) {\r\n\t\t\t\t\tranges {\r\n\t\t\t\t\t\tstartingLine\r\n\t\t\t\t\t\tendingLine\r\n\t\t\t\t\t\tage\r\n\t\t\t\t\t\tcommit {\r\n\t\t\t\t\t\t\toid\r\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\r\n\t\t\t\t\t\t\tmessage\r\n\t\t\t\t\t\t\tadditions\r\n\t\t\t\t\t\t\tchangedFiles\r\n\t\t\t\t\t\t\tdeletions\r\n\t\t\t\t\t\t\tauthor {\r\n\t\t\t\t\t\t\t\tavatarUrl\r\n\t\t\t\t\t\t\t\tdate\r\n\t\t\t\t\t\t\t\temail\r\n\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\tcommitter {\r\n\t\t\t\t\t\t\t\tdate\r\n\t\t\t\t\t\t\t\temail\r\n\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"gitkraken\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"0b8f151bd0458340b7779a64884c97754c3cedb8\",\r\n\t\"path\": \"package.json\"\r\n}","kind":"code"},{"code":"### Get Commit for File","kind":"markdown"},{"code":"query getCommitForFile(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tref(qualifiedName: $ref) {\r\n\t\t\ttarget {\r\n\t\t\t\t... on Commit {\r\n\t\t\t\t\thistory(first: 1, path: $path) {\r\n\t\t\t\t\t\tnodes {\r\n\t\t\t\t\t\t\toid\r\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\r\n\t\t\t\t\t\t\tmessage\r\n\t\t\t\t\t\t\tadditions\r\n\t\t\t\t\t\t\tchangedFiles\r\n\t\t\t\t\t\t\tdeletions\r\n\t\t\t\t\t\t\tauthor {\r\n\t\t\t\t\t\t\t\tdate\r\n\t\t\t\t\t\t\t\temail\r\n\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\tcommitter { date }\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"refs/heads/main\",\r\n\t\"path\": \"src/extension.ts\"\r\n}","kind":"code"},{"code":"### Get Current User","kind":"markdown"},{"code":"query getCurrentUser(\r\n\t$owner: String!\r\n\t$repo: String!\r\n) {\r\n\tviewer { name }\r\n\trepository(name: $repo owner: $owner) {\r\n\t\tviewerPermission\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get Commit","kind":"markdown"},{"code":"query getCommit(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: GitObjectID!\r\n) {\r\n\trepository(name: $repo owner: $owner) {\r\n\t\tobject(oid: $ref) {\r\n\t\t\t...on Commit {\r\n\t\t\t\toid\r\n\t\t\t\tparents(first: 3) { nodes { oid } }\r\n\t\t\t\tmessage\r\n\t\t\t\tadditions\r\n\t\t\t\tchangedFiles\r\n\t\t\t\tdeletions\r\n\t\t\t\tauthor {\r\n\t\t\t\t\tdate\r\n\t\t\t\t\temail\r\n\t\t\t\t\tname\r\n\t\t\t\t}\r\n\t\t\t\tcommitter { date }\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"54f28933055124d6ba3808a787f6947c929f9db0\"\r\n}","kind":"code"},{"code":"### Get Commits","kind":"markdown"},{"code":"query getCommits(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String\r\n\t$author: CommitAuthor\r\n\t$after: String\r\n\t$before: String\r\n\t$limit: Int = 100\r\n\t$since: GitTimestamp\r\n\t$until: GitTimestamp\r\n) {\r\n\tviewer { name }\r\n\trepository(name: $repo, owner: $owner) {\r\n\t\tobject(expression: $ref) {\r\n\t\t\t... on Commit {\r\n\t\t\t\thistory(first: $limit, author: $author, path: $path, after: $after, before: $before, since: $since, until: $until) {\r\n\t\t\t\t\tpageInfo {\r\n\t\t\t\t\t\tstartCursor\r\n\t\t\t\t\t\tendCursor\r\n\t\t\t\t\t\thasNextPage\r\n\t\t\t\t\t\thasPreviousPage\r\n\t\t\t\t\t}\r\n\t\t\t\t\tnodes {\r\n\t\t\t\t\t\t... on Commit {\r\n\t\t\t\t\t\t\toid\r\n\t\t\t\t\t\t\tmessage\r\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\r\n\t\t\t\t\t\t\tadditions\r\n\t\t\t\t\t\t\tchangedFiles\r\n\t\t\t\t\t\t\tdeletions\r\n\t\t\t\t\t\t\tauthor {\r\n\t\t\t\t\t\t\t\tavatarUrl\r\n\t\t\t\t\t\t\t\tdate\r\n\t\t\t\t\t\t\t\temail\r\n\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\tcommitter {\r\n\t\t\t\t\t\t\t\t date\r\n\t\t\t\t\t\t\t\t email\r\n\t\t\t\t\t\t\t\t name\r\n\t\t\t\t\t\t\t }\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"HEAD\",\r\n\t\"-path\": \"src/extension.ts\",\r\n\t\"since\": \"2022-02-07T00:00:00Z\"\r\n}","kind":"code"},{"code":"### Get Tags","kind":"markdown"},{"code":"query getTags(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$tagQuery: String\r\n\t$cursor: String\r\n\t$limit: Int = 100\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\trefs(query: $tagQuery, refPrefix: \"refs/tags/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\r\n\t\t\tpageInfo {\r\n\t\t\t\tendCursor\r\n\t\t\t\thasNextPage\r\n\t\t\t}\r\n\t\t\tnodes {\r\n\t\t\t\tname\r\n\t\t\t\ttarget {\r\n\t\t\t\t\toid\r\n\t\t\t\t\tcommitUrl\r\n\t\t\t\t\t...on Commit {\r\n\t\t\t\t\t\tauthoredDate\r\n\t\t\t\t\t\tcommittedDate\r\n\t\t\t\t\t\tmessage\r\n\t\t\t\t\t}\r\n\t\t\t\t\t...on Tag {\r\n\t\t\t\t\t\tmessage\r\n\t\t\t\t\t\ttagger { date }\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get collaborators ","kind":"markdown"},{"code":"query getCollaborators (\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$cursor: String\r\n\t$limit: Int = 100\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tcollaborators(affiliation: ALL, first: $limit, after: $cursor) {\r\n\t\t\tpageInfo {\r\n\t\t\t\tendCursor\r\n\t\t\t\thasNextPage\r\n\t\t\t}\r\n\t\t\tnodes {\r\n\t\t\t\tname\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Resolve reference","kind":"markdown"},{"code":"query resolveReference(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tobject(expression: $ref) {\r\n\t\t\t... on Commit {\r\n\t\t\t\thistory(first: 1, path: $path) {\r\n\t\t\t\t\tnodes { oid }\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"d790e9db047769de079f6838c3578f3a47bf5930^\",\r\n\t\"path\": \"CODE_OF_CONDUCT.md\"\r\n}","kind":"code"},{"code":"### Get branches that contain commit","kind":"markdown"},{"code":"query getCommitBranches(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$since: GitTimestamp!\r\n\t$until: GitTimestamp!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\trefs(first: 20, refPrefix: \"refs/heads/\", orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\r\n\t\t\tnodes {\r\n\t\t\t\tname\r\n\t\t\t\ttarget {\r\n\t\t\t\t\t... on Commit {\r\n\t\t\t\t\t\thistory(first: 3, since: $since until: $until) {\r\n\t\t\t\t\t\t\tnodes { oid }\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"since\": \"2022-01-06T01:07:46-04:00\",\r\n\t\"until\": \"2022-01-06T01:07:46-05:00\"\r\n}","kind":"code"},{"code":"query getCommitBranch(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$since: GitTimestamp!\r\n\t$until: GitTimestamp!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tref(qualifiedName: $ref) {\r\n\t\t\ttarget {\r\n\t\t\t\t... on Commit {\r\n\t\t\t\t\thistory(first: 3, since: $since until: $until) {\r\n\t\t\t\t\t\tnodes { oid }\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"refs/heads/main\",\r\n\t\"since\": \"2022-01-06T01:07:46-04:00\",\r\n\t\"until\": \"2022-01-06T01:07:46-05:00\"\r\n}","kind":"code"},{"code":"### Get commit count for branch (ref)","kind":"markdown"},{"code":"query getCommitCount(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tref(qualifiedName: $ref) {\r\n\t\t\ttarget {\r\n\t\t\t\t... on Commit {\r\n\t\t\t\t\thistory(first: 1) {\r\n\t\t\t\t\t\ttotalCount\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"refs/heads/main\"\r\n}","kind":"code"},{"code":"### Get commit refs (sha)","kind":"markdown"},{"code":"query getCommitRefs(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String\r\n\t$since: GitTimestamp\r\n\t$until: GitTimestamp\r\n\t$limit: Int = 1\r\n) {\r\n\tviewer { name }\r\n\trepository(name: $repo, owner: $owner) {\r\n\t\tref(qualifiedName: $ref) {\r\n\t\t\thistory(first: $limit, path: $path, since: $since, until: $until) {\r\n\t\t\t\tnodes { oid, message }\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"refs/heads/main\",\r\n\t\"path\": \"extension.ts\",\r\n\t\"limit\": 2\r\n}","kind":"code"},{"code":"### Get next file commit","kind":"markdown"},{"code":"query getCommitDate(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: GitObjectID!\r\n) {\r\n\trepository(name: $repo owner: $owner) {\r\n\t\tobject(oid: $ref) {\r\n\t\t\t...on Commit {\r\n\t\t\t\tcommitter { date }\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"a03ff942c40665c451bd4c7768f46e3e5f00e97c\"\r\n}","kind":"code"},{"code":"query getNextCommitCursor(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String!\r\n\t$since: GitTimestamp!\r\n) {\r\n\trepository(name: $repo owner: $owner) {\r\n\t\tobject(expression: $ref) {\r\n\t\t\t... on Commit {\r\n\t\t\t\thistory(first:1, path: $path, since: $since) {\r\n\t\t\t\t\ttotalCount\r\n\t\t\t\t\tpageInfo { startCursor }\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"b062e960b6ee5ca7ac081dd84d9217bd4b2051e0\",\r\n \"path\": \"src/extension.ts\",\r\n \"since\": \"2021-11-03T02:46:29-04:00\"\r\n}","kind":"code"},{"code":"query getNextCommit(\r\n\t$owner: String!\r\n\t$repo: String!\r\n $ref: String!\r\n $path: String!\r\n\t$before: String!\r\n) {\trepository(name: $repo owner: $owner) {\r\n object(expression: $ref) {\r\n ... on Commit {\r\n history(last:4, path: $path, before: $before) {\r\n totalCount\r\n pageInfo {\r\n startCursor\r\n }\r\n nodes {\r\n oid\r\n message\r\n committedDate\r\n }\r\n }\r\n }\r\n }\r\n }\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"496c35eaeff2c33d3f1256a25d83198ace6aa6b0\",\r\n \"path\": \"src/extension.ts\",\r\n \"before\": \"496c35eaeff2c33d3f1256a25d83198ace6aa6b0 4\"\r\n}","kind":"code"},{"code":"### Get Pull Request for Branch","kind":"markdown"},{"code":"query getPullRequestForBranch(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$branch: String!\r\n\t$limit: Int!\r\n\t$include: [PullRequestState!]\r\n\t$avatarSize: Int\r\n) {\r\n\trepository(name: $repo, owner: $owner) {\r\n\t\trefs(query: $branch, refPrefix: \"refs/heads/\", first: 1) {\r\n\t\t\tnodes {\r\n\t\t\t\tassociatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) {\r\n\t\t\t\t\tnodes {\r\n\t\t\t\t\t\tauthor {\r\n\t\t\t\t\t\t\tlogin\r\n\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\r\n\t\t\t\t\t\t\turl\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t\tpermalink\r\n\t\t\t\t\t\tnumber\r\n\t\t\t\t\t\ttitle\r\n\t\t\t\t\t\tstate\r\n\t\t\t\t\t\tcreatedAt\r\n\t\t\t\t\t\tupdatedAt\r\n\t\t\t\t\t\tclosedAt\r\n\t\t\t\t\t\tmergedAt\r\n\t\t\t\t\t\trepository {\r\n\t\t\t\t\t\t\tisFork\r\n\t\t\t\t\t\t\towner {\r\n\t\t\t\t\t\t\t\tlogin\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"gitkraken\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"branch\": \"main\",\r\n\t\"limit\": 1\r\n}","kind":"code"},{"code":"### Get My Assigned Pull Requests","kind":"markdown"},{"code":"query getMyAssignedPullRequests($assigned: String!) {\r\n search(first: 100, query: $assigned, type: ISSUE) {\r\n nodes {\r\n ... on PullRequest {\r\n assignees(first: 100) {\r\n nodes {\r\n login\r\n avatarUrl\r\n url\r\n }\r\n }\r\n author {\r\n login\r\n avatarUrl\r\n url\r\n }\r\n baseRefName\r\n baseRefOid\r\n baseRepository {\r\n name\r\n owner {\r\n login\r\n }\r\n url\r\n }\r\n checksUrl\r\n isDraft\r\n isCrossRepository\r\n isReadByViewer\r\n headRefName\r\n headRefOid\r\n headRepository {\r\n name\r\n owner {\r\n login\r\n }\r\n url\r\n }\r\n permalink\r\n number\r\n title\r\n state\r\n additions\r\n deletions\r\n createdAt\r\n updatedAt\r\n closedAt\r\n mergeable\r\n mergedAt\r\n mergedBy {\r\n login\r\n }\r\n reactions(content: THUMBS_UP) {\r\n totalCount\r\n }\r\n repository {\r\n isFork\r\n owner {\r\n login\r\n }\r\n viewerPermission\r\n }\r\n reviewDecision\r\n reviewRequests(first: 100) {\r\n nodes {\r\n asCodeOwner\r\n requestedReviewer {\r\n ... on User {\r\n login\r\n avatarUrl\r\n url\r\n }\r\n }\r\n }\r\n }\r\n totalCommentsCount\r\n viewerCanUpdate\r\n }\r\n }\r\n }\r\n}\r\n\r\nvariables {\r\n \"assigned\": \"assignee:@me is:pr is:open archived:false repo:gitkraken/vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get My Assigned Issues","kind":"markdown"},{"code":"query MyQuery($assigned: String!) {\r\n search(first: 2, query: $assigned, type: ISSUE) {\r\n nodes {\r\n ... on Issue {\r\n assignees(first: 100) {\r\n nodes {\r\n login\r\n url\r\n avatarUrl\r\n }\r\n }\r\n author {\r\n login\r\n avatarUrl\r\n url\r\n }\r\n comments {\r\n totalCount\r\n }\r\n number\r\n title\r\n url\r\n createdAt\r\n closedAt\r\n closed\r\n updatedAt\r\n labels(first: 20) {\r\n nodes {\r\n color\r\n name\r\n }\r\n }\r\n reactions(content: THUMBS_UP) {\r\n totalCount\r\n }\r\n repository {\r\n name\r\n owner {\r\n login\r\n }\r\n }\r\n }\r\n }\r\n }\r\n}\r\n\r\nvariables {\r\n \"assigned\": \"assignee:@me type:issue is:open archived:false repo:gitkraken/vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get PR CI Checks","kind":"markdown"},{"code":"query getPullRequest(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$number: Int!\r\n) {\r\n\trepository(name: $repo, owner: $owner) {\r\n\t\tpullRequest(number: $number) {\r\n\t\t\ttitle\r\n \t\tcommits(last: 1) {\r\n\t\t\t\tnodes {\r\n\t\t\t\t\tcommit {\r\n\t\t\t\t\t\toid\r\n\t\t\t\t\t\tstatusCheckRollup {\r\n\t\t\t\t\t\t\tstate\r\n\t\t\t\t\t\t\tcontexts(first: 10) {\r\n \t\t\t\tedges {\r\n \t\t\t\t\tnode {\r\n \t\t\t\t\t... on CheckRun {\r\n\t\t\t\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t\t\t\t\tstatus\r\n\t\t\t\t\t\t\t\t\t\t\tconclusion\r\n\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t}\r\n \t\t\t\t}\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"gitkraken\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"number\": 3036\r\n}","kind":"code"},{"code":"### Search for Pull Request\n","kind":"markdown"},{"code":"query searchMyPullRequests(\n\t$search: String!\n\t$avatarSize: Int\n) {\n\trateLimit {\n \tcost\n \t}\n\tviewer {\n\t\tlogin\n\t}\n\tsearch(first: 100, query: $search, type: ISSUE) {\n\t\tissueCount\n\t\tnodes {\n\t\t\t...on PullRequest {\n\t\t\t\tclosed\n\t\t\t\tclosedAt\n\t\t\t\tcreatedAt\n\t\t\t\tid\n\t\t\t\tnumber\n\t\t\t\tstate\n\t\t\t\ttitle\n\t\t\t\tupdatedAt\n\t\t\t\turl\n\t\t\t\tauthor {\n\t\t\t\t\tlogin\n\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t\tbaseRefName\n\t\t\t\tbaseRefOid\n\t\t\t\theadRefName\n\t\t\t\theadRefOid\n\t\t\t\theadRepository {\n\t\t\t\t\tname\n\t\t\t\t\towner {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t}\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t\tisCrossRepository\n\t\t\t\tmergedAt\n\t\t\t\tpermalink\n\t\t\t\trepository {\n\t\t\t\t\tisFork\n\t\t\t\t\tname\n\t\t\t\t\towner {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t}\n\t\t\t\t\tviewerPermission\n\t\t\t\t}\n\t\t\t\tadditions\n\t\t\t\tassignees(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\turl\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tchecksUrl\n\t\t\t\tdeletions\n\t\t\t\tisDraft\n\t\t\t\tmergeable\n\t\t\t\tmergedBy {\n\t\t\t\t\tlogin\n\t\t\t\t}\n\t\t\t\tmergeable\n\t\t\t\tmergedBy {\n\t\t\t\t\tlogin\n\t\t\t\t}\n\t\t\t\treviewDecision\n\t\t\t\tlatestReviews (first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t\tstate\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treviewRequests(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tasCodeOwner\n\t\t\t\t\t\tid\n\t\t\t\t\t\trequestedReviewer {\n\t\t\t\t\t\t\t... on User {\n\t\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\t\t\turl\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tstatusCheckRollup {\n\t\t\t\t\tstate\n\t\t\t\t}\n\t\t\t\ttotalCommentsCount\n\t\t\t\tviewerCanUpdate\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"search\": \"is:pr is:open archived:false involves:@me\",\n\t\"avatarSize\": 16\n}\n","kind":"code"},{"code":"### Search for Pull Request (Lite)\n","kind":"markdown"},{"code":"query searchMyPullRequests(\n\t$search: String!\n\t$avatarSize: Int\n) {\n\trateLimit {\n \tcost\n \t}\n\tviewer {\n\t\tlogin\n\t}\n\tsearch(first: 100, query: $search, type: ISSUE) {\n\t\tissueCount\n\t\tnodes {\n\t\t\t...on PullRequest {\n\t\t\t\tclosed\n\t\t\t\tclosedAt\n\t\t\t\tcreatedAt\n\t\t\t\tid\n\t\t\t\tnumber\n\t\t\t\tstate\n\t\t\t\ttitle\n\t\t\t\tupdatedAt\n\t\t\t\turl\n\t\t\t\tauthor {\n\t\t\t\t\tlogin\n\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t\tbaseRefName\n\t\t\t\tbaseRefOid\n\t\t\t\theadRefName\n\t\t\t\theadRefOid\n\t\t\t\theadRepository {\n\t\t\t\t\tname\n\t\t\t\t\towner {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t}\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t\tisCrossRepository\n\t\t\t\tmergedAt\n\t\t\t\tpermalink\n\t\t\t\trepository {\n\t\t\t\t\tisFork\n\t\t\t\t\tname\n\t\t\t\t\towner {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t}\n\t\t\t\t\tviewerPermission\n\t\t\t\t}\n\t\t\t\tadditions\n\t\t\t\tassignees(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\turl\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tchecksUrl\n\t\t\t\tdeletions\n\t\t\t\tisDraft\n\t\t\t\tmergeable\n\t\t\t\tmergedBy {\n\t\t\t\t\tlogin\n\t\t\t\t}\n\t\t\t\treviewDecision\n\t\t\t\t# myReviews:reviews(states: [APPROVED, CHANGES_REQUESTED, COMMENTED] author: \"@me\") {\n\t\t\t\t# \ttotalCount\n\t\t\t\t# }\n\t\t\t\tpendingReviews:reviews(first: 10,states: [PENDING]) {\n\t\t\t\t\ttotalCount\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t\tstate\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tchangesRequestedReviews:reviews(states: [CHANGES_REQUESTED]) {\n\t\t\t\t\ttotalCount\n\t\t\t\t}\n\t\t\t\tcommentedReviews:reviews(states: [COMMENTED]) {\n\t\t\t\t\ttotalCount\n\t\t\t\t}\n\t\t\t\treviewRequests {\n\t\t\t\t\ttotalCount\n\t\t\t\t}\n\t\t\t\t# latestReviews(first: 10) {\n\t\t\t\t# \tnodes {\n\t\t\t\t# \t\tauthor {\n\t\t\t\t# \t\t\tlogin\n\t\t\t\t# \t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t# \t\t\turl\n\t\t\t\t# \t\t}\n\t\t\t\t# \t\tstate\n\t\t\t\t# \t}\n\t\t\t\t# }\n\t\t\t\t# reviewRequests(first: 10) {\n\t\t\t\t# \tnodes {\n\t\t\t\t# \t\tasCodeOwner\n\t\t\t\t# \t\tid\n\t\t\t\t# \t\trequestedReviewer {\n\t\t\t\t# \t\t\t... on User {\n\t\t\t\t# \t\t\t\tlogin\n\t\t\t\t# \t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t# \t\t\t\turl\n\t\t\t\t# \t\t\t}\n\t\t\t\t# \t\t}\n\t\t\t\t# \t}\n\t\t\t\t# }\n\t\t\t\tstatusCheckRollup {\n\t\t\t\t\tstate\n\t\t\t\t}\n\t\t\t\ttotalCommentsCount\n\t\t\t\tviewerCanUpdate\n\t\t\t\tviewerLatestReview {\n\t\t\t\t\tid\n\t\t\t\t\tstate\n\t\t\t\t}\n\t\t\t\tviewerLatestReviewRequest {\n\t\t\t\t\tid\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"search\": \"is:pr is:open archived:false involves:@me\",\n\t\"avatarSize\": 16\n}\n","kind":"code"}]} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 5a748806bc17f..4367562dcbdc1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,7 @@ { "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, - "eslint.packageManager": "yarn", "files.associations": { ".eslintrc*.json": "jsonc" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 9326fb48adbc0..59246ed391a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,495 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- Adds support for Anthropic's Claude 3.5 Sonnet model for GitLens' experimental AI features +- Adds a new `counts` option to the `gitlens.launchpad.indicator.label` setting to show the status counts of items which need your attention in the _Launchpad_ status bar indicator +- Adds _Search for Commits within Selection_ command to the editor context menu when there is a selection +- Adds a `gitlens.launchpad.ignoredOrganizations` setting to specify an array of organizations (or users) to ignore in the _Launchpad_ +- Improves the tooltips of stashes in GitLens views + - Adds a `gitlens.views.formats.stashes.tooltip` setting to specify the tooltip format of the stashes in GitLens views +- Improves the display of branch and tag tips in the _File History_ and _Line History_ and in commit tooltips in GitLens views + - Adds provider-specific icons to tips of remote branches +- Adds pull request scroll and minimap markers to the _Commit Graph_ + +### Changed + +- Renames `Reset Stored AI Key` command to `Reset Stored AI Keys...` and adds confirmation prompt with options to reset only the current or all AI keys +- Renames _Open Inspect_ to _Inspect Commit Details_ +- Renames _Open Line Inspect_ to _Inspect Line Commit Details_ +- Renames _Open Details_ to _Inspect Commit Details_ +- Replaces _Open in Editor_ link in the Launchpad with a link to _gitkraken.dev_ + +### Fixed + +- Fixes [#3344](https://github.com/gitkraken/vscode-gitlens/issues/3344) - Make changing the AI key easier +- Fixes [#3377](https://github.com/gitkraken/vscode-gitlens/issues/3377) - Cannot read properties of undefined (reading 'start') +- Fixes [#3377](https://github.com/gitkraken/vscode-gitlens/issues/3378) - Deleting a worktree (without force) with working changes causes double prompts +- Fixes fixes issue with Jira integration not refreshing + +## [15.1.0] - 2024-06-05 + +### Added + +- Adds support for GitHub Copilot and other VS Code extension-provided AI models for GitLens' experimental AI features + - Adds a `gitlens.ai.experimental.model` setting to specify the AI model to use + - Adds a `gitlens.ai.experimental.vscode.model` setting to specify the VS Code extension-provided AI model to use when `gitlens.ai.experimental.model` is set to `vscode` +- Adds new Launchpad improvements: + - Collapsed state of Launchpad groups are now saved between uses + - The _Draft_ and _Pinned_ categories in the Launchpad now always sort their items by date + - The Launchpad and Launchpad status bar indicator now indicate when there is an error loading data + - The Launchpad indicator now shows the Launchpad icon next to the loading spinner when the Launchpad is loading + +### Changed + +- Changes the settings used to configure the AI models for GitLens' experimental AI features + - Adds a `gitlens.ai.experimental.model` setting to specify the AI model to use + - Removes the `gitlens.ai.experimental.provider`, `gitlens.ai.experimental.openai.model`, `gitlens.ai.experimental.anthropic.model`, and `gitlens.ai.experimental.gemini.model` settings in favor of the above + +### Fixed + +- Fixes [#3295](https://github.com/gitkraken/vscode-gitlens/issues/3295) - Incorrect pluralization in Authors lens — thanks to [PR #3296](https://github.com/gitkraken/vscode-gitlens/pull/3296) by bm-w ([@bm-w](https://github.com/bm-w)) +- Fixes [#3277](https://github.com/gitkraken/vscode-gitlens/issues/3277) - Unable to pull branch when the local branch whose name differs from its tracking branch + +## [15.0.4] - 2024-05-20 + +### Added + +- Adds a _Copy as Markdown_ context menu command to autolinks in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Adds a _Connect Remote Integration_ command to the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Adds `gitlens.currentLine.fontFamily`, `gitlens.currentLine.fontSize`, `gitlens.currentLine.fontStyle`, `gitlens.currentLine.fontWeight` settings to specify the font (family, size, style, and weight respectively) of the _Inline Blame_ annotation — closes [#3306](https://github.com/gitkraken/vscode-gitlens/issues/3306) +- Adds `gitlens.blame.fontStyle` settings to specify the font style of the _File Blame_ annotations + +### Changed + +- Improves the _Copy_ context menu command on autolinks in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Changes the _Open Issue on Remote_ context menu command on autolinks to _Open URL_ in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Changes the _Copy Issue URL_ context menu command on autolinks to _Copy URL_ in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Renames the _Connect to Remote_ command to _Connect Remote Integration_ +- Renames the _Disconnect from Remote_ command to _Disconnect Remote Integration_ + +### Fixed + +- Fixes [#3299](https://github.com/gitkraken/vscode-gitlens/issues/3299) - Branches view no longer displays text colors for branch status after updating to v15.0.0 or above +- Fixes [#3277](https://github.com/gitkraken/vscode-gitlens/issues/3277) (in pre-release only) - Unable to pull branch when the local branch whose name differs from its tracking branch +- Fixes "hang" in Worktrees view when a worktree is missing +- Fixes an issue where the Commit Graph header bar sometimes pushes "Fetch" to the right +- Fixes an issue where the autolink type (issue vs pull request) was not shown properly in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view + +## [15.0.3] - 2024-05-14 + +### Fixed + +- Fixes [#3288](https://github.com/gitkraken/vscode-gitlens/issues/3288) - Branch, Tags, Stashes, Local Branch, and Remote Branch "Markers" Are Missing/Removed From Minimap + +## [15.0.2] - 2024-05-14 + +### Fixed + +- Fixes [#3270](https://github.com/gitkraken/vscode-gitlens/issues/3270) - GitLens erroneously thinks certain branches are worktrees under some conditions + +## [15.0.1] - 2024-05-14 + +## [15.0.0] - 2024-05-14 + +### Added + +- Adds [Launchpad](https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links) `preview`, a new Pro feature bringing your GitHub pull requests into a unified, categorized list to keep you focused and your team unblocked + - Open using the new _GitLens: Open Launchpad_ command + - Categorizes pull requests by status + - _Current Branch_: Pull requests associated with your current branch + - _Ready to Merge_: Pull requests without conflicts, ci failures, change suggestions or other issues preventing merge + - _Blocked_: Pull requests with conflicts, CI failures, or that have no reviewers assigned + - _Needs Your Review_: Pull requests waiting for your review + - _Requires Follow-Up_: Pull requests that have been reviewed and need follow-up + - _Draft_: Draft pull requests + - _Pinned_: Pull requests you have pinned + - _Snoozed_: Pull requests you have snoozed + - _Other_: Other pull requests + - Action on a pull request directly from the Launchpad: + - Merge a pull request + - Open a pull request on GitHub + - Switch to or create a branch or worktree for a pull request to review changes + - Display a pull request's details in the _Overview_ + - Open a pull request's changes in the multi-diff editor + - View a pull request's branch in the _Commit Graph_ + - View or create code suggestions for a pull request + - Pin or snooze a pull request in the Launchpad + - Adds a status bar indicator of the _Launchpad_ + - Opens the Launchpad when clicked + - Shows the top pull request and its status in the status bar + - Also highlights your top pull request in the launchpad when opened from the indicator + - Provides a summary of your most critical pull requests on hover + - Each summary line includes a link to open the Launchpad to that category + - Adds new settings for the Launchpad and indicator + - `gitlens.launchpad.ignoredRepositories`: Array of repositories with `owner/name` format to ignore in the Launchpad + - `gitlens.launchpad.staleThreshold`: Value in days after which a pull request is considered stale and moved to the _Other_ category + - `gitlens.launchpad.indicator.enabled`: Specifies whether to show the Launchpad indicator in the status bar + - `gitlens.launchpad.indicator.icon`: Specifies the style of the Launchpad indicator icon + - `gitlens.launchpad.indicator.label`: Specifies the style of the Launchpad indicator label + - `gitlens.launchpad.indicator.groups`: Specifies which critical categories of pull requests to summarize in the indicator tooltip + - `gitlens.launchpad.indicator.useColors`: Specifies whether to use colors in the indicator + - `gitlens.launchpad.indicator.openInEditor`: Specifies whether to open the Launchpad in the editor when clicked + - `gitlens.launchpad.indicator.polling.enabled`: Specifies whether to regularly check for changes to pull requests + - `gitlens.launchpad.indicator.polling.interval`: Specifies the interval in minutes to check for changes to pull requests +- Adds new features that make code reviews easier + - Adds [Code Suggest](https://gitkraken.com/solutions/code-suggest?utm_source=gitlens-extension&utm_medium=in-app-links) `preview`, a cloud feature, that frees your code reviews from unnecessary restrictions + - Create a Code Suggestion from the _Inspect: Overview_ tab when on a PR's branch + - Upon creation of a Code Suggestion, a comment will appear on the pull request + - Code Suggestions can be viewed and apply directly from [gitkraken.dev](https://gitkraken.dev), or open in GitKraken Desktop or GitLens. + - See a PR's Code Suggestions from anywhere we currently display PR information in our views (Commits, Branches, Remotes) + - You can additionally start Code Suggestions from the Launchpad + - Adds a _Pull Request_ view to view PR commits and review file changes + - Adds a _Pull Request_ badge to the Graph and the Inspect Overview +- Adds rich Jira Cloud integration + - Enables rich automatic Jira autolinks in commit messages everywhere autolinks are supported in GitLens + - Adds a _Cloud Integrations_ button to the GitKraken Account view and a new `GitLens: Manage Cloud Integrations` command to manage connected cloud integrations + - Adds a _Manage Jira_ button to _Inspect_ and a link in Autolink settings to connect to Jira +- Adds support for Google Gemini for GitLens' experimental AI features + - Adds a `gitlens.ai.experimental.gemini.model` setting to specify the Gemini model +- Adds support for the latest OpenAI and Anthropic models for GitLens' experimental AI features +- Adds a new `gitlens.views.collapseWorktreesWhenPossible` setting to specify whether to try to collapse the opened worktrees into a single (common) repository in the views when possible + +### Changed + +- Reworks _Commit Details_, now called the _Inspect_ view + - Revamps the _Working Changes_ tab into the _Overview_ tab + - Provides richer branch status information and branch switching + - Adds Push, Pull, and Fetch actions + - Richer Pull Request Information + - Open details in the Pull Request view + - Links to open and compare changes + - List of the PR's Code Suggestions + - Create a Code Suggestion by clicking the _Suggest Changes for PR_ button +- Improves contributor and team member picking for the adding co-authors, _Code Suggest_, and _Cloud Patches_ +- Improves performance when creating colors derived from the VS Code theme +- Changes the command to open the Launchpad in the editor (formerly _Focus View_) from _GitLens: Show Focus_ to _GitLens: Open Launchpad in Editor_ +- Renames the setting `gitlens.focus.allowMultiple` to `gitlens.launchpad.allowMultiple` +- Updates most deep link prompts to quick picks or quick inputs, moves most prompts to before a repository is opened. +- Updates Pro upgrade links to use the newer gitkraken.dev site + +### Fixed + +- Fixes [#3221](https://github.com/gitkraken/vscode-gitlens/issues/3221) - Cannot use word "detached" in branch names +- Fixes [#3197](https://github.com/gitkraken/vscode-gitlens/issues/3197) - Only emojify standalone emojis — thanks to [PR #3208](https://github.com/gitkraken/vscode-gitlens/pull/3208) by may ([@m4rch3n1ng](https://github.com/m4rch3n1ng)) +- Fixes [#3180](https://github.com/gitkraken/vscode-gitlens/issues/3180) - Focus View feedback button is not working +- Fixes [#3179](https://github.com/gitkraken/vscode-gitlens/issues/3179) - The checkmarks in cherry pick are not displayed +- Fixes [#3249](https://github.com/gitkraken/vscode-gitlens/issues/3249) - Error "Cannot read properties of null (reading 'map') +- Fixes [#3198](https://github.com/gitkraken/vscode-gitlens/issues/3198) - Repository location in cloud workspace doesn't work when the repo descriptor does not contain a url +- Fixes [#3143](https://github.com/gitkraken/vscode-gitlens/issues/3143) - File Annotation icon isn't themed according to the icons... + +## [14.9.0] - 2024-03-06 + +### Added + +- Adds support for Anthropic's Claude 3 Opus & Sonnet models for GitLens' experimental AI features +- Adds a _Compare with Common Base_ command to branches in the _Commit Graph_ and views to review the changes if the selected branch were to be merged by comparing the common ancestor (merge base) with the current branch to the selected branch +- Adds an _Open All Changes with Common Base_ command to branches in the _Commit Graph_ and views to review the changes if the selected branch were to be merged in the multi-diff editor +- Adds a _Stash All Changes_ command to Source Control repository toolbar (off by default) +- Adds the repository name as a prefix to worktree name when adding to the current workspace +- Adds a better message when stashing only untracked files without including untracked files +- Adds a new group of _Cloud Patches_ titled as “Suggested Changes” that includes suggestions coming from pull requests. + +### Changed + +- Re-adds _Add to Workspace_ option when creating a worktree — closes [#3160](https://github.com/gitkraken/vscode-gitlens/issues/3160) +- Changes _Commit Graph_ date style to default to the default date style — refs [#3153](https://github.com/gitkraken/vscode-gitlens/issues/3153) +- Renames the _Compare Ancestry with Working Tree_ command on branches to _Compare Common Base with Working Tree_ for better clarity +- Improves _File Blame_ annotations performance and layout accuracy with certain character sets +- Improves string formatting performance + +### Fixed + +- Fixes [#3146](https://github.com/gitkraken/vscode-gitlens/issues/3146) - Search & Compare fails to remember items after restart +- Fixes [#3152](https://github.com/gitkraken/vscode-gitlens/issues/3152) - Fixes double encoding of redirect URLs during account sign-in which affects certain environments +- Fixes [#3153](https://github.com/gitkraken/vscode-gitlens/issues/3153) - `gitlens.defaultDateStyle` not working in Commit Details view +- Fixes the _Open Pull Request Changes_ & _Compare Pull Request_ commands to scope the changes only to the pull request +- Fixes broken _Compare Common Base with Working Tree_ (previously _Compare Ancestry with Working Tree_) +- Fixes issue when switching to a worktree via branch switch when there are multiple repos in the workspace + +## [14.8.2] - 2024-02-16 + +### Fixed + +- Fixes incorrect organization self-hosting message when creating a Cloud Patch + +## [14.8.1] - 2024-02-15 + +### Added + +- Adds a _Create New Branch..._ option to the _Git Switch to..._ command to easily create a new branch to switch to — closes [#3138](https://github.com/gitkraken/vscode-gitlens/issues/3138) +- Adds the ability to start a new trial from the _Account View_ and feature gates for users without a Pro account whose Pro trial has been expired for over 90 days. + +### Fixed + +- Fixes AI features not being displayed when signed-out of an account + +## [14.8.0] - 2024-02-08 + +### Added + +- Adds support for Cloud Patches hosted on your own dedicated storage for the highest level of security (requires an Enterprise plan) +- Improves worktree usage, discoverability, and accessibility + - Simplifies the create worktree and open worktree flows — reduces number of steps and options presented + - Adds _Create Branch in New Worktree_ confirmation option when creating branches, e.g. via the _GitLens: Git Create Branch..._ command + - Adds _Create Worktree for Branch_, _Create Worktree for Local Branch_, and _Create Worktree for New Local Branch_ confirmation options when switching branches, e.g. via the _GitLens: Git Switch to..._ command + - Adds a _Copy Working Changes to Worktree..._ command to the _Commit Graph_ and command palette to copy the current working changes to an existing worktree + - Avoids prompt to add a (required) remote and instead auto-adds the remote during worktree creation from a pull request +- Adds ability to open multiple changes in VS Code's new multi-diff editor, previously experimental and now enabled by default + - Adds an inline _Open All Changes_ command to commits, stashes, and comparisons in the views + - Changes _Open All Changes_ & _Open All Changes with Working Tree_ commands to use the new multi-diff editor when enabled + - Adds _Open All Changes, Individually_ & _Open All Changes with Working Tree, Individually_ commands to provide access to the previous behavior + - Renames the `gitlens.experimental.openChangesInMultiDiffEditor` setting to `gitlens.views.openChangesInMultiDiffEditor`, which is enabled by default, to specify whether to open changes in the multi-diff editor (single tab) or in individual diff editors (multiple tabs) + - Requires VS Code `1.86` or later, or VS Code `1.85` with `multiDiffEditor.experimental.enabled` enabled +- Adds new comparison features to pull requests in GitLens views + - Adds an _Open Pull Request Changes_ context menu command on pull requests in the _Commit Graph_ and other GitLens views to view pull request changes in a multi-diff editor (single tab) + - Requires VS Code `1.86` or later, or VS Code `1.85` with `multiDiffEditor.experimental.enabled` enabled + - Adds a _Compare Pull Request_ context menu command on pull requests in the _Commit Graph_ and other GitLens views to open a comparison between the head and base of the pull request for easy reviewing +- Adds an _Open in Commit Graph_ context menu command on pull requests in GitLens view to open the tip commit in the _Commit Graph_ +- Adds ability to copy changes, commits, stashes, and comparison as a patch to the clipboard + - Adds a _Copy as Patch_ context menu command on files, commits, stashes, and comparisons in GitLens views + - Adds a _Copy as Patch_ context menu command on files in the _Changes_ and _Staged Changes_ groups as well as the groups themselves in the _Source Control_ view + - Adds a _Apply Copied Patch_ command in the command palette to apply a patch from the clipboard +- Adds an _Open All Changes_ inline button to branch status (upstream) and branch status files in GitLens views +- Adds an _Open Changes_ submenu to branch status (upstream) and branch status files in GitLens views +- Adds ability to preserve inline and file annotations while editing, previously experimental and now enabled by default + - Renames the `gitlens.experimental.allowAnnotationsWhenDirty` setting to `gitlens.fileAnnotations.preserveWhileEditing`, which is enabled by default, to specify whether file annotations will be preserved while editing — closes [#1988](https://github.com/gitkraken/vscode-gitlens/issues/1988), [#3016](https://github.com/gitkraken/vscode-gitlens/issues/3016) + - Use the existing `gitlens.advanced.blame.delayAfterEdit` setting to control how long to wait (defaults to 5s) before the annotation will update while the file is still dirty, which only applies if the file is under the `gitlens.advanced.sizeThresholdAfterEdit` setting threshold (defaults to 5000 lines) +- Adds an _Open File Annotation Settings_ command to the _File Annotations_ submenu in the editor toolbar to open the GitLens Settings editor to the file annotations sections +- Adds `gitlens.blame.fontFamily`, `gitlens.blame.fontSize`, `gitlens.blame.fontWeight` settings to specify the font (family, size, and weight respectively) of the _File Blame_ annotations — closes [#3134](https://github.com/gitkraken/vscode-gitlens/issues/3134) +- Adds _Copy Link to Code_, _Copy Link to File_, and _Copy Link to File at Revision..._ commands to the _Share_ submenu in the editor line number (gutter) context menu +- Adds an alternate flow (pick another file) when using the _Open File at Revision..._ and _Open Changes with Revision..._ commands to open a file that has been renamed and the rename is currently unstaged — closes [#3109](https://github.com/gitkraken/vscode-gitlens/issues/3109) +- Adds access to most _Git Command Palette_ commands directly to the command palette +- Adds _Rename Stash..._ options to stash quick pick menus +- Adds support for the latest GPT-4 Turbo models + +### Changed + +- Changes adds avatars to commits in quick pick menus +- Changes the pull request to be first item in the _Commits_ view, when applicable +- Changes the branch comparison to be below the branch status in the _Commits_ view to keep top focus on the status over the comparison +- Renames "Open Worktree for Pull Request via GitLens..." to "Checkout Pull Request in Worktree (GitLens)..." +- Renames the `gitlens.experimental.openChangesInMultiDiffEditor` setting to `gitlens.views.openChangesInMultiDiffEditor` as it is no longer experimental and enabled by default + +### Fixed + +- Fixes [#3115](https://github.com/gitkraken/vscode-gitlens/issues/3115) - Always-on file annotations +- Fixes ahead/behind diffs on files (root) in the _Commits_ view to correctly show the diff of the range rather than the base to the working tree +- Fixes missing repository icons in the _Repositories_ view +- Fixes [#3116](https://github.com/gitkraken/vscode-gitlens/issues/3116) - Fix typos in README.md and package.json — thanks to [PR #3117](https://github.com/gitkraken/vscode-gitlens/pull/3117) by yutotnh ([@yutotnh](https://github.com/yutotnh)) + +## [14.7.0] - 2024-01-17 + +### Added + +- Adds the ability to share Cloud Patches with specific members of your GitKraken organization + - You can now share Cloud Patches exclusively with specific members of your organization by selecting _Collaborators Only_ when viewing or creating a Cloud Patch + - Click the _Invite_ button at the bottom of the _Patch Details_ view to add members of your organization to collaborate and click _Update Patch_ to save your changes + - Cloud Patch collaborators will see these Patches under the _Shared with Me_ section of the _Cloud Patches_ view +- Adds support for deep links to files and code + - Deep link format: `https://gitkraken.dev/link/r/{repoId}/f/{filePath}?[url={remoteUrl}|path={repoPath}]&lines={lines}&ref={ref}` + - Adds _Copy Link to File_, _Copy Link to File at Revision..._, and _Copy Link to Code_ commands to the _Copy As_ submenu in the editor context menu and to the _Share_ submenu of files in GitLens views +- Adds the ability to choose multiple stashes to drop in the _Git Command Palette_'s _stash drop_ command — closes [#3102](https://github.com/gitkraken/vscode-gitlens/issues/3102) +- Adds a new _prune_ subcommand to the _Git Command Palette_'s _branch_ command to easily delete local branches with missing upstreams +- Adds a new _Push Stash Snapshot_ confirmation option to the _Git Command Palette_'s _stash push_ command to save a stash without changing the working tree +- Adds _Copy_ to search results in the _Search & Compare_ view to copy the search query to more easily share or paste queries into the _Commit Graph_ +- Adds a status bar indicator when blame annotations (inline, statusbar, file annotations, etc) are paused because the file has unsaved changes (dirty), with a tooltip explaining why and how to configure/change the behavior +- Adds an experimental `gitlens.experimental.allowAnnotationsWhenDirty` setting to specify whether file annotations are allowed on files with unsaved changes (dirty) — closes [#1988](https://github.com/gitkraken/vscode-gitlens/issues/1988), [#3016](https://github.com/gitkraken/vscode-gitlens/issues/3016) + - Use the existing `gitlens.advanced.blame.delayAfterEdit` setting to control how long to wait (defaults to 5s) before the annotation will update while the file is still dirty, which only applies if the file is under the `gitlens.advanced.sizeThresholdAfterEdit` setting threshold (defaults to 5000 lines) +- Adds a `gitlens.fileAnnotations.dismissOnEscape` setting to specify whether pressing the `ESC` key dismisses the active file annotations — closes [#3016](https://github.com/gitkraken/vscode-gitlens/issues/3016) + +### Changed + +- Changes the commit search by file to allow some fuzziness by default — closes [#3086](https://github.com/gitkraken/vscode-gitlens/issues/3086) + - For example, if you enter `file:readme.txt`, we will treat it as `file:**/readme.txt`, or if you enter `file:readme` it will be treated as `file:*readme*` +- Improves the _Switch_ command to no longer fail when trying to switch to a branch that is linked to another worktree and instead offers to open the worktree +- Changes branch/tag "tips" that are shown on commits in many GitLens views to be truncated to 11 characters by default to avoid stealing to much real estate + +### Fixed + +- Fixes [#3087](https://github.com/gitkraken/vscode-gitlens/issues/3087) - Terminal executed commands fail if the GitLens terminal is closed +- Fixes [#2784](https://github.com/gitkraken/vscode-gitlens/issues/2784) - Git stash push error +- Fixes [#2926](https://github.com/gitkraken/vscode-gitlens/issues/2926) in more cases - "Open File at Revision" has incorrect editor label if revision contains path separator — thanks to [PR #3060](https://github.com/gitkraken/vscode-gitlens/issues/3060) by Ian Chamberlain ([@ian-h-chamberlain](https://github.com/ian-h-chamberlain)) +- Fixes [#3066](https://github.com/gitkraken/vscode-gitlens/issues/3066) - Editing a large file and switching away to another file without saving causes current line blame to disappear; thanks to [PR #3067](https://github.com/gitkraken/vscode-gitlens/pulls/3067) by Brandon Cheng ([@gluxon](https://github.com/gluxon)) +- Fixes [#3063](https://github.com/gitkraken/vscode-gitlens/issues/3063) - Missing icons in GitLens Settings UI +- Fixes issue with _Switch_ command not honoring the confirmation setting +- Fixes worktree delete from offering to delete main worktree (which isn't possible) +- Fixes worktree delete on windows when the worktree's folder is missing + +### Removed + +- Removes the `gitlens.experimental.nativeGit` setting as it is now the default experience — closes [#3055](https://github.com/gitkraken/vscode-gitlens/issues/3055) + +## [14.6.1] - 2023-12-14 + +### Fixed + +- Fixes [#3057](https://github.com/gitkraken/vscode-gitlens/issues/3057) - Uncommitted changes cause an error when gitlens.defaultDateSource is "committed" + +## [14.6.0] - 2023-12-13 + +### Added + +- Adds the ability to specify who can access a Cloud Patch when creating it + - _Anyone with the link_ — allows anyone with the link and a GitKraken account to access the Cloud Patch + - _Members of my Org with the link_ — allows only members of your selected GitKraken organization with the link to access the Cloud Patch + - (Coming soon to GitLens) Ability to explicitly share to specific members from your organization and add them as collaborators on a Cloud Patch + - Cloud Patches that have been explicitly shared with you, i.e. you are a collaborator, now will appear in the _Cloud Patches_ view under _Shared with Me_ +- Adds timed snoozing for items in the _Focus View_ — choose from a selection of times when snoozing and the item will automatically move out of the snoozed tab when that time expires +- Adds the ability to open folder changes — closes [#3020](https://github.com/gitkraken/vscode-gitlens/issues/3020) + - Adds _Open Folder Changes with Revision..._ & _Open Folder Changes with Branch or Tag..._ commands to the command palette and to the _Explorer_ and _Source Control_ views + - Requires VS Code `1.85` or later and `multiDiffEditor.experimental.enabled` to be enabled +- Adds last modified time of the file when showing blame annotations for uncommitted changes +- Adds search results to the minimap tooltips on the _Commit Graph_ +- Adds support for Anthropic's Claude 2.1 model for GitLens' experimental AI features +- Adds a status indicator when the upstream branch is missing in _Commits_ view +- Adds support for opening renamed/deleted files using the _Open File at Revision..._ & _Open File at Revision from..._ commands by showing a quick pick menu if the requested file doesn't exist in the selected revision — closes [#708](https://github.com/gitkraken/vscode-gitlens/issues/708) thanks to [PR #2825](https://github.com/gitkraken/vscode-gitlens/pull/2825) by Victor Hallberg ([@mogelbrod](https://github.com/mogelbrod)) +- Adds an _Open Changes_ submenu to comparisons in the _Search & Compare_ view +- Adds experimental `gitlens.experimental.openChangesInMultiDiffEditor` setting to specify whether to open multiple changes in VS Code's experimental multi-diff editor (single tab) or in individual diff editors (multiple tabs) + - Adds an inline _Open All Changes_ command to commits, stashes, and comparisons in the views + - Changes _Open All Changes_ & _Open All Changes with Working Tree_ commands to use the new multi-diff editor when enabled + - Adds _Open All Changes, Individually_ & _Open All Changes with Working Tree, Individually_ commands to provide access to the previous behavior + - Requires VS Code `1.85` or later and `multiDiffEditor.experimental.enabled` to be enabled +- Adds a confirmation prompt when attempting to undo a commit with uncommitted changes +- Adds a _[Show|Hide] Merge Commits_ toggle to the _Contributors_ view +- Adds _Open in Integrated Terminal_ command to repositories in the views — closes [#3053](https://github.com/gitkraken/vscode-gitlens/issues/3053) +- Adds _Open in Terminal_ & _Open in Integrated Terminal_ commands to the upstream status in the _Commits_ view +- Adds the ability to choose an active GitKraken organization in the _Account View_ for users with multiple GitKraken organizations. + +### Changed + +- Improves AI model choice selection for GitLens' experimental AI features +- Improves performance when logging is enabled +- Changes the contextual view title from GL to GitLens + +### Fixed + +- Fixes [#2663](https://github.com/gitkraken/vscode-gitlens/issues/2663) - Debounce bug: file blame isn't cleared when editing document while text in output window changes +- Fixes [#3050](https://github.com/gitkraken/vscode-gitlens/issues/3050) - Opening revision of a renamed file is broken +- Fixes [#3019](https://github.com/gitkraken/vscode-gitlens/issues/3019) - Commits Views not working +- Fixes [#3026](https://github.com/gitkraken/vscode-gitlens/issues/3026) - Gitlens stopped working in sub-repositories +- Fixes [#2746](https://github.com/gitkraken/vscode-gitlens/issues/2746) - Remove 'undo commit' command from gitlens inspect +- Fixes [#2482](https://github.com/gitkraken/vscode-gitlens/issues/2482) - Unresponsive "commits" view and "branches" view update due to git log +- Fixes duplicate entries in the _Search & Compare_ view when adding a new comparison from outside the view and before the view has loaded +- Fixes _Load more_ in the _File History_ view when the file has been renamed +- Fixes broken _Open Changed & Close Unchanged Files_ (`gitlens.views.openOnlyChangedFiles`) command in the views +- Fixes issues with _Contributors_ view updating when changing toggles +- Fixes issues with _Open [Previous] Changes with Working File_ command in comparisons +- Fixes banner styling on the _Commit Graph_ + +## [14.5.2] - 2023-11-30 + +### Added + +- Adds cyber week promotion + +## [14.5.1] - 2023-11-21 + +### Added + +- Adds support for OpenAI's GPT-4 Turbo and latest Anthropic models for GitLens' experimental AI features — closes [#3005](https://github.com/gitkraken/vscode-gitlens/issues/3005) + +### Changed + +- Improves the performance of the _Commit Graph_ when loading a large number of commits +- Refines AI prompts to provide better commit message generation and explanation results +- Updates Files Changed panel of _Commit Details_, which now supports indent settings and adds better accessibility + +### Fixed + +- Fixes [#3023](https://github.com/gitkraken/vscode-gitlens/issues/3023) - "Unable to show blame. Invalid or missing blame.ignoreRevsFile" with valid ignore revs file +- Fixes [#3018](https://github.com/gitkraken/vscode-gitlens/issues/3018) - Line blame overlay is broken when commit message contains a `)` +- Fixes [#2625](https://github.com/gitkraken/vscode-gitlens/issues/2625) - full issue ref has escape characters that break hover links +- Fixes stuck busy state of the _Commit Details_ Explain AI panel after canceling a request +- Fixes cloud patch deep links requiring a paid plan (while in preview) + +## [14.5.0] - 2023-11-13 + +### Added + +- Adds a preview of [Cloud Patches](https://www.gitkraken.com/solutions/cloud-patches), an all-new ☁️ feature — engage in early collaboration before the pull request: + - Share your work with others by creating a Cloud Patch from Working Changes, Commits, Stashes or Comparisons + - View Cloud Patches from URLs shared to you and apply them to your working tree or to a new or existing branch + - Manage your Cloud Patches from the new _Cloud Patches_ view in the GitLens side bar + - Adds a _Share as Cloud Patch..._ command to the command palette and to the _Share_ submenu in applicable GitLens views + - Adds a `gitlens.cloudPatches.enabled` setting to specify whether to enable Cloud Patches (defaults to `true`) +- Adds support to open multiple instances of the _Commit Graph_, _Focus_, and _Visual File History_ in the editor area + - Adds a _Split Commit Graph_ command to the _Commit Graph_ tab context menu + - Adds a `gitlens.graph.allowMultiple` setting to specify whether to allow opening multiple instances of the _Commit Graph_ in the editor area + - Adds a _Split Focus_ command to the _Focus_ tab context menu + - Adds a `gitlens.focus.allowMultiple` setting to specify whether to allow opening multiple instances of the _Focus_ in the editor area + - Adds a _Split Visual File History_ command to the _Visual File History_ tab context menu + - Adds a `gitlens.visualHistory.allowMultiple` setting to specify whether to allow opening multiple instances of the _Visual File History_ in the editor area +- Adds a _Generate Commit Message (Experimental)_ button to the SCM input when supported (currently `1.84.0-insider` only) + - Adds a `gitlens.ai.experimental.generateCommitMessage.enabled` setting to specify whether to enable GitLens' experimental, AI-powered, on-demand commit message generation — closes [#2652](https://github.com/gitkraken/vscode-gitlens/issues/2652) +- Improves the experience of the _Search Commits_ quick pick menu + - Adds a stateful authors picker to make it much easier to search for commits by specific authors + - Adds a file and folder picker to make it much easier to search for commits containing specific files or in specific folders +- Adds ability to sort repositories in the views and quick pick menus — closes [#2836](https://github.com/gitkraken/vscode-gitlens/issues/2836) thanks to [PR #2991](https://github.com/gitkraken/vscode-gitlens/pull/2991) + - Adds a `gitlens.sortRepositoriesBy` setting to specify how repositories are sorted in quick pick menus and views by Aidos Kanapyanov ([@aidoskanapyanov](https://github.com/aidoskanapyanov)) +- Adds a _[Show|Hide] Merge Commits_ toggle to the _Commits_ view — closes [#1399](https://github.com/gitkraken/vscode-gitlens/issues/1399) thanks to [PR #1540](https://github.com/gitkraken/vscode-gitlens/pull/1540) by Shashank Shastri ([@Shashank-Shastri](https://github.com/Shashank-Shastri)) +- Adds a _Filter Commits by Author..._ commands to the _Commits_ view and comparisons context menus to filter commits in the _Commits_ view by specific authors +- Adds ability to publish to a remote branch to a specific commit using the _Push to Commit_ command +- Adds an _Open Comparison on Remote_ command to comparisons in views +- Adds a _Share > Copy Link to Repository_ command on branches in the views +- Adds _Share > Copy Link to Branch_ and _Share > Copy Link to Repository_ commands on the current branch status in the _Commits_ view +- Adds a _Clear Reviewed Files_ command to comparisons to clear all reviewed files — closes [#2987](https://github.com/gitkraken/vscode-gitlens/issues/2987) +- Adds a _Collapse_ command to many view nodes +- Adds a `gitlens.liveshare.enabled` setting to specify whether to enable integration with Visual Studio Live Share + +### Changed + +- Improves accuracy, performance, and memory usage related to parsing diffs, used in _Changes_ hovers, _Changes_ file annotations, etc +- Improves confirmation messaging in the _Git Command Palette_ +- Refines merge/rebase messaging when there is nothing to do — refs [#1660](https://github.com/gitkraken/vscode-gitlens/issues/1660) +- Improves view messaging while loading/discovering repositories +- Honors VS Code's `git.useForcePushWithLease` and `git.useForcePushIfIncludes` settings when force pushing +- Changes _File Heatmap_ annotations to not color the entire line by default. Want it back, add `line` to the `gitlens.heatmap.locations` setting + +### Fixed + +- Fixes [#2997](https://github.com/gitkraken/vscode-gitlens/issues/2997) - "push to commit" pushes everything instead of up to the selected commit +- Fixes [#2615](https://github.com/gitkraken/vscode-gitlens/issues/2615) - Source Control views disappear after opening a file beyond a symbolic link +- Fixes [#2443](https://github.com/gitkraken/vscode-gitlens/issues/2443) - UNC-PATH: File History changes not displaying any changes when open +- Fixes [#2625](https://github.com/gitkraken/vscode-gitlens/issues/2625) - full issue ref has escape characters that break hover links +- Fixes [#2987](https://github.com/gitkraken/vscode-gitlens/issues/2987) - Unable to remove all marks on reviewed files with a single operation +- Fixes [#2923](https://github.com/gitkraken/vscode-gitlens/issues/2923) - TypeError: Only absolute URLs are supported +- Fixes [#2926](https://github.com/gitkraken/vscode-gitlens/issues/2926) - "Open File at Revision" has incorrect editor label if revision contains path separator +- Fixes [#2971](https://github.com/gitkraken/vscode-gitlens/issues/2971) - \[Regression\] The branch column header text disappears when you have a hidden ref +- Fixes [#2814](https://github.com/gitkraken/vscode-gitlens/issues/2814) - GitLens Inspect: "Files Changed" not following when switching between commits in File History +- Fixes [#2952](https://github.com/gitkraken/vscode-gitlens/issues/2952) - Inline blame not working because of missing ignoreRevsFile +- Fixes issue where _Changes_ hovers and _Changes_ file annotations sometimes weren't accurate +- Fixes intermittent issue where inline blame and other revision-based editor features are unavailable when repository discovery takes a bit +- Fixes intermittent issues where details sometimes get cleared/overwritten when opening the _Commit Details_ view +- Fixes issue when clicking on commits in the Visual File History to open the _Commit Details_ view +- Fixes issue opening stashes in the _Commit Details_ view from the _Stashes_ view +- Fixes issue where GitHub/GitLab enriched autolinks could incorrectly point to the wrong repository +- Fixes issue showing folder history in the _File History_ view when there are uncommitted changes (staged or unstaged) +- Fixes issue when pushing to a remote branch with different name than the local +- Fixes tooltip styling/theming on the _Commit Graph_ +- Fixes issues staged files in repositories not "opened" (discovered) by the built-in Git extension + +## [14.4.0] - 2023-10-13 + +### Added + +- Adds a _Working Changes_ tab to the _Commit Details_ and _Graph Details_ views to show your working tree changes + - Adds _Stage Changes_ and _Unstage Changes_ commands to files on the _Working Changes_ tab +- Adds a _[Show|Hide] Merge Commits_ toggle to the _File History_ view — closes [#2104](https://github.com/gitkraken/vscode-gitlens/issues/2104) & [#2944](https://github.com/gitkraken/vscode-gitlens/issues/2944) + - Adds a `gitlens.advanced.fileHistoryShowMergeCommits` setting to specify whether merge commits will be show in file histories +- Adds deep link support for workspaces in the _GitKraken Workspaces_ view + - Deep link format: `https://gitkraken.dev/link/workspaces/{workspaceId}` + - Adds a _Share_ submenu with a _Copy Link to Workspace_ command to workspaces in the _GitKraken Workspaces_ view + ### Changed +- Improves performance of inline blame, status bar blame, and hovers especially when working with remotes with connected integrations +- Changes the _File History_ view to follow renames and filters out merge commits by default — closes [#2104](https://github.com/gitkraken/vscode-gitlens/issues/2104) & [#2944](https://github.com/gitkraken/vscode-gitlens/issues/2944) +- Changes the _File History_ view to allow following renames while showing history across all branches (which was a previous limitation of Git) — closes [#2828](https://github.com/gitkraken/vscode-gitlens/issues/2828) +- Changes to use our own implementation of `fetch`, `push`, and `pull` Git operations, rather than delegating to VS Code to avoid limitations especially with GitKraken Workspaces. Please report any issues and you can revert this (for now) by setting `"gitlens.experimental.nativeGit"` to `"false"` in your settings - Relaxes PR autolink detection for Azure DevOps to use `PR ` instead of `Merged PR ` — closes [#2908](https://github.com/gitkraken/vscode-gitlens/issues/2908) +- Changes wording on `Reset Stored OpenAI Key` command to `Reset Stored AI Key` to reflect support for other providers ### Fixed @@ -16,13 +502,18 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Fixes [#2940](https://github.com/gitkraken/vscode-gitlens/issues/2940) - Can't use Azure OpenAI model because i can't save the openai key because of the verification - Fixes [#2928](https://github.com/gitkraken/vscode-gitlens/issues/2928) - Apply Changes should create new files when needed - Fixes [#2896](https://github.com/gitkraken/vscode-gitlens/issues/2896) - Repositories view stuck in loading state +- Fixes [#2460](https://github.com/gitkraken/vscode-gitlens/issues/2460) - Gitlens Remote provider doesn't work properly in "Commit graph" view - Fixes issue with "View as [List|Tree]" toggle not working in the _Commit Details_ view +- Fixes an issue with deep links sometimes failing to properly resolve when a matching repository without the remote is found +- Fixes an issue in the _Commit Graph_ where commits not in the history of a merge commit were showing in the same column +- Fixes `Reset Stored AI Key` command to work for the current provider +- Fixes an issue with parsing some renames in log output ## [14.3.0] - 2023-09-07 ### Added -- Adds checkboxes to files in the _Search & Compare_ view to allow for tracking review progress — closes [#836](https://github.com/gitkraken/vscode-gitlens/issues/836) +- Adds checkboxes to files in comparisons to allow for tracking review progress — closes [#836](https://github.com/gitkraken/vscode-gitlens/issues/836) - Allows the _Commit Graph_ to be open in the panel and in the editor area simultaneously - Adds an _Open Changes_ button to commits in the file history quick pick menu — closes [#2641](https://github.com/gitkraken/vscode-gitlens/issues/2641) thanks to [PR #2800](https://github.com/gitkraken/vscode-gitlens/pull/2800) by Omar Ghazi ([@omarfesal](https://github.com/omarfesal)) @@ -84,7 +575,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Fixed -- Fixes [#2744](https://github.com/gitkraken/vscode-gitlens/issues/2744) - GH enterprise access with focus view +- Fixes [#2744](https://github.com/gitkraken/vscode-gitlens/issues/2744) - GH enterprise access with _Focus_ - Fixes deeplink comparison ordering for a better experience - Fixes deeplinks to comparisons with working tree not resolving @@ -300,7 +791,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds tooltips to work-in-progress (WIP) and stash nodes - Adds a "Publish Branch" context menu action to local branches without an upstream branch — closes [#2619](https://github.com/gitkraken/vscode-gitlens/pull/2619) - Lowers the minimum width of the "Branch / Tag" column -- Adds actions to _Focus View_ Pull Requests +- Adds actions to _Focus_ Pull Requests - Switch to or create a local branch - Create or open a worktree from the branch - Adds a _Generate Commit Message (Experimental)..._ command to the SCM context menus @@ -385,10 +876,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added -- ✨ Adds a preview of the all-new **Focus View**, a [GitLens+ feature](https://gitkraken.com/gitlens/pro-features) — provides you with a comprehensive list of all your most important work across your connected GitHub repos: +- ✨ Adds a preview of the all-new **Focus**, a [GitLens+ feature](https://gitkraken.com/gitlens/pro-features) — provides you with a comprehensive list of all your most important work across your connected GitHub repos: - My Pull Requests: shows all GitHub PRs opened by you, assigned to you, or awaiting your review - My Issues: shows all issues created by you, assigned to you, or that mention you - - Open it via _GitLens+: Show Focus View_ from the Command Palette + - Open it via _GitLens+: Show Focus_ from the Command Palette - Adds new _Commit Graph_ features and improvements - Adds a new experimental minimap of commit activity to the _Commit Graph_ - Adds a new experimental _Changes_ column visualizing commit changes @@ -1253,7 +1744,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Refines the _Repositories_ view to better align its features with all the new views - Adds menu toggles, and the settings below to allow for far greater customization of the sections in the _Repositories_ view - - Adds a `gitlens.views.repositories.branches.showBranchComparison` setting to specify whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) under each branch in the _Repositories_ view + - Adds a `gitlens.views.repositories.branches.showBranchComparison` setting to specify whether to show a comparison of the branch with a user-selected reference (branch, tag, etc) under each branch in the _Repositories_ view - Adds a `gitlens.views.repositories.showBranches` setting to specify whether to show the branches for each repository - Adds a `gitlens.views.repositories.showCommits` setting to specify whether to show the commits on the current branch for each repository - Adds a `gitlens.views.repositories.showContributors` setting to specify whether to show the contributors for each repository @@ -4944,7 +5435,23 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Initial release but still heavily a work in progress. -[unreleased]: https://github.com/gitkraken/vscode-gitlens/compare/v14.3.0...HEAD +[unreleased]: https://github.com/gitkraken/vscode-gitlens/compare/v15.1.0...HEAD +[15.1.0]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.4...gitkraken:v15.1.0 +[15.0.4]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.3...gitkraken:v15.0.4 +[15.0.3]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.2...gitkraken:v15.0.3 +[15.0.2]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.1...gitkraken:v15.0.2 +[15.0.1]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.0...gitkraken:v15.0.1 +[15.0.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.9.0...gitkraken:v15.0.0 +[14.9.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.8.2...gitkraken:v14.9.0 +[14.8.2]: https://github.com/gitkraken/vscode-gitlens/compare/v14.8.1...gitkraken:v14.8.2 +[14.8.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.8.0...gitkraken:v14.8.1 +[14.8.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.7.0...gitkraken:v14.8.0 +[14.7.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.6.1...gitkraken:v14.7.0 +[14.6.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.6.0...gitkraken:v14.6.1 +[14.6.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.5.1...gitkraken:v14.6.0 +[14.5.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.5.0...gitkraken:v14.5.1 +[14.5.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.4.0...gitkraken:v14.5.0 +[14.4.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.3.0...gitkraken:v14.4.0 [14.3.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.2.1...gitkraken:v14.3.0 [14.2.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.2.0...gitkraken:v14.2.1 [14.2.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.1.1...gitkraken:v14.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index adc2e986d7e5d..0a7144c38e37d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,11 @@ yarn run package 1. Open the `vscode-gitlens` folder 2. Ensure the required [dependencies](#dependencies) are installed -3. Choose the `Watch & Run` launch configuration from the launch dropdown in the Run and Debug viewlet and press `F5`. +3. Choose the `Watch & Run` launch configuration from the launch dropdown in the Run and Debug viewlet and press `F5` +4. A new VS Code "Extension Development Host" window will open with the extension loaded and ready for debugging + 1. If the "Extension Development Host" window opened without a folder/workspace with a repository (required for most GitLens functionality), you will need to open one and then stop and restart the debug session + +In order to see any code changes reflected in the "Extension Development Host" window, you will need to restart the debug session, e.g. using the "Restart" button in the debug toolbar or by pressing `[Ctrl|Cmd]+Shift+F5`. Although, if the code changes are purely within a webview, you can refresh the webview by clicking the refresh button in the toolbar associated with the webview. _Note: If you see a pop-up with a message similar to "The task cannot be tracked. Make sure to have a problem matcher defined.", you will need to install the [TypeScript + Webpack Problem Matchers](https://marketplace.visualstudio.com/items?itemName=amodio.tsl-problem-matcher) extension._ @@ -112,7 +116,11 @@ _Note: If you see a pop-up with a message similar to "The task cannot be tracked 1. Open the `vscode-gitlens` folder 2. Ensure the required [dependencies](#dependencies) are installed -3. Choose the `Watch & Run (web)` launch configuration from the launch dropdown in the Run and Debug viewlet and press `F5`. +3. Choose the `Watch & Run (web)` launch configuration from the launch dropdown in the Run and Debug viewlet and press `F5` +4. A new VS Code "Extension Development Host" window will open with the extension loaded and ready for debugging + 1. If the "Extension Development Host" window opened without a folder/workspace with a repository (required for most GitLens functionality), you will need to open one and then stop and restart the debug session + +In order to see any code changes reflected in the "Extension Development Host" window, you will need to restart the debug session, e.g. using the "Restart" button in the debug toolbar or by pressing `[Ctrl|Cmd]+Shift+F5`. Although, if the code changes are purely within a webview, you can refresh the webview by clicking the refresh button in the toolbar associated with the webview. #### Using VS Code for the Web (locally) @@ -181,18 +189,38 @@ GitLens version changes are bucketed into two types: Note: `major` version bumps are only considered for more special circumstances. -#### Preparing +#### Preparing a Normal Release + +Before publishing a new release, do the following: + +1. Create a GitHub milestone for any potential patch releases, named `{major}.{minor}-patch` with a description of `Work intended for any patch releases before the {major}.{minor} release` +2. Create a GitHub milestone for the next release, `{major}.{minor+1}` with a description of `Work intended for the {release-month} {release-year} release` and a set the appropriate due date +3. Ensure all items in the `{major}.{minor}` GitHub milestone are closed and verified or moved into one of the above milestones +4. Close the `{major}.{minor}` and `{major}.{minor-1}-patch` GitHub milestones + +Then, use the [prep-release](scripts/prep-release.js) script to prepare a new release. The script updates the [package.json](package.json) and [CHANGELOG.md](CHANGELOG.md) appropriately, commits the changes as `Bumps to v{major}.{minor}.{patch}`, and creates a `v{major}.{minor}.{patch}` tag which when pushed will trigger the CI to publish a release. + +1. Ensure you are on the `main` branch and have a clean working tree +2. Ensure the [CHANGELOG.md](CHANGELOG.md) has been updated with the release notes +3. Run `yarn run prep-release` and enter the desired `{major}.{minor}.{patch}` version when prompted +4. Review the `Bumps to v{major}.{minor}.{patch}` commit +5. Run `git push --follow-tags` to push the commit and tag + +Pushing the `v{major}.{minor}.{patch}` tag will trigger the [Publish Stable workflow](.github/workflows/cd-stable.yml) to automatically package the extension, create a [GitHub release](https://github.com/gitkraken/vscode-gitlens/releases/latest), and deploy it to the [VS Marketplace](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens). -Use the [prep-release](scripts/prep-release.js) script to prepare a new release. The script updates the [package.json](package.json) and [CHANGELOG.md](CHANGELOG.md) appropriately, commits the changes as `Bumps to v`, and creates a `v` tag which when pushed will trigger the CI to publish a release. +If the action fails and retries are unsuccessful, the VSIX can be built locally with `yarn package` and uploaded manually to the marketplace. A GitHub release can also be [created manually](https://github.com/gitkraken/vscode-gitlens/releases/new) using `v{major}.{minor}.{patch}` as the title and the notes from the [CHANGELOG.md](CHANGELOG.md) with the VSIX attached. -1. Ensure you are on a clean working tree -2. Run `yarn run prep-release` and enter the desired version when prompted. -3. Review the `Bumps to v` commit -4. Run `git push --follow-tags` to push the commit and tag +#### Preparing a Patch Release -Pushing the `v` tag will trigger the [Publish Stable workflow](.github/workflows/cd-stable.yml) to automatically package the extension, create a [GitHub release](https://github.com/gitkraken/vscode-gitlens/releases/latest), and deploy it to the [VS Marketplace](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens). +Before publishing a new release, do the following: -If the action fails and retries are unsuccessful, the VSIX can be built locally with `yarn package` and uploaded manually to the marketplace. A GitHub release can also be [created manually](https://github.com/gitkraken/vscode-gitlens/releases/new) using `v` as the title and the notes from the [CHANGELOG.md](CHANGELOG.md) with the VSIX attached. +1. Ensure all items in the `{major}.{minor}-patch` GitHub milestone are closed and verified +2. Create, if needed, a `release/{major}.{minor}` branch from the latest `v{major}.{minor}.{patch}` tag +3. Cherry-pick the desired commits from `main` into the `release/{major}.{minor}` branch +4. Follow steps 2-5 in [Preparing a Normal Release](#preparing-a-normal-release) above +5. Manually update the [CHANGELOG.md](CHANGELOG.md) on `main` with the patch release notes + +Note: All patch releases for the same `{major}.{minor}` version use the same `release/{major}.{minor}` branch ### Pre-releases @@ -201,3 +229,20 @@ The [Publish Pre-release workflow](.github/workflows/cd-pre.yml) is automaticall ### Insiders (deprecated use pre-release instead) The Publish Insiders workflow is no longer available and was replaced with the pre-release edition. + +## Updating GL Icons + +To add new icons to the GL Icons font follow the steps below: + +- Add new SVG icons to the `images/icons` folder +- Append entries for the new icons to the end of the `images/icons/template/mapping.json` file + - Entries should be in the format of `: ` +- Optimize and build the icons by running the following from a terminal: + + ``` + yarn run icons:svgo + yarn run build:icons + + ``` + +Once you've finshed copy the new `glicons.woff2?` URL from `src/webviews/apps/shared/glicons.scss` and search and replace the old references with the new one. diff --git a/INDEX.LBL b/INDEX.LBL new file mode 100644 index 0000000000000..1827ec59dfad0 --- /dev/null +++ b/INDEX.LBL @@ -0,0 +1,213 @@ +PDS_VERSION_ID = PDS3 +RECORD_TYPE = FIXED_LENGTH +RECORD_BYTES = 287 +FILE_RECORDS = 54 +^INDEX_TABLE = "INDEX.TAB" +VOLUME_ID = MSGRMDS_7101 +DATA_SET_ID = "MESS-H-MDIS-5-RDR-HIW-V1.0" +INSTRUMENT_HOST_NAME = "MESSENGER" +INSTRUMENT_NAME = {"MERCURY DUAL IMAGING SYSTEM NARROW ANGLE CAMERA", + "MERCURY DUAL IMAGING SYSTEM WIDE ANGLE CAMERA"} + +OBJECT = INDEX_TABLE + INTERCHANGE_FORMAT = ASCII + ROW_BYTES = 287 + ROWS = 54 + COLUMNS = 22 + INDEX_TYPE = SINGLE + + OBJECT = COLUMN + COLUMN_NUMBER = 1 + NAME = "VOLUME_ID" + DATA_TYPE = CHARACTER + START_BYTE = 2 + BYTES = 12 + DESCRIPTION = "The identifier of the volume on which the + product is stored." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 2 + NAME = "PATH_NAME" + DATA_TYPE = CHARACTER + START_BYTE = 17 + BYTES = 8 + DESCRIPTION = "Path to directory containing file." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 3 + NAME = "FILE_NAME" + DATA_TYPE = CHARACTER + START_BYTE = 28 + BYTES = 26 + DESCRIPTION = "Name of file in archive." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 4 + NAME = "PRODUCT_ID" + DATA_TYPE = CHARACTER + START_BYTE = 57 + BYTES = 22 + DESCRIPTION = "A permanent, unique identifier assigned to + a data product by its producer." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 5 + NAME = "PRODUCT_CREATION_TIME" + DATA_TYPE = TIME + START_BYTE = 81 + BYTES = 23 + DESCRIPTION = "The time in UTC when the HIW product was + created." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 6 + NAME = "START_TIME" + DATA_TYPE = TIME + START_BYTE = 105 + BYTES = 19 + NOT_APPLICABLE_CONSTANT = 2011-03-29T09:20:03 + DESCRIPTION = "The start UTC date and time bound for + considered images." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 7 + NAME = "STOP_TIME" + DATA_TYPE = TIME + START_BYTE = 125 + BYTES = 19 + NOT_APPLICABLE_CONSTANT = 2015-04-30T11:07:43 + DESCRIPTION = "The ending UTC date and time bound for + considered images." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 8 + NAME = "PRODUCT_VERSION_ID" + DATA_TYPE = CHARACTER + START_BYTE = 146 + BYTES = 1 + DESCRIPTION = "Version number of the product." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 9 + NAME = "LINES" + DATA_TYPE = ASCII_INTEGER + START_BYTE = 149 + BYTES = 5 + DESCRIPTION = "Number of lines in the image." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 10 + NAME = "LINE_SAMPLES" + DATA_TYPE = ASCII_INTEGER + START_BYTE = 155 + BYTES = 5 + DESCRIPTION = "Number of line samples in the image." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 11 + NAME = "BANDS" + DATA_TYPE = ASCII_INTEGER + START_BYTE = 161 + BYTES = 1 + DESCRIPTION = "Number of bands in the image." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 12 + NAME = "MAP_PROJECTION_TYPE" + DATA_TYPE = CHARACTER + START_BYTE = 164 + BYTES = 19 + DESCRIPTION = "Map projection used." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 13 + NAME = "CENTER_LATITUDE" + DATA_TYPE = ASCII_REAL + START_BYTE = 185 + BYTES = 6 + DESCRIPTION = "For equirectangular projection, latitude of + true scale. For polar stereographic, 90 or + -90 for north or south pole." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 14 + NAME = "CENTER_LONGITUDE" + DATA_TYPE = ASCII_REAL + START_BYTE = 192 + BYTES = 7 + DESCRIPTION = "Origin/reference longitude of the + projection." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 15 + NAME = "MAP_SCALE" + DATA_TYPE = ASCII_REAL + START_BYTE = 200 + BYTES = 10 + DESCRIPTION = "Map scale in meters per pixel." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 16 + NAME = "MAP_RESOLUTION" + DATA_TYPE = ASCII_REAL + START_BYTE = 211 + BYTES = 3 + DESCRIPTION = "Map resoution in pixels/degree." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 17 + NAME = "LINE_PROJECTION_OFFSET" + DATA_TYPE = ASCII_REAL + START_BYTE = 215 + BYTES = 13 + DESCRIPTION = "Line offset value of map projection origin + from line and sample 1,1 (upper left)." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 18 + NAME = "SAMPLE_PROJECTION_OFFSET" + DATA_TYPE = ASCII_REAL + START_BYTE = 229 + BYTES = 11 + DESCRIPTION = "Sample offset value of map projection + origin from line and sample 1,1 (upper + left)." + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 19 + NAME = "MAXIMUM_LATITUDE" + DATA_TYPE = ASCII_REAL + START_BYTE = 241 + BYTES = 10 + DESCRIPTION = "Maximum latitude covered (center of + pixels)" + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 20 + NAME = "MINIMUM_LATITUDE" + DATA_TYPE = ASCII_REAL + START_BYTE = 252 + BYTES = 10 + DESCRIPTION = "Minimum latitude covered (center of + pixels)" + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 21 + NAME = "WESTERNMOST_LONGITUDE" + DATA_TYPE = ASCII_REAL + START_BYTE = 263 + BYTES = 11 + DESCRIPTION = "Westernmost longitude covered (center of + pixels)" + END_OBJECT = COLUMN + OBJECT = COLUMN + COLUMN_NUMBER = 22 + NAME = "EASTERNMOST_LONGITUDE" + DATA_TYPE = ASCII_REAL + START_BYTE = 275 + BYTES = 11 + DESCRIPTION = "Easternmost longitude covered (center of + pixels)" + END_OBJECT = COLUMN +END_OBJECT = INDEX_TABLE +END diff --git a/LICENSE b/LICENSE index a8bb71439ade0..8dc4c71f020f8 100644 --- a/LICENSE +++ b/LICENSE @@ -8,7 +8,7 @@ which are covered by LICENSE.plus. The MIT License (MIT) -Copyright (c) 2021-2023 Axosoft, LLC dba GitKraken ("GitKraken") +Copyright (c) 2021-2024 Axosoft, LLC dba GitKraken ("GitKraken") Copyright (c) 2016-2021 Eric Amodio Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/LICENSE.plus b/LICENSE.plus index 814b362324cca..7d3d6faa994dd 100644 --- a/LICENSE.plus +++ b/LICENSE.plus @@ -1,6 +1,6 @@ GitLens+ License -Copyright (c) 2021-2023 Axosoft, LLC dba GitKraken ("GitKraken") +Copyright (c) 2021-2024 Axosoft, LLC dba GitKraken ("GitKraken") With regard to the software set forth in or under any directory named "plus". diff --git a/README.md b/README.md index 19ef4e0bfe781..d10beb968df80 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ Use `Switch to Pre-Release Version` on the extension banner to live on the edge All features are free to use on all repos, **except** for features, -- marked with a ✨ require a [trial or paid plan](https://www.gitkraken.com/gitlens/pricing) for use on privately hosted repos -- marked with a ☁️ require a GitKraken Account, with access level based on your [plan](https://www.gitkraken.com/gitlens/pricing), e.g. Free, Pro, etc +- marked with a `Pro` require a [trial or paid plan](https://www.gitkraken.com/gitlens/pricing) for use on privately-hosted repos +- marked with a `Preview` require a GitKraken account, with access level based on your [plan](https://www.gitkraken.com/gitlens/pricing), e.g. Free, Pro, etc See the [FAQ](#is-gitlens-free-to-use 'Jump to FAQ') for more details. @@ -44,10 +44,13 @@ Quickly glimpse into when, why, and by whom a line or code block was changed. Ze - [**File Annotations**](#file-annotations) — Toggle on-demand whole file annotations to see authorship, recent changes, and a heatmap. - [**Revision Navigation**](#revision-navigation) — Explore the history of a file to see how the code evolved over time. - [**Side Bar Views**](#side-bar-views) — Powerful views into Git that don't come in the box. -- [**Commit Graph ✨**](#commit-graph-✨) — Visualize your repository and keep track of all work in progress. -- [**GitKraken Workspaces ☁️ and Focus ✨**](#gitkraken-workspaces-☁️-and-focus-✨) — Easily group and manage multiple repositories and bring pull requests and issues into a unified view. -- [**Visual File History ✨**](#visual-file-history-✨) — Identify the most impactful changes to a file and by whom. -- [**Worktrees ✨**](#worktrees-✨) — Simultaneously work on different branches of a repository. +- [**Commit Graph `Pro`**](#commit-graph-pro) — Visualize your repository and keep track of all work in progress. +- [**Launchpad `Preview`**](#launchpad-preview) — Stay focused and keep your team unblocked. +- [**Code Suggest `Preview`**](#code-suggest-preview) — Free your code reviews from unnecessary restrictions. +- [**Cloud Patches `Preview`**](#cloud-patches-preview) — Easily and securely share code with your teammates. +- [**Worktrees `Pro`**](#worktrees-pro) — Simultaneously work on different branches of a repository. +- [**Visual File History `Pro`**](#visual-file-history-pro) — Identify the most impactful changes to a file and by whom. +- [**GitKraken Workspaces `Preview`**](#gitkraken-workspaces-preview) — Easily group and manage multiple repositories. - [**Interactive Rebase Editor**](#interactive-rebase-editor) — Visualize and configure interactive rebase operations with a user-friendly editor. - [**Comprehensive Commands**](#comprehensive-commands) — A rich set of commands to help you do everything you need. - [**Integrations**](#integrations) — Simplify your workflow and quickly gain insights via integration with your Git hosting services. @@ -126,12 +129,12 @@ Our views are arranged for focus and productivity, although you can easily drag ### GitLens Inspect -An x-ray or developer tools inspector into your code, focused on providing contextual information and insights to what you're actively working on. +An x-ray or developer tools Inspect into your code, focused on providing contextual information and insights to what you're actively working on. -- **Commit Details** — See rich details of a commit or stash. +- **Inspect** — See rich details of a commit or stash. - **Line History** — Jump through the revision history of the selected line(s). - **File History** — Explore the revision history of a file, folder, or selected lines. -- [**Visual File History ✨**](#visual-file-history-✨) — Quickly see the evolution of a file, including when changes were made, how large they were, and who made them. +- [**Visual File History `Pro`**](#visual-file-history-pro) — Quickly see the evolution of a file, including when changes were made, how large they were, and who made them. - **Search & Compare** — Search and explore for a specific commit, message, author, changed file or files, or even a specific code change, or visualize comparisons between branches, tags, commits, and more. ### GitLens @@ -139,7 +142,8 @@ An x-ray or developer tools inspector into your code, focused on providing conte Quick access to many GitLens features. Also the home of GitKraken teams and collaboration services (e.g. GitKraken Workspaces), help, and support. - **Home** — Quick access to many features. -- [**GitKraken Workspaces ☁️**](#gitkraken-workspaces-☁️-and-focus-✨) — Easily group and manage multiple repositories together, accessible from anywhere, streamlining your workflow. +- [**Cloud Patches `Preview`**](#cloud-patches-preview) — Easily and securely share code with your teammates +- [**GitKraken Workspaces `Preview`**](#gitkraken-workspaces-preview) — Easily group and manage multiple repositories together, accessible from anywhere, streamlining your workflow. - **GitKraken Account** — Power-up with GitKraken Cloud Services. ### Source Control @@ -151,7 +155,7 @@ Shows additional views that are focused on exploring and managing your repositor - **Remotes** — Manage and navigate remotes and remote branches. - **Stashes** — Save and restore changes you are not yet ready to commit. - **Tags** — Manage and navigate tags. -- [**Worktrees ✨**](#worktrees-✨) — Simultaneously work on different branches of a repository. +- [**Worktrees `Pro`**](#worktrees-pro) — Simultaneously work on different branches of a repository. - **Contributors** — Ordered list of contributors, providing insights into individual contributions and involvement. - **Repositories** — Unifies the above views for more efficient management of multiple repositories. @@ -159,48 +163,62 @@ Shows additional views that are focused on exploring and managing your repositor Convenient and easy access to the Commit Graph with a dedicated details view. -- [**Commit Graph ✨**](#commit-graph-✨) — Visualize your repository and keep track of all work in progress. +- [**Commit Graph `Pro`**](#commit-graph-pro) — Visualize your repository and keep track of all work in progress. -## Commit Graph ✨ +## Commit Graph `Pro` Easily visualize your repository and keep track of all work in progress. -Use the rich commit search to find exactly what you're looking for. Its powerful filters allow you to search by a specific commit, message, author, a changed file or files, or even a specific code change. +Use the rich commit search to find exactly what you're looking for. Its powerful filters allow you to search by a specific commit, message, author, a changed file or files, or even a specific code change. [Learn more](https://gitkraken.com/solutions/commit-graph?utm_source=gitlens-extension&utm_medium=in-app-links)
- Commit Graph + Commit Graph
💡Quickly toggle the Graph via the `Toggle Commit Graph` command. 💡Maximize the Graph via the `Toggle Maximized Commit Graph` command. -## GitKraken Workspaces ☁️ and Focus ✨ +## Launchpad `Preview` -GitKraken Workspaces allow you to easily group and manage multiple repositories together, accessible from anywhere, streamlining your workflow. Create workspaces just for yourself or share (coming soon in GitLens) them with your team for faster onboarding and better collaboration. - -The Focus view brings all of your GitHub pull requests and issues into a unified actionable view to help to you more easily juggle work in progress, pending work, reviews, and more. Quickly see if anything requires your attention while keeping you focused. +Launchpad brings all of your GitHub pull requests into a unified, actionable list to better track work in progress, pending work, reviews, and more. Stay focused and take action on the most important items to keep your team unblocked. [Learn more](https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links)
- Focus View + Launchpad
-## Visual File History ✨ +## Code Suggest `Preview` -Quickly see the evolution of a file, including when changes were made, how large they were, and who made them. Use it to quickly find when the most impactful changes were made to a file or who best to talk to about file changes and more. +Liberate your code reviews from GitHub's restrictive, comment-only feedback style. Like suggesting changes on a Google-doc, suggest code changes from where you're already coding — your IDE and on anything in your project, not just on the lines of code changed in the PR. [Learn more](https://gitkraken.com/solutions/code-suggest?utm_source=gitlens-extension&utm_medium=in-app-links)
- Visual File History view + Code Suggest
-## Worktrees ✨ +## Cloud Patches `Preview` + +Easily and securely share code changes with your teammates or other developers by creating a Cloud Patch from your WIP, commit or stash and sharing the generated link. Use Cloud Patches to collaborate early for feedback on direction, approach, and more, to minimize rework and streamline your workflow. [Learn more](https://gitkraken.com/solutions/cloud-patches?utm_source=gitlens-extension&utm_medium=in-app-links) + +## Worktrees `Pro` Efficiently multitask by minimizing the context switching between branches, allowing you to easily work on different branches of a repository simultaneously. Avoid interrupting your work in progress when needing to review a pull request. Simply create a new worktree and open it in a new VS Code window, all without impacting your other work.
- Worktrees view + Worktrees view +
+ +## GitKraken Workspaces `Preview` + +GitKraken Workspaces allow you to easily group and manage multiple repositories together, accessible from anywhere, streamlining your workflow. Create workspaces just for yourself or share (coming soon in GitLens) them with your team for faster onboarding and better collaboration. [Learn more](https://gitkraken.com/solutions/workspaces?utm_source=gitlens-extension&utm_medium=in-app-links) + +## Visual File History `Pro` + +Quickly see the evolution of a file, including when changes were made, how large they were, and who made them. Use it to quickly find when the most impactful changes were made to a file or who best to talk to about file changes and more. + +
+ Visual File History view
## Interactive Rebase Editor @@ -227,7 +245,7 @@ A guided, step-by-step experience for quickly and safely executing Git commands. Use a series of new commands to: -- Expore the commit history of branches and files +- Explore the commit history of branches and files - Quickly search for and navigate to (and action upon) commits - Explore a file of a commit - View and explore your stashes @@ -237,9 +255,9 @@ Use a series of new commands to: Context switching kills productivity. GitLens not only reveals buried knowledge within your repository, it also brings additional context from issues and pull requests providing you with a wealth of information and insights at your fingertips. -Simplify your workflow and quickly gain insights with automatic linking of issues and pull requests across multiple Git hosting services including GitHub, GitHub Enterprise ✨, GitLab, GitLab self-managed ✨, Gitea, Gerrit, Google Source, Bitbucket, Bitbucket Server, Azure DevOps, and custom servers. +Simplify your workflow and quickly gain insights with automatic linking of issues and pull requests across multiple Git hosting services including GitHub, GitHub Enterprise `Pro`, GitLab, GitLab Self-Managed `Pro`, Jira, Gitea, Gerrit, Google Source, Bitbucket, Bitbucket Server, Azure DevOps, and custom servers. -All integration provide automatic linking, while rich integrations with GitHub & GitLab offer detailed hover information for autolinks, and correlations between pull requests, branches, and commits, as well as user avatars for added context. +All integrations provide automatic linking, while rich integrations with GitHub, GitLab and Jira offer detailed hover information for autolinks, and correlations between pull requests, branches, and commits, as well as user avatars for added context. ## Define your own autolinks @@ -251,7 +269,7 @@ Our incubator for experimentation and exploration with the community to gather e ## 🧪AI Explain Commit -Use the Explain panel on the **Commit Details** view to leverage AI to help you understand the changes introduced by a commit. +Use the Explain panel on the **Inspect** view to leverage AI to help you understand the changes introduced by a commit. ## 🧪Automatically Generate Commit Message @@ -259,30 +277,27 @@ Use the `Generate Commit Message` command from the Source Control view's context # Ready for GitLens Pro? -When you're ready to unlock the full potential of GitLens and enjoy all the benefits on your privately hosted repos, consider upgrading to GitLens Pro. With GitLens Pro, you'll gain access to ✨ features on privately hosted repos and ☁️ features based on the Pro plan. +When you're ready to unlock the full potential of GitLens and our [DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links) and enjoy all the benefits on your privately-hosted repos, consider upgrading to GitLens Pro. With GitLens Pro, you'll gain access to [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) on privately-hosted repos. -To learn more about the pricing and the additional ✨ and ☁️ features offered with GitLens Pro, visit the [GitLens Pricing page](https://www.gitkraken.com/gitlens/pricing). Upgrade to GitLens Pro today and take your Git workflow to the next level! +To learn more about the pricing and the additional features offered with GitLens Pro, visit the [GitLens Pricing page](https://www.gitkraken.com/gitlens/pricing?utm_source=gitlens-extension&utm_medium=in-app-links). Upgrade to GitLens Pro today and take your Git workflow to the next level! # FAQ ## Is GitLens free to use? -Yes. All features are free to use on all repos, **except** for features, - -- marked with a ✨ require a [trial or paid plan](https://www.gitkraken.com/gitlens/pricing) for use on privately hosted repos -- marked with a ☁️ require a GitKraken Account, with access level based on your [plan](https://www.gitkraken.com/gitlens/pricing), e.g. Free, Pro, etc +Yes. All features are free to use on all repos, **except** for `Pro` features, which require a [trial or paid plan](https://www.gitkraken.com/gitlens/pricing?utm_source=gitlens-extension&utm_medium=in-app-links). -While GitLens offers a remarkable set of free features, a subset of features tailored for professional developers and teams, marked with a ✨, require a trial or paid plan for use on privately hosted repos — use on local or publicly hosted repos is free for everyone. Additionally some features marked with a ☁️, rely on GitKraken Dev Services which requires an account and access is based on your plan, e.g. Free, Pro, etc. +While GitLens offers a remarkable set of free features, a subset of `Pro` features tailored for professional developers and teams, require a trial or paid plan for use on privately-hosted repos — use on local or publicly-hosted repos is free for everyone. Additionally `Preview` features may require a paid plan in the future and some, if cloud-backed, require a GitKraken account. -Preview ✨ features instantly for free for 3 days without an account, or start a free Pro trial to get an additional 7 days and gain access to ☁️ features to experience the full power of GitLens. +Preview `Pro` features instantly for free for 3 days without an account, or start a free GitLens Pro trial to get an additional 7 days and gain access to `Pro` features to experience the full power of GitLens. -## Are ✨ and ☁️ features free to use? +## Are `Pro` and `Preview` features free to use? -✨ features are free for use on local and publicly hosted repos, while a paid plan is required for use on privately hosted repos. ☁️ feature access is based on your plan including a Free plan. +`Pro` features are free for use on local and publicly-hosted repos, while a paid plan is required for use on privately-hosted repos. `Preview` features may require a paid plan in the future. ## Where can I find pricing? -Visit the [GitLens Pricing page](https://www.gitkraken.com/gitlens/pricing) for detailed pricing information and feature matrix for plans. +Visit the [GitLens Pricing page](https://www.gitkraken.com/gitlens/pricing?utm_source=gitlens-extension&utm_medium=in-app-links) for detailed pricing information and feature matrix for plans. # Support and Community @@ -361,6 +376,7 @@ A big thanks to the people that have contributed to this project 🙏❤️: - jogo- ([@jogo-](https://github.com/jogo-)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=jogo-) - Nils K ([@septatrix](https://github.com/septatrix)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=septatrix) - Chris Kaczor ([@ckaczor](https://github.com/ckaczor)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=ckaczor) +- Aidos Kanapyanov ([@aidoskanapyanov](https://github.com/aidoskanapyanov)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=aidoskanapyanov) - Allan Karlson ([@bees4ever](https://github.com/bees4ever)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=bees4ever) - Nafiur Rahman Khadem ([@ShafinKhadem](https://github.com/ShafinKhadem)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=ShafinKhadem) - Mathew King ([@MathewKing](https://github.com/MathewKing)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=MathewKing) @@ -386,6 +402,7 @@ A big thanks to the people that have contributed to this project 🙏❤️: - Connor Peet ([@connor4312](https://github.com/connor4312)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=connor4312) - Maxim Pekurin ([@pmaxim25](https://github.com/pmaxim25)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=pmaxim25) - Leo Dan Peña ([@leo9-py](https://github.com/leo9-py)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=leo9-py) +- Aman Prakash ([@gitgoap](https://github.com/gitgoap)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=gitgoap) - Arunprasad Rajkumar ([@arajkumar](https://github.com/arajkumar)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=arajkumar) - David Rees ([@studgeek](https://github.com/studgeek)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=studgeek) - Rickard ([@rickardp](https://github.com/rickardp)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=rickardp) @@ -423,6 +440,13 @@ A big thanks to the people that have contributed to this project 🙏❤️: - Zyck ([@qzyse2017](https://github.com/qzyse2017)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=qzyse2017) - Yonatan Greenfeld ([@YonatanGreenfeld](https://github.com/YonatanGreenfeld)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=YonatanGreenfeld) - WofWca ([@WofWca](https://github.com/WofWca)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=WofWca) +- 不见月 ([@nooooooom](https://github.com/nooooooom)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=nooooooom) +- Ian Chamberlain ([@ian-h-chamberlain](https://github.com/ian-h-chamberlain)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=ian-h-chamberlain) +- Brandon Cheng ([@gluxon](https://github.com/gluxon)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=gluxon) +- yutotnh ([@yutotnh](https://github.com/yutotnh)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=yutotnh) +- may ([@m4rch3n1ng](https://github.com/m4rch3n1ng)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=m4rch3n1ng) +- bm-w ([@bm-w](https://github.com/bm-w)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=bm-w) +- Tyler Johnson ([@TJohnsonSE](https://github.com/TJohnsonSE)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=TJohnsonSE) Also special thanks to the people that have provided support, testing, brainstorming, etc: diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index c63263d6fa585..f6161f83124e6 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -3,29 +3,65 @@ GitLens THIRD-PARTY SOFTWARE NOTICES AND INFORMATION This project incorporates components from the projects listed below. -1. @microsoft/fast-element version 1.12.0 (https://github.com/Microsoft/fast) -2. @microsoft/fast-react-wrapper version 0.3.19 (https://github.com/Microsoft/fast) -3. @octokit/graphql version 7.0.2 (https://github.com/octokit/graphql.js) -4. @octokit/request version 8.1.2 (https://github.com/octokit/request.js) -5. @opentelemetry/api version 1.6.0 (https://github.com/open-telemetry/opentelemetry-js) -6. @opentelemetry/exporter-trace-otlp-http version 0.43.0 (https://github.com/open-telemetry/opentelemetry-js) -7. @opentelemetry/sdk-trace-base version 1.17.0 (https://github.com/open-telemetry/opentelemetry-js) -8. @vscode/codicons version 0.0.33 (https://github.com/microsoft/vscode-codicons) -9. @vscode/webview-ui-toolkit version 1.2.2 (https://github.com/microsoft/vscode-webview-ui-toolkit) -10. ansi-regex version 6.0.1 (https://github.com/chalk/ansi-regex) -11. billboard.js version 3.9.4 (https://github.com/naver/billboard.js) -12. https-proxy-agent version 5.0.1 (https://github.com/TooTallNate/node-https-proxy-agent) -13. iconv-lite version 0.6.3 (https://github.com/ashtuchkin/iconv-lite) -14. lit version 2.8.0 (https://github.com/lit/lit) -15. microsoft/vscode (https://github.com/microsoft/vscode) -16. node-fetch version 2.7.0 (https://github.com/bitinn/node-fetch) -17. os-browserify version 0.3.0 (https://github.com/CoderPuppy/os-browserify) -18. path-browserify version 1.0.1 (https://github.com/browserify/path-browserify) -19. react-dom version 16.8.4 (https://github.com/facebook/react) -20. react version 16.8.4 (https://github.com/facebook/react) -21. sindresorhus/is-fullwidth-code-point (https://github.com/sindresorhus/is-fullwidth-code-point) -22. sindresorhus/string-width (https://github.com/sindresorhus/string-width) -23. sortablejs version 1.15.0 (https://github.com/SortableJS/Sortable) +1. @lit/react version 1.0.5 (https://github.com/lit/lit) +2. @microsoft/fast-element version 1.13.0 (https://github.com/Microsoft/fast) +3. @octokit/graphql version 8.1.1 (https://github.com/octokit/graphql.js) +4. @octokit/request version 9.1.1 (https://github.com/octokit/request.js) +5. @octokit/types version 13.5.0 (https://github.com/octokit/types.ts) +6. @opentelemetry/api version 1.9.0 (https://github.com/open-telemetry/opentelemetry-js) +7. @opentelemetry/exporter-trace-otlp-http version 0.52.0 (https://github.com/open-telemetry/opentelemetry-js) +8. @opentelemetry/sdk-trace-base version 1.25.0 (https://github.com/open-telemetry/opentelemetry-js) +9. @shoelace-style/shoelace version 2.15.1 (https://github.com/shoelace-style/shoelace) +10. @vscode/codicons version 0.0.36 (https://github.com/microsoft/vscode-codicons) +11. @vscode/webview-ui-toolkit version 1.4.0 (https://github.com/microsoft/vscode-webview-ui-toolkit) +12. ansi-regex version 6.0.1 (https://github.com/chalk/ansi-regex) +13. billboard.js version 3.12.4 (https://github.com/naver/billboard.js) +14. fast-string-truncated-width version 1.1.0 (https://github.com/fabiospampinato/fast-string-truncated-width) +15. https-proxy-agent version 5.0.1 (https://github.com/TooTallNate/node-https-proxy-agent) +16. iconv-lite version 0.6.3 (https://github.com/ashtuchkin/iconv-lite) +17. lit version 3.1.4 (https://github.com/lit/lit) +18. microsoft/vscode (https://github.com/microsoft/vscode) +19. node-fetch version 2.7.0 (https://github.com/bitinn/node-fetch) +20. os-browserify version 0.3.0 (https://github.com/CoderPuppy/os-browserify) +21. path-browserify version 1.0.1 (https://github.com/browserify/path-browserify) +22. react-dom version 16.8.4 (https://github.com/facebook/react) +23. react version 16.8.4 (https://github.com/facebook/react) +24. sindresorhus/is-fullwidth-code-point (https://github.com/sindresorhus/is-fullwidth-code-point) +25. sindresorhus/string-width (https://github.com/sindresorhus/string-width) +26. sortablejs version 1.15.0 (https://github.com/SortableJS/Sortable) + +%% @lit/react NOTICES AND INFORMATION BEGIN HERE +========================================= +BSD 3-Clause License + +Copyright (c) 2017 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +========================================= +END OF @lit/react NOTICES AND INFORMATION %% @microsoft/fast-element NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -94,44 +130,6 @@ Looking for a quick guide on building components? Check out [our Cheat Sheet](. ========================================= END OF @microsoft/fast-element NOTICES AND INFORMATION -%% @microsoft/fast-react-wrapper NOTICES AND INFORMATION BEGIN HERE -========================================= -# FAST React Wrapper - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![npm version](https://badge.fury.io/js/%40microsoft%2Ffast-react-wrapper.svg)](https://badge.fury.io/js/%40microsoft%2Ffast-react-wrapper) - -The `fast-react-wrapper` package contains a utility that enables automatically wrapping Web Components in a React component for ease of integration into React projects. - -## Installation - -### From NPM - -To install the `fast-react-wrapper` library, use either `npm` or `yarn` as follows: - -```shell -npm install --save @microsoft/fast-react-wrapper -``` - -```shell -yarn add @microsoft/fast-react-wrapper -``` - -Within your JavaScript or TypeScript code, you can then and use the wrapper like this: - -```ts -import React from 'react'; -import { provideReactWrapper } from '@microsoft/fast-react-wrapper'; - -const { wrap } = provideReactWrapper(React); - -const MyComponent = wrap(MyComponent); -``` - -For additional wrapper settings and more information on integrating with Design Systems, see [our integration docs](https://fast.design/docs/integrations/react). -========================================= -END OF @microsoft/fast-react-wrapper NOTICES AND INFORMATION - %% @octokit/graphql NOTICES AND INFORMATION BEGIN HERE ========================================= The MIT License @@ -186,6 +184,19 @@ THE SOFTWARE. ========================================= END OF @octokit/request NOTICES AND INFORMATION +%% @octokit/types NOTICES AND INFORMATION BEGIN HERE +========================================= +MIT License Copyright (c) 2019 Octokit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +========================================= +END OF @octokit/types NOTICES AND INFORMATION + %% @opentelemetry/api NOTICES AND INFORMATION BEGIN HERE ========================================= Apache License @@ -807,6 +818,19 @@ END OF @opentelemetry/exporter-trace-otlp-http NOTICES AND INFORMATION ========================================= END OF @opentelemetry/sdk-trace-base NOTICES AND INFORMATION +%% @shoelace-style/shoelace NOTICES AND INFORMATION BEGIN HERE +========================================= +Copyright (c) 2020 A Beautiful Site, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +========================================= +END OF @shoelace-style/shoelace NOTICES AND INFORMATION + %% @vscode/codicons NOTICES AND INFORMATION BEGIN HERE ========================================= Attribution 4.0 International @@ -1275,6 +1299,33 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= END OF billboard.js NOTICES AND INFORMATION +%% fast-string-truncated-width NOTICES AND INFORMATION BEGIN HERE +========================================= +The MIT License (MIT) + +Copyright (c) 2024-present Fabio Spampinato + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +========================================= +END OF fast-string-truncated-width NOTICES AND INFORMATION + %% https-proxy-agent NOTICES AND INFORMATION BEGIN HERE ========================================= https-proxy-agent diff --git a/images/dark/icon-git-orange.svg b/images/dark/icon-git-orange.svg deleted file mode 100644 index 0c9af12a52e1c..0000000000000 --- a/images/dark/icon-git-orange.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/images/dark/icon-git-progress.svg b/images/dark/icon-git-progress.svg deleted file mode 100644 index 90c3d1aa4630e..0000000000000 --- a/images/dark/icon-git-progress.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/images/dark/icon-git.svg b/images/dark/icon-git.svg deleted file mode 100644 index ded805271fd90..0000000000000 --- a/images/dark/icon-git.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/images/dark/icon-gitlens-filled-orange.svg b/images/dark/icon-gitlens-filled-orange.svg new file mode 100644 index 0000000000000..4471b752be14c --- /dev/null +++ b/images/dark/icon-gitlens-filled-orange.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/dark/icon-gitlens-filled-progress.svg b/images/dark/icon-gitlens-filled-progress.svg new file mode 100644 index 0000000000000..599a20db73515 --- /dev/null +++ b/images/dark/icon-gitlens-filled-progress.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/docs/code-suggest.png b/images/docs/code-suggest.png new file mode 100644 index 0000000000000..820720d1051f3 Binary files /dev/null and b/images/docs/code-suggest.png differ diff --git a/images/docs/commit-graph.png b/images/docs/commit-graph.png new file mode 100644 index 0000000000000..1105448cc736a Binary files /dev/null and b/images/docs/commit-graph.png differ diff --git a/images/docs/launchpad.png b/images/docs/launchpad.png new file mode 100644 index 0000000000000..501411ed9d91a Binary files /dev/null and b/images/docs/launchpad.png differ diff --git a/images/docs/worktrees.png b/images/docs/worktrees.png new file mode 100644 index 0000000000000..4693417912d12 Binary files /dev/null and b/images/docs/worktrees.png differ diff --git a/images/icons/arrow-up-force.svg b/images/icons/arrow-up-force.svg deleted file mode 100644 index 331e8dca2bc0e..0000000000000 --- a/images/icons/arrow-up-force.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/images/icons/cloud-patch-share.svg b/images/icons/cloud-patch-share.svg new file mode 100644 index 0000000000000..46dbb3f551f25 --- /dev/null +++ b/images/icons/cloud-patch-share.svg @@ -0,0 +1 @@ + diff --git a/images/icons/cloud-patch.svg b/images/icons/cloud-patch.svg new file mode 100644 index 0000000000000..f2dfe4c072f98 --- /dev/null +++ b/images/icons/cloud-patch.svg @@ -0,0 +1 @@ + diff --git a/images/icons/code-suggestion.svg b/images/icons/code-suggestion.svg new file mode 100644 index 0000000000000..08d83c3881b57 --- /dev/null +++ b/images/icons/code-suggestion.svg @@ -0,0 +1 @@ + diff --git a/images/icons/confirm-checked.svg b/images/icons/confirm-checked.svg new file mode 100644 index 0000000000000..2cfa5a21aa771 --- /dev/null +++ b/images/icons/confirm-checked.svg @@ -0,0 +1 @@ + diff --git a/images/icons/confirm-unchecked.svg b/images/icons/confirm-unchecked.svg new file mode 100644 index 0000000000000..c8b0e11af5c66 --- /dev/null +++ b/images/icons/confirm-unchecked.svg @@ -0,0 +1 @@ + diff --git a/images/icons/diff-multiple.svg b/images/icons/diff-multiple.svg new file mode 100644 index 0000000000000..b2f793b170800 --- /dev/null +++ b/images/icons/diff-multiple.svg @@ -0,0 +1 @@ + diff --git a/images/icons/diff-single.svg b/images/icons/diff-single.svg new file mode 100644 index 0000000000000..e5e1c9bacb0f5 --- /dev/null +++ b/images/icons/diff-single.svg @@ -0,0 +1 @@ + diff --git a/images/icons/gitlens-filled.svg b/images/icons/gitlens-filled.svg new file mode 100644 index 0000000000000..53da0f1b862cc --- /dev/null +++ b/images/icons/gitlens-filled.svg @@ -0,0 +1 @@ + diff --git a/images/icons/gitlens.svg b/images/icons/gitlens.svg index 73d6eca98f6e0..edb9b2b45c427 100644 --- a/images/icons/gitlens.svg +++ b/images/icons/gitlens.svg @@ -1 +1 @@ - + diff --git a/images/icons/inspect.svg b/images/icons/inspect.svg new file mode 100644 index 0000000000000..1cf2117992f8b --- /dev/null +++ b/images/icons/inspect.svg @@ -0,0 +1 @@ + diff --git a/images/icons/provider-jira.svg b/images/icons/provider-jira.svg new file mode 100644 index 0000000000000..b8a4f11270a4a --- /dev/null +++ b/images/icons/provider-jira.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repo-fetch.svg b/images/icons/repo-fetch.svg new file mode 100644 index 0000000000000..1fd0ea8dd589e --- /dev/null +++ b/images/icons/repo-fetch.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repo-force-push.svg b/images/icons/repo-force-push.svg new file mode 100644 index 0000000000000..15878f3d3d21e --- /dev/null +++ b/images/icons/repo-force-push.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repo-pull.svg b/images/icons/repo-pull.svg new file mode 100644 index 0000000000000..e28c1240cda95 --- /dev/null +++ b/images/icons/repo-pull.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repo-push.svg b/images/icons/repo-push.svg new file mode 100644 index 0000000000000..878d60ae0fac3 --- /dev/null +++ b/images/icons/repo-push.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repository-filled.svg b/images/icons/repository-filled.svg new file mode 100644 index 0000000000000..f0861f8dea9e0 --- /dev/null +++ b/images/icons/repository-filled.svg @@ -0,0 +1 @@ + diff --git a/images/icons/template/mapping.json b/images/icons/template/mapping.json index de968a4692356..f5a723fa67ebd 100644 --- a/images/icons/template/mapping.json +++ b/images/icons/template/mapping.json @@ -27,7 +27,7 @@ "switch": 61720, "expand": 61721, "list-auto": 61722, - "arrow-up-force": 61723, + "repo-force-push": 61723, "pinned-filled": 61724, "clock": 61725, "provider-azdo": 61726, @@ -37,5 +37,19 @@ "provider-github": 61730, "provider-gitlab": 61731, "gitlens-inspect": 61732, - "workspaces-view": 61733 + "workspaces-view": 61733, + "confirm-checked": 61734, + "confirm-unchecked": 61735, + "cloud-patch": 61736, + "cloud-patch-share": 61737, + "inspect": 61738, + "repository-filled": 61739, + "gitlens-filled": 61740, + "code-suggestion": 61741, + "diff-multiple": 61742, + "diff-single": 61743, + "repo-fetch": 61744, + "repo-pull": 61745, + "repo-push": 61746, + "provider-jira": 61747 } \ No newline at end of file diff --git a/images/light/icon-git-orange.svg b/images/light/icon-git-orange.svg deleted file mode 100644 index d11d99d1e2d79..0000000000000 --- a/images/light/icon-git-orange.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/images/light/icon-git-progress.svg b/images/light/icon-git-progress.svg deleted file mode 100644 index 0f55ae7b85a07..0000000000000 --- a/images/light/icon-git-progress.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/images/light/icon-git.svg b/images/light/icon-git.svg deleted file mode 100644 index 2a49cbe32f6be..0000000000000 --- a/images/light/icon-git.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/images/light/icon-gitlens-filled-orange.svg b/images/light/icon-gitlens-filled-orange.svg new file mode 100644 index 0000000000000..c43e1f2b460be --- /dev/null +++ b/images/light/icon-gitlens-filled-orange.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/light/icon-gitlens-filled-progress.svg b/images/light/icon-gitlens-filled-progress.svg new file mode 100644 index 0000000000000..ea67e6f3fcb6a --- /dev/null +++ b/images/light/icon-gitlens-filled-progress.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/index.tab b/index.tab new file mode 100644 index 0000000000000..ac9dfb862fb9a --- /dev/null +++ b/index.tab @@ -0,0 +1,2497 @@ +"AS16-M-0001","DATA/REVOLUTION_3/AS16-M-0001.lbl","AS16-M-0001.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0002","DATA/REVOLUTION_3/AS16-M-0002.lbl","AS16-M-0002.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0003","DATA/REVOLUTION_3/AS16-M-0003.lbl","AS16-M-0003.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0004","DATA/REVOLUTION_3/AS16-M-0004.lbl","AS16-M-0004.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0005","DATA/REVOLUTION_3/AS16-M-0005.lbl","AS16-M-0005.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0006","DATA/REVOLUTION_3/AS16-M-0006.lbl","AS16-M-0006.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0007","DATA/REVOLUTION_3/AS16-M-0007.lbl","AS16-M-0007.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0008","DATA/REVOLUTION_3/AS16-M-0008.lbl","AS16-M-0008.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0009","DATA/REVOLUTION_3/AS16-M-0009.lbl","AS16-M-0009.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0010","DATA/REVOLUTION_3/AS16-M-0010.lbl","AS16-M-0010.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0011","DATA/REVOLUTION_3/AS16-M-0011.lbl","AS16-M-0011.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0012","DATA/REVOLUTION_3/AS16-M-0012.lbl","AS16-M-0012.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0013","DATA/REVOLUTION_3/AS16-M-0013.lbl","AS16-M-0013.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0014","DATA/REVOLUTION_3/AS16-M-0014.lbl","AS16-M-0014.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0015","DATA/REVOLUTION_4/AS16-M-0015.lbl","AS16-M-0015.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0016","DATA/REVOLUTION_4/AS16-M-0016.lbl","AS16-M-0016.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0017","DATA/REVOLUTION_4/AS16-M-0017.lbl","AS16-M-0017.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0018","DATA/REVOLUTION_4/AS16-M-0018.lbl","AS16-M-0018.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0019","DATA/REVOLUTION_4/AS16-M-0019.lbl","AS16-M-0019.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0020","DATA/REVOLUTION_4/AS16-M-0020.lbl","AS16-M-0020.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0021","DATA/REVOLUTION_4/AS16-M-0021.lbl","AS16-M-0021.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0022","DATA/REVOLUTION_4/AS16-M-0022.lbl","AS16-M-0022.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0023","DATA/REVOLUTION_4/AS16-M-0023.lbl","AS16-M-0023.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0024","DATA/REVOLUTION_4/AS16-M-0024.lbl","AS16-M-0024.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0025","DATA/REVOLUTION_4/AS16-M-0025.lbl","AS16-M-0025.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0026","DATA/REVOLUTION_4/AS16-M-0026.lbl","AS16-M-0026.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0027","DATA/REVOLUTION_17/AS16-M-0027.lbl","AS16-M-0027.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0028","DATA/REVOLUTION_17/AS16-M-0028.lbl","AS16-M-0028.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0029","DATA/REVOLUTION_17/AS16-M-0029.lbl","AS16-M-0029.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0030","DATA/REVOLUTION_17/AS16-M-0030.lbl","AS16-M-0030.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0031","DATA/REVOLUTION_17/AS16-M-0031.lbl","AS16-M-0031.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0032","DATA/REVOLUTION_17/AS16-M-0032.lbl","AS16-M-0032.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0033","DATA/REVOLUTION_17/AS16-M-0033.lbl","AS16-M-0033.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0034","DATA/REVOLUTION_17/AS16-M-0034.lbl","AS16-M-0034.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0035","DATA/REVOLUTION_17/AS16-M-0035.lbl","AS16-M-0035.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0036","DATA/REVOLUTION_17/AS16-M-0036.lbl","AS16-M-0036.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0037","DATA/REVOLUTION_17/AS16-M-0037.lbl","AS16-M-0037.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0038","DATA/REVOLUTION_17/AS16-M-0038.lbl","AS16-M-0038.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0039","DATA/REVOLUTION_17/AS16-M-0039.lbl","AS16-M-0039.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0040","DATA/REVOLUTION_17/AS16-M-0040.lbl","AS16-M-0040.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0041","DATA/REVOLUTION_17/AS16-M-0041.lbl","AS16-M-0041.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0042","DATA/REVOLUTION_17/AS16-M-0042.lbl","AS16-M-0042.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0043","DATA/REVOLUTION_17/AS16-M-0043.lbl","AS16-M-0043.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0044","DATA/REVOLUTION_17/AS16-M-0044.lbl","AS16-M-0044.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0045","DATA/REVOLUTION_17/AS16-M-0045.lbl","AS16-M-0045.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0046","DATA/REVOLUTION_17/AS16-M-0046.lbl","AS16-M-0046.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0047","DATA/REVOLUTION_17/AS16-M-0047.lbl","AS16-M-0047.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0048","DATA/REVOLUTION_17/AS16-M-0048.lbl","AS16-M-0048.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0049","DATA/REVOLUTION_17/AS16-M-0049.lbl","AS16-M-0049.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0050","DATA/REVOLUTION_17/AS16-M-0050.lbl","AS16-M-0050.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0051","DATA/REVOLUTION_17/AS16-M-0051.lbl","AS16-M-0051.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0052","DATA/REVOLUTION_17/AS16-M-0052.lbl","AS16-M-0052.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0053","DATA/REVOLUTION_17/AS16-M-0053.lbl","AS16-M-0053.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0054","DATA/REVOLUTION_17/AS16-M-0054.lbl","AS16-M-0054.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0055","DATA/REVOLUTION_17/AS16-M-0055.lbl","AS16-M-0055.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0056","DATA/REVOLUTION_17/AS16-M-0056.lbl","AS16-M-0056.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0057","DATA/REVOLUTION_17/AS16-M-0057.lbl","AS16-M-0057.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0058","DATA/REVOLUTION_17/AS16-M-0058.lbl","AS16-M-0058.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0059","DATA/REVOLUTION_17/AS16-M-0059.lbl","AS16-M-0059.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0060","DATA/REVOLUTION_17/AS16-M-0060.lbl","AS16-M-0060.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0061","DATA/REVOLUTION_17/AS16-M-0061.lbl","AS16-M-0061.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0062","DATA/REVOLUTION_17/AS16-M-0062.lbl","AS16-M-0062.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0063","DATA/REVOLUTION_17/AS16-M-0063.lbl","AS16-M-0063.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0064","DATA/REVOLUTION_17/AS16-M-0064.lbl","AS16-M-0064.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0065","DATA/REVOLUTION_17/AS16-M-0065.lbl","AS16-M-0065.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0066","DATA/REVOLUTION_17/AS16-M-0066.lbl","AS16-M-0066.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0067","DATA/REVOLUTION_17/AS16-M-0067.lbl","AS16-M-0067.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0068","DATA/REVOLUTION_17/AS16-M-0068.lbl","AS16-M-0068.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0069","DATA/REVOLUTION_17/AS16-M-0069.lbl","AS16-M-0069.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0070","DATA/REVOLUTION_17/AS16-M-0070.lbl","AS16-M-0070.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0071","DATA/REVOLUTION_17/AS16-M-0071.lbl","AS16-M-0071.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0072","DATA/REVOLUTION_17/AS16-M-0072.lbl","AS16-M-0072.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0073","DATA/REVOLUTION_17/AS16-M-0073.lbl","AS16-M-0073.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0074","DATA/REVOLUTION_17/AS16-M-0074.lbl","AS16-M-0074.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0075","DATA/REVOLUTION_17/AS16-M-0075.lbl","AS16-M-0075.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0076","DATA/REVOLUTION_17/AS16-M-0076.lbl","AS16-M-0076.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0077","DATA/REVOLUTION_17/AS16-M-0077.lbl","AS16-M-0077.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0078","DATA/REVOLUTION_17/AS16-M-0078.lbl","AS16-M-0078.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0079","DATA/REVOLUTION_17/AS16-M-0079.lbl","AS16-M-0079.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0080","DATA/REVOLUTION_17/AS16-M-0080.lbl","AS16-M-0080.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0081","DATA/REVOLUTION_17/AS16-M-0081.lbl","AS16-M-0081.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0082","DATA/REVOLUTION_17/AS16-M-0082.lbl","AS16-M-0082.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0083","DATA/REVOLUTION_17/AS16-M-0083.lbl","AS16-M-0083.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0084","DATA/REVOLUTION_17/AS16-M-0084.lbl","AS16-M-0084.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0085","DATA/REVOLUTION_17/AS16-M-0085.lbl","AS16-M-0085.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0086","DATA/REVOLUTION_17/AS16-M-0086.lbl","AS16-M-0086.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0087","DATA/REVOLUTION_17/AS16-M-0087.lbl","AS16-M-0087.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0088","DATA/REVOLUTION_17/AS16-M-0088.lbl","AS16-M-0088.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0089","DATA/REVOLUTION_17/AS16-M-0089.lbl","AS16-M-0089.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0090","DATA/REVOLUTION_17/AS16-M-0090.lbl","AS16-M-0090.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0091","DATA/REVOLUTION_17/AS16-M-0091.lbl","AS16-M-0091.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0092","DATA/REVOLUTION_17/AS16-M-0092.lbl","AS16-M-0092.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0093","DATA/REVOLUTION_17/AS16-M-0093.lbl","AS16-M-0093.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0094","DATA/REVOLUTION_17/AS16-M-0094.lbl","AS16-M-0094.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0095","DATA/REVOLUTION_17/AS16-M-0095.lbl","AS16-M-0095.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0096","DATA/REVOLUTION_17/AS16-M-0096.lbl","AS16-M-0096.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0097","DATA/REVOLUTION_17/AS16-M-0097.lbl","AS16-M-0097.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0098","DATA/REVOLUTION_17/AS16-M-0098.lbl","AS16-M-0098.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0099","DATA/REVOLUTION_17/AS16-M-0099.lbl","AS16-M-0099.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0100","DATA/REVOLUTION_17/AS16-M-0100.lbl","AS16-M-0100.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0101","DATA/REVOLUTION_17/AS16-M-0101.lbl","AS16-M-0101.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0102","DATA/REVOLUTION_17/AS16-M-0102.lbl","AS16-M-0102.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0103","DATA/REVOLUTION_17/AS16-M-0103.lbl","AS16-M-0103.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0104","DATA/REVOLUTION_17/AS16-M-0104.lbl","AS16-M-0104.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0105","DATA/REVOLUTION_17/AS16-M-0105.lbl","AS16-M-0105.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0106","DATA/REVOLUTION_17/AS16-M-0106.lbl","AS16-M-0106.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0107","DATA/REVOLUTION_17/AS16-M-0107.lbl","AS16-M-0107.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0108","DATA/REVOLUTION_17/AS16-M-0108.lbl","AS16-M-0108.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0109","DATA/REVOLUTION_17/AS16-M-0109.lbl","AS16-M-0109.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0110","DATA/REVOLUTION_17/AS16-M-0110.lbl","AS16-M-0110.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0111","DATA/REVOLUTION_17/AS16-M-0111.lbl","AS16-M-0111.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0112","DATA/REVOLUTION_17/AS16-M-0112.lbl","AS16-M-0112.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0113","DATA/REVOLUTION_17/AS16-M-0113.lbl","AS16-M-0113.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0114","DATA/REVOLUTION_17/AS16-M-0114.lbl","AS16-M-0114.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0115","DATA/REVOLUTION_17/AS16-M-0115.lbl","AS16-M-0115.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0116","DATA/REVOLUTION_17/AS16-M-0116.lbl","AS16-M-0116.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0117","DATA/REVOLUTION_17/AS16-M-0117.lbl","AS16-M-0117.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0118","DATA/REVOLUTION_17/AS16-M-0118.lbl","AS16-M-0118.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0119","DATA/REVOLUTION_17/AS16-M-0119.lbl","AS16-M-0119.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0120","DATA/REVOLUTION_17/AS16-M-0120.lbl","AS16-M-0120.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0121","DATA/REVOLUTION_17/AS16-M-0121.lbl","AS16-M-0121.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0122","DATA/REVOLUTION_17/AS16-M-0122.lbl","AS16-M-0122.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0123","DATA/REVOLUTION_17/AS16-M-0123.lbl","AS16-M-0123.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0124","DATA/REVOLUTION_17/AS16-M-0124.lbl","AS16-M-0124.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0125","DATA/REVOLUTION_17/AS16-M-0125.lbl","AS16-M-0125.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0126","DATA/REVOLUTION_17/AS16-M-0126.lbl","AS16-M-0126.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0127","DATA/REVOLUTION_17/AS16-M-0127.lbl","AS16-M-0127.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0128","DATA/REVOLUTION_17/AS16-M-0128.lbl","AS16-M-0128.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0129","DATA/REVOLUTION_17/AS16-M-0129.lbl","AS16-M-0129.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0130","DATA/REVOLUTION_17/AS16-M-0130.lbl","AS16-M-0130.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0131","DATA/REVOLUTION_17/AS16-M-0131.lbl","AS16-M-0131.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0132","DATA/REVOLUTION_17/AS16-M-0132.lbl","AS16-M-0132.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0133","DATA/REVOLUTION_17/AS16-M-0133.lbl","AS16-M-0133.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0134","DATA/REVOLUTION_17/AS16-M-0134.lbl","AS16-M-0134.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0135","DATA/REVOLUTION_17/AS16-M-0135.lbl","AS16-M-0135.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0136","DATA/REVOLUTION_17/AS16-M-0136.lbl","AS16-M-0136.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0137","DATA/REVOLUTION_17/AS16-M-0137.lbl","AS16-M-0137.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0138","DATA/REVOLUTION_17/AS16-M-0138.lbl","AS16-M-0138.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0139","DATA/REVOLUTION_17/AS16-M-0139.lbl","AS16-M-0139.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0140","DATA/REVOLUTION_17/AS16-M-0140.lbl","AS16-M-0140.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0141","DATA/REVOLUTION_17/AS16-M-0141.lbl","AS16-M-0141.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0142","DATA/REVOLUTION_17/AS16-M-0142.lbl","AS16-M-0142.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0143","DATA/REVOLUTION_17/AS16-M-0143.lbl","AS16-M-0143.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0144","DATA/REVOLUTION_17/AS16-M-0144.lbl","AS16-M-0144.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0145","DATA/REVOLUTION_17/AS16-M-0145.lbl","AS16-M-0145.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0146","DATA/REVOLUTION_17/AS16-M-0146.lbl","AS16-M-0146.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0147","DATA/REVOLUTION_17/AS16-M-0147.lbl","AS16-M-0147.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0148","DATA/REVOLUTION_17/AS16-M-0148.lbl","AS16-M-0148.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0149","DATA/REVOLUTION_17/AS16-M-0149.lbl","AS16-M-0149.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0150","DATA/REVOLUTION_17/AS16-M-0150.lbl","AS16-M-0150.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0151","DATA/REVOLUTION_17/AS16-M-0151.lbl","AS16-M-0151.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0152","DATA/REVOLUTION_17/AS16-M-0152.lbl","AS16-M-0152.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0153","DATA/REVOLUTION_17/AS16-M-0153.lbl","AS16-M-0153.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0154","DATA/REVOLUTION_17/AS16-M-0154.lbl","AS16-M-0154.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0155","DATA/REVOLUTION_17/AS16-M-0155.lbl","AS16-M-0155.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0156","DATA/REVOLUTION_17/AS16-M-0156.lbl","AS16-M-0156.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0157","DATA/REVOLUTION_17/AS16-M-0157.lbl","AS16-M-0157.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0158","DATA/REVOLUTION_17/AS16-M-0158.lbl","AS16-M-0158.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0159","DATA/REVOLUTION_17/AS16-M-0159.lbl","AS16-M-0159.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0160","DATA/REVOLUTION_17/AS16-M-0160.lbl","AS16-M-0160.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0161","DATA/REVOLUTION_17/AS16-M-0161.lbl","AS16-M-0161.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0162","DATA/REVOLUTION_17/AS16-M-0162.lbl","AS16-M-0162.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0163","DATA/REVOLUTION_17/AS16-M-0163.lbl","AS16-M-0163.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0164","DATA/REVOLUTION_17/AS16-M-0164.lbl","AS16-M-0164.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0165","DATA/REVOLUTION_17/AS16-M-0165.lbl","AS16-M-0165.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0166","DATA/REVOLUTION_17/AS16-M-0166.lbl","AS16-M-0166.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0167","DATA/REVOLUTION_17/AS16-M-0167.lbl","AS16-M-0167.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0168","DATA/REVOLUTION_17/AS16-M-0168.lbl","AS16-M-0168.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0169","DATA/REVOLUTION_17/AS16-M-0169.lbl","AS16-M-0169.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0170","DATA/REVOLUTION_17/AS16-M-0170.lbl","AS16-M-0170.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0171","DATA/REVOLUTION_17/AS16-M-0171.lbl","AS16-M-0171.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0172","DATA/REVOLUTION_17/AS16-M-0172.lbl","AS16-M-0172.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0173","DATA/REVOLUTION_17/AS16-M-0173.lbl","AS16-M-0173.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0174","DATA/REVOLUTION_17/AS16-M-0174.lbl","AS16-M-0174.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0313","DATA/REVOLUTION_18/AS16-M-0313.lbl","AS16-M-0313.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0314","DATA/REVOLUTION_18/AS16-M-0314.lbl","AS16-M-0314.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0315","DATA/REVOLUTION_18/AS16-M-0315.lbl","AS16-M-0315.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0316","DATA/REVOLUTION_18/AS16-M-0316.lbl","AS16-M-0316.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0317","DATA/REVOLUTION_18/AS16-M-0317.lbl","AS16-M-0317.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0318","DATA/REVOLUTION_18/AS16-M-0318.lbl","AS16-M-0318.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0319","DATA/REVOLUTION_18/AS16-M-0319.lbl","AS16-M-0319.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0320","DATA/REVOLUTION_18/AS16-M-0320.lbl","AS16-M-0320.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0321","DATA/REVOLUTION_18/AS16-M-0321.lbl","AS16-M-0321.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0322","DATA/REVOLUTION_18/AS16-M-0322.lbl","AS16-M-0322.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0323","DATA/REVOLUTION_18/AS16-M-0323.lbl","AS16-M-0323.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0324","DATA/REVOLUTION_18/AS16-M-0324.lbl","AS16-M-0324.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0325","DATA/REVOLUTION_18/AS16-M-0325.lbl","AS16-M-0325.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0326","DATA/REVOLUTION_18/AS16-M-0326.lbl","AS16-M-0326.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0327","DATA/REVOLUTION_18/AS16-M-0327.lbl","AS16-M-0327.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0328","DATA/REVOLUTION_18/AS16-M-0328.lbl","AS16-M-0328.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0329","DATA/REVOLUTION_18/AS16-M-0329.lbl","AS16-M-0329.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0330","DATA/REVOLUTION_18/AS16-M-0330.lbl","AS16-M-0330.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0331","DATA/REVOLUTION_18/AS16-M-0331.lbl","AS16-M-0331.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0332","DATA/REVOLUTION_18/AS16-M-0332.lbl","AS16-M-0332.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0333","DATA/REVOLUTION_18/AS16-M-0333.lbl","AS16-M-0333.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0334","DATA/REVOLUTION_18/AS16-M-0334.lbl","AS16-M-0334.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0335","DATA/REVOLUTION_18/AS16-M-0335.lbl","AS16-M-0335.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0336","DATA/REVOLUTION_18/AS16-M-0336.lbl","AS16-M-0336.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0337","DATA/REVOLUTION_18/AS16-M-0337.lbl","AS16-M-0337.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0338","DATA/REVOLUTION_18/AS16-M-0338.lbl","AS16-M-0338.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0339","DATA/REVOLUTION_18/AS16-M-0339.lbl","AS16-M-0339.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0340","DATA/REVOLUTION_18/AS16-M-0340.lbl","AS16-M-0340.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0341","DATA/REVOLUTION_18/AS16-M-0341.lbl","AS16-M-0341.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0342","DATA/REVOLUTION_18/AS16-M-0342.lbl","AS16-M-0342.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0343","DATA/REVOLUTION_18/AS16-M-0343.lbl","AS16-M-0343.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0344","DATA/REVOLUTION_18/AS16-M-0344.lbl","AS16-M-0344.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0345","DATA/REVOLUTION_18/AS16-M-0345.lbl","AS16-M-0345.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0346","DATA/REVOLUTION_18/AS16-M-0346.lbl","AS16-M-0346.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0347","DATA/REVOLUTION_18/AS16-M-0347.lbl","AS16-M-0347.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0348","DATA/REVOLUTION_18/AS16-M-0348.lbl","AS16-M-0348.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0349","DATA/REVOLUTION_18/AS16-M-0349.lbl","AS16-M-0349.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0350","DATA/REVOLUTION_18/AS16-M-0350.lbl","AS16-M-0350.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0351","DATA/REVOLUTION_18/AS16-M-0351.lbl","AS16-M-0351.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0352","DATA/REVOLUTION_18/AS16-M-0352.lbl","AS16-M-0352.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0353","DATA/REVOLUTION_18/AS16-M-0353.lbl","AS16-M-0353.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0354","DATA/REVOLUTION_18/AS16-M-0354.lbl","AS16-M-0354.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0355","DATA/REVOLUTION_18/AS16-M-0355.lbl","AS16-M-0355.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0356","DATA/REVOLUTION_18/AS16-M-0356.lbl","AS16-M-0356.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0357","DATA/REVOLUTION_18/AS16-M-0357.lbl","AS16-M-0357.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0358","DATA/REVOLUTION_18/AS16-M-0358.lbl","AS16-M-0358.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0359","DATA/REVOLUTION_18/AS16-M-0359.lbl","AS16-M-0359.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0360","DATA/REVOLUTION_18/AS16-M-0360.lbl","AS16-M-0360.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0361","DATA/REVOLUTION_18/AS16-M-0361.lbl","AS16-M-0361.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0362","DATA/REVOLUTION_18/AS16-M-0362.lbl","AS16-M-0362.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0363","DATA/REVOLUTION_18/AS16-M-0363.lbl","AS16-M-0363.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0364","DATA/REVOLUTION_18/AS16-M-0364.lbl","AS16-M-0364.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0365","DATA/REVOLUTION_18/AS16-M-0365.lbl","AS16-M-0365.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0366","DATA/REVOLUTION_18/AS16-M-0366.lbl","AS16-M-0366.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0367","DATA/REVOLUTION_18/AS16-M-0367.lbl","AS16-M-0367.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0368","DATA/REVOLUTION_18/AS16-M-0368.lbl","AS16-M-0368.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0369","DATA/REVOLUTION_18/AS16-M-0369.lbl","AS16-M-0369.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0370","DATA/REVOLUTION_18/AS16-M-0370.lbl","AS16-M-0370.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0371","DATA/REVOLUTION_18/AS16-M-0371.lbl","AS16-M-0371.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0372","DATA/REVOLUTION_18/AS16-M-0372.lbl","AS16-M-0372.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0373","DATA/REVOLUTION_18/AS16-M-0373.lbl","AS16-M-0373.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0374","DATA/REVOLUTION_18/AS16-M-0374.lbl","AS16-M-0374.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0375","DATA/REVOLUTION_18/AS16-M-0375.lbl","AS16-M-0375.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0376","DATA/REVOLUTION_18/AS16-M-0376.lbl","AS16-M-0376.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0377","DATA/REVOLUTION_18/AS16-M-0377.lbl","AS16-M-0377.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0378","DATA/REVOLUTION_18/AS16-M-0378.lbl","AS16-M-0378.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0379","DATA/REVOLUTION_18/AS16-M-0379.lbl","AS16-M-0379.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0380","DATA/REVOLUTION_18/AS16-M-0380.lbl","AS16-M-0380.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0381","DATA/REVOLUTION_18/AS16-M-0381.lbl","AS16-M-0381.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0382","DATA/REVOLUTION_18/AS16-M-0382.lbl","AS16-M-0382.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0383","DATA/REVOLUTION_18/AS16-M-0383.lbl","AS16-M-0383.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0384","DATA/REVOLUTION_18/AS16-M-0384.lbl","AS16-M-0384.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0385","DATA/REVOLUTION_18/AS16-M-0385.lbl","AS16-M-0385.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0386","DATA/REVOLUTION_18/AS16-M-0386.lbl","AS16-M-0386.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0387","DATA/REVOLUTION_18/AS16-M-0387.lbl","AS16-M-0387.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0388","DATA/REVOLUTION_18/AS16-M-0388.lbl","AS16-M-0388.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0389","DATA/REVOLUTION_18/AS16-M-0389.lbl","AS16-M-0389.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0390","DATA/REVOLUTION_18/AS16-M-0390.lbl","AS16-M-0390.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0391","DATA/REVOLUTION_18/AS16-M-0391.lbl","AS16-M-0391.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0392","DATA/REVOLUTION_18/AS16-M-0392.lbl","AS16-M-0392.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0393","DATA/REVOLUTION_18/AS16-M-0393.lbl","AS16-M-0393.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0394","DATA/REVOLUTION_18/AS16-M-0394.lbl","AS16-M-0394.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0395","DATA/REVOLUTION_18/AS16-M-0395.lbl","AS16-M-0395.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0396","DATA/REVOLUTION_18/AS16-M-0396.lbl","AS16-M-0396.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0397","DATA/REVOLUTION_18/AS16-M-0397.lbl","AS16-M-0397.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0398","DATA/REVOLUTION_18/AS16-M-0398.lbl","AS16-M-0398.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0399","DATA/REVOLUTION_18/AS16-M-0399.lbl","AS16-M-0399.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0400","DATA/REVOLUTION_18/AS16-M-0400.lbl","AS16-M-0400.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0401","DATA/REVOLUTION_18/AS16-M-0401.lbl","AS16-M-0401.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0402","DATA/REVOLUTION_18/AS16-M-0402.lbl","AS16-M-0402.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0403","DATA/REVOLUTION_18/AS16-M-0403.lbl","AS16-M-0403.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0404","DATA/REVOLUTION_18/AS16-M-0404.lbl","AS16-M-0404.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0405","DATA/REVOLUTION_18/AS16-M-0405.lbl","AS16-M-0405.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0406","DATA/REVOLUTION_18/AS16-M-0406.lbl","AS16-M-0406.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0407","DATA/REVOLUTION_18/AS16-M-0407.lbl","AS16-M-0407.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0408","DATA/REVOLUTION_18/AS16-M-0408.lbl","AS16-M-0408.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0409","DATA/REVOLUTION_18/AS16-M-0409.lbl","AS16-M-0409.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0410","DATA/REVOLUTION_18/AS16-M-0410.lbl","AS16-M-0410.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0411","DATA/REVOLUTION_18/AS16-M-0411.lbl","AS16-M-0411.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0412","DATA/REVOLUTION_18/AS16-M-0412.lbl","AS16-M-0412.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0413","DATA/REVOLUTION_18/AS16-M-0413.lbl","AS16-M-0413.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0414","DATA/REVOLUTION_18/AS16-M-0414.lbl","AS16-M-0414.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0415","DATA/REVOLUTION_18/AS16-M-0415.lbl","AS16-M-0415.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0416","DATA/REVOLUTION_18/AS16-M-0416.lbl","AS16-M-0416.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0417","DATA/REVOLUTION_18/AS16-M-0417.lbl","AS16-M-0417.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0418","DATA/REVOLUTION_18/AS16-M-0418.lbl","AS16-M-0418.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0419","DATA/REVOLUTION_18/AS16-M-0419.lbl","AS16-M-0419.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0420","DATA/REVOLUTION_18/AS16-M-0420.lbl","AS16-M-0420.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0421","DATA/REVOLUTION_18/AS16-M-0421.lbl","AS16-M-0421.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0422","DATA/REVOLUTION_18/AS16-M-0422.lbl","AS16-M-0422.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0423","DATA/REVOLUTION_18/AS16-M-0423.lbl","AS16-M-0423.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0424","DATA/REVOLUTION_18/AS16-M-0424.lbl","AS16-M-0424.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0425","DATA/REVOLUTION_18/AS16-M-0425.lbl","AS16-M-0425.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0426","DATA/REVOLUTION_18/AS16-M-0426.lbl","AS16-M-0426.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0427","DATA/REVOLUTION_18/AS16-M-0427.lbl","AS16-M-0427.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0428","DATA/REVOLUTION_18/AS16-M-0428.lbl","AS16-M-0428.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0429","DATA/REVOLUTION_18/AS16-M-0429.lbl","AS16-M-0429.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0430","DATA/REVOLUTION_18/AS16-M-0430.lbl","AS16-M-0430.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0431","DATA/REVOLUTION_18/AS16-M-0431.lbl","AS16-M-0431.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0432","DATA/REVOLUTION_18/AS16-M-0432.lbl","AS16-M-0432.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0433","DATA/REVOLUTION_18/AS16-M-0433.lbl","AS16-M-0433.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0434","DATA/REVOLUTION_18/AS16-M-0434.lbl","AS16-M-0434.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0435","DATA/REVOLUTION_18/AS16-M-0435.lbl","AS16-M-0435.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0436","DATA/REVOLUTION_18/AS16-M-0436.lbl","AS16-M-0436.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0437","DATA/REVOLUTION_18/AS16-M-0437.lbl","AS16-M-0437.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0438","DATA/REVOLUTION_18/AS16-M-0438.lbl","AS16-M-0438.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0439","DATA/REVOLUTION_18/AS16-M-0439.lbl","AS16-M-0439.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0440","DATA/REVOLUTION_18/AS16-M-0440.lbl","AS16-M-0440.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0441","DATA/REVOLUTION_18/AS16-M-0441.lbl","AS16-M-0441.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0442","DATA/REVOLUTION_18/AS16-M-0442.lbl","AS16-M-0442.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0443","DATA/REVOLUTION_18/AS16-M-0443.lbl","AS16-M-0443.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0444","DATA/REVOLUTION_18/AS16-M-0444.lbl","AS16-M-0444.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0445","DATA/REVOLUTION_18/AS16-M-0445.lbl","AS16-M-0445.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0446","DATA/REVOLUTION_18/AS16-M-0446.lbl","AS16-M-0446.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0447","DATA/REVOLUTION_18/AS16-M-0447.lbl","AS16-M-0447.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0448","DATA/REVOLUTION_18/AS16-M-0448.lbl","AS16-M-0448.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0449","DATA/REVOLUTION_18/AS16-M-0449.lbl","AS16-M-0449.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0450","DATA/REVOLUTION_18/AS16-M-0450.lbl","AS16-M-0450.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0451","DATA/REVOLUTION_18/AS16-M-0451.lbl","AS16-M-0451.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0452","DATA/REVOLUTION_18/AS16-M-0452.lbl","AS16-M-0452.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0453","DATA/REVOLUTION_25/AS16-M-0453.lbl","AS16-M-0453.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0454","DATA/REVOLUTION_25/AS16-M-0454.lbl","AS16-M-0454.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0455","DATA/REVOLUTION_25/AS16-M-0455.lbl","AS16-M-0455.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0456","DATA/REVOLUTION_25/AS16-M-0456.lbl","AS16-M-0456.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0457","DATA/REVOLUTION_25/AS16-M-0457.lbl","AS16-M-0457.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0458","DATA/REVOLUTION_25/AS16-M-0458.lbl","AS16-M-0458.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0459","DATA/REVOLUTION_25/AS16-M-0459.lbl","AS16-M-0459.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0460","DATA/REVOLUTION_25/AS16-M-0460.lbl","AS16-M-0460.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0461","DATA/REVOLUTION_25/AS16-M-0461.lbl","AS16-M-0461.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0462","DATA/REVOLUTION_25/AS16-M-0462.lbl","AS16-M-0462.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0463","DATA/REVOLUTION_25/AS16-M-0463.lbl","AS16-M-0463.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0464","DATA/REVOLUTION_25/AS16-M-0464.lbl","AS16-M-0464.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0465","DATA/REVOLUTION_25/AS16-M-0465.lbl","AS16-M-0465.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0466","DATA/REVOLUTION_25/AS16-M-0466.lbl","AS16-M-0466.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0467","DATA/REVOLUTION_25/AS16-M-0467.lbl","AS16-M-0467.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0468","DATA/REVOLUTION_25/AS16-M-0468.lbl","AS16-M-0468.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0469","DATA/REVOLUTION_25/AS16-M-0469.lbl","AS16-M-0469.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0470","DATA/REVOLUTION_25/AS16-M-0470.lbl","AS16-M-0470.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0471","DATA/REVOLUTION_25/AS16-M-0471.lbl","AS16-M-0471.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0472","DATA/REVOLUTION_25/AS16-M-0472.lbl","AS16-M-0472.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0473","DATA/REVOLUTION_25/AS16-M-0473.lbl","AS16-M-0473.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0474","DATA/REVOLUTION_25/AS16-M-0474.lbl","AS16-M-0474.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0475","DATA/REVOLUTION_25/AS16-M-0475.lbl","AS16-M-0475.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0476","DATA/REVOLUTION_25/AS16-M-0476.lbl","AS16-M-0476.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0477","DATA/REVOLUTION_25/AS16-M-0477.lbl","AS16-M-0477.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0478","DATA/REVOLUTION_25/AS16-M-0478.lbl","AS16-M-0478.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0479","DATA/REVOLUTION_25/AS16-M-0479.lbl","AS16-M-0479.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0480","DATA/REVOLUTION_25/AS16-M-0480.lbl","AS16-M-0480.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0481","DATA/REVOLUTION_25/AS16-M-0481.lbl","AS16-M-0481.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0482","DATA/REVOLUTION_25/AS16-M-0482.lbl","AS16-M-0482.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0483","DATA/REVOLUTION_25/AS16-M-0483.lbl","AS16-M-0483.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0484","DATA/REVOLUTION_25/AS16-M-0484.lbl","AS16-M-0484.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0485","DATA/REVOLUTION_25/AS16-M-0485.lbl","AS16-M-0485.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0486","DATA/REVOLUTION_25/AS16-M-0486.lbl","AS16-M-0486.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0487","DATA/REVOLUTION_25/AS16-M-0487.lbl","AS16-M-0487.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0488","DATA/REVOLUTION_25/AS16-M-0488.lbl","AS16-M-0488.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0489","DATA/REVOLUTION_25/AS16-M-0489.lbl","AS16-M-0489.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0490","DATA/REVOLUTION_25/AS16-M-0490.lbl","AS16-M-0490.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0491","DATA/REVOLUTION_25/AS16-M-0491.lbl","AS16-M-0491.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0492","DATA/REVOLUTION_25/AS16-M-0492.lbl","AS16-M-0492.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0493","DATA/REVOLUTION_25/AS16-M-0493.lbl","AS16-M-0493.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0494","DATA/REVOLUTION_25/AS16-M-0494.lbl","AS16-M-0494.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0495","DATA/REVOLUTION_25/AS16-M-0495.lbl","AS16-M-0495.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0496","DATA/REVOLUTION_25/AS16-M-0496.lbl","AS16-M-0496.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0497","DATA/REVOLUTION_25/AS16-M-0497.lbl","AS16-M-0497.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0498","DATA/REVOLUTION_25/AS16-M-0498.lbl","AS16-M-0498.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0499","DATA/REVOLUTION_25/AS16-M-0499.lbl","AS16-M-0499.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0500","DATA/REVOLUTION_25/AS16-M-0500.lbl","AS16-M-0500.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0501","DATA/REVOLUTION_25/AS16-M-0501.lbl","AS16-M-0501.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0502","DATA/REVOLUTION_25/AS16-M-0502.lbl","AS16-M-0502.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0503","DATA/REVOLUTION_25/AS16-M-0503.lbl","AS16-M-0503.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0504","DATA/REVOLUTION_25/AS16-M-0504.lbl","AS16-M-0504.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0505","DATA/REVOLUTION_25/AS16-M-0505.lbl","AS16-M-0505.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0506","DATA/REVOLUTION_25/AS16-M-0506.lbl","AS16-M-0506.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0507","DATA/REVOLUTION_25/AS16-M-0507.lbl","AS16-M-0507.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0508","DATA/REVOLUTION_25/AS16-M-0508.lbl","AS16-M-0508.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0509","DATA/REVOLUTION_25/AS16-M-0509.lbl","AS16-M-0509.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0510","DATA/REVOLUTION_25/AS16-M-0510.lbl","AS16-M-0510.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0511","DATA/REVOLUTION_25/AS16-M-0511.lbl","AS16-M-0511.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0512","DATA/REVOLUTION_25/AS16-M-0512.lbl","AS16-M-0512.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0513","DATA/REVOLUTION_25/AS16-M-0513.lbl","AS16-M-0513.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0514","DATA/REVOLUTION_25/AS16-M-0514.lbl","AS16-M-0514.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0515","DATA/REVOLUTION_25/AS16-M-0515.lbl","AS16-M-0515.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0516","DATA/REVOLUTION_25/AS16-M-0516.lbl","AS16-M-0516.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0517","DATA/REVOLUTION_25/AS16-M-0517.lbl","AS16-M-0517.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0518","DATA/REVOLUTION_25/AS16-M-0518.lbl","AS16-M-0518.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0519","DATA/REVOLUTION_25/AS16-M-0519.lbl","AS16-M-0519.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0520","DATA/REVOLUTION_25/AS16-M-0520.lbl","AS16-M-0520.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0521","DATA/REVOLUTION_25/AS16-M-0521.lbl","AS16-M-0521.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0522","DATA/REVOLUTION_25/AS16-M-0522.lbl","AS16-M-0522.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0523","DATA/REVOLUTION_25/AS16-M-0523.lbl","AS16-M-0523.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0524","DATA/REVOLUTION_25/AS16-M-0524.lbl","AS16-M-0524.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0525","DATA/REVOLUTION_25/AS16-M-0525.lbl","AS16-M-0525.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0526","DATA/REVOLUTION_25/AS16-M-0526.lbl","AS16-M-0526.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0527","DATA/REVOLUTION_25/AS16-M-0527.lbl","AS16-M-0527.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0528","DATA/REVOLUTION_25/AS16-M-0528.lbl","AS16-M-0528.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0529","DATA/REVOLUTION_25/AS16-M-0529.lbl","AS16-M-0529.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0530","DATA/REVOLUTION_25/AS16-M-0530.lbl","AS16-M-0530.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0531","DATA/REVOLUTION_25/AS16-M-0531.lbl","AS16-M-0531.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0532","DATA/REVOLUTION_25/AS16-M-0532.lbl","AS16-M-0532.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0533","DATA/REVOLUTION_25/AS16-M-0533.lbl","AS16-M-0533.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0534","DATA/REVOLUTION_25/AS16-M-0534.lbl","AS16-M-0534.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0535","DATA/REVOLUTION_25/AS16-M-0535.lbl","AS16-M-0535.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0536","DATA/REVOLUTION_25/AS16-M-0536.lbl","AS16-M-0536.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0537","DATA/REVOLUTION_25/AS16-M-0537.lbl","AS16-M-0537.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0538","DATA/REVOLUTION_25/AS16-M-0538.lbl","AS16-M-0538.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0539","DATA/REVOLUTION_25/AS16-M-0539.lbl","AS16-M-0539.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0540","DATA/REVOLUTION_25/AS16-M-0540.lbl","AS16-M-0540.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0541","DATA/REVOLUTION_25/AS16-M-0541.lbl","AS16-M-0541.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0542","DATA/REVOLUTION_25/AS16-M-0542.lbl","AS16-M-0542.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0543","DATA/REVOLUTION_25/AS16-M-0543.lbl","AS16-M-0543.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0544","DATA/REVOLUTION_25/AS16-M-0544.lbl","AS16-M-0544.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0545","DATA/REVOLUTION_25/AS16-M-0545.lbl","AS16-M-0545.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0546","DATA/REVOLUTION_25/AS16-M-0546.lbl","AS16-M-0546.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0547","DATA/REVOLUTION_25/AS16-M-0547.lbl","AS16-M-0547.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0548","DATA/REVOLUTION_25/AS16-M-0548.lbl","AS16-M-0548.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0549","DATA/REVOLUTION_25/AS16-M-0549.lbl","AS16-M-0549.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0550","DATA/REVOLUTION_25/AS16-M-0550.lbl","AS16-M-0550.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0551","DATA/REVOLUTION_25/AS16-M-0551.lbl","AS16-M-0551.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0552","DATA/REVOLUTION_25/AS16-M-0552.lbl","AS16-M-0552.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0553","DATA/REVOLUTION_25/AS16-M-0553.lbl","AS16-M-0553.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0554","DATA/REVOLUTION_25/AS16-M-0554.lbl","AS16-M-0554.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0555","DATA/REVOLUTION_25/AS16-M-0555.lbl","AS16-M-0555.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0556","DATA/REVOLUTION_25/AS16-M-0556.lbl","AS16-M-0556.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0557","DATA/REVOLUTION_25/AS16-M-0557.lbl","AS16-M-0557.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0558","DATA/REVOLUTION_25/AS16-M-0558.lbl","AS16-M-0558.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0559","DATA/REVOLUTION_25/AS16-M-0559.lbl","AS16-M-0559.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0560","DATA/REVOLUTION_25/AS16-M-0560.lbl","AS16-M-0560.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0561","DATA/REVOLUTION_25/AS16-M-0561.lbl","AS16-M-0561.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0562","DATA/REVOLUTION_25/AS16-M-0562.lbl","AS16-M-0562.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0563","DATA/REVOLUTION_25/AS16-M-0563.lbl","AS16-M-0563.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0564","DATA/REVOLUTION_25/AS16-M-0564.lbl","AS16-M-0564.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0565","DATA/REVOLUTION_25/AS16-M-0565.lbl","AS16-M-0565.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0566","DATA/REVOLUTION_25/AS16-M-0566.lbl","AS16-M-0566.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0567","DATA/REVOLUTION_25/AS16-M-0567.lbl","AS16-M-0567.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0568","DATA/REVOLUTION_25/AS16-M-0568.lbl","AS16-M-0568.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0569","DATA/REVOLUTION_25/AS16-M-0569.lbl","AS16-M-0569.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0570","DATA/REVOLUTION_25/AS16-M-0570.lbl","AS16-M-0570.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0571","DATA/REVOLUTION_25/AS16-M-0571.lbl","AS16-M-0571.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0572","DATA/REVOLUTION_25/AS16-M-0572.lbl","AS16-M-0572.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0573","DATA/REVOLUTION_25/AS16-M-0573.lbl","AS16-M-0573.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0574","DATA/REVOLUTION_25/AS16-M-0574.lbl","AS16-M-0574.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0575","DATA/REVOLUTION_25/AS16-M-0575.lbl","AS16-M-0575.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0576","DATA/REVOLUTION_25/AS16-M-0576.lbl","AS16-M-0576.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0577","DATA/REVOLUTION_25/AS16-M-0577.lbl","AS16-M-0577.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0578","DATA/REVOLUTION_25/AS16-M-0578.lbl","AS16-M-0578.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0579","DATA/REVOLUTION_25/AS16-M-0579.lbl","AS16-M-0579.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0580","DATA/REVOLUTION_25/AS16-M-0580.lbl","AS16-M-0580.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0581","DATA/REVOLUTION_25/AS16-M-0581.lbl","AS16-M-0581.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0582","DATA/REVOLUTION_25/AS16-M-0582.lbl","AS16-M-0582.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0583","DATA/REVOLUTION_25/AS16-M-0583.lbl","AS16-M-0583.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0584","DATA/REVOLUTION_25/AS16-M-0584.lbl","AS16-M-0584.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0585","DATA/REVOLUTION_25/AS16-M-0585.lbl","AS16-M-0585.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0586","DATA/REVOLUTION_25/AS16-M-0586.lbl","AS16-M-0586.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0587","DATA/REVOLUTION_26/AS16-M-0587.lbl","AS16-M-0587.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0588","DATA/REVOLUTION_26/AS16-M-0588.lbl","AS16-M-0588.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0589","DATA/REVOLUTION_26/AS16-M-0589.lbl","AS16-M-0589.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0590","DATA/REVOLUTION_26/AS16-M-0590.lbl","AS16-M-0590.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0591","DATA/REVOLUTION_26/AS16-M-0591.lbl","AS16-M-0591.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0592","DATA/REVOLUTION_26/AS16-M-0592.lbl","AS16-M-0592.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0593","DATA/REVOLUTION_26/AS16-M-0593.lbl","AS16-M-0593.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0594","DATA/REVOLUTION_26/AS16-M-0594.lbl","AS16-M-0594.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0595","DATA/REVOLUTION_26/AS16-M-0595.lbl","AS16-M-0595.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0596","DATA/REVOLUTION_26/AS16-M-0596.lbl","AS16-M-0596.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0597","DATA/REVOLUTION_26/AS16-M-0597.lbl","AS16-M-0597.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0598","DATA/REVOLUTION_26/AS16-M-0598.lbl","AS16-M-0598.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0599","DATA/REVOLUTION_26/AS16-M-0599.lbl","AS16-M-0599.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0600","DATA/REVOLUTION_26/AS16-M-0600.lbl","AS16-M-0600.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0601","DATA/REVOLUTION_26/AS16-M-0601.lbl","AS16-M-0601.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0602","DATA/REVOLUTION_26/AS16-M-0602.lbl","AS16-M-0602.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0603","DATA/REVOLUTION_26/AS16-M-0603.lbl","AS16-M-0603.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0604","DATA/REVOLUTION_26/AS16-M-0604.lbl","AS16-M-0604.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0605","DATA/REVOLUTION_26/AS16-M-0605.lbl","AS16-M-0605.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0606","DATA/REVOLUTION_26/AS16-M-0606.lbl","AS16-M-0606.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0607","DATA/REVOLUTION_26/AS16-M-0607.lbl","AS16-M-0607.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0608","DATA/REVOLUTION_26/AS16-M-0608.lbl","AS16-M-0608.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0609","DATA/REVOLUTION_26/AS16-M-0609.lbl","AS16-M-0609.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0610","DATA/REVOLUTION_26/AS16-M-0610.lbl","AS16-M-0610.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0611","DATA/REVOLUTION_26/AS16-M-0611.lbl","AS16-M-0611.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0612","DATA/REVOLUTION_26/AS16-M-0612.lbl","AS16-M-0612.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0613","DATA/REVOLUTION_26/AS16-M-0613.lbl","AS16-M-0613.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0614","DATA/REVOLUTION_26/AS16-M-0614.lbl","AS16-M-0614.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0615","DATA/REVOLUTION_26/AS16-M-0615.lbl","AS16-M-0615.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0616","DATA/REVOLUTION_26/AS16-M-0616.lbl","AS16-M-0616.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0617","DATA/REVOLUTION_26/AS16-M-0617.lbl","AS16-M-0617.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0618","DATA/REVOLUTION_26/AS16-M-0618.lbl","AS16-M-0618.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0619","DATA/REVOLUTION_26/AS16-M-0619.lbl","AS16-M-0619.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0620","DATA/REVOLUTION_26/AS16-M-0620.lbl","AS16-M-0620.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0621","DATA/REVOLUTION_26/AS16-M-0621.lbl","AS16-M-0621.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0622","DATA/REVOLUTION_26/AS16-M-0622.lbl","AS16-M-0622.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0623","DATA/REVOLUTION_26/AS16-M-0623.lbl","AS16-M-0623.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0624","DATA/REVOLUTION_26/AS16-M-0624.lbl","AS16-M-0624.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0625","DATA/REVOLUTION_26/AS16-M-0625.lbl","AS16-M-0625.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0626","DATA/REVOLUTION_26/AS16-M-0626.lbl","AS16-M-0626.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0627","DATA/REVOLUTION_26/AS16-M-0627.lbl","AS16-M-0627.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0628","DATA/REVOLUTION_26/AS16-M-0628.lbl","AS16-M-0628.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0629","DATA/REVOLUTION_26/AS16-M-0629.lbl","AS16-M-0629.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0630","DATA/REVOLUTION_26/AS16-M-0630.lbl","AS16-M-0630.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0631","DATA/REVOLUTION_26/AS16-M-0631.lbl","AS16-M-0631.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0632","DATA/REVOLUTION_26/AS16-M-0632.lbl","AS16-M-0632.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0633","DATA/REVOLUTION_26/AS16-M-0633.lbl","AS16-M-0633.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0634","DATA/REVOLUTION_26/AS16-M-0634.lbl","AS16-M-0634.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0635","DATA/REVOLUTION_26/AS16-M-0635.lbl","AS16-M-0635.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0636","DATA/REVOLUTION_26/AS16-M-0636.lbl","AS16-M-0636.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0637","DATA/REVOLUTION_26/AS16-M-0637.lbl","AS16-M-0637.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0638","DATA/REVOLUTION_26/AS16-M-0638.lbl","AS16-M-0638.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0639","DATA/REVOLUTION_26/AS16-M-0639.lbl","AS16-M-0639.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0640","DATA/REVOLUTION_26/AS16-M-0640.lbl","AS16-M-0640.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0641","DATA/REVOLUTION_26/AS16-M-0641.lbl","AS16-M-0641.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0642","DATA/REVOLUTION_26/AS16-M-0642.lbl","AS16-M-0642.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0643","DATA/REVOLUTION_26/AS16-M-0643.lbl","AS16-M-0643.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0644","DATA/REVOLUTION_26/AS16-M-0644.lbl","AS16-M-0644.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0645","DATA/REVOLUTION_26/AS16-M-0645.lbl","AS16-M-0645.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0646","DATA/REVOLUTION_26/AS16-M-0646.lbl","AS16-M-0646.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0647","DATA/REVOLUTION_26/AS16-M-0647.lbl","AS16-M-0647.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0648","DATA/REVOLUTION_26/AS16-M-0648.lbl","AS16-M-0648.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0649","DATA/REVOLUTION_26/AS16-M-0649.lbl","AS16-M-0649.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0650","DATA/REVOLUTION_26/AS16-M-0650.lbl","AS16-M-0650.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0651","DATA/REVOLUTION_26/AS16-M-0651.lbl","AS16-M-0651.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0652","DATA/REVOLUTION_26/AS16-M-0652.lbl","AS16-M-0652.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0653","DATA/REVOLUTION_26/AS16-M-0653.lbl","AS16-M-0653.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0654","DATA/REVOLUTION_26/AS16-M-0654.lbl","AS16-M-0654.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0655","DATA/REVOLUTION_26/AS16-M-0655.lbl","AS16-M-0655.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0656","DATA/REVOLUTION_26/AS16-M-0656.lbl","AS16-M-0656.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0657","DATA/REVOLUTION_26/AS16-M-0657.lbl","AS16-M-0657.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0658","DATA/REVOLUTION_26/AS16-M-0658.lbl","AS16-M-0658.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0659","DATA/REVOLUTION_26/AS16-M-0659.lbl","AS16-M-0659.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0660","DATA/REVOLUTION_26/AS16-M-0660.lbl","AS16-M-0660.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0661","DATA/REVOLUTION_26/AS16-M-0661.lbl","AS16-M-0661.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0662","DATA/REVOLUTION_26/AS16-M-0662.lbl","AS16-M-0662.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0663","DATA/REVOLUTION_26/AS16-M-0663.lbl","AS16-M-0663.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0664","DATA/REVOLUTION_26/AS16-M-0664.lbl","AS16-M-0664.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0665","DATA/REVOLUTION_26/AS16-M-0665.lbl","AS16-M-0665.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0666","DATA/REVOLUTION_26/AS16-M-0666.lbl","AS16-M-0666.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0667","DATA/REVOLUTION_26/AS16-M-0667.lbl","AS16-M-0667.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0668","DATA/REVOLUTION_26/AS16-M-0668.lbl","AS16-M-0668.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0669","DATA/REVOLUTION_26/AS16-M-0669.lbl","AS16-M-0669.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0670","DATA/REVOLUTION_26/AS16-M-0670.lbl","AS16-M-0670.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0671","DATA/REVOLUTION_26/AS16-M-0671.lbl","AS16-M-0671.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0672","DATA/REVOLUTION_26/AS16-M-0672.lbl","AS16-M-0672.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0673","DATA/REVOLUTION_26/AS16-M-0673.lbl","AS16-M-0673.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0674","DATA/REVOLUTION_26/AS16-M-0674.lbl","AS16-M-0674.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0675","DATA/REVOLUTION_26/AS16-M-0675.lbl","AS16-M-0675.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0676","DATA/REVOLUTION_26/AS16-M-0676.lbl","AS16-M-0676.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0677","DATA/REVOLUTION_26/AS16-M-0677.lbl","AS16-M-0677.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0678","DATA/REVOLUTION_26/AS16-M-0678.lbl","AS16-M-0678.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0679","DATA/REVOLUTION_26/AS16-M-0679.lbl","AS16-M-0679.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0680","DATA/REVOLUTION_26/AS16-M-0680.lbl","AS16-M-0680.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0681","DATA/REVOLUTION_26/AS16-M-0681.lbl","AS16-M-0681.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0682","DATA/REVOLUTION_26/AS16-M-0682.lbl","AS16-M-0682.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0683","DATA/REVOLUTION_26/AS16-M-0683.lbl","AS16-M-0683.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0684","DATA/REVOLUTION_26/AS16-M-0684.lbl","AS16-M-0684.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0685","DATA/REVOLUTION_26/AS16-M-0685.lbl","AS16-M-0685.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0686","DATA/REVOLUTION_26/AS16-M-0686.lbl","AS16-M-0686.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0687","DATA/REVOLUTION_26/AS16-M-0687.lbl","AS16-M-0687.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0688","DATA/REVOLUTION_26/AS16-M-0688.lbl","AS16-M-0688.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0689","DATA/REVOLUTION_26/AS16-M-0689.lbl","AS16-M-0689.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0690","DATA/REVOLUTION_26/AS16-M-0690.lbl","AS16-M-0690.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0691","DATA/REVOLUTION_26/AS16-M-0691.lbl","AS16-M-0691.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0692","DATA/REVOLUTION_26/AS16-M-0692.lbl","AS16-M-0692.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0693","DATA/REVOLUTION_26/AS16-M-0693.lbl","AS16-M-0693.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0694","DATA/REVOLUTION_26/AS16-M-0694.lbl","AS16-M-0694.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0695","DATA/REVOLUTION_26/AS16-M-0695.lbl","AS16-M-0695.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0696","DATA/REVOLUTION_26/AS16-M-0696.lbl","AS16-M-0696.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0697","DATA/REVOLUTION_26/AS16-M-0697.lbl","AS16-M-0697.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0698","DATA/REVOLUTION_26/AS16-M-0698.lbl","AS16-M-0698.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0699","DATA/REVOLUTION_26/AS16-M-0699.lbl","AS16-M-0699.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0700","DATA/REVOLUTION_26/AS16-M-0700.lbl","AS16-M-0700.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0701","DATA/REVOLUTION_26/AS16-M-0701.lbl","AS16-M-0701.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0702","DATA/REVOLUTION_26/AS16-M-0702.lbl","AS16-M-0702.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0703","DATA/REVOLUTION_26/AS16-M-0703.lbl","AS16-M-0703.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0704","DATA/REVOLUTION_26/AS16-M-0704.lbl","AS16-M-0704.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0705","DATA/REVOLUTION_26/AS16-M-0705.lbl","AS16-M-0705.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0706","DATA/REVOLUTION_26/AS16-M-0706.lbl","AS16-M-0706.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0707","DATA/REVOLUTION_26/AS16-M-0707.lbl","AS16-M-0707.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0708","DATA/REVOLUTION_26/AS16-M-0708.lbl","AS16-M-0708.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0709","DATA/REVOLUTION_26/AS16-M-0709.lbl","AS16-M-0709.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0710","DATA/REVOLUTION_26/AS16-M-0710.lbl","AS16-M-0710.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0711","DATA/REVOLUTION_26/AS16-M-0711.lbl","AS16-M-0711.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0712","DATA/REVOLUTION_26/AS16-M-0712.lbl","AS16-M-0712.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0713","DATA/REVOLUTION_26/AS16-M-0713.lbl","AS16-M-0713.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0714","DATA/REVOLUTION_26/AS16-M-0714.lbl","AS16-M-0714.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0715","DATA/REVOLUTION_26/AS16-M-0715.lbl","AS16-M-0715.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0716","DATA/REVOLUTION_26/AS16-M-0716.lbl","AS16-M-0716.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0717","DATA/REVOLUTION_26/AS16-M-0717.lbl","AS16-M-0717.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0718","DATA/REVOLUTION_26/AS16-M-0718.lbl","AS16-M-0718.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0719","DATA/REVOLUTION_27/AS16-M-0719.lbl","AS16-M-0719.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0720","DATA/REVOLUTION_27/AS16-M-0720.lbl","AS16-M-0720.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0721","DATA/REVOLUTION_27/AS16-M-0721.lbl","AS16-M-0721.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0722","DATA/REVOLUTION_27/AS16-M-0722.lbl","AS16-M-0722.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0723","DATA/REVOLUTION_27/AS16-M-0723.lbl","AS16-M-0723.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0724","DATA/REVOLUTION_27/AS16-M-0724.lbl","AS16-M-0724.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0725","DATA/REVOLUTION_27/AS16-M-0725.lbl","AS16-M-0725.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0726","DATA/REVOLUTION_27/AS16-M-0726.lbl","AS16-M-0726.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0727","DATA/REVOLUTION_27/AS16-M-0727.lbl","AS16-M-0727.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0728","DATA/REVOLUTION_27/AS16-M-0728.lbl","AS16-M-0728.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0729","DATA/REVOLUTION_27/AS16-M-0729.lbl","AS16-M-0729.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0730","DATA/REVOLUTION_27/AS16-M-0730.lbl","AS16-M-0730.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0731","DATA/REVOLUTION_27/AS16-M-0731.lbl","AS16-M-0731.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0732","DATA/REVOLUTION_27/AS16-M-0732.lbl","AS16-M-0732.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0733","DATA/REVOLUTION_27/AS16-M-0733.lbl","AS16-M-0733.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0734","DATA/REVOLUTION_27/AS16-M-0734.lbl","AS16-M-0734.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0735","DATA/REVOLUTION_27/AS16-M-0735.lbl","AS16-M-0735.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0736","DATA/REVOLUTION_27/AS16-M-0736.lbl","AS16-M-0736.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0737","DATA/REVOLUTION_27/AS16-M-0737.lbl","AS16-M-0737.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0738","DATA/REVOLUTION_27/AS16-M-0738.lbl","AS16-M-0738.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0739","DATA/REVOLUTION_27/AS16-M-0739.lbl","AS16-M-0739.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0740","DATA/REVOLUTION_27/AS16-M-0740.lbl","AS16-M-0740.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0741","DATA/REVOLUTION_27/AS16-M-0741.lbl","AS16-M-0741.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0742","DATA/REVOLUTION_27/AS16-M-0742.lbl","AS16-M-0742.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0743","DATA/REVOLUTION_27/AS16-M-0743.lbl","AS16-M-0743.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0744","DATA/REVOLUTION_27/AS16-M-0744.lbl","AS16-M-0744.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0745","DATA/REVOLUTION_27/AS16-M-0745.lbl","AS16-M-0745.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0746","DATA/REVOLUTION_27/AS16-M-0746.lbl","AS16-M-0746.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0747","DATA/REVOLUTION_27/AS16-M-0747.lbl","AS16-M-0747.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0748","DATA/REVOLUTION_27/AS16-M-0748.lbl","AS16-M-0748.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0749","DATA/REVOLUTION_27/AS16-M-0749.lbl","AS16-M-0749.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0750","DATA/REVOLUTION_27/AS16-M-0750.lbl","AS16-M-0750.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0751","DATA/REVOLUTION_27/AS16-M-0751.lbl","AS16-M-0751.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0752","DATA/REVOLUTION_27/AS16-M-0752.lbl","AS16-M-0752.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0753","DATA/REVOLUTION_27/AS16-M-0753.lbl","AS16-M-0753.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0754","DATA/REVOLUTION_27/AS16-M-0754.lbl","AS16-M-0754.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0755","DATA/REVOLUTION_27/AS16-M-0755.lbl","AS16-M-0755.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0756","DATA/REVOLUTION_27/AS16-M-0756.lbl","AS16-M-0756.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0757","DATA/REVOLUTION_27/AS16-M-0757.lbl","AS16-M-0757.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0758","DATA/REVOLUTION_27/AS16-M-0758.lbl","AS16-M-0758.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0759","DATA/REVOLUTION_27/AS16-M-0759.lbl","AS16-M-0759.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0760","DATA/REVOLUTION_27/AS16-M-0760.lbl","AS16-M-0760.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0761","DATA/REVOLUTION_27/AS16-M-0761.lbl","AS16-M-0761.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0762","DATA/REVOLUTION_27/AS16-M-0762.lbl","AS16-M-0762.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0763","DATA/REVOLUTION_27/AS16-M-0763.lbl","AS16-M-0763.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0764","DATA/REVOLUTION_27/AS16-M-0764.lbl","AS16-M-0764.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0765","DATA/REVOLUTION_27/AS16-M-0765.lbl","AS16-M-0765.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0766","DATA/REVOLUTION_27/AS16-M-0766.lbl","AS16-M-0766.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0767","DATA/REVOLUTION_27/AS16-M-0767.lbl","AS16-M-0767.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0768","DATA/REVOLUTION_27/AS16-M-0768.lbl","AS16-M-0768.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0769","DATA/REVOLUTION_27/AS16-M-0769.lbl","AS16-M-0769.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0770","DATA/REVOLUTION_27/AS16-M-0770.lbl","AS16-M-0770.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0771","DATA/REVOLUTION_27/AS16-M-0771.lbl","AS16-M-0771.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0772","DATA/REVOLUTION_27/AS16-M-0772.lbl","AS16-M-0772.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0773","DATA/REVOLUTION_27/AS16-M-0773.lbl","AS16-M-0773.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0774","DATA/REVOLUTION_27/AS16-M-0774.lbl","AS16-M-0774.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0775","DATA/REVOLUTION_27/AS16-M-0775.lbl","AS16-M-0775.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0776","DATA/REVOLUTION_27/AS16-M-0776.lbl","AS16-M-0776.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0777","DATA/REVOLUTION_27/AS16-M-0777.lbl","AS16-M-0777.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0778","DATA/REVOLUTION_27/AS16-M-0778.lbl","AS16-M-0778.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0779","DATA/REVOLUTION_27/AS16-M-0779.lbl","AS16-M-0779.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0780","DATA/REVOLUTION_27/AS16-M-0780.lbl","AS16-M-0780.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0781","DATA/REVOLUTION_27/AS16-M-0781.lbl","AS16-M-0781.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0782","DATA/REVOLUTION_27/AS16-M-0782.lbl","AS16-M-0782.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0783","DATA/REVOLUTION_27/AS16-M-0783.lbl","AS16-M-0783.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0784","DATA/REVOLUTION_27/AS16-M-0784.lbl","AS16-M-0784.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0785","DATA/REVOLUTION_27/AS16-M-0785.lbl","AS16-M-0785.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0786","DATA/REVOLUTION_27/AS16-M-0786.lbl","AS16-M-0786.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0787","DATA/REVOLUTION_27/AS16-M-0787.lbl","AS16-M-0787.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0788","DATA/REVOLUTION_27/AS16-M-0788.lbl","AS16-M-0788.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0789","DATA/REVOLUTION_27/AS16-M-0789.lbl","AS16-M-0789.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0790","DATA/REVOLUTION_27/AS16-M-0790.lbl","AS16-M-0790.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0791","DATA/REVOLUTION_27/AS16-M-0791.lbl","AS16-M-0791.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0792","DATA/REVOLUTION_27/AS16-M-0792.lbl","AS16-M-0792.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0793","DATA/REVOLUTION_27/AS16-M-0793.lbl","AS16-M-0793.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0794","DATA/REVOLUTION_27/AS16-M-0794.lbl","AS16-M-0794.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0795","DATA/REVOLUTION_27/AS16-M-0795.lbl","AS16-M-0795.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0796","DATA/REVOLUTION_27/AS16-M-0796.lbl","AS16-M-0796.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0797","DATA/REVOLUTION_27/AS16-M-0797.lbl","AS16-M-0797.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0798","DATA/REVOLUTION_27/AS16-M-0798.lbl","AS16-M-0798.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0799","DATA/REVOLUTION_27/AS16-M-0799.lbl","AS16-M-0799.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0800","DATA/REVOLUTION_27/AS16-M-0800.lbl","AS16-M-0800.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0801","DATA/REVOLUTION_27/AS16-M-0801.lbl","AS16-M-0801.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0802","DATA/REVOLUTION_27/AS16-M-0802.lbl","AS16-M-0802.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0803","DATA/REVOLUTION_27/AS16-M-0803.lbl","AS16-M-0803.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0804","DATA/REVOLUTION_27/AS16-M-0804.lbl","AS16-M-0804.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0805","DATA/REVOLUTION_27/AS16-M-0805.lbl","AS16-M-0805.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0806","DATA/REVOLUTION_27/AS16-M-0806.lbl","AS16-M-0806.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0807","DATA/REVOLUTION_27/AS16-M-0807.lbl","AS16-M-0807.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0808","DATA/REVOLUTION_27/AS16-M-0808.lbl","AS16-M-0808.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0809","DATA/REVOLUTION_27/AS16-M-0809.lbl","AS16-M-0809.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0810","DATA/REVOLUTION_27/AS16-M-0810.lbl","AS16-M-0810.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0811","DATA/REVOLUTION_27/AS16-M-0811.lbl","AS16-M-0811.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0812","DATA/REVOLUTION_27/AS16-M-0812.lbl","AS16-M-0812.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0813","DATA/REVOLUTION_27/AS16-M-0813.lbl","AS16-M-0813.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0814","DATA/REVOLUTION_27/AS16-M-0814.lbl","AS16-M-0814.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0815","DATA/REVOLUTION_27/AS16-M-0815.lbl","AS16-M-0815.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0816","DATA/REVOLUTION_27/AS16-M-0816.lbl","AS16-M-0816.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0817","DATA/REVOLUTION_27/AS16-M-0817.lbl","AS16-M-0817.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0818","DATA/REVOLUTION_27/AS16-M-0818.lbl","AS16-M-0818.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0819","DATA/REVOLUTION_27/AS16-M-0819.lbl","AS16-M-0819.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0820","DATA/REVOLUTION_27/AS16-M-0820.lbl","AS16-M-0820.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0821","DATA/REVOLUTION_27/AS16-M-0821.lbl","AS16-M-0821.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0822","DATA/REVOLUTION_27/AS16-M-0822.lbl","AS16-M-0822.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0823","DATA/REVOLUTION_27/AS16-M-0823.lbl","AS16-M-0823.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0824","DATA/REVOLUTION_27/AS16-M-0824.lbl","AS16-M-0824.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0825","DATA/REVOLUTION_27/AS16-M-0825.lbl","AS16-M-0825.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0826","DATA/REVOLUTION_27/AS16-M-0826.lbl","AS16-M-0826.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0827","DATA/REVOLUTION_27/AS16-M-0827.lbl","AS16-M-0827.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0828","DATA/REVOLUTION_27/AS16-M-0828.lbl","AS16-M-0828.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0829","DATA/REVOLUTION_27/AS16-M-0829.lbl","AS16-M-0829.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0830","DATA/REVOLUTION_27/AS16-M-0830.lbl","AS16-M-0830.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0831","DATA/REVOLUTION_27/AS16-M-0831.lbl","AS16-M-0831.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0832","DATA/REVOLUTION_27/AS16-M-0832.lbl","AS16-M-0832.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0833","DATA/REVOLUTION_27/AS16-M-0833.lbl","AS16-M-0833.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0834","DATA/REVOLUTION_27/AS16-M-0834.lbl","AS16-M-0834.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0835","DATA/REVOLUTION_27/AS16-M-0835.lbl","AS16-M-0835.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0836","DATA/REVOLUTION_27/AS16-M-0836.lbl","AS16-M-0836.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0837","DATA/REVOLUTION_27/AS16-M-0837.lbl","AS16-M-0837.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0838","DATA/REVOLUTION_27/AS16-M-0838.lbl","AS16-M-0838.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0839","DATA/REVOLUTION_27/AS16-M-0839.lbl","AS16-M-0839.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0840","DATA/REVOLUTION_27/AS16-M-0840.lbl","AS16-M-0840.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0841","DATA/REVOLUTION_27/AS16-M-0841.lbl","AS16-M-0841.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0842","DATA/REVOLUTION_27/AS16-M-0842.lbl","AS16-M-0842.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0843","DATA/REVOLUTION_27/AS16-M-0843.lbl","AS16-M-0843.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0844","DATA/REVOLUTION_27/AS16-M-0844.lbl","AS16-M-0844.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0845","DATA/REVOLUTION_27/AS16-M-0845.lbl","AS16-M-0845.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0846","DATA/REVOLUTION_27/AS16-M-0846.lbl","AS16-M-0846.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0847","DATA/REVOLUTION_27/AS16-M-0847.lbl","AS16-M-0847.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0848","DATA/REVOLUTION_27/AS16-M-0848.lbl","AS16-M-0848.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0849","DATA/REVOLUTION_27/AS16-M-0849.lbl","AS16-M-0849.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0850","DATA/REVOLUTION_27/AS16-M-0850.lbl","AS16-M-0850.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0851","DATA/REVOLUTION_27/AS16-M-0851.lbl","AS16-M-0851.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0852","DATA/REVOLUTION_28/AS16-M-0852.lbl","AS16-M-0852.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0853","DATA/REVOLUTION_28/AS16-M-0853.lbl","AS16-M-0853.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0854","DATA/REVOLUTION_28/AS16-M-0854.lbl","AS16-M-0854.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0855","DATA/REVOLUTION_28/AS16-M-0855.lbl","AS16-M-0855.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0856","DATA/REVOLUTION_28/AS16-M-0856.lbl","AS16-M-0856.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0857","DATA/REVOLUTION_28/AS16-M-0857.lbl","AS16-M-0857.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0858","DATA/REVOLUTION_28/AS16-M-0858.lbl","AS16-M-0858.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0859","DATA/REVOLUTION_28/AS16-M-0859.lbl","AS16-M-0859.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0860","DATA/REVOLUTION_28/AS16-M-0860.lbl","AS16-M-0860.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0861","DATA/REVOLUTION_28/AS16-M-0861.lbl","AS16-M-0861.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0862","DATA/REVOLUTION_28/AS16-M-0862.lbl","AS16-M-0862.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0863","DATA/REVOLUTION_28/AS16-M-0863.lbl","AS16-M-0863.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0864","DATA/REVOLUTION_28/AS16-M-0864.lbl","AS16-M-0864.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0865","DATA/REVOLUTION_28/AS16-M-0865.lbl","AS16-M-0865.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0866","DATA/REVOLUTION_28/AS16-M-0866.lbl","AS16-M-0866.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0867","DATA/REVOLUTION_28/AS16-M-0867.lbl","AS16-M-0867.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0868","DATA/REVOLUTION_28/AS16-M-0868.lbl","AS16-M-0868.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0869","DATA/REVOLUTION_28/AS16-M-0869.lbl","AS16-M-0869.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0870","DATA/REVOLUTION_28/AS16-M-0870.lbl","AS16-M-0870.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0871","DATA/REVOLUTION_28/AS16-M-0871.lbl","AS16-M-0871.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0872","DATA/REVOLUTION_28/AS16-M-0872.lbl","AS16-M-0872.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0873","DATA/REVOLUTION_28/AS16-M-0873.lbl","AS16-M-0873.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0874","DATA/REVOLUTION_28/AS16-M-0874.lbl","AS16-M-0874.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0875","DATA/REVOLUTION_28/AS16-M-0875.lbl","AS16-M-0875.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0876","DATA/REVOLUTION_28/AS16-M-0876.lbl","AS16-M-0876.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0877","DATA/REVOLUTION_28/AS16-M-0877.lbl","AS16-M-0877.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0878","DATA/REVOLUTION_28/AS16-M-0878.lbl","AS16-M-0878.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0879","DATA/REVOLUTION_28/AS16-M-0879.lbl","AS16-M-0879.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0880","DATA/REVOLUTION_28/AS16-M-0880.lbl","AS16-M-0880.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0881","DATA/REVOLUTION_28/AS16-M-0881.lbl","AS16-M-0881.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0882","DATA/REVOLUTION_28/AS16-M-0882.lbl","AS16-M-0882.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0883","DATA/REVOLUTION_28/AS16-M-0883.lbl","AS16-M-0883.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0884","DATA/REVOLUTION_28/AS16-M-0884.lbl","AS16-M-0884.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0885","DATA/REVOLUTION_28/AS16-M-0885.lbl","AS16-M-0885.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0886","DATA/REVOLUTION_28/AS16-M-0886.lbl","AS16-M-0886.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0887","DATA/REVOLUTION_28/AS16-M-0887.lbl","AS16-M-0887.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0888","DATA/REVOLUTION_28/AS16-M-0888.lbl","AS16-M-0888.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0889","DATA/REVOLUTION_28/AS16-M-0889.lbl","AS16-M-0889.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0890","DATA/REVOLUTION_28/AS16-M-0890.lbl","AS16-M-0890.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0891","DATA/REVOLUTION_28/AS16-M-0891.lbl","AS16-M-0891.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0892","DATA/REVOLUTION_28/AS16-M-0892.lbl","AS16-M-0892.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0893","DATA/REVOLUTION_28/AS16-M-0893.lbl","AS16-M-0893.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0894","DATA/REVOLUTION_28/AS16-M-0894.lbl","AS16-M-0894.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0895","DATA/REVOLUTION_28/AS16-M-0895.lbl","AS16-M-0895.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0896","DATA/REVOLUTION_28/AS16-M-0896.lbl","AS16-M-0896.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0897","DATA/REVOLUTION_28/AS16-M-0897.lbl","AS16-M-0897.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0898","DATA/REVOLUTION_28/AS16-M-0898.lbl","AS16-M-0898.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0899","DATA/REVOLUTION_28/AS16-M-0899.lbl","AS16-M-0899.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0900","DATA/REVOLUTION_28/AS16-M-0900.lbl","AS16-M-0900.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0901","DATA/REVOLUTION_28/AS16-M-0901.lbl","AS16-M-0901.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0902","DATA/REVOLUTION_28/AS16-M-0902.lbl","AS16-M-0902.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0903","DATA/REVOLUTION_28/AS16-M-0903.lbl","AS16-M-0903.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0904","DATA/REVOLUTION_28/AS16-M-0904.lbl","AS16-M-0904.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0905","DATA/REVOLUTION_28/AS16-M-0905.lbl","AS16-M-0905.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0906","DATA/REVOLUTION_28/AS16-M-0906.lbl","AS16-M-0906.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0907","DATA/REVOLUTION_28/AS16-M-0907.lbl","AS16-M-0907.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0908","DATA/REVOLUTION_28/AS16-M-0908.lbl","AS16-M-0908.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0909","DATA/REVOLUTION_28/AS16-M-0909.lbl","AS16-M-0909.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0910","DATA/REVOLUTION_28/AS16-M-0910.lbl","AS16-M-0910.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0911","DATA/REVOLUTION_28/AS16-M-0911.lbl","AS16-M-0911.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0912","DATA/REVOLUTION_28/AS16-M-0912.lbl","AS16-M-0912.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0913","DATA/REVOLUTION_28/AS16-M-0913.lbl","AS16-M-0913.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0914","DATA/REVOLUTION_28/AS16-M-0914.lbl","AS16-M-0914.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0915","DATA/REVOLUTION_28/AS16-M-0915.lbl","AS16-M-0915.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0916","DATA/REVOLUTION_28/AS16-M-0916.lbl","AS16-M-0916.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0917","DATA/REVOLUTION_28/AS16-M-0917.lbl","AS16-M-0917.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0918","DATA/REVOLUTION_28/AS16-M-0918.lbl","AS16-M-0918.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0919","DATA/REVOLUTION_28/AS16-M-0919.lbl","AS16-M-0919.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0920","DATA/REVOLUTION_28/AS16-M-0920.lbl","AS16-M-0920.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0921","DATA/REVOLUTION_28/AS16-M-0921.lbl","AS16-M-0921.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0922","DATA/REVOLUTION_28/AS16-M-0922.lbl","AS16-M-0922.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0923","DATA/REVOLUTION_28/AS16-M-0923.lbl","AS16-M-0923.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0924","DATA/REVOLUTION_28/AS16-M-0924.lbl","AS16-M-0924.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0925","DATA/REVOLUTION_28/AS16-M-0925.lbl","AS16-M-0925.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0926","DATA/REVOLUTION_28/AS16-M-0926.lbl","AS16-M-0926.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0927","DATA/REVOLUTION_28/AS16-M-0927.lbl","AS16-M-0927.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0928","DATA/REVOLUTION_28/AS16-M-0928.lbl","AS16-M-0928.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0929","DATA/REVOLUTION_28/AS16-M-0929.lbl","AS16-M-0929.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0930","DATA/REVOLUTION_28/AS16-M-0930.lbl","AS16-M-0930.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0931","DATA/REVOLUTION_28/AS16-M-0931.lbl","AS16-M-0931.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0932","DATA/REVOLUTION_28/AS16-M-0932.lbl","AS16-M-0932.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0933","DATA/REVOLUTION_28/AS16-M-0933.lbl","AS16-M-0933.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0934","DATA/REVOLUTION_28/AS16-M-0934.lbl","AS16-M-0934.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0935","DATA/REVOLUTION_28/AS16-M-0935.lbl","AS16-M-0935.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0936","DATA/REVOLUTION_28/AS16-M-0936.lbl","AS16-M-0936.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0937","DATA/REVOLUTION_28/AS16-M-0937.lbl","AS16-M-0937.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0938","DATA/REVOLUTION_28/AS16-M-0938.lbl","AS16-M-0938.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0939","DATA/REVOLUTION_28/AS16-M-0939.lbl","AS16-M-0939.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0940","DATA/REVOLUTION_28/AS16-M-0940.lbl","AS16-M-0940.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0941","DATA/REVOLUTION_28/AS16-M-0941.lbl","AS16-M-0941.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0942","DATA/REVOLUTION_28/AS16-M-0942.lbl","AS16-M-0942.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0943","DATA/REVOLUTION_28/AS16-M-0943.lbl","AS16-M-0943.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0944","DATA/REVOLUTION_28/AS16-M-0944.lbl","AS16-M-0944.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0945","DATA/REVOLUTION_28/AS16-M-0945.lbl","AS16-M-0945.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0946","DATA/REVOLUTION_28/AS16-M-0946.lbl","AS16-M-0946.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0947","DATA/REVOLUTION_28/AS16-M-0947.lbl","AS16-M-0947.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0948","DATA/REVOLUTION_28/AS16-M-0948.lbl","AS16-M-0948.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0949","DATA/REVOLUTION_28/AS16-M-0949.lbl","AS16-M-0949.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0950","DATA/REVOLUTION_28/AS16-M-0950.lbl","AS16-M-0950.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0951","DATA/REVOLUTION_28/AS16-M-0951.lbl","AS16-M-0951.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0952","DATA/REVOLUTION_28/AS16-M-0952.lbl","AS16-M-0952.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0953","DATA/REVOLUTION_28/AS16-M-0953.lbl","AS16-M-0953.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0954","DATA/REVOLUTION_28/AS16-M-0954.lbl","AS16-M-0954.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0955","DATA/REVOLUTION_28/AS16-M-0955.lbl","AS16-M-0955.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0956","DATA/REVOLUTION_28/AS16-M-0956.lbl","AS16-M-0956.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0957","DATA/REVOLUTION_28/AS16-M-0957.lbl","AS16-M-0957.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0958","DATA/REVOLUTION_28/AS16-M-0958.lbl","AS16-M-0958.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0959","DATA/REVOLUTION_28/AS16-M-0959.lbl","AS16-M-0959.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0960","DATA/REVOLUTION_28/AS16-M-0960.lbl","AS16-M-0960.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0961","DATA/REVOLUTION_28/AS16-M-0961.lbl","AS16-M-0961.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0962","DATA/REVOLUTION_28/AS16-M-0962.lbl","AS16-M-0962.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0963","DATA/REVOLUTION_28/AS16-M-0963.lbl","AS16-M-0963.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0964","DATA/REVOLUTION_28/AS16-M-0964.lbl","AS16-M-0964.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0965","DATA/REVOLUTION_28/AS16-M-0965.lbl","AS16-M-0965.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0966","DATA/REVOLUTION_28/AS16-M-0966.lbl","AS16-M-0966.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0967","DATA/REVOLUTION_28/AS16-M-0967.lbl","AS16-M-0967.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0968","DATA/REVOLUTION_28/AS16-M-0968.lbl","AS16-M-0968.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0969","DATA/REVOLUTION_28/AS16-M-0969.lbl","AS16-M-0969.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0970","DATA/REVOLUTION_28/AS16-M-0970.lbl","AS16-M-0970.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0971","DATA/REVOLUTION_28/AS16-M-0971.lbl","AS16-M-0971.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0972","DATA/REVOLUTION_28/AS16-M-0972.lbl","AS16-M-0972.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0973","DATA/REVOLUTION_28/AS16-M-0973.lbl","AS16-M-0973.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0974","DATA/REVOLUTION_28/AS16-M-0974.lbl","AS16-M-0974.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0975","DATA/REVOLUTION_28/AS16-M-0975.lbl","AS16-M-0975.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0976","DATA/REVOLUTION_28/AS16-M-0976.lbl","AS16-M-0976.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0977","DATA/REVOLUTION_28/AS16-M-0977.lbl","AS16-M-0977.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0978","DATA/REVOLUTION_28/AS16-M-0978.lbl","AS16-M-0978.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0979","DATA/REVOLUTION_28/AS16-M-0979.lbl","AS16-M-0979.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0980","DATA/REVOLUTION_28/AS16-M-0980.lbl","AS16-M-0980.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0981","DATA/REVOLUTION_28/AS16-M-0981.lbl","AS16-M-0981.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0982","DATA/REVOLUTION_28/AS16-M-0982.lbl","AS16-M-0982.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0983","DATA/REVOLUTION_28/AS16-M-0983.lbl","AS16-M-0983.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0984","DATA/REVOLUTION_28/AS16-M-0984.lbl","AS16-M-0984.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0985","DATA/REVOLUTION_28/AS16-M-0985.lbl","AS16-M-0985.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0986","DATA/REVOLUTION_28/AS16-M-0986.lbl","AS16-M-0986.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0987","DATA/REVOLUTION_28/AS16-M-0987.lbl","AS16-M-0987.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0988","DATA/REVOLUTION_28/AS16-M-0988.lbl","AS16-M-0988.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0989","DATA/REVOLUTION_28/AS16-M-0989.lbl","AS16-M-0989.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0990","DATA/REVOLUTION_28/AS16-M-0990.lbl","AS16-M-0990.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0991","DATA/REVOLUTION_28/AS16-M-0991.lbl","AS16-M-0991.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0992","DATA/REVOLUTION_28/AS16-M-0992.lbl","AS16-M-0992.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0993","DATA/REVOLUTION_28/AS16-M-0993.lbl","AS16-M-0993.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0994","DATA/REVOLUTION_28/AS16-M-0994.lbl","AS16-M-0994.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0995","DATA/REVOLUTION_28/AS16-M-0995.lbl","AS16-M-0995.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0996","DATA/REVOLUTION_28/AS16-M-0996.lbl","AS16-M-0996.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0997","DATA/REVOLUTION_28/AS16-M-0997.lbl","AS16-M-0997.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0998","DATA/REVOLUTION_28/AS16-M-0998.lbl","AS16-M-0998.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-0999","DATA/REVOLUTION_28/AS16-M-0999.lbl","AS16-M-0999.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1144","DATA/REVOLUTION_29/AS16-M-1144.lbl","AS16-M-1144.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1145","DATA/REVOLUTION_29/AS16-M-1145.lbl","AS16-M-1145.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1146","DATA/REVOLUTION_29/AS16-M-1146.lbl","AS16-M-1146.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1147","DATA/REVOLUTION_29/AS16-M-1147.lbl","AS16-M-1147.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1148","DATA/REVOLUTION_29/AS16-M-1148.lbl","AS16-M-1148.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1149","DATA/REVOLUTION_29/AS16-M-1149.lbl","AS16-M-1149.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1150","DATA/REVOLUTION_29/AS16-M-1150.lbl","AS16-M-1150.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1151","DATA/REVOLUTION_29/AS16-M-1151.lbl","AS16-M-1151.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1152","DATA/REVOLUTION_29/AS16-M-1152.lbl","AS16-M-1152.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1153","DATA/REVOLUTION_29/AS16-M-1153.lbl","AS16-M-1153.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1154","DATA/REVOLUTION_29/AS16-M-1154.lbl","AS16-M-1154.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1155","DATA/REVOLUTION_29/AS16-M-1155.lbl","AS16-M-1155.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1156","DATA/REVOLUTION_29/AS16-M-1156.lbl","AS16-M-1156.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1157","DATA/REVOLUTION_29/AS16-M-1157.lbl","AS16-M-1157.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1158","DATA/REVOLUTION_29/AS16-M-1158.lbl","AS16-M-1158.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1159","DATA/REVOLUTION_29/AS16-M-1159.lbl","AS16-M-1159.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1160","DATA/REVOLUTION_29/AS16-M-1160.lbl","AS16-M-1160.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1161","DATA/REVOLUTION_29/AS16-M-1161.lbl","AS16-M-1161.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1162","DATA/REVOLUTION_29/AS16-M-1162.lbl","AS16-M-1162.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1163","DATA/REVOLUTION_29/AS16-M-1163.lbl","AS16-M-1163.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1164","DATA/REVOLUTION_29/AS16-M-1164.lbl","AS16-M-1164.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1165","DATA/REVOLUTION_29/AS16-M-1165.lbl","AS16-M-1165.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1166","DATA/REVOLUTION_29/AS16-M-1166.lbl","AS16-M-1166.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1167","DATA/REVOLUTION_29/AS16-M-1167.lbl","AS16-M-1167.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1168","DATA/REVOLUTION_29/AS16-M-1168.lbl","AS16-M-1168.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1169","DATA/REVOLUTION_29/AS16-M-1169.lbl","AS16-M-1169.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1170","DATA/REVOLUTION_29/AS16-M-1170.lbl","AS16-M-1170.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1171","DATA/REVOLUTION_29/AS16-M-1171.lbl","AS16-M-1171.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1172","DATA/REVOLUTION_29/AS16-M-1172.lbl","AS16-M-1172.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1173","DATA/REVOLUTION_29/AS16-M-1173.lbl","AS16-M-1173.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1174","DATA/REVOLUTION_29/AS16-M-1174.lbl","AS16-M-1174.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1175","DATA/REVOLUTION_29/AS16-M-1175.lbl","AS16-M-1175.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1176","DATA/REVOLUTION_29/AS16-M-1176.lbl","AS16-M-1176.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1177","DATA/REVOLUTION_29/AS16-M-1177.lbl","AS16-M-1177.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1178","DATA/REVOLUTION_29/AS16-M-1178.lbl","AS16-M-1178.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1179","DATA/REVOLUTION_29/AS16-M-1179.lbl","AS16-M-1179.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1180","DATA/REVOLUTION_29/AS16-M-1180.lbl","AS16-M-1180.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1181","DATA/REVOLUTION_29/AS16-M-1181.lbl","AS16-M-1181.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1182","DATA/REVOLUTION_29/AS16-M-1182.lbl","AS16-M-1182.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1183","DATA/REVOLUTION_29/AS16-M-1183.lbl","AS16-M-1183.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1184","DATA/REVOLUTION_29/AS16-M-1184.lbl","AS16-M-1184.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1185","DATA/REVOLUTION_29/AS16-M-1185.lbl","AS16-M-1185.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1186","DATA/REVOLUTION_29/AS16-M-1186.lbl","AS16-M-1186.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1187","DATA/REVOLUTION_29/AS16-M-1187.lbl","AS16-M-1187.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1188","DATA/REVOLUTION_29/AS16-M-1188.lbl","AS16-M-1188.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1189","DATA/REVOLUTION_29/AS16-M-1189.lbl","AS16-M-1189.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1190","DATA/REVOLUTION_29/AS16-M-1190.lbl","AS16-M-1190.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1191","DATA/REVOLUTION_29/AS16-M-1191.lbl","AS16-M-1191.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1192","DATA/REVOLUTION_29/AS16-M-1192.lbl","AS16-M-1192.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1193","DATA/REVOLUTION_29/AS16-M-1193.lbl","AS16-M-1193.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1194","DATA/REVOLUTION_29/AS16-M-1194.lbl","AS16-M-1194.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1195","DATA/REVOLUTION_29/AS16-M-1195.lbl","AS16-M-1195.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1196","DATA/REVOLUTION_29/AS16-M-1196.lbl","AS16-M-1196.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1197","DATA/REVOLUTION_29/AS16-M-1197.lbl","AS16-M-1197.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1198","DATA/REVOLUTION_29/AS16-M-1198.lbl","AS16-M-1198.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1199","DATA/REVOLUTION_29/AS16-M-1199.lbl","AS16-M-1199.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1200","DATA/REVOLUTION_29/AS16-M-1200.lbl","AS16-M-1200.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1201","DATA/REVOLUTION_29/AS16-M-1201.lbl","AS16-M-1201.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1202","DATA/REVOLUTION_29/AS16-M-1202.lbl","AS16-M-1202.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1203","DATA/REVOLUTION_29/AS16-M-1203.lbl","AS16-M-1203.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1204","DATA/REVOLUTION_29/AS16-M-1204.lbl","AS16-M-1204.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1205","DATA/REVOLUTION_29/AS16-M-1205.lbl","AS16-M-1205.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1206","DATA/REVOLUTION_29/AS16-M-1206.lbl","AS16-M-1206.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1207","DATA/REVOLUTION_29/AS16-M-1207.lbl","AS16-M-1207.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1208","DATA/REVOLUTION_29/AS16-M-1208.lbl","AS16-M-1208.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1209","DATA/REVOLUTION_29/AS16-M-1209.lbl","AS16-M-1209.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1210","DATA/REVOLUTION_29/AS16-M-1210.lbl","AS16-M-1210.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1211","DATA/REVOLUTION_29/AS16-M-1211.lbl","AS16-M-1211.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1212","DATA/REVOLUTION_29/AS16-M-1212.lbl","AS16-M-1212.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1213","DATA/REVOLUTION_29/AS16-M-1213.lbl","AS16-M-1213.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1214","DATA/REVOLUTION_29/AS16-M-1214.lbl","AS16-M-1214.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1215","DATA/REVOLUTION_29/AS16-M-1215.lbl","AS16-M-1215.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1216","DATA/REVOLUTION_29/AS16-M-1216.lbl","AS16-M-1216.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1217","DATA/REVOLUTION_29/AS16-M-1217.lbl","AS16-M-1217.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1218","DATA/REVOLUTION_29/AS16-M-1218.lbl","AS16-M-1218.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1219","DATA/REVOLUTION_29/AS16-M-1219.lbl","AS16-M-1219.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1220","DATA/REVOLUTION_29/AS16-M-1220.lbl","AS16-M-1220.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1221","DATA/REVOLUTION_29/AS16-M-1221.lbl","AS16-M-1221.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1222","DATA/REVOLUTION_29/AS16-M-1222.lbl","AS16-M-1222.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1223","DATA/REVOLUTION_29/AS16-M-1223.lbl","AS16-M-1223.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1224","DATA/REVOLUTION_29/AS16-M-1224.lbl","AS16-M-1224.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1225","DATA/REVOLUTION_29/AS16-M-1225.lbl","AS16-M-1225.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1226","DATA/REVOLUTION_29/AS16-M-1226.lbl","AS16-M-1226.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1227","DATA/REVOLUTION_29/AS16-M-1227.lbl","AS16-M-1227.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1228","DATA/REVOLUTION_29/AS16-M-1228.lbl","AS16-M-1228.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1229","DATA/REVOLUTION_29/AS16-M-1229.lbl","AS16-M-1229.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1230","DATA/REVOLUTION_29/AS16-M-1230.lbl","AS16-M-1230.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1231","DATA/REVOLUTION_29/AS16-M-1231.lbl","AS16-M-1231.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1232","DATA/REVOLUTION_29/AS16-M-1232.lbl","AS16-M-1232.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1233","DATA/REVOLUTION_29/AS16-M-1233.lbl","AS16-M-1233.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1234","DATA/REVOLUTION_29/AS16-M-1234.lbl","AS16-M-1234.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1235","DATA/REVOLUTION_29/AS16-M-1235.lbl","AS16-M-1235.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1236","DATA/REVOLUTION_29/AS16-M-1236.lbl","AS16-M-1236.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1237","DATA/REVOLUTION_29/AS16-M-1237.lbl","AS16-M-1237.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1238","DATA/REVOLUTION_29/AS16-M-1238.lbl","AS16-M-1238.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1239","DATA/REVOLUTION_29/AS16-M-1239.lbl","AS16-M-1239.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1240","DATA/REVOLUTION_29/AS16-M-1240.lbl","AS16-M-1240.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1241","DATA/REVOLUTION_29/AS16-M-1241.lbl","AS16-M-1241.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1242","DATA/REVOLUTION_29/AS16-M-1242.lbl","AS16-M-1242.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1243","DATA/REVOLUTION_29/AS16-M-1243.lbl","AS16-M-1243.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1244","DATA/REVOLUTION_29/AS16-M-1244.lbl","AS16-M-1244.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1245","DATA/REVOLUTION_29/AS16-M-1245.lbl","AS16-M-1245.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1246","DATA/REVOLUTION_29/AS16-M-1246.lbl","AS16-M-1246.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1247","DATA/REVOLUTION_29/AS16-M-1247.lbl","AS16-M-1247.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1248","DATA/REVOLUTION_29/AS16-M-1248.lbl","AS16-M-1248.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1249","DATA/REVOLUTION_29/AS16-M-1249.lbl","AS16-M-1249.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1250","DATA/REVOLUTION_29/AS16-M-1250.lbl","AS16-M-1250.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1251","DATA/REVOLUTION_29/AS16-M-1251.lbl","AS16-M-1251.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1252","DATA/REVOLUTION_29/AS16-M-1252.lbl","AS16-M-1252.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1253","DATA/REVOLUTION_29/AS16-M-1253.lbl","AS16-M-1253.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1254","DATA/REVOLUTION_29/AS16-M-1254.lbl","AS16-M-1254.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1255","DATA/REVOLUTION_29/AS16-M-1255.lbl","AS16-M-1255.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1256","DATA/REVOLUTION_29/AS16-M-1256.lbl","AS16-M-1256.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1257","DATA/REVOLUTION_29/AS16-M-1257.lbl","AS16-M-1257.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1258","DATA/REVOLUTION_29/AS16-M-1258.lbl","AS16-M-1258.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1259","DATA/REVOLUTION_29/AS16-M-1259.lbl","AS16-M-1259.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1260","DATA/REVOLUTION_29/AS16-M-1260.lbl","AS16-M-1260.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1261","DATA/REVOLUTION_29/AS16-M-1261.lbl","AS16-M-1261.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1262","DATA/REVOLUTION_29/AS16-M-1262.lbl","AS16-M-1262.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1263","DATA/REVOLUTION_29/AS16-M-1263.lbl","AS16-M-1263.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1264","DATA/REVOLUTION_29/AS16-M-1264.lbl","AS16-M-1264.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1265","DATA/REVOLUTION_29/AS16-M-1265.lbl","AS16-M-1265.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1266","DATA/REVOLUTION_29/AS16-M-1266.lbl","AS16-M-1266.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1267","DATA/REVOLUTION_29/AS16-M-1267.lbl","AS16-M-1267.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1268","DATA/REVOLUTION_29/AS16-M-1268.lbl","AS16-M-1268.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1269","DATA/REVOLUTION_29/AS16-M-1269.lbl","AS16-M-1269.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1270","DATA/REVOLUTION_29/AS16-M-1270.lbl","AS16-M-1270.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1271","DATA/REVOLUTION_29/AS16-M-1271.lbl","AS16-M-1271.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1272","DATA/REVOLUTION_29/AS16-M-1272.lbl","AS16-M-1272.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1273","DATA/REVOLUTION_29/AS16-M-1273.lbl","AS16-M-1273.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1274","DATA/REVOLUTION_29/AS16-M-1274.lbl","AS16-M-1274.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1275","DATA/REVOLUTION_29/AS16-M-1275.lbl","AS16-M-1275.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1276","DATA/REVOLUTION_29/AS16-M-1276.lbl","AS16-M-1276.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1277","DATA/REVOLUTION_29/AS16-M-1277.lbl","AS16-M-1277.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1278","DATA/REVOLUTION_29/AS16-M-1278.lbl","AS16-M-1278.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1279","DATA/REVOLUTION_29/AS16-M-1279.lbl","AS16-M-1279.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1280","DATA/REVOLUTION_29/AS16-M-1280.lbl","AS16-M-1280.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1281","DATA/REVOLUTION_29/AS16-M-1281.lbl","AS16-M-1281.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1282","DATA/REVOLUTION_29/AS16-M-1282.lbl","AS16-M-1282.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1283","DATA/REVOLUTION_29/AS16-M-1283.lbl","AS16-M-1283.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1284","DATA/REVOLUTION_29/AS16-M-1284.lbl","AS16-M-1284.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1285","DATA/REVOLUTION_29/AS16-M-1285.lbl","AS16-M-1285.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1286","DATA/REVOLUTION_29/AS16-M-1286.lbl","AS16-M-1286.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1287","DATA/REVOLUTION_29/AS16-M-1287.lbl","AS16-M-1287.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1288","DATA/REVOLUTION_29/AS16-M-1288.lbl","AS16-M-1288.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1289","DATA/REVOLUTION_29/AS16-M-1289.lbl","AS16-M-1289.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1290","DATA/REVOLUTION_29/AS16-M-1290.lbl","AS16-M-1290.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1291","DATA/REVOLUTION_29/AS16-M-1291.lbl","AS16-M-1291.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1292","DATA/REVOLUTION_37/AS16-M-1292.lbl","AS16-M-1292.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1293","DATA/REVOLUTION_37/AS16-M-1293.lbl","AS16-M-1293.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1294","DATA/REVOLUTION_37/AS16-M-1294.lbl","AS16-M-1294.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1295","DATA/REVOLUTION_37/AS16-M-1295.lbl","AS16-M-1295.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1296","DATA/REVOLUTION_37/AS16-M-1296.lbl","AS16-M-1296.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1297","DATA/REVOLUTION_37/AS16-M-1297.lbl","AS16-M-1297.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1298","DATA/REVOLUTION_37/AS16-M-1298.lbl","AS16-M-1298.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1299","DATA/REVOLUTION_37/AS16-M-1299.lbl","AS16-M-1299.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1300","DATA/REVOLUTION_37/AS16-M-1300.lbl","AS16-M-1300.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1301","DATA/REVOLUTION_37/AS16-M-1301.lbl","AS16-M-1301.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1302","DATA/REVOLUTION_37/AS16-M-1302.lbl","AS16-M-1302.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1303","DATA/REVOLUTION_37/AS16-M-1303.lbl","AS16-M-1303.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1304","DATA/REVOLUTION_37/AS16-M-1304.lbl","AS16-M-1304.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1305","DATA/REVOLUTION_37/AS16-M-1305.lbl","AS16-M-1305.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1306","DATA/REVOLUTION_37/AS16-M-1306.lbl","AS16-M-1306.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1307","DATA/REVOLUTION_37/AS16-M-1307.lbl","AS16-M-1307.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1308","DATA/REVOLUTION_37/AS16-M-1308.lbl","AS16-M-1308.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1309","DATA/REVOLUTION_37/AS16-M-1309.lbl","AS16-M-1309.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1310","DATA/REVOLUTION_37/AS16-M-1310.lbl","AS16-M-1310.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1311","DATA/REVOLUTION_37/AS16-M-1311.lbl","AS16-M-1311.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1312","DATA/REVOLUTION_37/AS16-M-1312.lbl","AS16-M-1312.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1313","DATA/REVOLUTION_37/AS16-M-1313.lbl","AS16-M-1313.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1314","DATA/REVOLUTION_37/AS16-M-1314.lbl","AS16-M-1314.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1315","DATA/REVOLUTION_37/AS16-M-1315.lbl","AS16-M-1315.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1316","DATA/REVOLUTION_37/AS16-M-1316.lbl","AS16-M-1316.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1317","DATA/REVOLUTION_37/AS16-M-1317.lbl","AS16-M-1317.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1318","DATA/REVOLUTION_37/AS16-M-1318.lbl","AS16-M-1318.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1319","DATA/REVOLUTION_37/AS16-M-1319.lbl","AS16-M-1319.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1320","DATA/REVOLUTION_37/AS16-M-1320.lbl","AS16-M-1320.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1321","DATA/REVOLUTION_37/AS16-M-1321.lbl","AS16-M-1321.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1322","DATA/REVOLUTION_37/AS16-M-1322.lbl","AS16-M-1322.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1323","DATA/REVOLUTION_37/AS16-M-1323.lbl","AS16-M-1323.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1324","DATA/REVOLUTION_37/AS16-M-1324.lbl","AS16-M-1324.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1325","DATA/REVOLUTION_37/AS16-M-1325.lbl","AS16-M-1325.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1326","DATA/REVOLUTION_37/AS16-M-1326.lbl","AS16-M-1326.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1327","DATA/REVOLUTION_37/AS16-M-1327.lbl","AS16-M-1327.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1328","DATA/REVOLUTION_37/AS16-M-1328.lbl","AS16-M-1328.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1329","DATA/REVOLUTION_37/AS16-M-1329.lbl","AS16-M-1329.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1330","DATA/REVOLUTION_37/AS16-M-1330.lbl","AS16-M-1330.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1331","DATA/REVOLUTION_37/AS16-M-1331.lbl","AS16-M-1331.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1332","DATA/REVOLUTION_37/AS16-M-1332.lbl","AS16-M-1332.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1333","DATA/REVOLUTION_37/AS16-M-1333.lbl","AS16-M-1333.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1334","DATA/REVOLUTION_37/AS16-M-1334.lbl","AS16-M-1334.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1335","DATA/REVOLUTION_37/AS16-M-1335.lbl","AS16-M-1335.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1336","DATA/REVOLUTION_37/AS16-M-1336.lbl","AS16-M-1336.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1337","DATA/REVOLUTION_37/AS16-M-1337.lbl","AS16-M-1337.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1338","DATA/REVOLUTION_37/AS16-M-1338.lbl","AS16-M-1338.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1339","DATA/REVOLUTION_37/AS16-M-1339.lbl","AS16-M-1339.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1340","DATA/REVOLUTION_37/AS16-M-1340.lbl","AS16-M-1340.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1341","DATA/REVOLUTION_37/AS16-M-1341.lbl","AS16-M-1341.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1342","DATA/REVOLUTION_37/AS16-M-1342.lbl","AS16-M-1342.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1343","DATA/REVOLUTION_37/AS16-M-1343.lbl","AS16-M-1343.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1344","DATA/REVOLUTION_37/AS16-M-1344.lbl","AS16-M-1344.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1345","DATA/REVOLUTION_37/AS16-M-1345.lbl","AS16-M-1345.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1346","DATA/REVOLUTION_37/AS16-M-1346.lbl","AS16-M-1346.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1347","DATA/REVOLUTION_37/AS16-M-1347.lbl","AS16-M-1347.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1348","DATA/REVOLUTION_37/AS16-M-1348.lbl","AS16-M-1348.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1349","DATA/REVOLUTION_37/AS16-M-1349.lbl","AS16-M-1349.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1350","DATA/REVOLUTION_37/AS16-M-1350.lbl","AS16-M-1350.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1351","DATA/REVOLUTION_37/AS16-M-1351.lbl","AS16-M-1351.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1352","DATA/REVOLUTION_37/AS16-M-1352.lbl","AS16-M-1352.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1353","DATA/REVOLUTION_37/AS16-M-1353.lbl","AS16-M-1353.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1354","DATA/REVOLUTION_37/AS16-M-1354.lbl","AS16-M-1354.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1355","DATA/REVOLUTION_37/AS16-M-1355.lbl","AS16-M-1355.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1356","DATA/REVOLUTION_37/AS16-M-1356.lbl","AS16-M-1356.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1357","DATA/REVOLUTION_37/AS16-M-1357.lbl","AS16-M-1357.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1358","DATA/REVOLUTION_37/AS16-M-1358.lbl","AS16-M-1358.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1359","DATA/REVOLUTION_37/AS16-M-1359.lbl","AS16-M-1359.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1360","DATA/REVOLUTION_37/AS16-M-1360.lbl","AS16-M-1360.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1361","DATA/REVOLUTION_37/AS16-M-1361.lbl","AS16-M-1361.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1362","DATA/REVOLUTION_37/AS16-M-1362.lbl","AS16-M-1362.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1363","DATA/REVOLUTION_37/AS16-M-1363.lbl","AS16-M-1363.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1364","DATA/REVOLUTION_37/AS16-M-1364.lbl","AS16-M-1364.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1365","DATA/REVOLUTION_37/AS16-M-1365.lbl","AS16-M-1365.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1366","DATA/REVOLUTION_37/AS16-M-1366.lbl","AS16-M-1366.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1367","DATA/REVOLUTION_37/AS16-M-1367.lbl","AS16-M-1367.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1368","DATA/REVOLUTION_37/AS16-M-1368.lbl","AS16-M-1368.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1369","DATA/REVOLUTION_37/AS16-M-1369.lbl","AS16-M-1369.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1370","DATA/REVOLUTION_37/AS16-M-1370.lbl","AS16-M-1370.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1371","DATA/REVOLUTION_37/AS16-M-1371.lbl","AS16-M-1371.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1372","DATA/REVOLUTION_37/AS16-M-1372.lbl","AS16-M-1372.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1373","DATA/REVOLUTION_37/AS16-M-1373.lbl","AS16-M-1373.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1374","DATA/REVOLUTION_37/AS16-M-1374.lbl","AS16-M-1374.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1375","DATA/REVOLUTION_37/AS16-M-1375.lbl","AS16-M-1375.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1376","DATA/REVOLUTION_37/AS16-M-1376.lbl","AS16-M-1376.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1377","DATA/REVOLUTION_37/AS16-M-1377.lbl","AS16-M-1377.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1378","DATA/REVOLUTION_37/AS16-M-1378.lbl","AS16-M-1378.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1379","DATA/REVOLUTION_37/AS16-M-1379.lbl","AS16-M-1379.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1380","DATA/REVOLUTION_37/AS16-M-1380.lbl","AS16-M-1380.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1381","DATA/REVOLUTION_37/AS16-M-1381.lbl","AS16-M-1381.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1382","DATA/REVOLUTION_37/AS16-M-1382.lbl","AS16-M-1382.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1383","DATA/REVOLUTION_37/AS16-M-1383.lbl","AS16-M-1383.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1384","DATA/REVOLUTION_37/AS16-M-1384.lbl","AS16-M-1384.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1385","DATA/REVOLUTION_37/AS16-M-1385.lbl","AS16-M-1385.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1386","DATA/REVOLUTION_37/AS16-M-1386.lbl","AS16-M-1386.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1387","DATA/REVOLUTION_37/AS16-M-1387.lbl","AS16-M-1387.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1388","DATA/REVOLUTION_37/AS16-M-1388.lbl","AS16-M-1388.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1389","DATA/REVOLUTION_37/AS16-M-1389.lbl","AS16-M-1389.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1390","DATA/REVOLUTION_37/AS16-M-1390.lbl","AS16-M-1390.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1391","DATA/REVOLUTION_37/AS16-M-1391.lbl","AS16-M-1391.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1392","DATA/REVOLUTION_37/AS16-M-1392.lbl","AS16-M-1392.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1393","DATA/REVOLUTION_37/AS16-M-1393.lbl","AS16-M-1393.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1394","DATA/REVOLUTION_37/AS16-M-1394.lbl","AS16-M-1394.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1395","DATA/REVOLUTION_37/AS16-M-1395.lbl","AS16-M-1395.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1396","DATA/REVOLUTION_37/AS16-M-1396.lbl","AS16-M-1396.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1397","DATA/REVOLUTION_37/AS16-M-1397.lbl","AS16-M-1397.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1398","DATA/REVOLUTION_37/AS16-M-1398.lbl","AS16-M-1398.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1399","DATA/REVOLUTION_37/AS16-M-1399.lbl","AS16-M-1399.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1400","DATA/REVOLUTION_37/AS16-M-1400.lbl","AS16-M-1400.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1401","DATA/REVOLUTION_37/AS16-M-1401.lbl","AS16-M-1401.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1402","DATA/REVOLUTION_37/AS16-M-1402.lbl","AS16-M-1402.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1403","DATA/REVOLUTION_37/AS16-M-1403.lbl","AS16-M-1403.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1404","DATA/REVOLUTION_37/AS16-M-1404.lbl","AS16-M-1404.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1405","DATA/REVOLUTION_37/AS16-M-1405.lbl","AS16-M-1405.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1406","DATA/REVOLUTION_37/AS16-M-1406.lbl","AS16-M-1406.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1407","DATA/REVOLUTION_37/AS16-M-1407.lbl","AS16-M-1407.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1408","DATA/REVOLUTION_37/AS16-M-1408.lbl","AS16-M-1408.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1409","DATA/REVOLUTION_37/AS16-M-1409.lbl","AS16-M-1409.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1410","DATA/REVOLUTION_37/AS16-M-1410.lbl","AS16-M-1410.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1411","DATA/REVOLUTION_37/AS16-M-1411.lbl","AS16-M-1411.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1412","DATA/REVOLUTION_37/AS16-M-1412.lbl","AS16-M-1412.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1413","DATA/REVOLUTION_37/AS16-M-1413.lbl","AS16-M-1413.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1414","DATA/REVOLUTION_37/AS16-M-1414.lbl","AS16-M-1414.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1415","DATA/REVOLUTION_37/AS16-M-1415.lbl","AS16-M-1415.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1416","DATA/REVOLUTION_37/AS16-M-1416.lbl","AS16-M-1416.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1417","DATA/REVOLUTION_37/AS16-M-1417.lbl","AS16-M-1417.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1418","DATA/REVOLUTION_37/AS16-M-1418.lbl","AS16-M-1418.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1419","DATA/REVOLUTION_37/AS16-M-1419.lbl","AS16-M-1419.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1420","DATA/REVOLUTION_37/AS16-M-1420.lbl","AS16-M-1420.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1421","DATA/REVOLUTION_37/AS16-M-1421.lbl","AS16-M-1421.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1422","DATA/REVOLUTION_37/AS16-M-1422.lbl","AS16-M-1422.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1423","DATA/REVOLUTION_37/AS16-M-1423.lbl","AS16-M-1423.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1551","DATA/REVOLUTION_38/AS16-M-1551.lbl","AS16-M-1551.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1552","DATA/REVOLUTION_38/AS16-M-1552.lbl","AS16-M-1552.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1553","DATA/REVOLUTION_38/AS16-M-1553.lbl","AS16-M-1553.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1554","DATA/REVOLUTION_38/AS16-M-1554.lbl","AS16-M-1554.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1555","DATA/REVOLUTION_38/AS16-M-1555.lbl","AS16-M-1555.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1556","DATA/REVOLUTION_38/AS16-M-1556.lbl","AS16-M-1556.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1557","DATA/REVOLUTION_38/AS16-M-1557.lbl","AS16-M-1557.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1558","DATA/REVOLUTION_38/AS16-M-1558.lbl","AS16-M-1558.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1559","DATA/REVOLUTION_38/AS16-M-1559.lbl","AS16-M-1559.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1560","DATA/REVOLUTION_38/AS16-M-1560.lbl","AS16-M-1560.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1561","DATA/REVOLUTION_38/AS16-M-1561.lbl","AS16-M-1561.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1562","DATA/REVOLUTION_38/AS16-M-1562.lbl","AS16-M-1562.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1563","DATA/REVOLUTION_38/AS16-M-1563.lbl","AS16-M-1563.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1564","DATA/REVOLUTION_38/AS16-M-1564.lbl","AS16-M-1564.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1565","DATA/REVOLUTION_38/AS16-M-1565.lbl","AS16-M-1565.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1566","DATA/REVOLUTION_38/AS16-M-1566.lbl","AS16-M-1566.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1567","DATA/REVOLUTION_38/AS16-M-1567.lbl","AS16-M-1567.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1568","DATA/REVOLUTION_38/AS16-M-1568.lbl","AS16-M-1568.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1569","DATA/REVOLUTION_38/AS16-M-1569.lbl","AS16-M-1569.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1570","DATA/REVOLUTION_38/AS16-M-1570.lbl","AS16-M-1570.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1571","DATA/REVOLUTION_38/AS16-M-1571.lbl","AS16-M-1571.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1572","DATA/REVOLUTION_38/AS16-M-1572.lbl","AS16-M-1572.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1573","DATA/REVOLUTION_38/AS16-M-1573.lbl","AS16-M-1573.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1574","DATA/REVOLUTION_38/AS16-M-1574.lbl","AS16-M-1574.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1575","DATA/REVOLUTION_38/AS16-M-1575.lbl","AS16-M-1575.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1576","DATA/REVOLUTION_38/AS16-M-1576.lbl","AS16-M-1576.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1577","DATA/REVOLUTION_38/AS16-M-1577.lbl","AS16-M-1577.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1578","DATA/REVOLUTION_38/AS16-M-1578.lbl","AS16-M-1578.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1579","DATA/REVOLUTION_38/AS16-M-1579.lbl","AS16-M-1579.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1580","DATA/REVOLUTION_38/AS16-M-1580.lbl","AS16-M-1580.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1581","DATA/REVOLUTION_38/AS16-M-1581.lbl","AS16-M-1581.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1582","DATA/REVOLUTION_38/AS16-M-1582.lbl","AS16-M-1582.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1583","DATA/REVOLUTION_38/AS16-M-1583.lbl","AS16-M-1583.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1584","DATA/REVOLUTION_38/AS16-M-1584.lbl","AS16-M-1584.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1585","DATA/REVOLUTION_38/AS16-M-1585.lbl","AS16-M-1585.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1586","DATA/REVOLUTION_38/AS16-M-1586.lbl","AS16-M-1586.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1587","DATA/REVOLUTION_38/AS16-M-1587.lbl","AS16-M-1587.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1588","DATA/REVOLUTION_38/AS16-M-1588.lbl","AS16-M-1588.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1589","DATA/REVOLUTION_38/AS16-M-1589.lbl","AS16-M-1589.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1590","DATA/REVOLUTION_38/AS16-M-1590.lbl","AS16-M-1590.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1591","DATA/REVOLUTION_38/AS16-M-1591.lbl","AS16-M-1591.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1592","DATA/REVOLUTION_38/AS16-M-1592.lbl","AS16-M-1592.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1593","DATA/REVOLUTION_38/AS16-M-1593.lbl","AS16-M-1593.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1594","DATA/REVOLUTION_38/AS16-M-1594.lbl","AS16-M-1594.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1595","DATA/REVOLUTION_38/AS16-M-1595.lbl","AS16-M-1595.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1596","DATA/REVOLUTION_38/AS16-M-1596.lbl","AS16-M-1596.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1597","DATA/REVOLUTION_38/AS16-M-1597.lbl","AS16-M-1597.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1598","DATA/REVOLUTION_38/AS16-M-1598.lbl","AS16-M-1598.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1599","DATA/REVOLUTION_38/AS16-M-1599.lbl","AS16-M-1599.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1600","DATA/REVOLUTION_38/AS16-M-1600.lbl","AS16-M-1600.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1601","DATA/REVOLUTION_38/AS16-M-1601.lbl","AS16-M-1601.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1602","DATA/REVOLUTION_38/AS16-M-1602.lbl","AS16-M-1602.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1603","DATA/REVOLUTION_38/AS16-M-1603.lbl","AS16-M-1603.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1604","DATA/REVOLUTION_38/AS16-M-1604.lbl","AS16-M-1604.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1605","DATA/REVOLUTION_38/AS16-M-1605.lbl","AS16-M-1605.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1606","DATA/REVOLUTION_38/AS16-M-1606.lbl","AS16-M-1606.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1607","DATA/REVOLUTION_38/AS16-M-1607.lbl","AS16-M-1607.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1608","DATA/REVOLUTION_38/AS16-M-1608.lbl","AS16-M-1608.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1609","DATA/REVOLUTION_38/AS16-M-1609.lbl","AS16-M-1609.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1610","DATA/REVOLUTION_38/AS16-M-1610.lbl","AS16-M-1610.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1611","DATA/REVOLUTION_38/AS16-M-1611.lbl","AS16-M-1611.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1612","DATA/REVOLUTION_38/AS16-M-1612.lbl","AS16-M-1612.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1613","DATA/REVOLUTION_38/AS16-M-1613.lbl","AS16-M-1613.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1614","DATA/REVOLUTION_38/AS16-M-1614.lbl","AS16-M-1614.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1615","DATA/REVOLUTION_38/AS16-M-1615.lbl","AS16-M-1615.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1616","DATA/REVOLUTION_38/AS16-M-1616.lbl","AS16-M-1616.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1617","DATA/REVOLUTION_38/AS16-M-1617.lbl","AS16-M-1617.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1618","DATA/REVOLUTION_38/AS16-M-1618.lbl","AS16-M-1618.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1619","DATA/REVOLUTION_38/AS16-M-1619.lbl","AS16-M-1619.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1620","DATA/REVOLUTION_38/AS16-M-1620.lbl","AS16-M-1620.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1621","DATA/REVOLUTION_38/AS16-M-1621.lbl","AS16-M-1621.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1622","DATA/REVOLUTION_38/AS16-M-1622.lbl","AS16-M-1622.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1623","DATA/REVOLUTION_38/AS16-M-1623.lbl","AS16-M-1623.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1624","DATA/REVOLUTION_38/AS16-M-1624.lbl","AS16-M-1624.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1625","DATA/REVOLUTION_38/AS16-M-1625.lbl","AS16-M-1625.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1626","DATA/REVOLUTION_38/AS16-M-1626.lbl","AS16-M-1626.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1627","DATA/REVOLUTION_38/AS16-M-1627.lbl","AS16-M-1627.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1628","DATA/REVOLUTION_38/AS16-M-1628.lbl","AS16-M-1628.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1629","DATA/REVOLUTION_38/AS16-M-1629.lbl","AS16-M-1629.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1630","DATA/REVOLUTION_38/AS16-M-1630.lbl","AS16-M-1630.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1631","DATA/REVOLUTION_38/AS16-M-1631.lbl","AS16-M-1631.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1632","DATA/REVOLUTION_38/AS16-M-1632.lbl","AS16-M-1632.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1633","DATA/REVOLUTION_38/AS16-M-1633.lbl","AS16-M-1633.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1634","DATA/REVOLUTION_38/AS16-M-1634.lbl","AS16-M-1634.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1635","DATA/REVOLUTION_38/AS16-M-1635.lbl","AS16-M-1635.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1636","DATA/REVOLUTION_38/AS16-M-1636.lbl","AS16-M-1636.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1637","DATA/REVOLUTION_38/AS16-M-1637.lbl","AS16-M-1637.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1638","DATA/REVOLUTION_38/AS16-M-1638.lbl","AS16-M-1638.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1639","DATA/REVOLUTION_38/AS16-M-1639.lbl","AS16-M-1639.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1640","DATA/REVOLUTION_38/AS16-M-1640.lbl","AS16-M-1640.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1641","DATA/REVOLUTION_38/AS16-M-1641.lbl","AS16-M-1641.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1642","DATA/REVOLUTION_38/AS16-M-1642.lbl","AS16-M-1642.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1643","DATA/REVOLUTION_38/AS16-M-1643.lbl","AS16-M-1643.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1644","DATA/REVOLUTION_38/AS16-M-1644.lbl","AS16-M-1644.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1645","DATA/REVOLUTION_38/AS16-M-1645.lbl","AS16-M-1645.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1646","DATA/REVOLUTION_38/AS16-M-1646.lbl","AS16-M-1646.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1647","DATA/REVOLUTION_38/AS16-M-1647.lbl","AS16-M-1647.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1648","DATA/REVOLUTION_38/AS16-M-1648.lbl","AS16-M-1648.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1649","DATA/REVOLUTION_38/AS16-M-1649.lbl","AS16-M-1649.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1650","DATA/REVOLUTION_38/AS16-M-1650.lbl","AS16-M-1650.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1651","DATA/REVOLUTION_38/AS16-M-1651.lbl","AS16-M-1651.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1652","DATA/REVOLUTION_38/AS16-M-1652.lbl","AS16-M-1652.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1653","DATA/REVOLUTION_38/AS16-M-1653.lbl","AS16-M-1653.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1654","DATA/REVOLUTION_38/AS16-M-1654.lbl","AS16-M-1654.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1655","DATA/REVOLUTION_38/AS16-M-1655.lbl","AS16-M-1655.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1656","DATA/REVOLUTION_38/AS16-M-1656.lbl","AS16-M-1656.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1657","DATA/REVOLUTION_38/AS16-M-1657.lbl","AS16-M-1657.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1658","DATA/REVOLUTION_38/AS16-M-1658.lbl","AS16-M-1658.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1659","DATA/REVOLUTION_38/AS16-M-1659.lbl","AS16-M-1659.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1660","DATA/REVOLUTION_38/AS16-M-1660.lbl","AS16-M-1660.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1661","DATA/REVOLUTION_38/AS16-M-1661.lbl","AS16-M-1661.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1662","DATA/REVOLUTION_38/AS16-M-1662.lbl","AS16-M-1662.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1663","DATA/REVOLUTION_38/AS16-M-1663.lbl","AS16-M-1663.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1664","DATA/REVOLUTION_38/AS16-M-1664.lbl","AS16-M-1664.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1665","DATA/REVOLUTION_38/AS16-M-1665.lbl","AS16-M-1665.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1666","DATA/REVOLUTION_38/AS16-M-1666.lbl","AS16-M-1666.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1667","DATA/REVOLUTION_38/AS16-M-1667.lbl","AS16-M-1667.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1668","DATA/REVOLUTION_38/AS16-M-1668.lbl","AS16-M-1668.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1669","DATA/REVOLUTION_38/AS16-M-1669.lbl","AS16-M-1669.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1670","DATA/REVOLUTION_38/AS16-M-1670.lbl","AS16-M-1670.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1671","DATA/REVOLUTION_38/AS16-M-1671.lbl","AS16-M-1671.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1672","DATA/REVOLUTION_38/AS16-M-1672.lbl","AS16-M-1672.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1673","DATA/REVOLUTION_38/AS16-M-1673.lbl","AS16-M-1673.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1674","DATA/REVOLUTION_38/AS16-M-1674.lbl","AS16-M-1674.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1675","DATA/REVOLUTION_38/AS16-M-1675.lbl","AS16-M-1675.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1676","DATA/REVOLUTION_38/AS16-M-1676.lbl","AS16-M-1676.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1677","DATA/REVOLUTION_38/AS16-M-1677.lbl","AS16-M-1677.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1678","DATA/REVOLUTION_38/AS16-M-1678.lbl","AS16-M-1678.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1679","DATA/REVOLUTION_38/AS16-M-1679.lbl","AS16-M-1679.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1680","DATA/REVOLUTION_38/AS16-M-1680.lbl","AS16-M-1680.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1681","DATA/REVOLUTION_38/AS16-M-1681.lbl","AS16-M-1681.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1682","DATA/REVOLUTION_38/AS16-M-1682.lbl","AS16-M-1682.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1683","DATA/REVOLUTION_38/AS16-M-1683.lbl","AS16-M-1683.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1684","DATA/REVOLUTION_38/AS16-M-1684.lbl","AS16-M-1684.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1685","DATA/REVOLUTION_38/AS16-M-1685.lbl","AS16-M-1685.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1686","DATA/REVOLUTION_38/AS16-M-1686.lbl","AS16-M-1686.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1687","DATA/REVOLUTION_38/AS16-M-1687.lbl","AS16-M-1687.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1688","DATA/REVOLUTION_38/AS16-M-1688.lbl","AS16-M-1688.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1689","DATA/REVOLUTION_38/AS16-M-1689.lbl","AS16-M-1689.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1690","DATA/REVOLUTION_38/AS16-M-1690.lbl","AS16-M-1690.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1691","DATA/REVOLUTION_38/AS16-M-1691.lbl","AS16-M-1691.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1840","DATA/REVOLUTION_39/AS16-M-1840.lbl","AS16-M-1840.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1841","DATA/REVOLUTION_39/AS16-M-1841.lbl","AS16-M-1841.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1842","DATA/REVOLUTION_39/AS16-M-1842.lbl","AS16-M-1842.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1843","DATA/REVOLUTION_39/AS16-M-1843.lbl","AS16-M-1843.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1844","DATA/REVOLUTION_39/AS16-M-1844.lbl","AS16-M-1844.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1845","DATA/REVOLUTION_39/AS16-M-1845.lbl","AS16-M-1845.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1846","DATA/REVOLUTION_39/AS16-M-1846.lbl","AS16-M-1846.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1847","DATA/REVOLUTION_39/AS16-M-1847.lbl","AS16-M-1847.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1848","DATA/REVOLUTION_39/AS16-M-1848.lbl","AS16-M-1848.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1849","DATA/REVOLUTION_39/AS16-M-1849.lbl","AS16-M-1849.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1850","DATA/REVOLUTION_39/AS16-M-1850.lbl","AS16-M-1850.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1851","DATA/REVOLUTION_39/AS16-M-1851.lbl","AS16-M-1851.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1852","DATA/REVOLUTION_39/AS16-M-1852.lbl","AS16-M-1852.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1853","DATA/REVOLUTION_39/AS16-M-1853.lbl","AS16-M-1853.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1854","DATA/REVOLUTION_39/AS16-M-1854.lbl","AS16-M-1854.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1855","DATA/REVOLUTION_39/AS16-M-1855.lbl","AS16-M-1855.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1856","DATA/REVOLUTION_39/AS16-M-1856.lbl","AS16-M-1856.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1857","DATA/REVOLUTION_39/AS16-M-1857.lbl","AS16-M-1857.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1858","DATA/REVOLUTION_39/AS16-M-1858.lbl","AS16-M-1858.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1859","DATA/REVOLUTION_39/AS16-M-1859.lbl","AS16-M-1859.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1860","DATA/REVOLUTION_39/AS16-M-1860.lbl","AS16-M-1860.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1861","DATA/REVOLUTION_39/AS16-M-1861.lbl","AS16-M-1861.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1862","DATA/REVOLUTION_39/AS16-M-1862.lbl","AS16-M-1862.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1863","DATA/REVOLUTION_39/AS16-M-1863.lbl","AS16-M-1863.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1864","DATA/REVOLUTION_39/AS16-M-1864.lbl","AS16-M-1864.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1865","DATA/REVOLUTION_39/AS16-M-1865.lbl","AS16-M-1865.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1866","DATA/REVOLUTION_39/AS16-M-1866.lbl","AS16-M-1866.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1867","DATA/REVOLUTION_39/AS16-M-1867.lbl","AS16-M-1867.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1868","DATA/REVOLUTION_39/AS16-M-1868.lbl","AS16-M-1868.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1869","DATA/REVOLUTION_39/AS16-M-1869.lbl","AS16-M-1869.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1870","DATA/REVOLUTION_39/AS16-M-1870.lbl","AS16-M-1870.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1871","DATA/REVOLUTION_39/AS16-M-1871.lbl","AS16-M-1871.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1872","DATA/REVOLUTION_39/AS16-M-1872.lbl","AS16-M-1872.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1873","DATA/REVOLUTION_39/AS16-M-1873.lbl","AS16-M-1873.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1874","DATA/REVOLUTION_39/AS16-M-1874.lbl","AS16-M-1874.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1875","DATA/REVOLUTION_39/AS16-M-1875.lbl","AS16-M-1875.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1876","DATA/REVOLUTION_39/AS16-M-1876.lbl","AS16-M-1876.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1877","DATA/REVOLUTION_39/AS16-M-1877.lbl","AS16-M-1877.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1878","DATA/REVOLUTION_39/AS16-M-1878.lbl","AS16-M-1878.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1879","DATA/REVOLUTION_39/AS16-M-1879.lbl","AS16-M-1879.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1880","DATA/REVOLUTION_39/AS16-M-1880.lbl","AS16-M-1880.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1881","DATA/REVOLUTION_39/AS16-M-1881.lbl","AS16-M-1881.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1882","DATA/REVOLUTION_39/AS16-M-1882.lbl","AS16-M-1882.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1883","DATA/REVOLUTION_39/AS16-M-1883.lbl","AS16-M-1883.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1884","DATA/REVOLUTION_39/AS16-M-1884.lbl","AS16-M-1884.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1885","DATA/REVOLUTION_39/AS16-M-1885.lbl","AS16-M-1885.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1886","DATA/REVOLUTION_39/AS16-M-1886.lbl","AS16-M-1886.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1887","DATA/REVOLUTION_39/AS16-M-1887.lbl","AS16-M-1887.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1888","DATA/REVOLUTION_39/AS16-M-1888.lbl","AS16-M-1888.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1889","DATA/REVOLUTION_39/AS16-M-1889.lbl","AS16-M-1889.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1890","DATA/REVOLUTION_39/AS16-M-1890.lbl","AS16-M-1890.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1891","DATA/REVOLUTION_39/AS16-M-1891.lbl","AS16-M-1891.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1892","DATA/REVOLUTION_39/AS16-M-1892.lbl","AS16-M-1892.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1893","DATA/REVOLUTION_39/AS16-M-1893.lbl","AS16-M-1893.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1894","DATA/REVOLUTION_39/AS16-M-1894.lbl","AS16-M-1894.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1895","DATA/REVOLUTION_39/AS16-M-1895.lbl","AS16-M-1895.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1896","DATA/REVOLUTION_39/AS16-M-1896.lbl","AS16-M-1896.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1897","DATA/REVOLUTION_39/AS16-M-1897.lbl","AS16-M-1897.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1898","DATA/REVOLUTION_39/AS16-M-1898.lbl","AS16-M-1898.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1899","DATA/REVOLUTION_39/AS16-M-1899.lbl","AS16-M-1899.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1900","DATA/REVOLUTION_39/AS16-M-1900.lbl","AS16-M-1900.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1901","DATA/REVOLUTION_39/AS16-M-1901.lbl","AS16-M-1901.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1902","DATA/REVOLUTION_39/AS16-M-1902.lbl","AS16-M-1902.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1903","DATA/REVOLUTION_39/AS16-M-1903.lbl","AS16-M-1903.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1904","DATA/REVOLUTION_39/AS16-M-1904.lbl","AS16-M-1904.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1905","DATA/REVOLUTION_39/AS16-M-1905.lbl","AS16-M-1905.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1906","DATA/REVOLUTION_39/AS16-M-1906.lbl","AS16-M-1906.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1907","DATA/REVOLUTION_39/AS16-M-1907.lbl","AS16-M-1907.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1908","DATA/REVOLUTION_39/AS16-M-1908.lbl","AS16-M-1908.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1909","DATA/REVOLUTION_39/AS16-M-1909.lbl","AS16-M-1909.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1910","DATA/REVOLUTION_39/AS16-M-1910.lbl","AS16-M-1910.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1911","DATA/REVOLUTION_39/AS16-M-1911.lbl","AS16-M-1911.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1912","DATA/REVOLUTION_39/AS16-M-1912.lbl","AS16-M-1912.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1913","DATA/REVOLUTION_39/AS16-M-1913.lbl","AS16-M-1913.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1914","DATA/REVOLUTION_39/AS16-M-1914.lbl","AS16-M-1914.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1915","DATA/REVOLUTION_39/AS16-M-1915.lbl","AS16-M-1915.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1916","DATA/REVOLUTION_39/AS16-M-1916.lbl","AS16-M-1916.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1917","DATA/REVOLUTION_39/AS16-M-1917.lbl","AS16-M-1917.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1918","DATA/REVOLUTION_39/AS16-M-1918.lbl","AS16-M-1918.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1919","DATA/REVOLUTION_39/AS16-M-1919.lbl","AS16-M-1919.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1920","DATA/REVOLUTION_39/AS16-M-1920.lbl","AS16-M-1920.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1921","DATA/REVOLUTION_39/AS16-M-1921.lbl","AS16-M-1921.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1922","DATA/REVOLUTION_39/AS16-M-1922.lbl","AS16-M-1922.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1923","DATA/REVOLUTION_39/AS16-M-1923.lbl","AS16-M-1923.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1924","DATA/REVOLUTION_39/AS16-M-1924.lbl","AS16-M-1924.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1925","DATA/REVOLUTION_39/AS16-M-1925.lbl","AS16-M-1925.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1926","DATA/REVOLUTION_39/AS16-M-1926.lbl","AS16-M-1926.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1927","DATA/REVOLUTION_39/AS16-M-1927.lbl","AS16-M-1927.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1928","DATA/REVOLUTION_39/AS16-M-1928.lbl","AS16-M-1928.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1929","DATA/REVOLUTION_39/AS16-M-1929.lbl","AS16-M-1929.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1930","DATA/REVOLUTION_39/AS16-M-1930.lbl","AS16-M-1930.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1931","DATA/REVOLUTION_39/AS16-M-1931.lbl","AS16-M-1931.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1932","DATA/REVOLUTION_39/AS16-M-1932.lbl","AS16-M-1932.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1933","DATA/REVOLUTION_39/AS16-M-1933.lbl","AS16-M-1933.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1934","DATA/REVOLUTION_39/AS16-M-1934.lbl","AS16-M-1934.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1935","DATA/REVOLUTION_39/AS16-M-1935.lbl","AS16-M-1935.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1936","DATA/REVOLUTION_39/AS16-M-1936.lbl","AS16-M-1936.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1937","DATA/REVOLUTION_39/AS16-M-1937.lbl","AS16-M-1937.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1938","DATA/REVOLUTION_39/AS16-M-1938.lbl","AS16-M-1938.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1939","DATA/REVOLUTION_39/AS16-M-1939.lbl","AS16-M-1939.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1940","DATA/REVOLUTION_39/AS16-M-1940.lbl","AS16-M-1940.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1941","DATA/REVOLUTION_39/AS16-M-1941.lbl","AS16-M-1941.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1942","DATA/REVOLUTION_39/AS16-M-1942.lbl","AS16-M-1942.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1943","DATA/REVOLUTION_39/AS16-M-1943.lbl","AS16-M-1943.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1944","DATA/REVOLUTION_39/AS16-M-1944.lbl","AS16-M-1944.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1945","DATA/REVOLUTION_39/AS16-M-1945.lbl","AS16-M-1945.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1946","DATA/REVOLUTION_39/AS16-M-1946.lbl","AS16-M-1946.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1947","DATA/REVOLUTION_39/AS16-M-1947.lbl","AS16-M-1947.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1948","DATA/REVOLUTION_39/AS16-M-1948.lbl","AS16-M-1948.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1949","DATA/REVOLUTION_39/AS16-M-1949.lbl","AS16-M-1949.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1950","DATA/REVOLUTION_39/AS16-M-1950.lbl","AS16-M-1950.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1951","DATA/REVOLUTION_39/AS16-M-1951.lbl","AS16-M-1951.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1952","DATA/REVOLUTION_39/AS16-M-1952.lbl","AS16-M-1952.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1953","DATA/REVOLUTION_39/AS16-M-1953.lbl","AS16-M-1953.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1954","DATA/REVOLUTION_39/AS16-M-1954.lbl","AS16-M-1954.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1955","DATA/REVOLUTION_39/AS16-M-1955.lbl","AS16-M-1955.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1956","DATA/REVOLUTION_39/AS16-M-1956.lbl","AS16-M-1956.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1957","DATA/REVOLUTION_39/AS16-M-1957.lbl","AS16-M-1957.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1958","DATA/REVOLUTION_39/AS16-M-1958.lbl","AS16-M-1958.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1959","DATA/REVOLUTION_39/AS16-M-1959.lbl","AS16-M-1959.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1960","DATA/REVOLUTION_39/AS16-M-1960.lbl","AS16-M-1960.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1961","DATA/REVOLUTION_39/AS16-M-1961.lbl","AS16-M-1961.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1962","DATA/REVOLUTION_39/AS16-M-1962.lbl","AS16-M-1962.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1963","DATA/REVOLUTION_39/AS16-M-1963.lbl","AS16-M-1963.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1964","DATA/REVOLUTION_39/AS16-M-1964.lbl","AS16-M-1964.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1965","DATA/REVOLUTION_39/AS16-M-1965.lbl","AS16-M-1965.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1966","DATA/REVOLUTION_39/AS16-M-1966.lbl","AS16-M-1966.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1967","DATA/REVOLUTION_39/AS16-M-1967.lbl","AS16-M-1967.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1968","DATA/REVOLUTION_39/AS16-M-1968.lbl","AS16-M-1968.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1969","DATA/REVOLUTION_39/AS16-M-1969.lbl","AS16-M-1969.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1970","DATA/REVOLUTION_39/AS16-M-1970.lbl","AS16-M-1970.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1971","DATA/REVOLUTION_39/AS16-M-1971.lbl","AS16-M-1971.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1972","DATA/REVOLUTION_39/AS16-M-1972.lbl","AS16-M-1972.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1973","DATA/REVOLUTION_39/AS16-M-1973.lbl","AS16-M-1973.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1974","DATA/REVOLUTION_39/AS16-M-1974.lbl","AS16-M-1974.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1975","DATA/REVOLUTION_39/AS16-M-1975.lbl","AS16-M-1975.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1976","DATA/REVOLUTION_39/AS16-M-1976.lbl","AS16-M-1976.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1977","DATA/REVOLUTION_39/AS16-M-1977.lbl","AS16-M-1977.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1978","DATA/REVOLUTION_39/AS16-M-1978.lbl","AS16-M-1978.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1979","DATA/REVOLUTION_39/AS16-M-1979.lbl","AS16-M-1979.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1980","DATA/REVOLUTION_39/AS16-M-1980.lbl","AS16-M-1980.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1981","DATA/REVOLUTION_39/AS16-M-1981.lbl","AS16-M-1981.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1982","DATA/REVOLUTION_39/AS16-M-1982.lbl","AS16-M-1982.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1983","DATA/REVOLUTION_39/AS16-M-1983.lbl","AS16-M-1983.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1984","DATA/REVOLUTION_39/AS16-M-1984.lbl","AS16-M-1984.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1985","DATA/REVOLUTION_39/AS16-M-1985.lbl","AS16-M-1985.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-1986","DATA/REVOLUTION_39/AS16-M-1986.lbl","AS16-M-1986.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2069","DATA/REVOLUTION_47/AS16-M-2069.lbl","AS16-M-2069.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2070","DATA/REVOLUTION_47/AS16-M-2070.lbl","AS16-M-2070.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2071","DATA/REVOLUTION_47/AS16-M-2071.lbl","AS16-M-2071.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2072","DATA/REVOLUTION_47/AS16-M-2072.lbl","AS16-M-2072.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2073","DATA/REVOLUTION_47/AS16-M-2073.lbl","AS16-M-2073.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2074","DATA/REVOLUTION_47/AS16-M-2074.lbl","AS16-M-2074.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2075","DATA/REVOLUTION_47/AS16-M-2075.lbl","AS16-M-2075.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2076","DATA/REVOLUTION_47/AS16-M-2076.lbl","AS16-M-2076.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2077","DATA/REVOLUTION_47/AS16-M-2077.lbl","AS16-M-2077.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2078","DATA/REVOLUTION_47/AS16-M-2078.lbl","AS16-M-2078.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2079","DATA/REVOLUTION_47/AS16-M-2079.lbl","AS16-M-2079.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2080","DATA/REVOLUTION_47/AS16-M-2080.lbl","AS16-M-2080.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2081","DATA/REVOLUTION_47/AS16-M-2081.lbl","AS16-M-2081.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2082","DATA/REVOLUTION_47/AS16-M-2082.lbl","AS16-M-2082.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2083","DATA/REVOLUTION_47/AS16-M-2083.lbl","AS16-M-2083.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2084","DATA/REVOLUTION_47/AS16-M-2084.lbl","AS16-M-2084.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2085","DATA/REVOLUTION_47/AS16-M-2085.lbl","AS16-M-2085.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2086","DATA/REVOLUTION_47/AS16-M-2086.lbl","AS16-M-2086.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2087","DATA/REVOLUTION_47/AS16-M-2087.lbl","AS16-M-2087.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2088","DATA/REVOLUTION_47/AS16-M-2088.lbl","AS16-M-2088.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2089","DATA/REVOLUTION_47/AS16-M-2089.lbl","AS16-M-2089.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2090","DATA/REVOLUTION_47/AS16-M-2090.lbl","AS16-M-2090.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2091","DATA/REVOLUTION_47/AS16-M-2091.lbl","AS16-M-2091.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2092","DATA/REVOLUTION_47/AS16-M-2092.lbl","AS16-M-2092.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2093","DATA/REVOLUTION_47/AS16-M-2093.lbl","AS16-M-2093.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2094","DATA/REVOLUTION_47/AS16-M-2094.lbl","AS16-M-2094.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2095","DATA/REVOLUTION_47/AS16-M-2095.lbl","AS16-M-2095.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2096","DATA/REVOLUTION_47/AS16-M-2096.lbl","AS16-M-2096.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2097","DATA/REVOLUTION_47/AS16-M-2097.lbl","AS16-M-2097.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2098","DATA/REVOLUTION_47/AS16-M-2098.lbl","AS16-M-2098.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2099","DATA/REVOLUTION_47/AS16-M-2099.lbl","AS16-M-2099.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2100","DATA/REVOLUTION_47/AS16-M-2100.lbl","AS16-M-2100.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2101","DATA/REVOLUTION_47/AS16-M-2101.lbl","AS16-M-2101.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2102","DATA/REVOLUTION_47/AS16-M-2102.lbl","AS16-M-2102.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2103","DATA/REVOLUTION_47/AS16-M-2103.lbl","AS16-M-2103.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2104","DATA/REVOLUTION_47/AS16-M-2104.lbl","AS16-M-2104.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2105","DATA/REVOLUTION_47/AS16-M-2105.lbl","AS16-M-2105.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2106","DATA/REVOLUTION_47/AS16-M-2106.lbl","AS16-M-2106.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2107","DATA/REVOLUTION_47/AS16-M-2107.lbl","AS16-M-2107.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2108","DATA/REVOLUTION_47/AS16-M-2108.lbl","AS16-M-2108.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2109","DATA/REVOLUTION_47/AS16-M-2109.lbl","AS16-M-2109.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2110","DATA/REVOLUTION_47/AS16-M-2110.lbl","AS16-M-2110.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2111","DATA/REVOLUTION_47/AS16-M-2111.lbl","AS16-M-2111.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2112","DATA/REVOLUTION_47/AS16-M-2112.lbl","AS16-M-2112.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2113","DATA/REVOLUTION_47/AS16-M-2113.lbl","AS16-M-2113.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2114","DATA/REVOLUTION_47/AS16-M-2114.lbl","AS16-M-2114.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2115","DATA/REVOLUTION_47/AS16-M-2115.lbl","AS16-M-2115.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2116","DATA/REVOLUTION_47/AS16-M-2116.lbl","AS16-M-2116.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2117","DATA/REVOLUTION_47/AS16-M-2117.lbl","AS16-M-2117.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2118","DATA/REVOLUTION_47/AS16-M-2118.lbl","AS16-M-2118.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2119","DATA/REVOLUTION_47/AS16-M-2119.lbl","AS16-M-2119.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2120","DATA/REVOLUTION_47/AS16-M-2120.lbl","AS16-M-2120.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2121","DATA/REVOLUTION_47/AS16-M-2121.lbl","AS16-M-2121.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2122","DATA/REVOLUTION_47/AS16-M-2122.lbl","AS16-M-2122.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2123","DATA/REVOLUTION_47/AS16-M-2123.lbl","AS16-M-2123.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2124","DATA/REVOLUTION_47/AS16-M-2124.lbl","AS16-M-2124.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2125","DATA/REVOLUTION_47/AS16-M-2125.lbl","AS16-M-2125.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2126","DATA/REVOLUTION_47/AS16-M-2126.lbl","AS16-M-2126.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2127","DATA/REVOLUTION_47/AS16-M-2127.lbl","AS16-M-2127.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2128","DATA/REVOLUTION_47/AS16-M-2128.lbl","AS16-M-2128.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2129","DATA/REVOLUTION_47/AS16-M-2129.lbl","AS16-M-2129.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2130","DATA/REVOLUTION_47/AS16-M-2130.lbl","AS16-M-2130.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2131","DATA/REVOLUTION_47/AS16-M-2131.lbl","AS16-M-2131.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2132","DATA/REVOLUTION_47/AS16-M-2132.lbl","AS16-M-2132.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2133","DATA/REVOLUTION_47/AS16-M-2133.lbl","AS16-M-2133.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2134","DATA/REVOLUTION_47/AS16-M-2134.lbl","AS16-M-2134.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2135","DATA/REVOLUTION_47/AS16-M-2135.lbl","AS16-M-2135.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2136","DATA/REVOLUTION_47/AS16-M-2136.lbl","AS16-M-2136.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2137","DATA/REVOLUTION_47/AS16-M-2137.lbl","AS16-M-2137.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2138","DATA/REVOLUTION_47/AS16-M-2138.lbl","AS16-M-2138.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2139","DATA/REVOLUTION_47/AS16-M-2139.lbl","AS16-M-2139.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2140","DATA/REVOLUTION_47/AS16-M-2140.lbl","AS16-M-2140.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2141","DATA/REVOLUTION_47/AS16-M-2141.lbl","AS16-M-2141.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2142","DATA/REVOLUTION_47/AS16-M-2142.lbl","AS16-M-2142.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2143","DATA/REVOLUTION_47/AS16-M-2143.lbl","AS16-M-2143.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2144","DATA/REVOLUTION_47/AS16-M-2144.lbl","AS16-M-2144.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2145","DATA/REVOLUTION_47/AS16-M-2145.lbl","AS16-M-2145.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2146","DATA/REVOLUTION_47/AS16-M-2146.lbl","AS16-M-2146.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2147","DATA/REVOLUTION_47/AS16-M-2147.lbl","AS16-M-2147.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2148","DATA/REVOLUTION_47/AS16-M-2148.lbl","AS16-M-2148.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2149","DATA/REVOLUTION_47/AS16-M-2149.lbl","AS16-M-2149.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2150","DATA/REVOLUTION_47/AS16-M-2150.lbl","AS16-M-2150.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2151","DATA/REVOLUTION_47/AS16-M-2151.lbl","AS16-M-2151.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2152","DATA/REVOLUTION_47/AS16-M-2152.lbl","AS16-M-2152.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2153","DATA/REVOLUTION_47/AS16-M-2153.lbl","AS16-M-2153.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2154","DATA/REVOLUTION_47/AS16-M-2154.lbl","AS16-M-2154.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2155","DATA/REVOLUTION_47/AS16-M-2155.lbl","AS16-M-2155.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2156","DATA/REVOLUTION_47/AS16-M-2156.lbl","AS16-M-2156.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2157","DATA/REVOLUTION_47/AS16-M-2157.lbl","AS16-M-2157.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2158","DATA/REVOLUTION_47/AS16-M-2158.lbl","AS16-M-2158.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2159","DATA/REVOLUTION_47/AS16-M-2159.lbl","AS16-M-2159.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2160","DATA/REVOLUTION_47/AS16-M-2160.lbl","AS16-M-2160.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2161","DATA/REVOLUTION_47/AS16-M-2161.lbl","AS16-M-2161.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2162","DATA/REVOLUTION_47/AS16-M-2162.lbl","AS16-M-2162.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2163","DATA/REVOLUTION_47/AS16-M-2163.lbl","AS16-M-2163.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2164","DATA/REVOLUTION_47/AS16-M-2164.lbl","AS16-M-2164.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2165","DATA/REVOLUTION_47/AS16-M-2165.lbl","AS16-M-2165.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2166","DATA/REVOLUTION_47/AS16-M-2166.lbl","AS16-M-2166.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2167","DATA/REVOLUTION_47/AS16-M-2167.lbl","AS16-M-2167.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2168","DATA/REVOLUTION_47/AS16-M-2168.lbl","AS16-M-2168.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2169","DATA/REVOLUTION_47/AS16-M-2169.lbl","AS16-M-2169.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2170","DATA/REVOLUTION_47/AS16-M-2170.lbl","AS16-M-2170.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2171","DATA/REVOLUTION_47/AS16-M-2171.lbl","AS16-M-2171.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2172","DATA/REVOLUTION_47/AS16-M-2172.lbl","AS16-M-2172.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2173","DATA/REVOLUTION_47/AS16-M-2173.lbl","AS16-M-2173.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2174","DATA/REVOLUTION_47/AS16-M-2174.lbl","AS16-M-2174.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2175","DATA/REVOLUTION_47/AS16-M-2175.lbl","AS16-M-2175.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2176","DATA/REVOLUTION_47/AS16-M-2176.lbl","AS16-M-2176.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2177","DATA/REVOLUTION_47/AS16-M-2177.lbl","AS16-M-2177.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2178","DATA/REVOLUTION_47/AS16-M-2178.lbl","AS16-M-2178.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2179","DATA/REVOLUTION_47/AS16-M-2179.lbl","AS16-M-2179.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2180","DATA/REVOLUTION_47/AS16-M-2180.lbl","AS16-M-2180.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2181","DATA/REVOLUTION_47/AS16-M-2181.lbl","AS16-M-2181.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2182","DATA/REVOLUTION_47/AS16-M-2182.lbl","AS16-M-2182.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2183","DATA/REVOLUTION_47/AS16-M-2183.lbl","AS16-M-2183.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2184","DATA/REVOLUTION_47/AS16-M-2184.lbl","AS16-M-2184.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2185","DATA/REVOLUTION_47/AS16-M-2185.lbl","AS16-M-2185.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2186","DATA/REVOLUTION_47/AS16-M-2186.lbl","AS16-M-2186.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2187","DATA/REVOLUTION_47/AS16-M-2187.lbl","AS16-M-2187.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2188","DATA/REVOLUTION_47/AS16-M-2188.lbl","AS16-M-2188.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2189","DATA/REVOLUTION_47/AS16-M-2189.lbl","AS16-M-2189.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2190","DATA/REVOLUTION_47/AS16-M-2190.lbl","AS16-M-2190.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2191","DATA/REVOLUTION_47/AS16-M-2191.lbl","AS16-M-2191.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2192","DATA/REVOLUTION_47/AS16-M-2192.lbl","AS16-M-2192.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2193","DATA/REVOLUTION_47/AS16-M-2193.lbl","AS16-M-2193.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2194","DATA/REVOLUTION_47/AS16-M-2194.lbl","AS16-M-2194.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2195","DATA/REVOLUTION_47/AS16-M-2195.lbl","AS16-M-2195.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2196","DATA/REVOLUTION_47/AS16-M-2196.lbl","AS16-M-2196.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2197","DATA/REVOLUTION_47/AS16-M-2197.lbl","AS16-M-2197.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2198","DATA/REVOLUTION_47/AS16-M-2198.lbl","AS16-M-2198.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2199","DATA/REVOLUTION_47/AS16-M-2199.lbl","AS16-M-2199.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2200","DATA/REVOLUTION_47/AS16-M-2200.lbl","AS16-M-2200.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2201","DATA/REVOLUTION_47/AS16-M-2201.lbl","AS16-M-2201.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2202","DATA/REVOLUTION_47/AS16-M-2202.lbl","AS16-M-2202.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2203","DATA/REVOLUTION_47/AS16-M-2203.lbl","AS16-M-2203.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2204","DATA/REVOLUTION_47/AS16-M-2204.lbl","AS16-M-2204.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2205","DATA/REVOLUTION_47/AS16-M-2205.lbl","AS16-M-2205.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2206","DATA/REVOLUTION_47/AS16-M-2206.lbl","AS16-M-2206.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2207","DATA/REVOLUTION_47/AS16-M-2207.lbl","AS16-M-2207.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2208","DATA/REVOLUTION_47/AS16-M-2208.lbl","AS16-M-2208.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2209","DATA/REVOLUTION_47/AS16-M-2209.lbl","AS16-M-2209.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2210","DATA/REVOLUTION_47/AS16-M-2210.lbl","AS16-M-2210.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2211","DATA/REVOLUTION_47/AS16-M-2211.lbl","AS16-M-2211.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2212","DATA/REVOLUTION_47/AS16-M-2212.lbl","AS16-M-2212.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2213","DATA/REVOLUTION_47/AS16-M-2213.lbl","AS16-M-2213.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2214","DATA/REVOLUTION_47/AS16-M-2214.lbl","AS16-M-2214.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2215","DATA/REVOLUTION_47/AS16-M-2215.lbl","AS16-M-2215.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2216","DATA/REVOLUTION_47/AS16-M-2216.lbl","AS16-M-2216.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2217","DATA/REVOLUTION_47/AS16-M-2217.lbl","AS16-M-2217.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2357","DATA/REVOLUTION_48/AS16-M-2357.lbl","AS16-M-2357.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2358","DATA/REVOLUTION_48/AS16-M-2358.lbl","AS16-M-2358.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2359","DATA/REVOLUTION_48/AS16-M-2359.lbl","AS16-M-2359.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2360","DATA/REVOLUTION_48/AS16-M-2360.lbl","AS16-M-2360.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2361","DATA/REVOLUTION_48/AS16-M-2361.lbl","AS16-M-2361.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2362","DATA/REVOLUTION_48/AS16-M-2362.lbl","AS16-M-2362.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2363","DATA/REVOLUTION_48/AS16-M-2363.lbl","AS16-M-2363.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2364","DATA/REVOLUTION_48/AS16-M-2364.lbl","AS16-M-2364.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2365","DATA/REVOLUTION_48/AS16-M-2365.lbl","AS16-M-2365.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2366","DATA/REVOLUTION_48/AS16-M-2366.lbl","AS16-M-2366.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2367","DATA/REVOLUTION_48/AS16-M-2367.lbl","AS16-M-2367.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2368","DATA/REVOLUTION_48/AS16-M-2368.lbl","AS16-M-2368.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2369","DATA/REVOLUTION_48/AS16-M-2369.lbl","AS16-M-2369.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2370","DATA/REVOLUTION_48/AS16-M-2370.lbl","AS16-M-2370.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2371","DATA/REVOLUTION_48/AS16-M-2371.lbl","AS16-M-2371.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2372","DATA/REVOLUTION_48/AS16-M-2372.lbl","AS16-M-2372.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2373","DATA/REVOLUTION_48/AS16-M-2373.lbl","AS16-M-2373.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2374","DATA/REVOLUTION_48/AS16-M-2374.lbl","AS16-M-2374.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2375","DATA/REVOLUTION_48/AS16-M-2375.lbl","AS16-M-2375.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2376","DATA/REVOLUTION_48/AS16-M-2376.lbl","AS16-M-2376.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2377","DATA/REVOLUTION_48/AS16-M-2377.lbl","AS16-M-2377.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2378","DATA/REVOLUTION_48/AS16-M-2378.lbl","AS16-M-2378.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2379","DATA/REVOLUTION_48/AS16-M-2379.lbl","AS16-M-2379.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2380","DATA/REVOLUTION_48/AS16-M-2380.lbl","AS16-M-2380.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2381","DATA/REVOLUTION_48/AS16-M-2381.lbl","AS16-M-2381.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2382","DATA/REVOLUTION_48/AS16-M-2382.lbl","AS16-M-2382.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2383","DATA/REVOLUTION_48/AS16-M-2383.lbl","AS16-M-2383.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2384","DATA/REVOLUTION_48/AS16-M-2384.lbl","AS16-M-2384.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2385","DATA/REVOLUTION_48/AS16-M-2385.lbl","AS16-M-2385.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2386","DATA/REVOLUTION_48/AS16-M-2386.lbl","AS16-M-2386.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2387","DATA/REVOLUTION_48/AS16-M-2387.lbl","AS16-M-2387.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2388","DATA/REVOLUTION_48/AS16-M-2388.lbl","AS16-M-2388.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2389","DATA/REVOLUTION_48/AS16-M-2389.lbl","AS16-M-2389.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2390","DATA/REVOLUTION_48/AS16-M-2390.lbl","AS16-M-2390.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2391","DATA/REVOLUTION_48/AS16-M-2391.lbl","AS16-M-2391.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2392","DATA/REVOLUTION_48/AS16-M-2392.lbl","AS16-M-2392.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2393","DATA/REVOLUTION_48/AS16-M-2393.lbl","AS16-M-2393.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2394","DATA/REVOLUTION_48/AS16-M-2394.lbl","AS16-M-2394.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2395","DATA/REVOLUTION_48/AS16-M-2395.lbl","AS16-M-2395.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2396","DATA/REVOLUTION_48/AS16-M-2396.lbl","AS16-M-2396.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2397","DATA/REVOLUTION_48/AS16-M-2397.lbl","AS16-M-2397.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2398","DATA/REVOLUTION_48/AS16-M-2398.lbl","AS16-M-2398.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2399","DATA/REVOLUTION_48/AS16-M-2399.lbl","AS16-M-2399.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2400","DATA/REVOLUTION_48/AS16-M-2400.lbl","AS16-M-2400.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2401","DATA/REVOLUTION_48/AS16-M-2401.lbl","AS16-M-2401.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2402","DATA/REVOLUTION_48/AS16-M-2402.lbl","AS16-M-2402.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2403","DATA/REVOLUTION_48/AS16-M-2403.lbl","AS16-M-2403.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2404","DATA/REVOLUTION_48/AS16-M-2404.lbl","AS16-M-2404.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2405","DATA/REVOLUTION_48/AS16-M-2405.lbl","AS16-M-2405.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2406","DATA/REVOLUTION_48/AS16-M-2406.lbl","AS16-M-2406.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2407","DATA/REVOLUTION_48/AS16-M-2407.lbl","AS16-M-2407.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2408","DATA/REVOLUTION_48/AS16-M-2408.lbl","AS16-M-2408.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2409","DATA/REVOLUTION_48/AS16-M-2409.lbl","AS16-M-2409.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2410","DATA/REVOLUTION_48/AS16-M-2410.lbl","AS16-M-2410.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2411","DATA/REVOLUTION_48/AS16-M-2411.lbl","AS16-M-2411.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2412","DATA/REVOLUTION_48/AS16-M-2412.lbl","AS16-M-2412.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2413","DATA/REVOLUTION_48/AS16-M-2413.lbl","AS16-M-2413.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2414","DATA/REVOLUTION_48/AS16-M-2414.lbl","AS16-M-2414.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2415","DATA/REVOLUTION_48/AS16-M-2415.lbl","AS16-M-2415.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2416","DATA/REVOLUTION_48/AS16-M-2416.lbl","AS16-M-2416.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2417","DATA/REVOLUTION_48/AS16-M-2417.lbl","AS16-M-2417.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2418","DATA/REVOLUTION_48/AS16-M-2418.lbl","AS16-M-2418.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2419","DATA/REVOLUTION_48/AS16-M-2419.lbl","AS16-M-2419.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2420","DATA/REVOLUTION_48/AS16-M-2420.lbl","AS16-M-2420.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2421","DATA/REVOLUTION_48/AS16-M-2421.lbl","AS16-M-2421.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2422","DATA/REVOLUTION_48/AS16-M-2422.lbl","AS16-M-2422.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2423","DATA/REVOLUTION_48/AS16-M-2423.lbl","AS16-M-2423.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2424","DATA/REVOLUTION_48/AS16-M-2424.lbl","AS16-M-2424.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2425","DATA/REVOLUTION_48/AS16-M-2425.lbl","AS16-M-2425.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2426","DATA/REVOLUTION_48/AS16-M-2426.lbl","AS16-M-2426.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2427","DATA/REVOLUTION_48/AS16-M-2427.lbl","AS16-M-2427.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2428","DATA/REVOLUTION_48/AS16-M-2428.lbl","AS16-M-2428.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2429","DATA/REVOLUTION_48/AS16-M-2429.lbl","AS16-M-2429.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2430","DATA/REVOLUTION_48/AS16-M-2430.lbl","AS16-M-2430.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2431","DATA/REVOLUTION_48/AS16-M-2431.lbl","AS16-M-2431.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2432","DATA/REVOLUTION_48/AS16-M-2432.lbl","AS16-M-2432.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2433","DATA/REVOLUTION_48/AS16-M-2433.lbl","AS16-M-2433.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2434","DATA/REVOLUTION_48/AS16-M-2434.lbl","AS16-M-2434.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2435","DATA/REVOLUTION_48/AS16-M-2435.lbl","AS16-M-2435.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2436","DATA/REVOLUTION_48/AS16-M-2436.lbl","AS16-M-2436.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2437","DATA/REVOLUTION_48/AS16-M-2437.lbl","AS16-M-2437.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2438","DATA/REVOLUTION_48/AS16-M-2438.lbl","AS16-M-2438.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2439","DATA/REVOLUTION_48/AS16-M-2439.lbl","AS16-M-2439.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2440","DATA/REVOLUTION_48/AS16-M-2440.lbl","AS16-M-2440.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2441","DATA/REVOLUTION_48/AS16-M-2441.lbl","AS16-M-2441.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2442","DATA/REVOLUTION_48/AS16-M-2442.lbl","AS16-M-2442.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2443","DATA/REVOLUTION_48/AS16-M-2443.lbl","AS16-M-2443.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2444","DATA/REVOLUTION_48/AS16-M-2444.lbl","AS16-M-2444.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2445","DATA/REVOLUTION_48/AS16-M-2445.lbl","AS16-M-2445.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2446","DATA/REVOLUTION_48/AS16-M-2446.lbl","AS16-M-2446.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2447","DATA/REVOLUTION_48/AS16-M-2447.lbl","AS16-M-2447.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2448","DATA/REVOLUTION_48/AS16-M-2448.lbl","AS16-M-2448.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2449","DATA/REVOLUTION_48/AS16-M-2449.lbl","AS16-M-2449.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2450","DATA/REVOLUTION_48/AS16-M-2450.lbl","AS16-M-2450.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2451","DATA/REVOLUTION_48/AS16-M-2451.lbl","AS16-M-2451.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2452","DATA/REVOLUTION_48/AS16-M-2452.lbl","AS16-M-2452.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2453","DATA/REVOLUTION_48/AS16-M-2453.lbl","AS16-M-2453.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2454","DATA/REVOLUTION_48/AS16-M-2454.lbl","AS16-M-2454.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2455","DATA/REVOLUTION_48/AS16-M-2455.lbl","AS16-M-2455.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2456","DATA/REVOLUTION_48/AS16-M-2456.lbl","AS16-M-2456.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2457","DATA/REVOLUTION_48/AS16-M-2457.lbl","AS16-M-2457.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2458","DATA/REVOLUTION_48/AS16-M-2458.lbl","AS16-M-2458.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2459","DATA/REVOLUTION_48/AS16-M-2459.lbl","AS16-M-2459.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2460","DATA/REVOLUTION_48/AS16-M-2460.lbl","AS16-M-2460.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2461","DATA/REVOLUTION_48/AS16-M-2461.lbl","AS16-M-2461.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2462","DATA/REVOLUTION_48/AS16-M-2462.lbl","AS16-M-2462.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2463","DATA/REVOLUTION_48/AS16-M-2463.lbl","AS16-M-2463.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2464","DATA/REVOLUTION_48/AS16-M-2464.lbl","AS16-M-2464.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2465","DATA/REVOLUTION_48/AS16-M-2465.lbl","AS16-M-2465.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2466","DATA/REVOLUTION_48/AS16-M-2466.lbl","AS16-M-2466.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2467","DATA/REVOLUTION_48/AS16-M-2467.lbl","AS16-M-2467.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2468","DATA/REVOLUTION_48/AS16-M-2468.lbl","AS16-M-2468.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2469","DATA/REVOLUTION_48/AS16-M-2469.lbl","AS16-M-2469.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2470","DATA/REVOLUTION_48/AS16-M-2470.lbl","AS16-M-2470.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2471","DATA/REVOLUTION_48/AS16-M-2471.lbl","AS16-M-2471.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2472","DATA/REVOLUTION_48/AS16-M-2472.lbl","AS16-M-2472.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2473","DATA/REVOLUTION_48/AS16-M-2473.lbl","AS16-M-2473.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2474","DATA/REVOLUTION_48/AS16-M-2474.lbl","AS16-M-2474.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2475","DATA/REVOLUTION_48/AS16-M-2475.lbl","AS16-M-2475.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2476","DATA/REVOLUTION_48/AS16-M-2476.lbl","AS16-M-2476.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2477","DATA/REVOLUTION_48/AS16-M-2477.lbl","AS16-M-2477.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2478","DATA/REVOLUTION_48/AS16-M-2478.lbl","AS16-M-2478.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2479","DATA/REVOLUTION_48/AS16-M-2479.lbl","AS16-M-2479.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2480","DATA/REVOLUTION_48/AS16-M-2480.lbl","AS16-M-2480.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2481","DATA/REVOLUTION_48/AS16-M-2481.lbl","AS16-M-2481.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2482","DATA/REVOLUTION_48/AS16-M-2482.lbl","AS16-M-2482.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2483","DATA/REVOLUTION_48/AS16-M-2483.lbl","AS16-M-2483.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2484","DATA/REVOLUTION_48/AS16-M-2484.lbl","AS16-M-2484.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2485","DATA/REVOLUTION_48/AS16-M-2485.lbl","AS16-M-2485.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2486","DATA/REVOLUTION_48/AS16-M-2486.lbl","AS16-M-2486.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2487","DATA/REVOLUTION_48/AS16-M-2487.lbl","AS16-M-2487.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2488","DATA/REVOLUTION_48/AS16-M-2488.lbl","AS16-M-2488.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2489","DATA/REVOLUTION_48/AS16-M-2489.lbl","AS16-M-2489.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2490","DATA/REVOLUTION_48/AS16-M-2490.lbl","AS16-M-2490.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2491","DATA/REVOLUTION_48/AS16-M-2491.lbl","AS16-M-2491.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2492","DATA/REVOLUTION_48/AS16-M-2492.lbl","AS16-M-2492.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2493","DATA/REVOLUTION_48/AS16-M-2493.lbl","AS16-M-2493.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2494","DATA/REVOLUTION_48/AS16-M-2494.lbl","AS16-M-2494.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2495","DATA/REVOLUTION_48/AS16-M-2495.lbl","AS16-M-2495.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2496","DATA/REVOLUTION_48/AS16-M-2496.lbl","AS16-M-2496.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2497","DATA/REVOLUTION_48/AS16-M-2497.lbl","AS16-M-2497.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2498","DATA/REVOLUTION_48/AS16-M-2498.lbl","AS16-M-2498.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2499","DATA/REVOLUTION_48/AS16-M-2499.lbl","AS16-M-2499.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2500","DATA/REVOLUTION_48/AS16-M-2500.lbl","AS16-M-2500.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2501","DATA/REVOLUTION_59/AS16-M-2501.lbl","AS16-M-2501.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2502","DATA/REVOLUTION_59/AS16-M-2502.lbl","AS16-M-2502.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2503","DATA/REVOLUTION_59/AS16-M-2503.lbl","AS16-M-2503.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2504","DATA/REVOLUTION_59/AS16-M-2504.lbl","AS16-M-2504.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2505","DATA/REVOLUTION_59/AS16-M-2505.lbl","AS16-M-2505.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2506","DATA/REVOLUTION_59/AS16-M-2506.lbl","AS16-M-2506.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2507","DATA/REVOLUTION_59/AS16-M-2507.lbl","AS16-M-2507.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2508","DATA/REVOLUTION_59/AS16-M-2508.lbl","AS16-M-2508.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2509","DATA/REVOLUTION_59/AS16-M-2509.lbl","AS16-M-2509.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2510","DATA/REVOLUTION_59/AS16-M-2510.lbl","AS16-M-2510.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2511","DATA/REVOLUTION_59/AS16-M-2511.lbl","AS16-M-2511.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2512","DATA/REVOLUTION_59/AS16-M-2512.lbl","AS16-M-2512.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2513","DATA/REVOLUTION_59/AS16-M-2513.lbl","AS16-M-2513.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2514","DATA/REVOLUTION_59/AS16-M-2514.lbl","AS16-M-2514.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2515","DATA/REVOLUTION_59/AS16-M-2515.lbl","AS16-M-2515.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2516","DATA/REVOLUTION_59/AS16-M-2516.lbl","AS16-M-2516.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2517","DATA/REVOLUTION_59/AS16-M-2517.lbl","AS16-M-2517.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2518","DATA/REVOLUTION_59/AS16-M-2518.lbl","AS16-M-2518.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2519","DATA/REVOLUTION_59/AS16-M-2519.lbl","AS16-M-2519.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2520","DATA/REVOLUTION_59/AS16-M-2520.lbl","AS16-M-2520.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2521","DATA/REVOLUTION_59/AS16-M-2521.lbl","AS16-M-2521.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2522","DATA/REVOLUTION_59/AS16-M-2522.lbl","AS16-M-2522.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2523","DATA/REVOLUTION_59/AS16-M-2523.lbl","AS16-M-2523.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2524","DATA/REVOLUTION_59/AS16-M-2524.lbl","AS16-M-2524.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2525","DATA/REVOLUTION_59/AS16-M-2525.lbl","AS16-M-2525.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2526","DATA/REVOLUTION_59/AS16-M-2526.lbl","AS16-M-2526.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2527","DATA/REVOLUTION_59/AS16-M-2527.lbl","AS16-M-2527.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2528","DATA/REVOLUTION_59/AS16-M-2528.lbl","AS16-M-2528.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2529","DATA/REVOLUTION_59/AS16-M-2529.lbl","AS16-M-2529.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2530","DATA/REVOLUTION_59/AS16-M-2530.lbl","AS16-M-2530.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2531","DATA/REVOLUTION_59/AS16-M-2531.lbl","AS16-M-2531.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2532","DATA/REVOLUTION_59/AS16-M-2532.lbl","AS16-M-2532.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2692","DATA/REVOLUTION_60/AS16-M-2692.lbl","AS16-M-2692.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2693","DATA/REVOLUTION_60/AS16-M-2693.lbl","AS16-M-2693.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2694","DATA/REVOLUTION_60/AS16-M-2694.lbl","AS16-M-2694.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2695","DATA/REVOLUTION_60/AS16-M-2695.lbl","AS16-M-2695.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2696","DATA/REVOLUTION_60/AS16-M-2696.lbl","AS16-M-2696.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2697","DATA/REVOLUTION_60/AS16-M-2697.lbl","AS16-M-2697.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2698","DATA/REVOLUTION_60/AS16-M-2698.lbl","AS16-M-2698.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2699","DATA/REVOLUTION_60/AS16-M-2699.lbl","AS16-M-2699.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2700","DATA/REVOLUTION_60/AS16-M-2700.lbl","AS16-M-2700.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2701","DATA/REVOLUTION_60/AS16-M-2701.lbl","AS16-M-2701.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2702","DATA/REVOLUTION_60/AS16-M-2702.lbl","AS16-M-2702.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2703","DATA/REVOLUTION_60/AS16-M-2703.lbl","AS16-M-2703.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2704","DATA/REVOLUTION_60/AS16-M-2704.lbl","AS16-M-2704.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2705","DATA/REVOLUTION_60/AS16-M-2705.lbl","AS16-M-2705.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2706","DATA/REVOLUTION_60/AS16-M-2706.lbl","AS16-M-2706.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2707","DATA/REVOLUTION_60/AS16-M-2707.lbl","AS16-M-2707.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2708","DATA/REVOLUTION_60/AS16-M-2708.lbl","AS16-M-2708.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2709","DATA/REVOLUTION_60/AS16-M-2709.lbl","AS16-M-2709.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2710","DATA/REVOLUTION_60/AS16-M-2710.lbl","AS16-M-2710.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2711","DATA/REVOLUTION_60/AS16-M-2711.lbl","AS16-M-2711.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2712","DATA/REVOLUTION_60/AS16-M-2712.lbl","AS16-M-2712.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2713","DATA/REVOLUTION_60/AS16-M-2713.lbl","AS16-M-2713.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2714","DATA/REVOLUTION_60/AS16-M-2714.lbl","AS16-M-2714.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2715","DATA/REVOLUTION_60/AS16-M-2715.lbl","AS16-M-2715.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2716","DATA/REVOLUTION_60/AS16-M-2716.lbl","AS16-M-2716.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2717","DATA/REVOLUTION_60/AS16-M-2717.lbl","AS16-M-2717.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2718","DATA/REVOLUTION_60/AS16-M-2718.lbl","AS16-M-2718.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2719","DATA/REVOLUTION_60/AS16-M-2719.lbl","AS16-M-2719.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2720","DATA/REVOLUTION_60/AS16-M-2720.lbl","AS16-M-2720.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2721","DATA/REVOLUTION_60/AS16-M-2721.lbl","AS16-M-2721.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2722","DATA/REVOLUTION_60/AS16-M-2722.lbl","AS16-M-2722.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2723","DATA/REVOLUTION_60/AS16-M-2723.lbl","AS16-M-2723.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2724","DATA/REVOLUTION_60/AS16-M-2724.lbl","AS16-M-2724.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2725","DATA/REVOLUTION_60/AS16-M-2725.lbl","AS16-M-2725.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2726","DATA/REVOLUTION_60/AS16-M-2726.lbl","AS16-M-2726.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2727","DATA/REVOLUTION_60/AS16-M-2727.lbl","AS16-M-2727.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2728","DATA/REVOLUTION_60/AS16-M-2728.lbl","AS16-M-2728.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2729","DATA/REVOLUTION_60/AS16-M-2729.lbl","AS16-M-2729.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2730","DATA/REVOLUTION_60/AS16-M-2730.lbl","AS16-M-2730.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2731","DATA/REVOLUTION_60/AS16-M-2731.lbl","AS16-M-2731.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2732","DATA/REVOLUTION_60/AS16-M-2732.lbl","AS16-M-2732.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2733","DATA/REVOLUTION_60/AS16-M-2733.lbl","AS16-M-2733.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2734","DATA/REVOLUTION_60/AS16-M-2734.lbl","AS16-M-2734.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2735","DATA/REVOLUTION_60/AS16-M-2735.lbl","AS16-M-2735.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2736","DATA/REVOLUTION_60/AS16-M-2736.lbl","AS16-M-2736.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2737","DATA/REVOLUTION_60/AS16-M-2737.lbl","AS16-M-2737.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2738","DATA/REVOLUTION_60/AS16-M-2738.lbl","AS16-M-2738.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2739","DATA/REVOLUTION_60/AS16-M-2739.lbl","AS16-M-2739.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2740","DATA/REVOLUTION_60/AS16-M-2740.lbl","AS16-M-2740.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2741","DATA/REVOLUTION_60/AS16-M-2741.lbl","AS16-M-2741.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2742","DATA/REVOLUTION_60/AS16-M-2742.lbl","AS16-M-2742.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2743","DATA/REVOLUTION_60/AS16-M-2743.lbl","AS16-M-2743.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2744","DATA/REVOLUTION_60/AS16-M-2744.lbl","AS16-M-2744.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2745","DATA/REVOLUTION_60/AS16-M-2745.lbl","AS16-M-2745.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2746","DATA/REVOLUTION_60/AS16-M-2746.lbl","AS16-M-2746.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2747","DATA/REVOLUTION_60/AS16-M-2747.lbl","AS16-M-2747.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2748","DATA/REVOLUTION_60/AS16-M-2748.lbl","AS16-M-2748.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2749","DATA/REVOLUTION_60/AS16-M-2749.lbl","AS16-M-2749.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2750","DATA/REVOLUTION_60/AS16-M-2750.lbl","AS16-M-2750.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2751","DATA/REVOLUTION_60/AS16-M-2751.lbl","AS16-M-2751.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2752","DATA/REVOLUTION_60/AS16-M-2752.lbl","AS16-M-2752.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2753","DATA/REVOLUTION_60/AS16-M-2753.lbl","AS16-M-2753.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2754","DATA/REVOLUTION_60/AS16-M-2754.lbl","AS16-M-2754.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2755","DATA/REVOLUTION_60/AS16-M-2755.lbl","AS16-M-2755.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2756","DATA/REVOLUTION_60/AS16-M-2756.lbl","AS16-M-2756.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2757","DATA/REVOLUTION_60/AS16-M-2757.lbl","AS16-M-2757.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2758","DATA/REVOLUTION_60/AS16-M-2758.lbl","AS16-M-2758.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2759","DATA/REVOLUTION_60/AS16-M-2759.lbl","AS16-M-2759.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2760","DATA/REVOLUTION_60/AS16-M-2760.lbl","AS16-M-2760.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2761","DATA/REVOLUTION_60/AS16-M-2761.lbl","AS16-M-2761.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2762","DATA/REVOLUTION_60/AS16-M-2762.lbl","AS16-M-2762.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2763","DATA/REVOLUTION_60/AS16-M-2763.lbl","AS16-M-2763.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2764","DATA/REVOLUTION_60/AS16-M-2764.lbl","AS16-M-2764.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2765","DATA/REVOLUTION_60/AS16-M-2765.lbl","AS16-M-2765.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2766","DATA/REVOLUTION_60/AS16-M-2766.lbl","AS16-M-2766.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2767","DATA/REVOLUTION_60/AS16-M-2767.lbl","AS16-M-2767.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2768","DATA/REVOLUTION_60/AS16-M-2768.lbl","AS16-M-2768.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2769","DATA/REVOLUTION_60/AS16-M-2769.lbl","AS16-M-2769.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2770","DATA/REVOLUTION_60/AS16-M-2770.lbl","AS16-M-2770.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2771","DATA/REVOLUTION_60/AS16-M-2771.lbl","AS16-M-2771.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2772","DATA/REVOLUTION_60/AS16-M-2772.lbl","AS16-M-2772.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2773","DATA/REVOLUTION_60/AS16-M-2773.lbl","AS16-M-2773.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2774","DATA/REVOLUTION_60/AS16-M-2774.lbl","AS16-M-2774.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2775","DATA/REVOLUTION_60/AS16-M-2775.lbl","AS16-M-2775.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2776","DATA/REVOLUTION_60/AS16-M-2776.lbl","AS16-M-2776.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2777","DATA/REVOLUTION_60/AS16-M-2777.lbl","AS16-M-2777.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2778","DATA/REVOLUTION_60/AS16-M-2778.lbl","AS16-M-2778.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2779","DATA/REVOLUTION_60/AS16-M-2779.lbl","AS16-M-2779.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2780","DATA/REVOLUTION_60/AS16-M-2780.lbl","AS16-M-2780.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2781","DATA/REVOLUTION_60/AS16-M-2781.lbl","AS16-M-2781.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2782","DATA/REVOLUTION_60/AS16-M-2782.lbl","AS16-M-2782.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2783","DATA/REVOLUTION_60/AS16-M-2783.lbl","AS16-M-2783.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2784","DATA/REVOLUTION_60/AS16-M-2784.lbl","AS16-M-2784.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2785","DATA/REVOLUTION_60/AS16-M-2785.lbl","AS16-M-2785.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2786","DATA/REVOLUTION_60/AS16-M-2786.lbl","AS16-M-2786.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2787","DATA/REVOLUTION_60/AS16-M-2787.lbl","AS16-M-2787.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2788","DATA/REVOLUTION_60/AS16-M-2788.lbl","AS16-M-2788.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2789","DATA/REVOLUTION_60/AS16-M-2789.lbl","AS16-M-2789.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2790","DATA/REVOLUTION_60/AS16-M-2790.lbl","AS16-M-2790.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2791","DATA/REVOLUTION_60/AS16-M-2791.lbl","AS16-M-2791.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2792","DATA/REVOLUTION_60/AS16-M-2792.lbl","AS16-M-2792.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2793","DATA/REVOLUTION_60/AS16-M-2793.lbl","AS16-M-2793.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2794","DATA/REVOLUTION_60/AS16-M-2794.lbl","AS16-M-2794.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2795","DATA/REVOLUTION_60/AS16-M-2795.lbl","AS16-M-2795.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2796","DATA/REVOLUTION_60/AS16-M-2796.lbl","AS16-M-2796.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2797","DATA/REVOLUTION_60/AS16-M-2797.lbl","AS16-M-2797.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2798","DATA/REVOLUTION_60/AS16-M-2798.lbl","AS16-M-2798.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2799","DATA/REVOLUTION_60/AS16-M-2799.lbl","AS16-M-2799.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2800","DATA/REVOLUTION_60/AS16-M-2800.lbl","AS16-M-2800.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2801","DATA/REVOLUTION_60/AS16-M-2801.lbl","AS16-M-2801.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2802","DATA/REVOLUTION_60/AS16-M-2802.lbl","AS16-M-2802.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2803","DATA/REVOLUTION_60/AS16-M-2803.lbl","AS16-M-2803.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2804","DATA/REVOLUTION_60/AS16-M-2804.lbl","AS16-M-2804.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2805","DATA/REVOLUTION_60/AS16-M-2805.lbl","AS16-M-2805.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2806","DATA/REVOLUTION_60/AS16-M-2806.lbl","AS16-M-2806.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2807","DATA/REVOLUTION_60/AS16-M-2807.lbl","AS16-M-2807.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2808","DATA/REVOLUTION_60/AS16-M-2808.lbl","AS16-M-2808.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2809","DATA/REVOLUTION_60/AS16-M-2809.lbl","AS16-M-2809.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2810","DATA/REVOLUTION_60/AS16-M-2810.lbl","AS16-M-2810.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2811","DATA/REVOLUTION_60/AS16-M-2811.lbl","AS16-M-2811.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2812","DATA/REVOLUTION_60/AS16-M-2812.lbl","AS16-M-2812.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2813","DATA/REVOLUTION_60/AS16-M-2813.lbl","AS16-M-2813.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2814","DATA/REVOLUTION_60/AS16-M-2814.lbl","AS16-M-2814.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2815","DATA/REVOLUTION_60/AS16-M-2815.lbl","AS16-M-2815.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2816","DATA/REVOLUTION_60/AS16-M-2816.lbl","AS16-M-2816.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2817","DATA/REVOLUTION_60/AS16-M-2817.lbl","AS16-M-2817.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2818","DATA/REVOLUTION_60/AS16-M-2818.lbl","AS16-M-2818.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2819","DATA/REVOLUTION_60/AS16-M-2819.lbl","AS16-M-2819.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2820","DATA/REVOLUTION_60/AS16-M-2820.lbl","AS16-M-2820.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2821","DATA/REVOLUTION_60/AS16-M-2821.lbl","AS16-M-2821.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2822","DATA/REVOLUTION_60/AS16-M-2822.lbl","AS16-M-2822.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2823","DATA/REVOLUTION_60/AS16-M-2823.lbl","AS16-M-2823.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2824","DATA/REVOLUTION_60/AS16-M-2824.lbl","AS16-M-2824.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2825","DATA/REVOLUTION_60/AS16-M-2825.lbl","AS16-M-2825.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2826","DATA/REVOLUTION_60/AS16-M-2826.lbl","AS16-M-2826.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2827","DATA/REVOLUTION_60/AS16-M-2827.lbl","AS16-M-2827.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2828","DATA/REVOLUTION_60/AS16-M-2828.lbl","AS16-M-2828.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2829","DATA/REVOLUTION_60/AS16-M-2829.lbl","AS16-M-2829.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2830","DATA/REVOLUTION_60/AS16-M-2830.lbl","AS16-M-2830.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2831","DATA/REVOLUTION_60/AS16-M-2831.lbl","AS16-M-2831.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2832","DATA/REVOLUTION_60/AS16-M-2832.lbl","AS16-M-2832.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2833","DATA/REVOLUTION_60/AS16-M-2833.lbl","AS16-M-2833.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2834","DATA/REVOLUTION_60/AS16-M-2834.lbl","AS16-M-2834.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2835","DATA/REVOLUTION_60/AS16-M-2835.lbl","AS16-M-2835.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2836","DATA/REVOLUTION_60/AS16-M-2836.lbl","AS16-M-2836.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2837","DATA/REVOLUTION_60/AS16-M-2837.lbl","AS16-M-2837.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2838","DATA/REVOLUTION_60/AS16-M-2838.lbl","AS16-M-2838.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2839","DATA/REVOLUTION_60/AS16-M-2839.lbl","AS16-M-2839.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2840","DATA/REVOLUTION_60/AS16-M-2840.lbl","AS16-M-2840.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2841","DATA/REVOLUTION_60/AS16-M-2841.lbl","AS16-M-2841.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2842","DATA/REVOLUTION_60/AS16-M-2842.lbl","AS16-M-2842.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2843","DATA/REVOLUTION_60/AS16-M-2843.lbl","AS16-M-2843.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2844","DATA/REVOLUTION_60/AS16-M-2844.lbl","AS16-M-2844.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2845","DATA/REVOLUTION_60/AS16-M-2845.lbl","AS16-M-2845.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2852","DATA/REVOLUTION_63/AS16-M-2852.lbl","AS16-M-2852.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2853","DATA/REVOLUTION_63/AS16-M-2853.lbl","AS16-M-2853.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2854","DATA/REVOLUTION_63/AS16-M-2854.lbl","AS16-M-2854.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2855","DATA/REVOLUTION_63/AS16-M-2855.lbl","AS16-M-2855.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2856","DATA/REVOLUTION_63/AS16-M-2856.lbl","AS16-M-2856.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2857","DATA/REVOLUTION_63/AS16-M-2857.lbl","AS16-M-2857.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2858","DATA/REVOLUTION_63/AS16-M-2858.lbl","AS16-M-2858.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2859","DATA/REVOLUTION_63/AS16-M-2859.lbl","AS16-M-2859.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2860","DATA/REVOLUTION_63/AS16-M-2860.lbl","AS16-M-2860.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2861","DATA/REVOLUTION_63/AS16-M-2861.lbl","AS16-M-2861.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2862","DATA/REVOLUTION_63/AS16-M-2862.lbl","AS16-M-2862.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2863","DATA/REVOLUTION_63/AS16-M-2863.lbl","AS16-M-2863.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2864","DATA/REVOLUTION_63/AS16-M-2864.lbl","AS16-M-2864.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2865","DATA/REVOLUTION_63/AS16-M-2865.lbl","AS16-M-2865.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2866","DATA/REVOLUTION_63/AS16-M-2866.lbl","AS16-M-2866.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2867","DATA/REVOLUTION_63/AS16-M-2867.lbl","AS16-M-2867.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2868","DATA/REVOLUTION_63/AS16-M-2868.lbl","AS16-M-2868.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2869","DATA/REVOLUTION_63/AS16-M-2869.lbl","AS16-M-2869.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2870","DATA/REVOLUTION_63/AS16-M-2870.lbl","AS16-M-2870.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2871","DATA/REVOLUTION_63/AS16-M-2871.lbl","AS16-M-2871.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2872","DATA/REVOLUTION_63/AS16-M-2872.lbl","AS16-M-2872.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2873","DATA/REVOLUTION_63/AS16-M-2873.lbl","AS16-M-2873.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2874","DATA/REVOLUTION_63/AS16-M-2874.lbl","AS16-M-2874.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2875","DATA/REVOLUTION_63/AS16-M-2875.lbl","AS16-M-2875.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2876","DATA/REVOLUTION_63/AS16-M-2876.lbl","AS16-M-2876.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2877","DATA/REVOLUTION_63/AS16-M-2877.lbl","AS16-M-2877.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2878","DATA/REVOLUTION_63/AS16-M-2878.lbl","AS16-M-2878.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2879","DATA/REVOLUTION_63/AS16-M-2879.lbl","AS16-M-2879.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2880","DATA/REVOLUTION_63/AS16-M-2880.lbl","AS16-M-2880.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2881","DATA/REVOLUTION_63/AS16-M-2881.lbl","AS16-M-2881.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2882","DATA/REVOLUTION_63/AS16-M-2882.lbl","AS16-M-2882.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2883","DATA/REVOLUTION_63/AS16-M-2883.lbl","AS16-M-2883.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2884","DATA/REVOLUTION_63/AS16-M-2884.lbl","AS16-M-2884.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2885","DATA/REVOLUTION_63/AS16-M-2885.lbl","AS16-M-2885.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2886","DATA/REVOLUTION_63/AS16-M-2886.lbl","AS16-M-2886.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2887","DATA/REVOLUTION_63/AS16-M-2887.lbl","AS16-M-2887.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2888","DATA/REVOLUTION_63/AS16-M-2888.lbl","AS16-M-2888.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2889","DATA/REVOLUTION_63/AS16-M-2889.lbl","AS16-M-2889.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2890","DATA/REVOLUTION_63/AS16-M-2890.lbl","AS16-M-2890.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2891","DATA/REVOLUTION_63/AS16-M-2891.lbl","AS16-M-2891.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2892","DATA/REVOLUTION_63/AS16-M-2892.lbl","AS16-M-2892.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2893","DATA/REVOLUTION_63/AS16-M-2893.lbl","AS16-M-2893.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2894","DATA/REVOLUTION_63/AS16-M-2894.lbl","AS16-M-2894.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2895","DATA/REVOLUTION_63/AS16-M-2895.lbl","AS16-M-2895.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2896","DATA/REVOLUTION_63/AS16-M-2896.lbl","AS16-M-2896.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2897","DATA/REVOLUTION_63/AS16-M-2897.lbl","AS16-M-2897.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2898","DATA/REVOLUTION_63/AS16-M-2898.lbl","AS16-M-2898.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2899","DATA/REVOLUTION_63/AS16-M-2899.lbl","AS16-M-2899.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2900","DATA/REVOLUTION_63/AS16-M-2900.lbl","AS16-M-2900.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2901","DATA/REVOLUTION_63/AS16-M-2901.lbl","AS16-M-2901.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2902","DATA/REVOLUTION_63/AS16-M-2902.lbl","AS16-M-2902.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2903","DATA/REVOLUTION_63/AS16-M-2903.lbl","AS16-M-2903.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2904","DATA/REVOLUTION_63/AS16-M-2904.lbl","AS16-M-2904.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2905","DATA/REVOLUTION_63/AS16-M-2905.lbl","AS16-M-2905.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2906","DATA/REVOLUTION_63/AS16-M-2906.lbl","AS16-M-2906.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2907","DATA/REVOLUTION_63/AS16-M-2907.lbl","AS16-M-2907.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2908","DATA/REVOLUTION_63/AS16-M-2908.lbl","AS16-M-2908.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2909","DATA/REVOLUTION_63/AS16-M-2909.lbl","AS16-M-2909.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2910","DATA/REVOLUTION_63/AS16-M-2910.lbl","AS16-M-2910.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2911","DATA/REVOLUTION_63/AS16-M-2911.lbl","AS16-M-2911.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2912","DATA/REVOLUTION_63/AS16-M-2912.lbl","AS16-M-2912.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2913","DATA/REVOLUTION_63/AS16-M-2913.lbl","AS16-M-2913.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2914","DATA/REVOLUTION_63/AS16-M-2914.lbl","AS16-M-2914.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2915","DATA/REVOLUTION_63/AS16-M-2915.lbl","AS16-M-2915.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2916","DATA/REVOLUTION_63/AS16-M-2916.lbl","AS16-M-2916.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2917","DATA/REVOLUTION_63/AS16-M-2917.lbl","AS16-M-2917.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2918","DATA/REVOLUTION_63/AS16-M-2918.lbl","AS16-M-2918.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2919","DATA/REVOLUTION_63/AS16-M-2919.lbl","AS16-M-2919.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2920","DATA/REVOLUTION_63/AS16-M-2920.lbl","AS16-M-2920.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2921","DATA/REVOLUTION_63/AS16-M-2921.lbl","AS16-M-2921.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2922","DATA/REVOLUTION_63/AS16-M-2922.lbl","AS16-M-2922.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2923","DATA/REVOLUTION_63/AS16-M-2923.lbl","AS16-M-2923.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2924","DATA/REVOLUTION_63/AS16-M-2924.lbl","AS16-M-2924.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2925","DATA/REVOLUTION_63/AS16-M-2925.lbl","AS16-M-2925.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2926","DATA/REVOLUTION_63/AS16-M-2926.lbl","AS16-M-2926.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2927","DATA/REVOLUTION_63/AS16-M-2927.lbl","AS16-M-2927.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2928","DATA/REVOLUTION_63/AS16-M-2928.lbl","AS16-M-2928.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2929","DATA/REVOLUTION_63/AS16-M-2929.lbl","AS16-M-2929.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2930","DATA/REVOLUTION_63/AS16-M-2930.lbl","AS16-M-2930.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2931","DATA/REVOLUTION_63/AS16-M-2931.lbl","AS16-M-2931.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2932","DATA/REVOLUTION_63/AS16-M-2932.lbl","AS16-M-2932.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2933","DATA/REVOLUTION_63/AS16-M-2933.lbl","AS16-M-2933.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2934","DATA/REVOLUTION_63/AS16-M-2934.lbl","AS16-M-2934.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2935","DATA/REVOLUTION_63/AS16-M-2935.lbl","AS16-M-2935.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2936","DATA/REVOLUTION_63/AS16-M-2936.lbl","AS16-M-2936.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2937","DATA/REVOLUTION_63/AS16-M-2937.lbl","AS16-M-2937.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2938","DATA/REVOLUTION_63/AS16-M-2938.lbl","AS16-M-2938.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2939","DATA/REVOLUTION_63/AS16-M-2939.lbl","AS16-M-2939.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2940","DATA/REVOLUTION_63/AS16-M-2940.lbl","AS16-M-2940.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2941","DATA/REVOLUTION_63/AS16-M-2941.lbl","AS16-M-2941.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2942","DATA/REVOLUTION_63/AS16-M-2942.lbl","AS16-M-2942.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2943","DATA/REVOLUTION_63/AS16-M-2943.lbl","AS16-M-2943.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2944","DATA/REVOLUTION_63/AS16-M-2944.lbl","AS16-M-2944.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2945","DATA/REVOLUTION_63/AS16-M-2945.lbl","AS16-M-2945.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2946","DATA/REVOLUTION_63/AS16-M-2946.lbl","AS16-M-2946.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2947","DATA/REVOLUTION_63/AS16-M-2947.lbl","AS16-M-2947.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2948","DATA/REVOLUTION_63/AS16-M-2948.lbl","AS16-M-2948.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2949","DATA/REVOLUTION_63/AS16-M-2949.lbl","AS16-M-2949.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2950","DATA/REVOLUTION_63/AS16-M-2950.lbl","AS16-M-2950.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2951","DATA/REVOLUTION_63/AS16-M-2951.lbl","AS16-M-2951.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2952","DATA/REVOLUTION_63/AS16-M-2952.lbl","AS16-M-2952.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2953","DATA/REVOLUTION_63/AS16-M-2953.lbl","AS16-M-2953.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2954","DATA/REVOLUTION_63/AS16-M-2954.lbl","AS16-M-2954.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2955","DATA/REVOLUTION_63/AS16-M-2955.lbl","AS16-M-2955.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2956","DATA/REVOLUTION_63/AS16-M-2956.lbl","AS16-M-2956.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2957","DATA/REVOLUTION_63/AS16-M-2957.lbl","AS16-M-2957.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2958","DATA/REVOLUTION_63/AS16-M-2958.lbl","AS16-M-2958.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2959","DATA/REVOLUTION_63/AS16-M-2959.lbl","AS16-M-2959.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2960","DATA/REVOLUTION_63/AS16-M-2960.lbl","AS16-M-2960.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2961","DATA/REVOLUTION_63/AS16-M-2961.lbl","AS16-M-2961.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2962","DATA/REVOLUTION_63/AS16-M-2962.lbl","AS16-M-2962.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2963","DATA/REVOLUTION_63/AS16-M-2963.lbl","AS16-M-2963.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2964","DATA/REVOLUTION_63/AS16-M-2964.lbl","AS16-M-2964.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2965","DATA/REVOLUTION_63/AS16-M-2965.lbl","AS16-M-2965.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2966","DATA/REVOLUTION_63/AS16-M-2966.lbl","AS16-M-2966.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2967","DATA/REVOLUTION_63/AS16-M-2967.lbl","AS16-M-2967.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2968","DATA/REVOLUTION_63/AS16-M-2968.lbl","AS16-M-2968.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2969","DATA/REVOLUTION_63/AS16-M-2969.lbl","AS16-M-2969.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2970","DATA/REVOLUTION_63/AS16-M-2970.lbl","AS16-M-2970.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2971","DATA/REVOLUTION_63/AS16-M-2971.lbl","AS16-M-2971.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2972","DATA/REVOLUTION_63/AS16-M-2972.lbl","AS16-M-2972.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2973","DATA/REVOLUTION_63/AS16-M-2973.lbl","AS16-M-2973.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2974","DATA/REVOLUTION_63/AS16-M-2974.lbl","AS16-M-2974.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2975","DATA/REVOLUTION_63/AS16-M-2975.lbl","AS16-M-2975.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2976","DATA/REVOLUTION_63/AS16-M-2976.lbl","AS16-M-2976.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2977","DATA/REVOLUTION_63/AS16-M-2977.lbl","AS16-M-2977.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2978","DATA/REVOLUTION_63/AS16-M-2978.lbl","AS16-M-2978.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2979","DATA/REVOLUTION_63/AS16-M-2979.lbl","AS16-M-2979.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2980","DATA/REVOLUTION_63/AS16-M-2980.lbl","AS16-M-2980.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2981","DATA/REVOLUTION_63/AS16-M-2981.lbl","AS16-M-2981.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2982","DATA/REVOLUTION_63/AS16-M-2982.lbl","AS16-M-2982.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2983","DATA/REVOLUTION_63/AS16-M-2983.lbl","AS16-M-2983.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2984","DATA/REVOLUTION_63/AS16-M-2984.lbl","AS16-M-2984.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2985","DATA/REVOLUTION_63/AS16-M-2985.lbl","AS16-M-2985.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2986","DATA/REVOLUTION_63/AS16-M-2986.lbl","AS16-M-2986.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2987","DATA/REVOLUTION_63/AS16-M-2987.lbl","AS16-M-2987.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2988","DATA/REVOLUTION_63/AS16-M-2988.lbl","AS16-M-2988.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2989","DATA/REVOLUTION_63/AS16-M-2989.lbl","AS16-M-2989.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2990","DATA/REVOLUTION_63/AS16-M-2990.lbl","AS16-M-2990.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2991","DATA/REVOLUTION_63/AS16-M-2991.lbl","AS16-M-2991.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2992","DATA/REVOLUTION_63/AS16-M-2992.lbl","AS16-M-2992.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2993","DATA/REVOLUTION_63/AS16-M-2993.lbl","AS16-M-2993.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2994","DATA/REVOLUTION_63/AS16-M-2994.lbl","AS16-M-2994.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2995","DATA/REVOLUTION_63/AS16-M-2995.lbl","AS16-M-2995.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2996","DATA/REVOLUTION_63/AS16-M-2996.lbl","AS16-M-2996.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2997","DATA/REVOLUTION_63/AS16-M-2997.lbl","AS16-M-2997.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2998","DATA/REVOLUTION_63/AS16-M-2998.lbl","AS16-M-2998.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-2999","DATA/REVOLUTION_63/AS16-M-2999.lbl","AS16-M-2999.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3000","DATA/REVOLUTION_TE/AS16-M-3000.lbl","AS16-M-3000.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3001","DATA/REVOLUTION_TE/AS16-M-3001.lbl","AS16-M-3001.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3002","DATA/REVOLUTION_TE/AS16-M-3002.lbl","AS16-M-3002.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3003","DATA/REVOLUTION_TE/AS16-M-3003.lbl","AS16-M-3003.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3004","DATA/REVOLUTION_TE/AS16-M-3004.lbl","AS16-M-3004.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3005","DATA/REVOLUTION_TE/AS16-M-3005.lbl","AS16-M-3005.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3006","DATA/REVOLUTION_TE/AS16-M-3006.lbl","AS16-M-3006.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3007","DATA/REVOLUTION_TE/AS16-M-3007.lbl","AS16-M-3007.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3008","DATA/REVOLUTION_TE/AS16-M-3008.lbl","AS16-M-3008.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3009","DATA/REVOLUTION_TE/AS16-M-3009.lbl","AS16-M-3009.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3010","DATA/REVOLUTION_TE/AS16-M-3010.lbl","AS16-M-3010.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3011","DATA/REVOLUTION_TE/AS16-M-3011.lbl","AS16-M-3011.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3012","DATA/REVOLUTION_TE/AS16-M-3012.lbl","AS16-M-3012.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3013","DATA/REVOLUTION_TE/AS16-M-3013.lbl","AS16-M-3013.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3014","DATA/REVOLUTION_TE/AS16-M-3014.lbl","AS16-M-3014.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3015","DATA/REVOLUTION_TE/AS16-M-3015.lbl","AS16-M-3015.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3016","DATA/REVOLUTION_TE/AS16-M-3016.lbl","AS16-M-3016.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3017","DATA/REVOLUTION_TE/AS16-M-3017.lbl","AS16-M-3017.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3018","DATA/REVOLUTION_TE/AS16-M-3018.lbl","AS16-M-3018.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3019","DATA/REVOLUTION_TE/AS16-M-3019.lbl","AS16-M-3019.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3020","DATA/REVOLUTION_TE/AS16-M-3020.lbl","AS16-M-3020.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3021","DATA/REVOLUTION_TE/AS16-M-3021.lbl","AS16-M-3021.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3022","DATA/REVOLUTION_TE/AS16-M-3022.lbl","AS16-M-3022.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3023","DATA/REVOLUTION_TE/AS16-M-3023.lbl","AS16-M-3023.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3024","DATA/REVOLUTION_TE/AS16-M-3024.lbl","AS16-M-3024.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3025","DATA/REVOLUTION_TE/AS16-M-3025.lbl","AS16-M-3025.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3026","DATA/REVOLUTION_TE/AS16-M-3026.lbl","AS16-M-3026.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3027","DATA/REVOLUTION_TE/AS16-M-3027.lbl","AS16-M-3027.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3028","DATA/REVOLUTION_TE/AS16-M-3028.lbl","AS16-M-3028.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3029","DATA/REVOLUTION_TE/AS16-M-3029.lbl","AS16-M-3029.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3030","DATA/REVOLUTION_TE/AS16-M-3030.lbl","AS16-M-3030.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3031","DATA/REVOLUTION_TE/AS16-M-3031.lbl","AS16-M-3031.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3032","DATA/REVOLUTION_TE/AS16-M-3032.lbl","AS16-M-3032.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3033","DATA/REVOLUTION_TE/AS16-M-3033.lbl","AS16-M-3033.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3034","DATA/REVOLUTION_TE/AS16-M-3034.lbl","AS16-M-3034.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3035","DATA/REVOLUTION_TE/AS16-M-3035.lbl","AS16-M-3035.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3036","DATA/REVOLUTION_TE/AS16-M-3036.lbl","AS16-M-3036.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3037","DATA/REVOLUTION_TE/AS16-M-3037.lbl","AS16-M-3037.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3038","DATA/REVOLUTION_TE/AS16-M-3038.lbl","AS16-M-3038.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3039","DATA/REVOLUTION_TE/AS16-M-3039.lbl","AS16-M-3039.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3040","DATA/REVOLUTION_TE/AS16-M-3040.lbl","AS16-M-3040.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3041","DATA/REVOLUTION_TE/AS16-M-3041.lbl","AS16-M-3041.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3042","DATA/REVOLUTION_TE/AS16-M-3042.lbl","AS16-M-3042.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3043","DATA/REVOLUTION_TE/AS16-M-3043.lbl","AS16-M-3043.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3044","DATA/REVOLUTION_TE/AS16-M-3044.lbl","AS16-M-3044.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3045","DATA/REVOLUTION_TE/AS16-M-3045.lbl","AS16-M-3045.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3046","DATA/REVOLUTION_TE/AS16-M-3046.lbl","AS16-M-3046.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3047","DATA/REVOLUTION_TE/AS16-M-3047.lbl","AS16-M-3047.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3048","DATA/REVOLUTION_TE/AS16-M-3048.lbl","AS16-M-3048.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3049","DATA/REVOLUTION_TE/AS16-M-3049.lbl","AS16-M-3049.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3050","DATA/REVOLUTION_TE/AS16-M-3050.lbl","AS16-M-3050.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3051","DATA/REVOLUTION_TE/AS16-M-3051.lbl","AS16-M-3051.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3052","DATA/REVOLUTION_TE/AS16-M-3052.lbl","AS16-M-3052.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3053","DATA/REVOLUTION_TE/AS16-M-3053.lbl","AS16-M-3053.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3054","DATA/REVOLUTION_TE/AS16-M-3054.lbl","AS16-M-3054.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3055","DATA/REVOLUTION_TE/AS16-M-3055.lbl","AS16-M-3055.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3056","DATA/REVOLUTION_TE/AS16-M-3056.lbl","AS16-M-3056.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3057","DATA/REVOLUTION_TE/AS16-M-3057.lbl","AS16-M-3057.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3058","DATA/REVOLUTION_TE/AS16-M-3058.lbl","AS16-M-3058.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3059","DATA/REVOLUTION_TE/AS16-M-3059.lbl","AS16-M-3059.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3060","DATA/REVOLUTION_TE/AS16-M-3060.lbl","AS16-M-3060.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3061","DATA/REVOLUTION_TE/AS16-M-3061.lbl","AS16-M-3061.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3062","DATA/REVOLUTION_TE/AS16-M-3062.lbl","AS16-M-3062.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3063","DATA/REVOLUTION_TE/AS16-M-3063.lbl","AS16-M-3063.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3064","DATA/REVOLUTION_TE/AS16-M-3064.lbl","AS16-M-3064.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3065","DATA/REVOLUTION_TE/AS16-M-3065.lbl","AS16-M-3065.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3066","DATA/REVOLUTION_TE/AS16-M-3066.lbl","AS16-M-3066.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3067","DATA/REVOLUTION_TE/AS16-M-3067.lbl","AS16-M-3067.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3068","DATA/REVOLUTION_TE/AS16-M-3068.lbl","AS16-M-3068.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3069","DATA/REVOLUTION_TE/AS16-M-3069.lbl","AS16-M-3069.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3070","DATA/REVOLUTION_TE/AS16-M-3070.lbl","AS16-M-3070.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3071","DATA/REVOLUTION_TE/AS16-M-3071.lbl","AS16-M-3071.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3072","DATA/REVOLUTION_TE/AS16-M-3072.lbl","AS16-M-3072.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3073","DATA/REVOLUTION_TE/AS16-M-3073.lbl","AS16-M-3073.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3074","DATA/REVOLUTION_TE/AS16-M-3074.lbl","AS16-M-3074.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3075","DATA/REVOLUTION_TE/AS16-M-3075.lbl","AS16-M-3075.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3076","DATA/REVOLUTION_TE/AS16-M-3076.lbl","AS16-M-3076.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3077","DATA/REVOLUTION_TE/AS16-M-3077.lbl","AS16-M-3077.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3078","DATA/REVOLUTION_TE/AS16-M-3078.lbl","AS16-M-3078.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3079","DATA/REVOLUTION_TE/AS16-M-3079.lbl","AS16-M-3079.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3080","DATA/REVOLUTION_TE/AS16-M-3080.lbl","AS16-M-3080.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3081","DATA/REVOLUTION_TE/AS16-M-3081.lbl","AS16-M-3081.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3082","DATA/REVOLUTION_TE/AS16-M-3082.lbl","AS16-M-3082.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3083","DATA/REVOLUTION_TE/AS16-M-3083.lbl","AS16-M-3083.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3084","DATA/REVOLUTION_TE/AS16-M-3084.lbl","AS16-M-3084.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3085","DATA/REVOLUTION_TE/AS16-M-3085.lbl","AS16-M-3085.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3086","DATA/REVOLUTION_TE/AS16-M-3086.lbl","AS16-M-3086.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3087","DATA/REVOLUTION_TE/AS16-M-3087.lbl","AS16-M-3087.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3088","DATA/REVOLUTION_TE/AS16-M-3088.lbl","AS16-M-3088.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3089","DATA/REVOLUTION_TE/AS16-M-3089.lbl","AS16-M-3089.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3090","DATA/REVOLUTION_TE/AS16-M-3090.lbl","AS16-M-3090.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3091","DATA/REVOLUTION_TE/AS16-M-3091.lbl","AS16-M-3091.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3092","DATA/REVOLUTION_TE/AS16-M-3092.lbl","AS16-M-3092.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3093","DATA/REVOLUTION_TE/AS16-M-3093.lbl","AS16-M-3093.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3094","DATA/REVOLUTION_TE/AS16-M-3094.lbl","AS16-M-3094.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3095","DATA/REVOLUTION_TE/AS16-M-3095.lbl","AS16-M-3095.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3096","DATA/REVOLUTION_TE/AS16-M-3096.lbl","AS16-M-3096.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3097","DATA/REVOLUTION_TE/AS16-M-3097.lbl","AS16-M-3097.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3098","DATA/REVOLUTION_TE/AS16-M-3098.lbl","AS16-M-3098.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3099","DATA/REVOLUTION_TE/AS16-M-3099.lbl","AS16-M-3099.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3100","DATA/REVOLUTION_TE/AS16-M-3100.lbl","AS16-M-3100.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3101","DATA/REVOLUTION_TE/AS16-M-3101.lbl","AS16-M-3101.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3102","DATA/REVOLUTION_TE/AS16-M-3102.lbl","AS16-M-3102.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3103","DATA/REVOLUTION_TE/AS16-M-3103.lbl","AS16-M-3103.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3104","DATA/REVOLUTION_TE/AS16-M-3104.lbl","AS16-M-3104.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3105","DATA/REVOLUTION_TE/AS16-M-3105.lbl","AS16-M-3105.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3106","DATA/REVOLUTION_TE/AS16-M-3106.lbl","AS16-M-3106.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3107","DATA/REVOLUTION_TE/AS16-M-3107.lbl","AS16-M-3107.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3108","DATA/REVOLUTION_TE/AS16-M-3108.lbl","AS16-M-3108.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3109","DATA/REVOLUTION_TE/AS16-M-3109.lbl","AS16-M-3109.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3110","DATA/REVOLUTION_TE/AS16-M-3110.lbl","AS16-M-3110.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3111","DATA/REVOLUTION_TE/AS16-M-3111.lbl","AS16-M-3111.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3112","DATA/REVOLUTION_TE/AS16-M-3112.lbl","AS16-M-3112.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3113","DATA/REVOLUTION_TE/AS16-M-3113.lbl","AS16-M-3113.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3114","DATA/REVOLUTION_TE/AS16-M-3114.lbl","AS16-M-3114.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3115","DATA/REVOLUTION_TE/AS16-M-3115.lbl","AS16-M-3115.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3116","DATA/REVOLUTION_TE/AS16-M-3116.lbl","AS16-M-3116.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3117","DATA/REVOLUTION_TE/AS16-M-3117.lbl","AS16-M-3117.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3118","DATA/REVOLUTION_TE/AS16-M-3118.lbl","AS16-M-3118.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3119","DATA/REVOLUTION_TE/AS16-M-3119.lbl","AS16-M-3119.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3120","DATA/REVOLUTION_TE/AS16-M-3120.lbl","AS16-M-3120.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3121","DATA/REVOLUTION_TE/AS16-M-3121.lbl","AS16-M-3121.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3122","DATA/REVOLUTION_TE/AS16-M-3122.lbl","AS16-M-3122.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3123","DATA/REVOLUTION_TE/AS16-M-3123.lbl","AS16-M-3123.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3124","DATA/REVOLUTION_TE/AS16-M-3124.lbl","AS16-M-3124.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3125","DATA/REVOLUTION_TE/AS16-M-3125.lbl","AS16-M-3125.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3126","DATA/REVOLUTION_TE/AS16-M-3126.lbl","AS16-M-3126.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3127","DATA/REVOLUTION_TE/AS16-M-3127.lbl","AS16-M-3127.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3128","DATA/REVOLUTION_TE/AS16-M-3128.lbl","AS16-M-3128.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3129","DATA/REVOLUTION_TE/AS16-M-3129.lbl","AS16-M-3129.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3130","DATA/REVOLUTION_TE/AS16-M-3130.lbl","AS16-M-3130.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3131","DATA/REVOLUTION_TE/AS16-M-3131.lbl","AS16-M-3131.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3132","DATA/REVOLUTION_TE/AS16-M-3132.lbl","AS16-M-3132.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3133","DATA/REVOLUTION_TE/AS16-M-3133.lbl","AS16-M-3133.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3134","DATA/REVOLUTION_TE/AS16-M-3134.lbl","AS16-M-3134.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3135","DATA/REVOLUTION_TE/AS16-M-3135.lbl","AS16-M-3135.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3136","DATA/REVOLUTION_TE/AS16-M-3136.lbl","AS16-M-3136.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3137","DATA/REVOLUTION_TE/AS16-M-3137.lbl","AS16-M-3137.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3138","DATA/REVOLUTION_TE/AS16-M-3138.lbl","AS16-M-3138.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3139","DATA/REVOLUTION_TE/AS16-M-3139.lbl","AS16-M-3139.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3140","DATA/REVOLUTION_TE/AS16-M-3140.lbl","AS16-M-3140.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3141","DATA/REVOLUTION_TE/AS16-M-3141.lbl","AS16-M-3141.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3142","DATA/REVOLUTION_TE/AS16-M-3142.lbl","AS16-M-3142.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3143","DATA/REVOLUTION_TE/AS16-M-3143.lbl","AS16-M-3143.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3144","DATA/REVOLUTION_TE/AS16-M-3144.lbl","AS16-M-3144.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3145","DATA/REVOLUTION_TE/AS16-M-3145.lbl","AS16-M-3145.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3146","DATA/REVOLUTION_TE/AS16-M-3146.lbl","AS16-M-3146.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3147","DATA/REVOLUTION_TE/AS16-M-3147.lbl","AS16-M-3147.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3148","DATA/REVOLUTION_TE/AS16-M-3148.lbl","AS16-M-3148.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3149","DATA/REVOLUTION_TE/AS16-M-3149.lbl","AS16-M-3149.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3150","DATA/REVOLUTION_TE/AS16-M-3150.lbl","AS16-M-3150.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3151","DATA/REVOLUTION_TE/AS16-M-3151.lbl","AS16-M-3151.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3152","DATA/REVOLUTION_TE/AS16-M-3152.lbl","AS16-M-3152.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3153","DATA/REVOLUTION_TE/AS16-M-3153.lbl","AS16-M-3153.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3154","DATA/REVOLUTION_TE/AS16-M-3154.lbl","AS16-M-3154.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3155","DATA/REVOLUTION_TE/AS16-M-3155.lbl","AS16-M-3155.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3156","DATA/REVOLUTION_TE/AS16-M-3156.lbl","AS16-M-3156.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3157","DATA/REVOLUTION_TE/AS16-M-3157.lbl","AS16-M-3157.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3158","DATA/REVOLUTION_TE/AS16-M-3158.lbl","AS16-M-3158.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3159","DATA/REVOLUTION_TE/AS16-M-3159.lbl","AS16-M-3159.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3160","DATA/REVOLUTION_TE/AS16-M-3160.lbl","AS16-M-3160.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3161","DATA/REVOLUTION_TE/AS16-M-3161.lbl","AS16-M-3161.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3162","DATA/REVOLUTION_TE/AS16-M-3162.lbl","AS16-M-3162.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3163","DATA/REVOLUTION_TE/AS16-M-3163.lbl","AS16-M-3163.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3164","DATA/REVOLUTION_TE/AS16-M-3164.lbl","AS16-M-3164.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3165","DATA/REVOLUTION_TE/AS16-M-3165.lbl","AS16-M-3165.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3166","DATA/REVOLUTION_TE/AS16-M-3166.lbl","AS16-M-3166.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3167","DATA/REVOLUTION_TE/AS16-M-3167.lbl","AS16-M-3167.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3168","DATA/REVOLUTION_TE/AS16-M-3168.lbl","AS16-M-3168.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3169","DATA/REVOLUTION_TE/AS16-M-3169.lbl","AS16-M-3169.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3170","DATA/REVOLUTION_TE/AS16-M-3170.lbl","AS16-M-3170.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3171","DATA/REVOLUTION_TE/AS16-M-3171.lbl","AS16-M-3171.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3172","DATA/REVOLUTION_TE/AS16-M-3172.lbl","AS16-M-3172.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3173","DATA/REVOLUTION_TE/AS16-M-3173.lbl","AS16-M-3173.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3174","DATA/REVOLUTION_TE/AS16-M-3174.lbl","AS16-M-3174.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3175","DATA/REVOLUTION_TE/AS16-M-3175.lbl","AS16-M-3175.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3176","DATA/REVOLUTION_TE/AS16-M-3176.lbl","AS16-M-3176.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3177","DATA/REVOLUTION_TE/AS16-M-3177.lbl","AS16-M-3177.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3178","DATA/REVOLUTION_TE/AS16-M-3178.lbl","AS16-M-3178.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3179","DATA/REVOLUTION_TE/AS16-M-3179.lbl","AS16-M-3179.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3180","DATA/REVOLUTION_TE/AS16-M-3180.lbl","AS16-M-3180.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3181","DATA/REVOLUTION_TE/AS16-M-3181.lbl","AS16-M-3181.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3182","DATA/REVOLUTION_TE/AS16-M-3182.lbl","AS16-M-3182.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3183","DATA/REVOLUTION_TE/AS16-M-3183.lbl","AS16-M-3183.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3184","DATA/REVOLUTION_TE/AS16-M-3184.lbl","AS16-M-3184.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3185","DATA/REVOLUTION_TE/AS16-M-3185.lbl","AS16-M-3185.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3186","DATA/REVOLUTION_TE/AS16-M-3186.lbl","AS16-M-3186.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3187","DATA/REVOLUTION_TE/AS16-M-3187.lbl","AS16-M-3187.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3188","DATA/REVOLUTION_TE/AS16-M-3188.lbl","AS16-M-3188.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3189","DATA/REVOLUTION_TE/AS16-M-3189.lbl","AS16-M-3189.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3190","DATA/REVOLUTION_TE/AS16-M-3190.lbl","AS16-M-3190.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3191","DATA/REVOLUTION_TE/AS16-M-3191.lbl","AS16-M-3191.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3192","DATA/REVOLUTION_TE/AS16-M-3192.lbl","AS16-M-3192.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3193","DATA/REVOLUTION_TE/AS16-M-3193.lbl","AS16-M-3193.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3194","DATA/REVOLUTION_TE/AS16-M-3194.lbl","AS16-M-3194.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3195","DATA/REVOLUTION_TE/AS16-M-3195.lbl","AS16-M-3195.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3196","DATA/REVOLUTION_TE/AS16-M-3196.lbl","AS16-M-3196.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3197","DATA/REVOLUTION_TE/AS16-M-3197.lbl","AS16-M-3197.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3198","DATA/REVOLUTION_TE/AS16-M-3198.lbl","AS16-M-3198.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3199","DATA/REVOLUTION_TE/AS16-M-3199.lbl","AS16-M-3199.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3200","DATA/REVOLUTION_TE/AS16-M-3200.lbl","AS16-M-3200.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3201","DATA/REVOLUTION_TE/AS16-M-3201.lbl","AS16-M-3201.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3202","DATA/REVOLUTION_TE/AS16-M-3202.lbl","AS16-M-3202.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3203","DATA/REVOLUTION_TE/AS16-M-3203.lbl","AS16-M-3203.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3204","DATA/REVOLUTION_TE/AS16-M-3204.lbl","AS16-M-3204.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3205","DATA/REVOLUTION_TE/AS16-M-3205.lbl","AS16-M-3205.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3206","DATA/REVOLUTION_TE/AS16-M-3206.lbl","AS16-M-3206.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3207","DATA/REVOLUTION_TE/AS16-M-3207.lbl","AS16-M-3207.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3208","DATA/REVOLUTION_TE/AS16-M-3208.lbl","AS16-M-3208.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3209","DATA/REVOLUTION_TE/AS16-M-3209.lbl","AS16-M-3209.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3210","DATA/REVOLUTION_TE/AS16-M-3210.lbl","AS16-M-3210.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3211","DATA/REVOLUTION_TE/AS16-M-3211.lbl","AS16-M-3211.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3212","DATA/REVOLUTION_TE/AS16-M-3212.lbl","AS16-M-3212.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3213","DATA/REVOLUTION_TE/AS16-M-3213.lbl","AS16-M-3213.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3214","DATA/REVOLUTION_TE/AS16-M-3214.lbl","AS16-M-3214.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3215","DATA/REVOLUTION_TE/AS16-M-3215.lbl","AS16-M-3215.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3216","DATA/REVOLUTION_TE/AS16-M-3216.lbl","AS16-M-3216.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3217","DATA/REVOLUTION_TE/AS16-M-3217.lbl","AS16-M-3217.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3218","DATA/REVOLUTION_TE/AS16-M-3218.lbl","AS16-M-3218.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3219","DATA/REVOLUTION_TE/AS16-M-3219.lbl","AS16-M-3219.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3220","DATA/REVOLUTION_TE/AS16-M-3220.lbl","AS16-M-3220.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3221","DATA/REVOLUTION_TE/AS16-M-3221.lbl","AS16-M-3221.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3222","DATA/REVOLUTION_TE/AS16-M-3222.lbl","AS16-M-3222.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3223","DATA/REVOLUTION_TE/AS16-M-3223.lbl","AS16-M-3223.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3224","DATA/REVOLUTION_TE/AS16-M-3224.lbl","AS16-M-3224.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3225","DATA/REVOLUTION_TE/AS16-M-3225.lbl","AS16-M-3225.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3226","DATA/REVOLUTION_TE/AS16-M-3226.lbl","AS16-M-3226.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3227","DATA/REVOLUTION_TE/AS16-M-3227.lbl","AS16-M-3227.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3228","DATA/REVOLUTION_TE/AS16-M-3228.lbl","AS16-M-3228.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3229","DATA/REVOLUTION_TE/AS16-M-3229.lbl","AS16-M-3229.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3230","DATA/REVOLUTION_TE/AS16-M-3230.lbl","AS16-M-3230.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3231","DATA/REVOLUTION_TE/AS16-M-3231.lbl","AS16-M-3231.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3232","DATA/REVOLUTION_TE/AS16-M-3232.lbl","AS16-M-3232.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3233","DATA/REVOLUTION_TE/AS16-M-3233.lbl","AS16-M-3233.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3234","DATA/REVOLUTION_TE/AS16-M-3234.lbl","AS16-M-3234.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3235","DATA/REVOLUTION_TE/AS16-M-3235.lbl","AS16-M-3235.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3236","DATA/REVOLUTION_TE/AS16-M-3236.lbl","AS16-M-3236.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3237","DATA/REVOLUTION_TE/AS16-M-3237.lbl","AS16-M-3237.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3238","DATA/REVOLUTION_TE/AS16-M-3238.lbl","AS16-M-3238.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3239","DATA/REVOLUTION_TE/AS16-M-3239.lbl","AS16-M-3239.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3240","DATA/REVOLUTION_TE/AS16-M-3240.lbl","AS16-M-3240.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3241","DATA/REVOLUTION_TE/AS16-M-3241.lbl","AS16-M-3241.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3242","DATA/REVOLUTION_TE/AS16-M-3242.lbl","AS16-M-3242.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3243","DATA/REVOLUTION_TE/AS16-M-3243.lbl","AS16-M-3243.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3244","DATA/REVOLUTION_TE/AS16-M-3244.lbl","AS16-M-3244.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3245","DATA/REVOLUTION_TE/AS16-M-3245.lbl","AS16-M-3245.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3246","DATA/REVOLUTION_TE/AS16-M-3246.lbl","AS16-M-3246.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3247","DATA/REVOLUTION_TE/AS16-M-3247.lbl","AS16-M-3247.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3248","DATA/REVOLUTION_TE/AS16-M-3248.lbl","AS16-M-3248.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3249","DATA/REVOLUTION_TE/AS16-M-3249.lbl","AS16-M-3249.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3250","DATA/REVOLUTION_TE/AS16-M-3250.lbl","AS16-M-3250.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3251","DATA/REVOLUTION_TE/AS16-M-3251.lbl","AS16-M-3251.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3252","DATA/REVOLUTION_TE/AS16-M-3252.lbl","AS16-M-3252.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3253","DATA/REVOLUTION_TE/AS16-M-3253.lbl","AS16-M-3253.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3254","DATA/REVOLUTION_TE/AS16-M-3254.lbl","AS16-M-3254.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3255","DATA/REVOLUTION_TE/AS16-M-3255.lbl","AS16-M-3255.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3256","DATA/REVOLUTION_TE/AS16-M-3256.lbl","AS16-M-3256.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3257","DATA/REVOLUTION_TE/AS16-M-3257.lbl","AS16-M-3257.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3258","DATA/REVOLUTION_TE/AS16-M-3258.lbl","AS16-M-3258.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3259","DATA/REVOLUTION_TE/AS16-M-3259.lbl","AS16-M-3259.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3260","DATA/REVOLUTION_TE/AS16-M-3260.lbl","AS16-M-3260.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3261","DATA/REVOLUTION_TE/AS16-M-3261.lbl","AS16-M-3261.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3262","DATA/REVOLUTION_TE/AS16-M-3262.lbl","AS16-M-3262.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3263","DATA/REVOLUTION_TE/AS16-M-3263.lbl","AS16-M-3263.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3264","DATA/REVOLUTION_TE/AS16-M-3264.lbl","AS16-M-3264.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3265","DATA/REVOLUTION_TE/AS16-M-3265.lbl","AS16-M-3265.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3266","DATA/REVOLUTION_TE/AS16-M-3266.lbl","AS16-M-3266.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3267","DATA/REVOLUTION_TE/AS16-M-3267.lbl","AS16-M-3267.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3268","DATA/REVOLUTION_TE/AS16-M-3268.lbl","AS16-M-3268.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3269","DATA/REVOLUTION_TE/AS16-M-3269.lbl","AS16-M-3269.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3270","DATA/REVOLUTION_TE/AS16-M-3270.lbl","AS16-M-3270.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3271","DATA/REVOLUTION_TE/AS16-M-3271.lbl","AS16-M-3271.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3272","DATA/REVOLUTION_TE/AS16-M-3272.lbl","AS16-M-3272.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3273","DATA/REVOLUTION_TE/AS16-M-3273.lbl","AS16-M-3273.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3274","DATA/REVOLUTION_TE/AS16-M-3274.lbl","AS16-M-3274.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3275","DATA/REVOLUTION_TE/AS16-M-3275.lbl","AS16-M-3275.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3276","DATA/REVOLUTION_TE/AS16-M-3276.lbl","AS16-M-3276.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3277","DATA/REVOLUTION_TE/AS16-M-3277.lbl","AS16-M-3277.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3278","DATA/REVOLUTION_TE/AS16-M-3278.lbl","AS16-M-3278.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3279","DATA/REVOLUTION_TE/AS16-M-3279.lbl","AS16-M-3279.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3280","DATA/REVOLUTION_TE/AS16-M-3280.lbl","AS16-M-3280.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3281","DATA/REVOLUTION_TE/AS16-M-3281.lbl","AS16-M-3281.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3282","DATA/REVOLUTION_TE/AS16-M-3282.lbl","AS16-M-3282.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3283","DATA/REVOLUTION_TE/AS16-M-3283.lbl","AS16-M-3283.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3284","DATA/REVOLUTION_TE/AS16-M-3284.lbl","AS16-M-3284.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3285","DATA/REVOLUTION_TE/AS16-M-3285.lbl","AS16-M-3285.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3286","DATA/REVOLUTION_TE/AS16-M-3286.lbl","AS16-M-3286.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3287","DATA/REVOLUTION_TE/AS16-M-3287.lbl","AS16-M-3287.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3288","DATA/REVOLUTION_TE/AS16-M-3288.lbl","AS16-M-3288.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3289","DATA/REVOLUTION_TE/AS16-M-3289.lbl","AS16-M-3289.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3290","DATA/REVOLUTION_TE/AS16-M-3290.lbl","AS16-M-3290.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3291","DATA/REVOLUTION_TE/AS16-M-3291.lbl","AS16-M-3291.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3292","DATA/REVOLUTION_TE/AS16-M-3292.lbl","AS16-M-3292.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3293","DATA/REVOLUTION_TE/AS16-M-3293.lbl","AS16-M-3293.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3294","DATA/REVOLUTION_TE/AS16-M-3294.lbl","AS16-M-3294.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3295","DATA/REVOLUTION_TE/AS16-M-3295.lbl","AS16-M-3295.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3296","DATA/REVOLUTION_TE/AS16-M-3296.lbl","AS16-M-3296.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3297","DATA/REVOLUTION_TE/AS16-M-3297.lbl","AS16-M-3297.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3298","DATA/REVOLUTION_TE/AS16-M-3298.lbl","AS16-M-3298.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3299","DATA/REVOLUTION_TE/AS16-M-3299.lbl","AS16-M-3299.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3300","DATA/REVOLUTION_TE/AS16-M-3300.lbl","AS16-M-3300.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3301","DATA/REVOLUTION_TE/AS16-M-3301.lbl","AS16-M-3301.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3302","DATA/REVOLUTION_TE/AS16-M-3302.lbl","AS16-M-3302.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3303","DATA/REVOLUTION_TE/AS16-M-3303.lbl","AS16-M-3303.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3304","DATA/REVOLUTION_TE/AS16-M-3304.lbl","AS16-M-3304.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3305","DATA/REVOLUTION_TE/AS16-M-3305.lbl","AS16-M-3305.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3306","DATA/REVOLUTION_TE/AS16-M-3306.lbl","AS16-M-3306.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3307","DATA/REVOLUTION_TE/AS16-M-3307.lbl","AS16-M-3307.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3308","DATA/REVOLUTION_TE/AS16-M-3308.lbl","AS16-M-3308.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3309","DATA/REVOLUTION_TE/AS16-M-3309.lbl","AS16-M-3309.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3310","DATA/REVOLUTION_TE/AS16-M-3310.lbl","AS16-M-3310.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3311","DATA/REVOLUTION_TE/AS16-M-3311.lbl","AS16-M-3311.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3312","DATA/REVOLUTION_TE/AS16-M-3312.lbl","AS16-M-3312.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3313","DATA/REVOLUTION_TE/AS16-M-3313.lbl","AS16-M-3313.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3314","DATA/REVOLUTION_TE/AS16-M-3314.lbl","AS16-M-3314.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3315","DATA/REVOLUTION_TE/AS16-M-3315.lbl","AS16-M-3315.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3316","DATA/REVOLUTION_TE/AS16-M-3316.lbl","AS16-M-3316.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3317","DATA/REVOLUTION_TE/AS16-M-3317.lbl","AS16-M-3317.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3318","DATA/REVOLUTION_TE/AS16-M-3318.lbl","AS16-M-3318.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3319","DATA/REVOLUTION_TE/AS16-M-3319.lbl","AS16-M-3319.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3320","DATA/REVOLUTION_TE/AS16-M-3320.lbl","AS16-M-3320.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3321","DATA/REVOLUTION_TE/AS16-M-3321.lbl","AS16-M-3321.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3322","DATA/REVOLUTION_TE/AS16-M-3322.lbl","AS16-M-3322.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3323","DATA/REVOLUTION_TE/AS16-M-3323.lbl","AS16-M-3323.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3324","DATA/REVOLUTION_TE/AS16-M-3324.lbl","AS16-M-3324.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3325","DATA/REVOLUTION_TE/AS16-M-3325.lbl","AS16-M-3325.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3326","DATA/REVOLUTION_TE/AS16-M-3326.lbl","AS16-M-3326.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3327","DATA/REVOLUTION_TE/AS16-M-3327.lbl","AS16-M-3327.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3328","DATA/REVOLUTION_TE/AS16-M-3328.lbl","AS16-M-3328.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3329","DATA/REVOLUTION_TE/AS16-M-3329.lbl","AS16-M-3329.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3330","DATA/REVOLUTION_TE/AS16-M-3330.lbl","AS16-M-3330.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3331","DATA/REVOLUTION_TE/AS16-M-3331.lbl","AS16-M-3331.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3332","DATA/REVOLUTION_TE/AS16-M-3332.lbl","AS16-M-3332.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3333","DATA/REVOLUTION_TE/AS16-M-3333.lbl","AS16-M-3333.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3334","DATA/REVOLUTION_TE/AS16-M-3334.lbl","AS16-M-3334.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3335","DATA/REVOLUTION_TE/AS16-M-3335.lbl","AS16-M-3335.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3336","DATA/REVOLUTION_TE/AS16-M-3336.lbl","AS16-M-3336.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3337","DATA/REVOLUTION_TE/AS16-M-3337.lbl","AS16-M-3337.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3338","DATA/REVOLUTION_TE/AS16-M-3338.lbl","AS16-M-3338.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3339","DATA/REVOLUTION_TE/AS16-M-3339.lbl","AS16-M-3339.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3340","DATA/REVOLUTION_TE/AS16-M-3340.lbl","AS16-M-3340.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3341","DATA/REVOLUTION_TE/AS16-M-3341.lbl","AS16-M-3341.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3342","DATA/REVOLUTION_TE/AS16-M-3342.lbl","AS16-M-3342.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3343","DATA/REVOLUTION_TE/AS16-M-3343.lbl","AS16-M-3343.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3344","DATA/REVOLUTION_TE/AS16-M-3344.lbl","AS16-M-3344.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3345","DATA/REVOLUTION_TE/AS16-M-3345.lbl","AS16-M-3345.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3346","DATA/REVOLUTION_TE/AS16-M-3346.lbl","AS16-M-3346.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3347","DATA/REVOLUTION_TE/AS16-M-3347.lbl","AS16-M-3347.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3348","DATA/REVOLUTION_TE/AS16-M-3348.lbl","AS16-M-3348.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3349","DATA/REVOLUTION_TE/AS16-M-3349.lbl","AS16-M-3349.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3350","DATA/REVOLUTION_TE/AS16-M-3350.lbl","AS16-M-3350.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3351","DATA/REVOLUTION_TE/AS16-M-3351.lbl","AS16-M-3351.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3352","DATA/REVOLUTION_TE/AS16-M-3352.lbl","AS16-M-3352.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3353","DATA/REVOLUTION_TE/AS16-M-3353.lbl","AS16-M-3353.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3354","DATA/REVOLUTION_TE/AS16-M-3354.lbl","AS16-M-3354.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3355","DATA/REVOLUTION_TE/AS16-M-3355.lbl","AS16-M-3355.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3356","DATA/REVOLUTION_TE/AS16-M-3356.lbl","AS16-M-3356.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3357","DATA/REVOLUTION_TE/AS16-M-3357.lbl","AS16-M-3357.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3358","DATA/REVOLUTION_TE/AS16-M-3358.lbl","AS16-M-3358.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3359","DATA/REVOLUTION_TE/AS16-M-3359.lbl","AS16-M-3359.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3360","DATA/REVOLUTION_TE/AS16-M-3360.lbl","AS16-M-3360.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3361","DATA/REVOLUTION_TE/AS16-M-3361.lbl","AS16-M-3361.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3362","DATA/REVOLUTION_TE/AS16-M-3362.lbl","AS16-M-3362.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3363","DATA/REVOLUTION_TE/AS16-M-3363.lbl","AS16-M-3363.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3364","DATA/REVOLUTION_TE/AS16-M-3364.lbl","AS16-M-3364.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3365","DATA/REVOLUTION_TE/AS16-M-3365.lbl","AS16-M-3365.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3366","DATA/REVOLUTION_TE/AS16-M-3366.lbl","AS16-M-3366.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3367","DATA/REVOLUTION_TE/AS16-M-3367.lbl","AS16-M-3367.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3368","DATA/REVOLUTION_TE/AS16-M-3368.lbl","AS16-M-3368.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3369","DATA/REVOLUTION_TE/AS16-M-3369.lbl","AS16-M-3369.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3370","DATA/REVOLUTION_TE/AS16-M-3370.lbl","AS16-M-3370.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3371","DATA/REVOLUTION_TE/AS16-M-3371.lbl","AS16-M-3371.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3372","DATA/REVOLUTION_TE/AS16-M-3372.lbl","AS16-M-3372.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3373","DATA/REVOLUTION_TE/AS16-M-3373.lbl","AS16-M-3373.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3374","DATA/REVOLUTION_TE/AS16-M-3374.lbl","AS16-M-3374.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3375","DATA/REVOLUTION_TE/AS16-M-3375.lbl","AS16-M-3375.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3376","DATA/REVOLUTION_TE/AS16-M-3376.lbl","AS16-M-3376.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3377","DATA/REVOLUTION_TE/AS16-M-3377.lbl","AS16-M-3377.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3378","DATA/REVOLUTION_TE/AS16-M-3378.lbl","AS16-M-3378.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3379","DATA/REVOLUTION_TE/AS16-M-3379.lbl","AS16-M-3379.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3380","DATA/REVOLUTION_TE/AS16-M-3380.lbl","AS16-M-3380.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3381","DATA/REVOLUTION_TE/AS16-M-3381.lbl","AS16-M-3381.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3382","DATA/REVOLUTION_TE/AS16-M-3382.lbl","AS16-M-3382.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3383","DATA/REVOLUTION_TE/AS16-M-3383.lbl","AS16-M-3383.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3384","DATA/REVOLUTION_TE/AS16-M-3384.lbl","AS16-M-3384.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3385","DATA/REVOLUTION_TE/AS16-M-3385.lbl","AS16-M-3385.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3386","DATA/REVOLUTION_TE/AS16-M-3386.lbl","AS16-M-3386.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3387","DATA/REVOLUTION_TE/AS16-M-3387.lbl","AS16-M-3387.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3388","DATA/REVOLUTION_TE/AS16-M-3388.lbl","AS16-M-3388.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3389","DATA/REVOLUTION_TE/AS16-M-3389.lbl","AS16-M-3389.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3390","DATA/REVOLUTION_TE/AS16-M-3390.lbl","AS16-M-3390.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3391","DATA/REVOLUTION_TE/AS16-M-3391.lbl","AS16-M-3391.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3392","DATA/REVOLUTION_TE/AS16-M-3392.lbl","AS16-M-3392.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3393","DATA/REVOLUTION_TE/AS16-M-3393.lbl","AS16-M-3393.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3394","DATA/REVOLUTION_TE/AS16-M-3394.lbl","AS16-M-3394.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3395","DATA/REVOLUTION_TE/AS16-M-3395.lbl","AS16-M-3395.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3396","DATA/REVOLUTION_TE/AS16-M-3396.lbl","AS16-M-3396.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3397","DATA/REVOLUTION_TE/AS16-M-3397.lbl","AS16-M-3397.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3398","DATA/REVOLUTION_TE/AS16-M-3398.lbl","AS16-M-3398.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3399","DATA/REVOLUTION_TE/AS16-M-3399.lbl","AS16-M-3399.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3400","DATA/REVOLUTION_TE/AS16-M-3400.lbl","AS16-M-3400.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3401","DATA/REVOLUTION_TE/AS16-M-3401.lbl","AS16-M-3401.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3402","DATA/REVOLUTION_TE/AS16-M-3402.lbl","AS16-M-3402.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3403","DATA/REVOLUTION_TE/AS16-M-3403.lbl","AS16-M-3403.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3404","DATA/REVOLUTION_TE/AS16-M-3404.lbl","AS16-M-3404.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3405","DATA/REVOLUTION_TE/AS16-M-3405.lbl","AS16-M-3405.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3406","DATA/REVOLUTION_TE/AS16-M-3406.lbl","AS16-M-3406.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3407","DATA/REVOLUTION_TE/AS16-M-3407.lbl","AS16-M-3407.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3408","DATA/REVOLUTION_TE/AS16-M-3408.lbl","AS16-M-3408.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3409","DATA/REVOLUTION_TE/AS16-M-3409.lbl","AS16-M-3409.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3410","DATA/REVOLUTION_TE/AS16-M-3410.lbl","AS16-M-3410.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3411","DATA/REVOLUTION_TE/AS16-M-3411.lbl","AS16-M-3411.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3412","DATA/REVOLUTION_TE/AS16-M-3412.lbl","AS16-M-3412.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3413","DATA/REVOLUTION_TE/AS16-M-3413.lbl","AS16-M-3413.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3414","DATA/REVOLUTION_TE/AS16-M-3414.lbl","AS16-M-3414.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3415","DATA/REVOLUTION_TE/AS16-M-3415.lbl","AS16-M-3415.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3416","DATA/REVOLUTION_TE/AS16-M-3416.lbl","AS16-M-3416.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3417","DATA/REVOLUTION_TE/AS16-M-3417.lbl","AS16-M-3417.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3418","DATA/REVOLUTION_TE/AS16-M-3418.lbl","AS16-M-3418.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3419","DATA/REVOLUTION_TE/AS16-M-3419.lbl","AS16-M-3419.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3420","DATA/REVOLUTION_TE/AS16-M-3420.lbl","AS16-M-3420.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3421","DATA/REVOLUTION_TE/AS16-M-3421.lbl","AS16-M-3421.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3422","DATA/REVOLUTION_TE/AS16-M-3422.lbl","AS16-M-3422.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3423","DATA/REVOLUTION_TE/AS16-M-3423.lbl","AS16-M-3423.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3424","DATA/REVOLUTION_TE/AS16-M-3424.lbl","AS16-M-3424.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3425","DATA/REVOLUTION_TE/AS16-M-3425.lbl","AS16-M-3425.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3426","DATA/REVOLUTION_TE/AS16-M-3426.lbl","AS16-M-3426.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3427","DATA/REVOLUTION_TE/AS16-M-3427.lbl","AS16-M-3427.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3428","DATA/REVOLUTION_TE/AS16-M-3428.lbl","AS16-M-3428.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3429","DATA/REVOLUTION_TE/AS16-M-3429.lbl","AS16-M-3429.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3430","DATA/REVOLUTION_TE/AS16-M-3430.lbl","AS16-M-3430.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3431","DATA/REVOLUTION_TE/AS16-M-3431.lbl","AS16-M-3431.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3432","DATA/REVOLUTION_TE/AS16-M-3432.lbl","AS16-M-3432.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3433","DATA/REVOLUTION_TE/AS16-M-3433.lbl","AS16-M-3433.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3434","DATA/REVOLUTION_TE/AS16-M-3434.lbl","AS16-M-3434.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3435","DATA/REVOLUTION_TE/AS16-M-3435.lbl","AS16-M-3435.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3436","DATA/REVOLUTION_TE/AS16-M-3436.lbl","AS16-M-3436.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3437","DATA/REVOLUTION_TE/AS16-M-3437.lbl","AS16-M-3437.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3438","DATA/REVOLUTION_TE/AS16-M-3438.lbl","AS16-M-3438.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3439","DATA/REVOLUTION_TE/AS16-M-3439.lbl","AS16-M-3439.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" +"AS16-M-3440","DATA/REVOLUTION_TE/AS16-M-3440.lbl","AS16-M-3440.tif","A16MC_0001","A16C-L-MC-2-SCANNED-IMAGES-V1.0","MOON","METRIC CAMERA","APOLLO 16 COMMAND AND SERVICE MODULE" diff --git a/package.json b/package.json index f7c04066899a4..54df4b70b2c2c 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,9 @@ "name": "gitlens", "displayName": "GitLens — Git supercharged", "description": "Supercharge Git within VS Code — Visualize code authorship at a glance via Git blame annotations and CodeLens, seamlessly navigate and explore Git repositories, gain valuable insights via rich visualizations and powerful comparison commands, and so much more", - "version": "14.3.0", + "version": "15.1.0", "engines": { - "vscode": "^1.80.0" + "vscode": "^1.82.0" }, "license": "SEE LICENSE IN LICENSE", "publisher": "eamodio", @@ -50,10 +50,12 @@ "activationEvents": [ "onAuthenticationRequest:gitlens-gitkraken", "onFileSystem:gitlens", - "onWebviewPanel:gitlens.welcome", - "onWebviewPanel:gitlens.settings", - "onWebviewPanel:gitlens.graph", "onWebviewPanel:gitlens.focus", + "onWebviewPanel:gitlens.graph", + "onWebviewPanel:gitlens.patchDetails", + "onWebviewPanel:gitlens.settings", + "onWebviewPanel:gitlens.timeline", + "onWebviewPanel:gitlens.welcome", "onStartupFinished" ], "capabilities": { @@ -97,6 +99,34 @@ "scope": "window", "order": 31 }, + "gitlens.currentLine.fontFamily": { + "type": "string", + "default": "", + "markdownDescription": "Specifies the font family of the inline blame annotation", + "scope": "window", + "order": 33 + }, + "gitlens.currentLine.fontSize": { + "type": "number", + "default": 0, + "markdownDescription": "Specifies the font size of the inline blame annotation", + "scope": "window", + "order": 34 + }, + "gitlens.currentLine.fontStyle": { + "type": "string", + "default": "normal", + "markdownDescription": "Specifies the font style of the inline blame annotation", + "scope": "window", + "order": 35 + }, + "gitlens.currentLine.fontWeight": { + "type": "string", + "default": "normal", + "markdownDescription": "Specifies the font weight of the inline blame annotation", + "scope": "window", + "order": 36 + }, "gitlens.currentLine.scrollable": { "type": "boolean", "default": true, @@ -110,7 +140,7 @@ "null" ], "default": null, - "markdownDescription": "Specifies how to format absolute dates (e.g. using the `${date}` token) for the cinline blame annotation. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", + "markdownDescription": "Specifies how to format absolute dates (e.g. using the `${date}` token) for the inline blame annotation. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", "scope": "window", "order": 50 } @@ -167,7 +197,7 @@ "Compares the current committed file with the previous commit", "Reveals the commit in the Side Bar", "Searches for commits within the range", - "Shows a commit details quick pick menu", + "Shows an Inspect quick pick menu", "Shows a commit file details quick pick menu", "Shows a file history quick pick menu", "Shows a branch history quick pick menu", @@ -219,8 +249,8 @@ "Toggles file changes from the commit", "Compares the current committed file with the previous commit", "Reveals the commit in the Side Bar", - "Shows the commit details", - "Shows a commit details quick pick menu", + "Shows the Inspect", + "Shows an Inspect quick pick menu", "Shows a commit file details quick pick menu", "Shows a file history quick pick menu", "Shows a branch history quick pick menu", @@ -386,8 +416,8 @@ "Compares the current line commit with the previous", "Compares the current line commit with the working tree", "Reveals the commit in the Side Bar", - "Shows the commit details", - "Shows a commit details quick pick menu", + "Shows the Inspect", + "Shows an Inspect quick pick menu", "Shows a commit file details quick pick menu", "Shows a file history quick pick menu", "Shows a branch history quick pick menu", @@ -576,6 +606,13 @@ "title": "Views", "order": 20, "properties": { + "gitlens.views.collapseWorktreesWhenPossible": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to try to collapse the opened worktrees into a single (common) repository in the views when possible", + "scope": "window", + "order": 1 + }, "gitlens.views.defaultItemLimit": { "type": "number", "default": 10, @@ -599,7 +636,7 @@ }, "gitlens.views.formats.commits.label": { "type": "string", - "default": "${❰ tips ❱➤ }${message}", + "default": "${❰ tips|11? ❱➤ }${message}", "markdownDescription": "Specifies the format of commits in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", "scope": "window", "order": 30 @@ -613,7 +650,7 @@ }, "gitlens.views.formats.commits.tooltip": { "type": "string", - "default": "${link}${' via 'pullRequest}${'  •  'changesDetail}${'    'tips}\n\n${avatar}  __${author}__, ${ago}   _(${date})_ \n\n${message}${\n\n---\n\nfootnotes}", + "default": "${link}${'  •  'changesDetail}${'    'tips} \\\n${avatar}  __${author}__, ${ago}${' via 'pullRequest}   _(${date})_ \n\n${message}${\n\n---\n\nfootnotes}", "markdownDescription": "Specifies the tooltip format (in markdown) of commits in the views. See [_Commit Tokens_](https://github.com/eamodio/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", "scope": "window", "order": 32 @@ -653,10 +690,17 @@ "scope": "window", "order": 51 }, - "gitlens.views.experimental.multiSelect.enabled": { + "gitlens.views.formats.stashes.tooltip": { + "type": "string", + "default": "${link}${' on `'stashOnRef`}${'  •  'changesDetail} \\\n  ${ago}   _(${date})_ \n\n${message}${\n\n---\n\nfootnotes}", + "markdownDescription": "Specifies the tooltip format (in markdown) of stashes in the views. See [_Commit Tokens_](https://github.com/eamodio/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "scope": "window", + "order": 52 + }, + "gitlens.views.openChangesInMultiDiffEditor": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to enable experimental multi-select support in the views.\n**NOTE**: Requires a restart to take effect.", + "default": true, + "markdownDescription": "Specifies whether to open multiple changes in the multi-diff editor (single tab) or in individual diff editors (multiple tabs)", "scope": "window", "order": 60 }, @@ -723,7 +767,7 @@ "Compares the working tree with a user-selected reference" ], "default": "working", - "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag. etc) in the _Commits_ view", + "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag, etc) in the _Commits_ view", "scope": "window", "order": 10 }, @@ -812,7 +856,7 @@ }, { "id": "commit-details-view", - "title": "Commit Details View", + "title": "Inspect View", "order": 22, "properties": { "gitlens.views.commitDetails.autolinks.enabled": { @@ -912,7 +956,7 @@ "Compares the working tree with a user-selected reference" ], "default": "working", - "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag. etc) in the _Repositories_ view", + "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag, etc) in the _Repositories_ view", "scope": "window", "order": 10 }, @@ -1110,7 +1154,7 @@ "Compares the branch with a user-selected reference" ], "default": "branch", - "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) under each branch in the _Repositories_ view", + "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag, etc) under each branch in the _Repositories_ view", "scope": "window", "order": 100 }, @@ -1120,6 +1164,66 @@ } } }, + { + "id": "pull-request-view", + "title": "Pull Request View", + "order": 21, + "properties": { + "gitlens.views.pullRequest.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.pullRequest.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Pull Request_ view will display files", + "scope": "window", + "order": 30 + }, + "gitlens.views.pullRequest.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Pull Request_ view. Only applies when `#gitlens.views.pullRequest.files.layout#` is set to `auto`", + "scope": "window", + "order": 31 + }, + "gitlens.views.pullRequest.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Pull Request_ view. Only applies when `#gitlens.views.pullRequest.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 32 + }, + "gitlens.views.pullRequest.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Pull Request_ view will display file icons", + "scope": "window", + "order": 33 + }, + "gitlens.views.pullRequest.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Pull Request_ view", + "scope": "window", + "order": 40 + } + } + }, { "id": "file-history-view", "title": "File History View", @@ -1194,8 +1298,8 @@ }, "gitlens.advanced.fileHistoryFollowsRenames": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether file histories will follow renames — will affect how merge commits are shown in histories", + "default": true, + "markdownDescription": "Specifies whether file histories will follow renames", "scope": "window", "order": 100 }, @@ -1205,6 +1309,13 @@ "markdownDescription": "Specifies whether file histories will show commits from all branches", "scope": "window", "order": 101 + }, + "gitlens.advanced.fileHistoryShowMergeCommits": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether file histories will show merge commits", + "scope": "window", + "order": 102 } } }, @@ -1245,7 +1356,7 @@ "Compares the branch with a user-selected reference" ], "default": "branch", - "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) in the _Branches_ view", + "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag, etc) in the _Branches_ view", "scope": "window", "order": 10 }, @@ -1285,25 +1396,6 @@ "scope": "window", "order": 30 }, - "gitlens.sortBranchesBy": { - "type": "string", - "default": "date:desc", - "enum": [ - "date:desc", - "date:asc", - "name:asc", - "name:desc" - ], - "enumDescriptions": [ - "Sorts branches by the most recent commit date in descending order", - "Sorts branches by the most recent commit date in ascending order", - "Sorts branches by name in ascending order", - "Sorts branches by name in descending order" - ], - "markdownDescription": "Specifies how branches are sorted in quick pick menus and views", - "scope": "window", - "order": 40 - }, "gitlens.views.branches.files.layout": { "type": "string", "default": "auto", @@ -1549,25 +1641,6 @@ "scope": "window", "order": 10 }, - "gitlens.sortTagsBy": { - "type": "string", - "default": "date:desc", - "enum": [ - "date:desc", - "date:asc", - "name:asc", - "name:desc" - ], - "enumDescriptions": [ - "Sorts tags by date in descending order", - "Sorts tags by date in ascending order", - "Sorts tags by name in ascending order", - "Sorts tags by name in descending order" - ], - "markdownDescription": "Specifies how tags are sorted in quick pick menus and views", - "scope": "window", - "order": 20 - }, "gitlens.views.tags.files.layout": { "type": "string", "default": "auto", @@ -1684,7 +1757,7 @@ "Compares the worktree branch with a user-selected reference" ], "default": "working", - "markdownDescription": "Specifies whether to show a comparison of the worktree branch with a user-selected reference (branch, tag. etc) in the _Worktrees_ view", + "markdownDescription": "Specifies whether to show a comparison of the worktree branch with a user-selected reference (branch, tag, etc) in the _Worktrees_ view", "scope": "window", "order": 20 }, @@ -1804,29 +1877,6 @@ "scope": "window", "order": 31 }, - "gitlens.sortContributorsBy": { - "type": "string", - "default": "count:desc", - "enum": [ - "count:desc", - "count:asc", - "date:desc", - "date:asc", - "name:asc", - "name:desc" - ], - "enumDescriptions": [ - "Sorts contributors by commit count in descending order", - "Sorts contributors by commit count in ascending order", - "Sorts contributors by the most recent commit date in descending order", - "Sorts contributors by the most recent commit date in ascending order", - "Sorts contributors by name in ascending order", - "Sorts contributors by name in descending order" - ], - "markdownDescription": "Specifies how contributors are sorted in quick pick menus and views", - "scope": "window", - "order": 40 - }, "gitlens.views.contributors.files.layout": { "type": "string", "default": "auto", @@ -1964,106 +2014,522 @@ } }, { - "id": "file-blame", - "title": "File Blame", - "order": 100, + "id": "cloud-patches-view", + "title": "Cloud Patches View", + "order": 33, "properties": { - "gitlens.blame.toggleMode": { + "gitlens.views.drafts.files.layout": { "type": "string", - "default": "file", + "default": "auto", "enum": [ - "file", - "window" + "auto", + "list", + "tree" ], "enumDescriptions": [ - "Toggles each file individually", - "Toggles the window, i.e. all files at once" + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.drafts.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" ], - "markdownDescription": "Specifies how the file blame annotations will be toggled", + "markdownDescription": "Specifies how the _Cloud Patches_ view will display files", "scope": "window", - "order": 10 + "order": 30 }, - "gitlens.blame.format": { - "type": "string", - "default": "${message|50?} ${agoOrDate|14-}", - "markdownDescription": "Specifies the format of the file blame annotations. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.blame.dateFormat#` setting", + "gitlens.views.drafts.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Cloud Patches_ view. Only applies when `#gitlens.views.drafts.files.layout#` is set to `auto`", "scope": "window", - "order": 20 + "order": 31 }, - "gitlens.blame.heatmap.enabled": { + "gitlens.views.drafts.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to provide a heatmap indicator in the file blame annotations", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Cloud Patches_ view. Only applies when `#gitlens.views.drafts.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 30 + "order": 32 }, - "gitlens.blame.heatmap.location": { + "gitlens.views.drafts.files.icon": { "type": "string", - "default": "right", + "default": "type", "enum": [ - "left", - "right" + "status", + "type" ], "enumDescriptions": [ - "Adds a heatmap indicator on the left edge of the file blame annotations", - "Adds a heatmap indicator on the right edge of the file blame annotations" + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" ], - "markdownDescription": "Specifies where the heatmap indicators will be shown in the file blame annotations", + "markdownDescription": "Specifies how the _Cloud Patches_ view will display file icons", "scope": "window", - "order": 31 + "order": 33 }, - "gitlens.blame.avatars": { + "gitlens.views.drafts.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show avatar images in the file blame annotations", + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Cloud Patches_ view", "scope": "window", "order": 40 + } + } + }, + { + "id": "workspaces-view", + "title": "GitKraken Workspaces View", + "order": 34, + "properties": { + "gitlens.views.workspaces.showBranchComparison": { + "type": [ + "boolean", + "string" + ], + "enum": [ + false, + "branch", + "working" + ], + "enumDescriptions": [ + "Hides the branch comparison", + "Compares the current branch with a user-selected reference", + "Compares the working tree with a user-selected reference" + ], + "default": "working", + "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag, etc) in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 10 }, - "gitlens.blame.compact": { + "gitlens.views.workspaces.showUpstreamStatus": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to compact (deduplicate) matching adjacent file blame annotations", + "markdownDescription": "Specifies whether to show the upstream status of the current branch for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 50 + "order": 11 }, - "gitlens.blame.highlight.enabled": { + "gitlens.views.workspaces.includeWorkingTree": { "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to highlight lines associated with the current line", + "default": false, + "markdownDescription": "Specifies whether to include working tree file status for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 60 + "order": 12 }, - "gitlens.blame.highlight.locations": { - "type": "array", - "default": [ - "gutter", - "line", - "overview" - ], - "items": { - "type": "string", - "enum": [ - "gutter", - "line", - "overview" - ], - "enumDescriptions": [ - "Adds an indicator to the gutter", - "Adds a full-line highlight background color", - "Adds an indicator to the scroll bar" - ] - }, - "minItems": 1, - "maxItems": 3, - "uniqueItems": true, - "markdownDescription": "Specifies where the associated line highlights will be shown", + "gitlens.views.workspaces.pullRequests.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to query for pull requests associated with branches and commits in the _GitKraken Workspaces_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 61 + "order": 20 }, - "gitlens.blame.separateLines": { + "gitlens.views.workspaces.pullRequests.showForBranches": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether file blame annotations will be separated by a small gap", + "markdownDescription": "Specifies whether to show pull requests (if any) associated with branches in the _GitKraken Workspaces_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 21 + }, + "gitlens.views.workspaces.pullRequests.showForCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _GitKraken Workspaces_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 22 + }, + "gitlens.views.workspaces.showCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the commits on the current branch for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 30 + }, + "gitlens.views.workspaces.showBranches": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the branches for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 31 + }, + "gitlens.views.workspaces.showRemotes": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the remotes for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 32 + }, + "gitlens.views.workspaces.showStashes": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the stashes for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 33 + }, + "gitlens.views.workspaces.showTags": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the tags for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 34 + }, + "gitlens.views.workspaces.showContributors": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the contributors for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 35 + }, + "gitlens.views.workspaces.showWorktrees": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the worktrees for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 36 + }, + "gitlens.views.workspaces.showIncomingActivity": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show the experimental incoming activity for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 37 + }, + "gitlens.views.workspaces.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 60 + }, + "gitlens.views.workspaces.branches.layout": { + "type": "string", + "default": "tree", + "enum": [ + "list", + "tree" + ], + "enumDescriptions": [ + "Displays branches as a list", + "Displays branches as a tree when branch names contain slashes `/`" + ], + "markdownDescription": "Specifies how the _GitKraken Workspaces_ view will display branches", + "scope": "window", + "order": 70 + }, + "gitlens.views.workspaces.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.workspaces.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _GitKraken Workspaces_ view will display files", + "scope": "window", + "order": 80 + }, + "gitlens.views.workspaces.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _GitKraken Workspaces_ view. Only applies when `#gitlens.views.workspaces.files.layout#` is set to `auto`", + "scope": "window", + "order": 81 + }, + "gitlens.views.workspaces.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _GitKraken Workspaces_ view. Only applies when `#gitlens.views.workspaces.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 82 + }, + "gitlens.views.workspaces.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _GitKraken Workspaces_ view will display file icons", + "scope": "window", + "order": 83 + }, + "gitlens.views.workspaces.compact": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show the _GitKraken Workspaces_ view in a compact display density", + "scope": "window", + "order": 90 + }, + "gitlens.views.workspaces.branches.showBranchComparison": { + "type": [ + "boolean", + "string" + ], + "enum": [ + false, + "branch" + ], + "enumDescriptions": [ + "Hides the branch comparison", + "Compares the branch with a user-selected reference" + ], + "default": "branch", + "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag, etc) under each branch in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 100 + } + } + }, + { + "id": "patch-details-view", + "title": "Patch Details View", + "order": 35, + "properties": { + "gitlens.views.patchDetails.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.patchDetails.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Patch Details_ view will display files", + "scope": "window", + "order": 30 + }, + "gitlens.views.patchDetails.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Patch Details_ view. Only applies when `#gitlens.views.patchDetails.files.layout#` is set to `auto`", + "scope": "window", + "order": 31 + }, + "gitlens.views.patchDetails.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Patch Details_ view. Only applies when `#gitlens.views.patchDetails.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 32 + }, + "gitlens.views.patchDetails.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Patch Details_ view will display file icons", + "scope": "window", + "order": 33 + }, + "gitlens.views.patchDetails.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Patch Details_ view", + "scope": "window", + "order": 40 + } + } + }, + { + "id": "file-annotations", + "title": "File Annotations", + "order": 14, + "properties": { + "gitlens.fileAnnotations.dismissOnEscape": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether pressing the `ESC` key dismisses the active file annotations", + "scope": "window", + "order": 10 + }, + "gitlens.fileAnnotations.command": { + "type": [ + "string", + "null" + ], + "default": null, + "enum": [ + null, + "blame", + "heatmap", + "changes" + ], + "enumDescriptions": [ + "Shows a menu to choose which file annotations to toggle", + "Toggles file blame annotations", + "Toggles file heatmap annotations", + "Toggles file changes annotations" + ], + "markdownDescription": "Specifies whether the file annotations button in the editor title shows a menu or immediately toggles the specified file annotations", + "scope": "window", + "order": 20 + }, + "gitlens.fileAnnotations.preserveWhileEditing": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether file annotations will be preserved while editing. Use `#gitlens.advanced.blame.delayAfterEdit#` to control how long to wait before the annotation will update while the file is still dirty", + "scope": "window", + "order": 30 + }, + "gitlens.advanced.blame.delayAfterEdit": { + "type": "number", + "default": 5000, + "markdownDescription": "Specifies the time (in milliseconds) to wait before re-blaming an unsaved document after an edit but before it is saved. Use 0 to specify an infinite wait. Only applies if the file is under the `#gitlens.advanced.sizeThresholdAfterEdit#`", + "scope": "window", + "order": 90 + }, + "gitlens.advanced.blame.sizeThresholdAfterEdit": { + "type": "number", + "default": 5000, + "markdownDescription": "Specifies the maximum document size (in lines) allowed to be re-blamed after an edit while still unsaved. Use 0 to specify no maximum", + "scope": "window", + "order": 91 + } + } + }, + { + "id": "file-blame", + "title": "File Blame", + "order": 15, + "properties": { + "gitlens.blame.toggleMode": { + "type": "string", + "default": "file", + "enum": [ + "file", + "window" + ], + "enumDescriptions": [ + "Toggles each file individually", + "Toggles the window, i.e. all files at once" + ], + "markdownDescription": "Specifies how the file blame annotations will be toggled", + "scope": "window", + "order": 10 + }, + "gitlens.blame.format": { + "type": "string", + "default": "${message|50?} ${agoOrDate|14-}", + "markdownDescription": "Specifies the format of the file blame annotations. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.blame.dateFormat#` setting", + "scope": "window", + "order": 20 + }, + "gitlens.blame.fontFamily": { + "type": "string", + "default": "", + "markdownDescription": "Specifies the font family of the file blame annotations", + "scope": "window", + "order": 22 + }, + "gitlens.blame.fontSize": { + "type": "number", + "default": 0, + "markdownDescription": "Specifies the font size of the file blame annotations", + "scope": "window", + "order": 23 + }, + "gitlens.blame.fontStyle": { + "type": "string", + "default": "normal", + "markdownDescription": "Specifies the font style of the file blame annotations", + "scope": "window", + "order": 24 + }, + "gitlens.blame.fontWeight": { + "type": "string", + "default": "normal", + "markdownDescription": "Specifies the font weight of the file blame annotations", + "scope": "window", + "order": 25 + }, + "gitlens.blame.heatmap.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to provide a heatmap indicator in the file blame annotations", + "scope": "window", + "order": 30 + }, + "gitlens.blame.heatmap.location": { + "type": "string", + "default": "right", + "enum": [ + "left", + "right" + ], + "enumDescriptions": [ + "Adds a heatmap indicator on the left edge of the file blame annotations", + "Adds a heatmap indicator on the right edge of the file blame annotations" + ], + "markdownDescription": "Specifies where the heatmap indicators will be shown in the file blame annotations", + "scope": "window", + "order": 31 + }, + "gitlens.blame.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images in the file blame annotations", + "scope": "window", + "order": 40 + }, + "gitlens.blame.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (deduplicate) matching adjacent file blame annotations", + "scope": "window", + "order": 50 + }, + "gitlens.blame.highlight.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to highlight lines associated with the current line", + "scope": "window", + "order": 60 + }, + "gitlens.blame.highlight.locations": { + "type": "array", + "default": [ + "gutter", + "line", + "overview" + ], + "items": { + "type": "string", + "enum": [ + "gutter", + "line", + "overview" + ], + "enumDescriptions": [ + "Adds an indicator to the gutter", + "Adds a full-line highlight background color", + "Adds an indicator to the scroll bar" + ] + }, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "markdownDescription": "Specifies where the associated line highlights will be shown", + "scope": "window", + "order": 61 + }, + "gitlens.blame.separateLines": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether file blame annotations will be separated by a small gap", "scope": "window", "order": 70 }, @@ -2082,7 +2548,7 @@ { "id": "file-changes", "title": "File Changes", - "order": 101, + "order": 16, "properties": { "gitlens.changes.toggleMode": { "type": "string", @@ -2131,7 +2597,7 @@ { "id": "file-heatmap", "title": "File Heatmap", - "order": 102, + "order": 17, "properties": { "gitlens.heatmap.toggleMode": { "type": "string", @@ -2152,7 +2618,6 @@ "type": "array", "default": [ "gutter", - "line", "overview" ], "items": { @@ -2208,8 +2673,15 @@ { "id": "graph", "title": "Commit Graph", - "order": 105, + "order": 200, "properties": { + "gitlens.graph.allowMultiple": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to allow opening multiple instances of the _Commit Graph_ in the editor area", + "scope": "window", + "order": 5 + }, "gitlens.graph.defaultItemLimit": { "type": "number", "default": 500, @@ -2359,12 +2831,18 @@ "scope": "window", "order": 30 }, + "gitlens.graph.onlyFollowFirstParent": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to only follow the first parent when showing commits on the _Commit Graph_", + "order": 31 + }, "gitlens.graph.dateStyle": { "type": [ "string", "null" ], - "default": "relative", + "default": null, "enum": [ "relative", "absolute" @@ -2431,53 +2909,232 @@ "scope": "window", "order": 101 }, - "gitlens.graph.minimap.additionalTypes": { + "gitlens.graph.minimap.additionalTypes": { + "type": "array", + "default": [ + "localBranches", + "stashes" + ], + "items": { + "type": "string", + "enum": [ + "localBranches", + "remoteBranches", + "stashes", + "tags" + ], + "enumDescriptions": [ + "Marks the location of local branches", + "Marks the location of remote branches", + "Marks the location of stashes", + "Marks the location of tags" + ] + }, + "minItems": 0, + "maxItems": 4, + "uniqueItems": true, + "markdownDescription": "Specifies additional markers to show on the minimap in the _Commit Graph_", + "scope": "window", + "order": 102 + } + } + }, + { + "id": "cloud-patches", + "title": "Cloud Patches (Preview)", + "order": 300, + "properties": { + "gitlens.cloudPatches.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to enable the preview of _Cloud Patches_, which allow you to easily and securely share code with your teammates or other developers", + "scope": "window", + "order": 10 + } + } + }, + { + "id": "focus", + "title": "Launchpad (Preview)", + "order": 400, + "properties": { + "gitlens.launchpad.ignoredRepositories": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "markdownDescription": "Specifies the repositories to ignore in the _Launchpad_", + "scope": "window", + "order": 10 + }, + "gitlens.launchpad.ignoredOrganizations": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "markdownDescription": "Specifies the organizations to ignore in the _Launchpad_", + "scope": "window", + "order": 11 + }, + "gitlens.launchpad.staleThreshold": { + "type": [ + "number", + "null" + ], + "default": null, + "markdownDescription": "Specifies the number of days after which a pull request is considered stale and moved to Other in the _Launchpad_", + "scope": "window", + "order": 20 + }, + "gitlens.launchpad.indicator.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to enable status bar indicator for _Launchpad_", + "scope": "window", + "order": 100 + }, + "gitlens.launchpad.indicator.icon": { + "type": "string", + "enum": [ + "default", + "group" + ], + "enumDescriptions": [ + "Shows the Launchpad icon", + "Shows the icon of the highest priority group" + ], + "default": "default", + "markdownDescription": "Specifies the style of the _Launchpad_ status bar indicator icon", + "scope": "window", + "order": 110 + }, + "gitlens.launchpad.indicator.label": { + "type": [ + "boolean", + "string" + ], + "enum": [ + false, + "item", + "counts" + ], + "enumDescriptions": [ + "Hides the label", + "Shows the highest priority item which needs your attention", + "Shows the status counts of items which need your attention" + ], + "default": "item", + "markdownDescription": "Specifies the display of the _Launchpad_ status bar indicator label", + "scope": "window", + "order": 120 + }, + "gitlens.launchpad.indicator.groups": { "type": "array", "default": [ - "localBranches", - "stashes" + "mergeable", + "blocked", + "needs-review", + "follow-up" ], "items": { "type": "string", "enum": [ - "localBranches", - "remoteBranches", - "stashes", - "tags" + "mergeable", + "blocked", + "needs-review", + "follow-up" ], "enumDescriptions": [ - "Marks the location of local branches", - "Marks the location of remote branches", - "Marks the location of stashes", - "Marks the location of tags" + "Shows mergeable pull requests", + "Shows blocked pull requests", + "Shows pull requests needing your review", + "Shows pull requests needing follow-up" ] }, - "minItems": 0, - "maxItems": 4, + "minItems": 1, "uniqueItems": true, - "markdownDescription": "Specifies additional markers to show on the minimap in the _Commit Graph_", + "markdownDescription": "Specifies the groups of pull requests to show on the _Launchpad_ status bar indicator", "scope": "window", - "order": 102 + "order": 130 + }, + "gitlens.launchpad.indicator.useColors": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to use colors on the _Launchpad_ status bar indicator", + "scope": "window", + "order": 140 + }, + "gitlens.launchpad.indicator.polling.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether the status bar indicator will fetch and display pull request data for _Launchpad_", + "scope": "window", + "order": 150 + }, + "gitlens.launchpad.indicator.polling.interval": { + "type": "number", + "default": 30, + "markdownDescription": "Specifies the rate (in minutes) at which the status bar indicator will fetch pull request data for _Launchpad_. Use 0 to disable automatic polling", + "scope": "window", + "order": 160 + }, + "gitlens.launchpad.indicator.openInEditor": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to open _Launchpad_ as an editor tab when clicking on the status bar indicator", + "scope": "window", + "order": 170 + }, + "gitlens.launchpad.allowMultiple": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to allow opening multiple instances of the _Launchpad_ as an editor tab", + "scope": "window", + "order": 1000 + }, + "gitlens.launchpad.experimental.queryLimit": { + "type": "number", + "default": 100, + "markdownDescription": "Specifies an experimental limit on the number of pull requests to be queried in the _Launchpad_", + "scope": "window", + "order": 1100 + }, + "gitlens.launchpad.experimental.queryUseInvolvesFilter": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies an experimental flag whether to use the `involves` filter in the GraphQL query for _Launchpad_", + "scope": "window", + "order": 1110 } } }, { "id": "visual-history", "title": "Visual File History", - "order": 106, + "order": 500, "properties": { + "gitlens.visualHistory.allowMultiple": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to allow opening multiple instances of the _Visual File History_ in the editor area", + "scope": "window", + "order": 10 + }, "gitlens.visualHistory.queryLimit": { "type": "number", "default": 20, - "markdownDescription": "Specifies the limit on the how many commits can be queried for statistics in the Visual File History, because of rate limits. Only applies to virtual workspaces.", - "scope": "window" + "markdownDescription": "Specifies the limit on the how many commits can be queried for statistics in the _Visual File History_, because of rate limits. Only applies to virtual workspaces.", + "scope": "window", + "order": 20 } } }, { "id": "rebase-editor", "title": "Interactive Rebase Editor", - "order": 107, + "order": 600, "properties": { "gitlens.rebaseEditor.ordering": { "type": "string", @@ -2519,8 +3176,15 @@ { "id": "git-command-palette", "title": "Git Command Palette", - "order": 110, + "order": 700, "properties": { + "gitlens.gitCommands.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images in quick pick menus when applicable", + "scope": "window", + "order": 5 + }, "gitlens.gitCommands.sortBy": { "type": "string", "default": "usage", @@ -2540,8 +3204,7 @@ "type": "array", "default": [ "fetch:command", - "stash-push:command", - "switch:command" + "stash-push:command" ], "items": { "type": "string", @@ -2644,7 +3307,7 @@ { "id": "integrations", "title": "Integrations", - "order": 111, + "order": 800, "properties": { "gitlens.autolinks": { "type": [ @@ -2843,19 +3506,26 @@ "scope": "window", "order": 40 }, + "gitlens.liveshare.enabled": { + "type": "boolean", + "default": true, + "description": "Specifies whether to enable integration with Visual Studio Live Share", + "scope": "window", + "order": 50 + }, "gitlens.liveshare.allowGuestAccess": { "type": "boolean", "default": true, "description": "Specifies whether to allow guest access to GitLens features when using Visual Studio Live Share", "scope": "window", - "order": 50 + "order": 51 } } }, { "id": "terminal", "title": "Terminal", - "order": 112, + "order": 900, "properties": { "gitlens.terminalLinks.enabled": { "type": "boolean", @@ -2882,55 +3552,86 @@ }, { "id": "ai", - "title": "AI", - "order": 113, + "title": "AI (Experimental)", + "order": 1000, "properties": { + "gitlens.ai.experimental.generateCommitMessage.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to enable GitLens' experimental, AI-powered, on-demand commit message generation", + "scope": "window", + "order": 1 + }, "gitlens.experimental.generateCommitMessagePrompt": { "type": "string", - "default": "Commit messages must have a short description that is less than 50 chars followed by a newline and a more detailed description.\n- Write concisely using an informal tone and avoid specific names from the code", - "markdownDescription": "Specifies the prompt to use to tell OpenAI how to structure or format the generated commit message", + "default": "Now, please generate a commit message. Ensure that it includes a precise and informative subject line that succinctly summarizes the crux of the changes in under 50 characters. If necessary, follow with an explanatory body providing insight into the nature of the changes, the reasoning behind them, and any significant consequences or considerations arising from them. Conclude with any relevant issue references at the end of the message.", + "markdownDescription": "Specifies the prompt to use to tell the AI provider how to structure or format the generated commit message", "scope": "window", - "order": 1 + "order": 2 }, - "gitlens.ai.experimental.provider": { + "gitlens.experimental.generateCloudPatchMessagePrompt": { "type": "string", - "default": "openai", - "enum": [ - "openai", - "anthropic" - ], - "enumDescriptions": [ - "OpenAI", - "Anthropic" - ], - "markdownDescription": "Specifies the AI provider to use for GitLens' experimental AI features", + "default": "Now, please generate a title and optional description. Ensure that it includes a precise and informative subject line that succinctly summarizes the crux of the changes in under 50 characters. If necessary, follow with an explanatory body providing insight into the nature of the changes, the reasoning behind them, and any significant consequences or considerations arising from them. Conclude with any relevant issue references at the end of the message.", + "markdownDescription": "Specifies the prompt to use to tell the AI provider how to structure or format the generated title and description", "scope": "window", - "order": 100 + "order": 3 }, - "gitlens.ai.experimental.openai.model": { + "gitlens.experimental.generateCodeSuggestMessagePrompt": { "type": "string", - "default": "gpt-3.5-turbo", + "default": "Now, please generate a title and optional description. Ensure that it includes a precise and informative subject line that succinctly summarizes the crux of the changes in under 50 characters. If necessary, follow with an explanatory body providing insight into the nature of the changes, the reasoning behind them, and any significant consequences or considerations arising from them. Conclude with any relevant issue references at the end of the message.", + "markdownDescription": "Specifies the prompt to use to tell the AI provider how to structure or format the generated title and description", + "scope": "window", + "order": 3 + }, + "gitlens.ai.experimental.model": { + "type": [ + "string", + "null" + ], + "default": null, "enum": [ - "gpt-3.5-turbo", - "gpt-3.5-turbo-16k", - "gpt-3.5-turbo-0613", - "gpt-4", - "gpt-4-0613", - "gpt-4-32k", - "gpt-4-32k-0613" + "openai:gpt-4o", + "openai:gpt-4-turbo", + "openai:gpt-4-turbo-preview", + "openai:gpt-4", + "openai:gpt-4-32k", + "openai:gpt-3.5-turbo", + "openai:gpt-3.5-turbo-16k", + "anthropic:claude-3-opus-20240229", + "anthropic:claude-3-5-sonnet-20240620", + "anthropic:claude-3-sonnet-20240229", + "anthropic:claude-3-haiku-20240307", + "anthropic:claude-2.1", + "anthropic:claude-2", + "anthropic:claude-instant-1", + "google:gemini-1.5-pro-latest", + "google:gemini-1.5-flash-latest", + "google:gemini-1.0-pro", + "vscode" ], "enumDescriptions": [ - "GPT 3.5 Turbo", - "GPT 3.5 Turbo 16k", - "GPT 3.5 Turbo (June 13)", - "GPT 4", - "GPT 4 (June 13)", - "GPT 4 32k", - "GPT 4 32k (June 13)" + "OpenAI GPT-4 Omni", + "OpenAI GPT-4 Turbo with Vision", + "OpenAI GPT-4 Turbo Preview", + "OpenAI GPT-4", + "OpenAI GPT-4 32k", + "OpenAI GPT-3.5 Turbo", + "OpenAI GPT-3.5 Turbo 16k", + "Anthropic Claude 3 Opus", + "Anthropic Claude 3.5 Sonnet", + "Anthropic Claude 3 Sonnet", + "Anthropic Claude 3 Haiku", + "Anthropic Claude 2.1", + "Anthropic Claude 2", + "Anthropic Claude Instant 1.2", + "Google Gemini 1.5 Pro (Latest)", + "Google Gemini 1.5 Flash", + "Google Gemini 1.0 Pro", + "VS Code Extension" ], - "markdownDescription": "Specifies the OpenAI model to use for GitLens' experimental AI features", + "markdownDescription": "Specifies the AI model to use for GitLens' experimental AI features", "scope": "window", - "order": 101 + "order": 100 }, "gitlens.ai.experimental.openai.url": { "type": [ @@ -2942,33 +3643,23 @@ "scope": "window", "order": 102 }, - "gitlens.ai.experimental.anthropic.model": { - "type": "string", - "default": "claude-v1", - "enum": [ - "claude-v1", - "claude-v1-100k", - "claude-instant-v1", - "claude-instant-v1-100k", - "claude-2" - ], - "enumDescriptions": [ - "Claude v1", - "Claude v1 100k", - "Claude Instant v1", - "Claude Instant v1 100k", - "Claude 2" + "gitlens.ai.experimental.vscode.model": { + "type": [ + "string", + "null" ], - "markdownDescription": "Specifies the Anthropic model to use for GitLens' experimental AI features", + "default": null, + "pattern": "^(.*):(.*)$", + "markdownDescription": "Specifies the VS Code provided model to use for GitLens' experimental AI features, formatted as `vendor:family`", "scope": "window", - "order": 102 + "order": 105 } } }, { "id": "date-times", "title": "Date & Times", - "order": 120, + "order": 1100, "properties": { "gitlens.defaultDateStyle": { "type": "string", @@ -3001,7 +3692,7 @@ "null" ], "default": null, - "markdownDescription": "Specifies the locale, a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_major_primary_language_subtags), to use for date formatting, defaults to the VS Code locale. Use `system` to follow the current system locale, or choose a specific locale, e.g `en-US` — US English, `en-GB` — British English, `de-DE` — German, 'ja-JP = Japanese, etc.", + "markdownDescription": "Specifies the locale, a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_major_primary_language_subtags), to use for date formatting, defaults to the VS Code locale. Use `system` to follow the current system locale, or choose a specific locale, e.g `en-US` — US English, `en-GB` — British English, `de-DE` — German, `ja-JP` = Japanese, etc.", "scope": "window", "order": 21 }, @@ -3042,10 +3733,99 @@ } } }, + { + "id": "sorting", + "title": "Sorting", + "order": 1200, + "properties": { + "gitlens.sortRepositoriesBy": { + "type": "string", + "default": "discovered", + "enum": [ + "discovered", + "lastFetched:desc", + "lastFetched:asc", + "name:asc", + "name:desc" + ], + "enumDescriptions": [ + "Sorts repositories by discovery or workspace order", + "Sorts repositories by last fetched date in descending order", + "Sorts repositories by last fetched date in ascending order", + "Sorts repositories by name in ascending order", + "Sorts repositories by name in descending order" + ], + "markdownDescription": "Specifies how repositories are sorted in quick pick menus and views", + "scope": "window", + "order": 10 + }, + "gitlens.sortBranchesBy": { + "type": "string", + "default": "date:desc", + "enum": [ + "date:desc", + "date:asc", + "name:asc", + "name:desc" + ], + "enumDescriptions": [ + "Sorts branches by the most recent commit date in descending order", + "Sorts branches by the most recent commit date in ascending order", + "Sorts branches by name in ascending order", + "Sorts branches by name in descending order" + ], + "markdownDescription": "Specifies how branches are sorted in quick pick menus and views", + "scope": "window", + "order": 20 + }, + "gitlens.sortTagsBy": { + "type": "string", + "default": "date:desc", + "enum": [ + "date:desc", + "date:asc", + "name:asc", + "name:desc" + ], + "enumDescriptions": [ + "Sorts tags by date in descending order", + "Sorts tags by date in ascending order", + "Sorts tags by name in ascending order", + "Sorts tags by name in descending order" + ], + "markdownDescription": "Specifies how tags are sorted in quick pick menus and views", + "scope": "window", + "order": 30 + }, + "gitlens.sortContributorsBy": { + "type": "string", + "default": "count:desc", + "enum": [ + "count:desc", + "count:asc", + "date:desc", + "date:asc", + "name:asc", + "name:desc" + ], + "enumDescriptions": [ + "Sorts contributors by commit count in descending order", + "Sorts contributors by commit count in ascending order", + "Sorts contributors by the most recent commit date in descending order", + "Sorts contributors by the most recent commit date in ascending order", + "Sorts contributors by name in ascending order", + "Sorts contributors by name in descending order" + ], + "markdownDescription": "Specifies how contributors are sorted in quick pick menus and views", + "scope": "window", + "order": 40 + } + } + }, { "id": "menus-toolbars", "title": "Menus & Toolbars", - "order": 121, + "order": 1300, "properties": { "gitlens.menus": { "anyOf": [ @@ -3227,6 +4007,9 @@ "properties": { "graph": { "type": "boolean" + }, + "stash": { + "type": "boolean" } } } @@ -3250,6 +4033,9 @@ }, "graph": { "type": "boolean" + }, + "patch": { + "type": "boolean" } } } @@ -3288,6 +4074,9 @@ "openClose": { "type": "boolean" }, + "patch": { + "type": "boolean" + }, "stash": { "type": "boolean" } @@ -3384,11 +4173,13 @@ "graph": true }, "scmRepositoryInline": { - "graph": true + "graph": true, + "stash": false }, "scmRepository": { "authors": true, "generateCommitMessage": true, + "patch": true, "graph": false }, "scmGroupInline": { @@ -3397,6 +4188,7 @@ "scmGroup": { "compare": true, "openClose": true, + "patch": true, "stash": true }, "scmItemInline": { @@ -3420,7 +4212,7 @@ { "id": "keyboard", "title": "Keyboard Shortcuts", - "order": 122, + "order": 1400, "properties": { "gitlens.keymap": { "type": "string", @@ -3432,7 +4224,7 @@ ], "enumDescriptions": [ "Adds an alternate set of shortcut keys that start with `Alt` (⌥ on macOS)", - "Adds a chorded set of shortcut keys that start with `Ctrl+Alt+G` (`⌥⌘G` on macOS)", + "Adds a chorded set of shortcut keys that start with `Ctrl+Shift+G` (`⌥⌘G` on macOS)", "No shortcut keys will be added" ], "markdownDescription": "Specifies the keymap to use for GitLens shortcut keys", @@ -3444,7 +4236,7 @@ { "id": "modes", "title": "Modes", - "order": 123, + "order": 1500, "properties": { "gitlens.mode.statusBar.enabled": { "type": "boolean", @@ -3619,10 +4411,45 @@ } } }, + { + "id": "gitkraken", + "title": "GitKraken", + "order": 9000, + "properties": { + "gitlens.gitKraken.activeOrganizationId": { + "type": "string", + "markdownDescription": "Specifies the ID of the user's active GitKraken organization in GitLens", + "scope": "window", + "order": 1 + } + } + }, + { + "id": "experimental", + "title": "Experimental", + "order": 9500, + "properties": { + "gitlens.cloudPatches.experimental.layout": { + "type": "string", + "default": "view", + "enum": [ + "editor", + "view" + ], + "enumDescriptions": [ + "Prefer showing Cloud Patches in the editor area", + "Prefer showing Cloud Patches in a view" + ], + "markdownDescription": "(Experimental) Specifies the preferred layout of for _Cloud Patches_", + "scope": "window", + "order": 20 + } + } + }, { "id": "advanced", "title": "Advanced", - "order": 1000, + "order": 10000, "properties": { "gitlens.detectNestedRepositories": { "type": "boolean", @@ -3654,7 +4481,9 @@ "suppressRebaseSwitchToTextWarning": false, "suppressIntegrationDisconnectedTooManyFailedRequestsWarning": false, "suppressIntegrationRequestFailed500Warning": false, - "suppressIntegrationRequestTimedOutWarning": false + "suppressIntegrationRequestTimedOutWarning": false, + "suppressBlameInvalidIgnoreRevsFileWarning": false, + "suppressBlameInvalidIgnoreRevsFileBadRevisionWarning": false }, "properties": { "suppressCommitHasNoPreviousCommitWarning": { @@ -3726,6 +4555,16 @@ "type": "boolean", "default": false, "description": "Integration Request Timed Out Warning" + }, + "suppressBlameInvalidIgnoreRevsFileWarning": { + "type": "boolean", + "default": false, + "description": "Invalid Blame IgnoreRevs File Warning" + }, + "suppressBlameInvalidIgnoreRevsFileBadRevisionWarning": { + "type": "boolean", + "default": false, + "description": "Invalid Revision in Blame IgnoreRevs File Warning" } }, "additionalProperties": false, @@ -3734,7 +4573,10 @@ "order": 5 }, "gitlens.advanced.repositorySearchDepth": { - "type": "number", + "type": [ + "number", + "null" + ], "default": null, "markdownDescription": "Specifies how many folders deep to search for repositories. Defaults to `#git.repositoryScanMaxDepth#`", "scope": "resource", @@ -3796,20 +4638,6 @@ "scope": "resource", "order": 41 }, - "gitlens.advanced.blame.delayAfterEdit": { - "type": "number", - "default": 5000, - "markdownDescription": "Specifies the time (in milliseconds) to wait before re-blaming an unsaved document after an edit. Use 0 to specify an infinite wait", - "scope": "window", - "order": 42 - }, - "gitlens.advanced.blame.sizeThresholdAfterEdit": { - "type": "number", - "default": 5000, - "markdownDescription": "Specifies the maximum document size (in lines) allowed to be re-blamed after an edit while still unsaved. Use 0 to specify no maximum", - "scope": "window", - "order": 43 - }, "gitlens.advanced.similarityThreshold": { "type": [ "number", @@ -3885,16 +4713,6 @@ "scope": "window", "order": 110 }, - "gitlens.experimental.nativeGit": { - "type": [ - "boolean", - "null" - ], - "default": true, - "markdownDescription": "(Experimental) Specifies whether to use Git directly for fetch/push/pull operation instead of relying on VS Code's built-in Git implementation", - "scope": "window", - "order": 120 - }, "gitlens.advanced.useSymmetricDifferenceNotation": { "deprecationMessage": "Deprecated. This setting is no longer used", "markdownDescription": "Deprecated. This setting is no longer used" @@ -3922,18 +4740,20 @@ }, "gitlens.outputLevel": { "type": "string", - "default": "errors", + "default": "warn", "enum": [ - "silent", - "errors", - "verbose", + "off", + "error", + "warn", + "info", "debug" ], "enumDescriptions": [ "Logs nothing", "Logs only errors", - "Logs all errors, warnings, and messages", - "Logs all errors, warnings, and messages with extra context useful for debugging" + "Logs errors and warnings", + "Logs errors, warnings, and messages", + "Logs verbose errors, warnings, and messages. Best for issue reporting." ], "markdownDescription": "Specifies how much (if any) output will be sent to the GitLens output channel", "scope": "window", @@ -3948,41 +4768,19 @@ "monsterid", "retro", "robohash", - "wavatar" - ], - "enumDescriptions": [ - "A geometric pattern", - "A simple, cartoon-style silhouetted outline of a person (does not vary by email hash)", - "A monster with different colors, faces, etc", - "8-bit arcade-style pixelated faces", - "A robot with different colors, faces, etc", - "A face with differing features and backgrounds" - ], - "markdownDescription": "Specifies the style of the gravatar default (fallback) images", - "scope": "window", - "order": 40 - }, - "gitlens.fileAnnotations.command": { - "type": [ - "string", - "null" - ], - "default": null, - "enum": [ - null, - "blame", - "heatmap", - "changes" + "wavatar" ], "enumDescriptions": [ - "Shows a menu to choose which file annotations to toggle", - "Toggles file blame annotations", - "Toggles file heatmap annotations", - "Toggles file changes annotations" + "A geometric pattern", + "A simple, cartoon-style silhouetted outline of a person (does not vary by email hash)", + "A monster with different colors, faces, etc", + "8-bit arcade-style pixelated faces", + "A robot with different colors, faces, etc", + "A face with differing features and backgrounds" ], - "markdownDescription": "Specifies whether the file annotations button in the editor title shows a menu or immediately toggles the specified file annotations", + "markdownDescription": "Specifies the style of the gravatar default (fallback) images", "scope": "window", - "order": 50 + "order": 40 }, "gitlens.proxy": { "type": [ @@ -4304,6 +5102,24 @@ "highContrast": "#c74e39" } }, + { + "id": "gitlens.decorations.statusMergingOrRebasingConflictForegroundColor", + "description": "Specifies the decoration foreground color of the status during a rebase operation with conflicts", + "defaults": { + "light": "#ad0707", + "dark": "#c74e39", + "highContrast": "#c74e39" + } + }, + { + "id": "gitlens.decorations.statusMergingOrRebasingForegroundColor", + "description": "Specifies the decoration foreground color of the status during a rebase operation", + "defaults": { + "dark": "#D8AF1B", + "light": "#D8AF1B", + "highContrast": "#D8AF1B" + } + }, { "id": "gitlens.decorations.workspaceRepoMissingForegroundColor", "description": "Specifies the decoration foreground color of workspace repos which are missing a local path", @@ -4539,6 +5355,26 @@ "highContrast": "#3087cf" } }, + { + "id": "gitlens.graphMinimapMarkerPullRequestsColor", + "description": "Specifies the color marking pull requests on the minimap of the _Commit Graph_", + "defaults": { + "light": "#ff8f18", + "highContrastLight": "#ff8f18", + "dark": "#c76801", + "highContrast": "#c76801" + } + }, + { + "id": "gitlens.graphScrollMarkerPullRequestsColor", + "description": "Specifies the color marking pull requests on the scrollbar of the _Commit Graph_", + "defaults": { + "light": "#ff8f18", + "highContrastLight": "#ff8f18", + "dark": "#c76801", + "highContrast": "#c76801" + } + }, { "id": "gitlens.graphMinimapMarkerRemoteBranchesColor", "description": "Specifies the color marking remote branches on the minimap of the _Commit Graph_", @@ -4598,21 +5434,80 @@ "dark": "#6b562e", "highContrast": "#6b562e" } + }, + { + "id": "gitlens.launchpadIndicatorMergeableColor", + "description": "Specifies the color of the _Launchpad_ indicator icon when the priority is mergeable", + "defaults": { + "light": "#42c954", + "dark": "#3fb950", + "highContrast": "#68ff79" + } + }, + { + "id": "gitlens.launchpadIndicatorMergeableHoverColor", + "description": "Specifies the color of the _Launchpad_ indicator icon in the hover when the priority is mergeable", + "defaults": { + "light": "#42c954", + "dark": "#3fb950", + "highContrast": "#68ff79" + } + }, + { + "id": "gitlens.launchpadIndicatorBlockedColor", + "description": "Specifies the color of the _Launchpad_ indicator icon when the priority is blocked", + "defaults": { + "light": "#ad0707", + "dark": "#c74e39", + "highContrast": "#ff003c" + } + }, + { + "id": "gitlens.launchpadIndicatorBlockedHoverColor", + "description": "Specifies the color of the _Launchpad_ indicator icon in the hover when the priority is blocked", + "defaults": { + "light": "#ad0707", + "dark": "#c74e39", + "highContrast": "#ff003c" + } + }, + { + "id": "gitlens.launchpadIndicatorAttentionColor", + "description": "Specifies the color of the _Launchpad_ indicator icon when the priority is follow-up or needs review", + "defaults": { + "dark": "#D8AF1B", + "light": "#cc9b15", + "highContrast": "#D8AF1B" + } + }, + { + "id": "gitlens.launchpadIndicatorAttentionHoverColor", + "description": "Specifies the color of the _Launchpad_ indicator icon in the hover when the priority is follow-up or needs review", + "defaults": { + "dark": "#D8AF1B", + "light": "#cc9b15", + "highContrast": "#D8AF1B" + } } ], "commands": [ { "command": "gitlens.generateCommitMessage", - "title": "Generate Commit Message (Experimental)...", + "title": "Generate Commit Message (GitLens)...", + "category": "GitLens" + }, + { + "command": "gitlens.reset", + "title": "Reset Stored Data...", "category": "GitLens" }, { "command": "gitlens.resetAIKey", - "title": "Reset Stored AI Key", + "title": "Reset Stored AI Keys...", "category": "GitLens" }, { - "command": "gitlens.plus.loginOrSignUp", + "command": "gitlens.plus.login", "title": "Sign In to GitKraken...", "category": "GitLens" }, @@ -4621,18 +5516,33 @@ "title": "Sign Out of GitKraken", "category": "GitLens" }, + { + "command": "gitlens.plus.signUp", + "title": "Sign Up for GitKraken...", + "category": "GitLens" + }, { "command": "gitlens.plus.startPreviewTrial", "title": "Preview Pro", "category": "GitLens" }, + { + "command": "gitlens.plus.reactivateProTrial", + "title": "Reactivate Pro Trial", + "category": "GitLens" + }, { "command": "gitlens.plus.manage", "title": "Manage Your Account...", "category": "GitLens" }, { - "command": "gitlens.plus.purchase", + "command": "gitlens.plus.cloudIntegrations.manage", + "title": "Manage Cloud Integrations...", + "category": "GitLens" + }, + { + "command": "gitlens.plus.upgrade", "title": "Upgrade to Pro...", "category": "GitLens" }, @@ -4647,23 +5557,68 @@ "category": "GitLens" }, { - "command": "gitlens.plus.reset", - "title": "Reset", + "command": "gitlens.plus.refreshRepositoryAccess", + "title": "Refresh Repository Access", "category": "GitLens" }, { - "command": "gitlens.plus.resetRepositoryAccess", - "title": "Reset Repository Access Cache", + "command": "gitlens.gk.switchOrganization", + "title": "Switch Organization...", "category": "GitLens" }, { - "command": "gitlens.plus.refreshRepositoryAccess", - "title": "Refresh Repository Access", + "command": "gitlens.getStarted", + "title": "Get Started", "category": "GitLens" }, { - "command": "gitlens.getStarted", - "title": "Get Started", + "command": "gitlens.showPatchDetailsPage", + "title": "Show Patch Details", + "category": "GitLens" + }, + { + "command": "gitlens.applyPatchFromClipboard", + "title": "Apply Copied Patch", + "category": "GitLens" + }, + { + "command": "gitlens.copyPatchToClipboard", + "title": "Copy as Patch", + "category": "GitLens" + }, + { + "command": "gitlens.copyWorkingChangesToWorktree", + "title": "Copy Working Changes to Worktree...", + "category": "GitLens" + }, + { + "command": "gitlens.graph.copyWorkingChangesToWorktree", + "title": "Copy Working Changes to Worktree...", + "category": "GitLens" + }, + { + "command": "gitlens.createPatch", + "title": "Create Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.createCloudPatch", + "title": "Create Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.shareAsCloudPatch", + "title": "Share as Cloud Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.openCloudPatch", + "title": "Open Cloud Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.openPatch", + "title": "Open Patch...", "category": "GitLens" }, { @@ -4673,7 +5628,7 @@ }, { "command": "gitlens.showCommitDetailsView", - "title": "Show Commit Details View", + "title": "Show Inspect View", "category": "GitLens" }, { @@ -4686,16 +5641,39 @@ "title": "Show Contributors View", "category": "GitLens" }, + { + "command": "gitlens.showDraftsView", + "title": "Show Cloud Patches View", + "category": "GitLens" + }, { "command": "gitlens.showFileHistoryView", "title": "Show File History View", "category": "GitLens" }, + { + "command": "gitlens.showLaunchpad", + "title": "Open Launchpad", + "category": "GitLens", + "icon": "$(rocket)" + }, { "command": "gitlens.showFocusPage", - "title": "Show Focus View", + "title": "Open Launchpad in Editor", + "category": "GitLens", + "icon": "$(rocket)" + }, + { + "command": "gitlens.launchpad.split", + "title": "Split Launchpad in Editor", + "category": "GitLens", + "icon": "$(split-horizontal)" + }, + { + "command": "gitlens.launchpad.indicator.toggle", + "title": "Toggle Launchpad Indicator", "category": "GitLens", - "icon": "$(layers)" + "icon": "$(rocket)" }, { "command": "gitlens.showGraph", @@ -4705,10 +5683,16 @@ }, { "command": "gitlens.showGraphPage", - "title": "Show Commit Graph in Editor Area", + "title": "Show Commit Graph in Editor", "category": "GitLens", "icon": "$(gitlens-graph)" }, + { + "command": "gitlens.graph.split", + "title": "Split Commit Graph", + "category": "GitLens", + "icon": "$(split-horizontal)" + }, { "command": "gitlens.showGraphView", "title": "Show Commit Graph View", @@ -4776,95 +5760,113 @@ "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#views", + "command": "gitlens.showSettingsPage!views", "title": "Open Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#autolinks", + "command": "gitlens.showSettingsPage!autolinks", "title": "Configure Autolinks", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#branches-view", + "command": "gitlens.showSettingsPage!file-annotations", + "title": "Open File Annotation Settings", + "category": "GitLens", + "icon": "$(gear)" + }, + { + "command": "gitlens.showSettingsPage!branches-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#commits-view", + "command": "gitlens.showSettingsPage!commits-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#contributors-view", + "command": "gitlens.showSettingsPage!contributors-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#file-history-view", + "command": "gitlens.showSettingsPage!file-history-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#line-history-view", + "command": "gitlens.showSettingsPage!line-history-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#remotes-view", + "command": "gitlens.showSettingsPage!remotes-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#repositories-view", + "command": "gitlens.showSettingsPage!repositories-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#search-compare-view", + "command": "gitlens.showSettingsPage!search-compare-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#stashes-view", + "command": "gitlens.showSettingsPage!stashes-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#tags-view", + "command": "gitlens.showSettingsPage!tags-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#worktrees-view", + "command": "gitlens.showSettingsPage!worktrees-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#commit-graph", + "command": "gitlens.showSettingsPage!commit-graph", "title": "Open Commit Graph Settings", "category": "GitLens", "icon": "$(gear)" }, { "command": "gitlens.showTimelinePage", + "title": "Show Visual File History", + "category": "GitLens", + "icon": "$(graph-scatter)" + }, + { + "command": "gitlens.showInTimeline", "title": "Open Visual File History", "category": "GitLens", "icon": "$(graph-scatter)" }, + { + "command": "gitlens.timeline.split", + "title": "Split Visual File History", + "category": "GitLens", + "icon": "$(split-horizontal)" + }, { "command": "gitlens.showStashesView", "title": "Show Stashes View", @@ -4928,21 +5930,21 @@ "title": "Open Changes with Next Revision", "category": "GitLens", "icon": "$(gitlens-next-commit)", - "enablement": "gitlens:activeFileStatus =~ /revision/" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithNextInDiffLeft", "title": "Open Changes with Next Revision", "category": "GitLens", "icon": "$(gitlens-next-commit)", - "enablement": "gitlens:activeFileStatus =~ /revision/" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithNextInDiffRight", "title": "Open Changes with Next Revision", "category": "GitLens", "icon": "$(gitlens-next-commit)", - "enablement": "gitlens:activeFileStatus =~ /revision/" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithPrevious", @@ -4967,6 +5969,16 @@ "title": "Open Line Changes with Previous Revision", "category": "GitLens" }, + { + "command": "gitlens.diffFolderWithRevision", + "title": "Open Folder Changes with Revision...", + "category": "GitLens" + }, + { + "command": "gitlens.diffFolderWithRevisionFrom", + "title": "Open Folder Changes with Branch or Tag...", + "category": "GitLens" + }, { "command": "gitlens.diffWithRevision", "title": "Open Changes with Revision...", @@ -5015,91 +6027,71 @@ "command": "gitlens.toggleFileBlame", "title": "Toggle File Blame", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileBlameInDiffLeft", "title": "Toggle File Blame", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileBlameInDiffRight", "title": "Toggle File Blame", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" + }, + { + "command": "gitlens.annotations.nextChange", + "title": "Next Change", + "icon": "$(arrow-down)" + }, + { + "command": "gitlens.annotations.previousChange", + "title": "Previous Change", + "icon": "$(arrow-up)" }, { "command": "gitlens.clearFileAnnotations", "title": "Clear File Annotations", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git-orange.svg", - "light": "images/light/icon-git-orange.svg" - } + "icon": "$(gitlens-gitlens-filled)" }, { "command": "gitlens.computingFileAnnotations", "title": "Computing File Annotations...", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git-progress.svg", - "light": "images/light/icon-git-progress.svg" - } + "icon": "$(gitlens-gitlens-filled)" }, { "command": "gitlens.toggleFileHeatmap", "title": "Toggle File Heatmap", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileHeatmapInDiffLeft", "title": "Toggle File Heatmap", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileHeatmapInDiffRight", "title": "Toggle File Heatmap", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileChanges", "title": "Toggle File Changes", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileChangesOnly", "title": "Toggle File Changes", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleLineBlame", @@ -5121,11 +6113,41 @@ "title": "Git Branch...", "category": "GitLens" }, + { + "command": "gitlens.gitCommands.branch.create", + "title": "Git Create Branch...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.branch.delete", + "title": "Git Delete Branch...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.branch.prune", + "title": "Git Prune Branches...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.branch.rename", + "title": "Git Rename Branch...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.checkout", + "title": "Git Checkout...", + "category": "GitLens" + }, { "command": "gitlens.gitCommands.cherryPick", "title": "Git Cherry Pick...", "category": "GitLens" }, + { + "command": "gitlens.gitCommands.history", + "title": "Git History (log)...", + "category": "GitLens" + }, { "command": "gitlens.gitCommands.merge", "title": "Git Merge...", @@ -5136,6 +6158,26 @@ "title": "Git Rebase...", "category": "GitLens" }, + { + "command": "gitlens.gitCommands.remote", + "title": "Git Remote...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.remote.add", + "title": "Git Add Remote...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.remote.prune", + "title": "Git Prune Remote...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.remote.remove", + "title": "Git Remove Remote...", + "category": "GitLens" + }, { "command": "gitlens.gitCommands.reset", "title": "Git Reset...", @@ -5146,9 +6188,49 @@ "title": "Git Revert...", "category": "GitLens" }, + { + "command": "gitlens.gitCommands.show", + "title": "Git Show...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.stash", + "title": "Git Stash...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.stash.drop", + "title": "Git Drop Stash...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.stash.list", + "title": "Git Stash List...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.stash.pop", + "title": "Git Pop Stash...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.stash.push", + "title": "Git Push Stash...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.stash.rename", + "title": "Git Rename Stash...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.status", + "title": "Git Status...", + "category": "GitLens" + }, { "command": "gitlens.gitCommands.switch", - "title": "Git Switch...", + "title": "Git Switch to...", "category": "GitLens" }, { @@ -5156,14 +6238,34 @@ "title": "Git Tag...", "category": "GitLens" }, + { + "command": "gitlens.gitCommands.tag.create", + "title": "Git Create Tag...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.tag.delete", + "title": "Git Delete Tag...", + "category": "GitLens" + }, { "command": "gitlens.gitCommands.worktree", "title": "Git Worktree...", "category": "GitLens" }, + { + "command": "gitlens.gitCommands.worktree.create", + "title": "Git Create Worktree...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.worktree.delete", + "title": "Git Delete Worktree...", + "category": "GitLens" + }, { "command": "gitlens.gitCommands.worktree.open", - "title": "Open Git Worktree...", + "title": "Git Open Worktree...", "category": "GitLens" }, { @@ -5209,19 +6311,19 @@ }, { "command": "gitlens.showCommitInView", - "title": "Open Commit Details", + "title": "Inspect Commit Details", "category": "GitLens", "icon": "$(eye)" }, { "command": "gitlens.showLineCommitInView", - "title": "Open Line Commit Details", + "title": "Inspect Line Commit Details", "category": "GitLens", "icon": "$(eye)" }, { "command": "gitlens.showInDetailsView", - "title": "Open Details", + "title": "Inspect Details", "category": "GitLens", "icon": "$(eye)" }, @@ -5260,21 +6362,21 @@ "title": "Show Revision Commit", "category": "GitLens", "icon": "$(gitlens-commit-horizontal)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.showQuickRevisionDetailsInDiffLeft", "title": "Show Revision Commit", "category": "GitLens", "icon": "$(gitlens-commit-horizontal)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.showQuickRevisionDetailsInDiffRight", "title": "Show Revision Commit", "category": "GitLens", "icon": "$(gitlens-commit-horizontal)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.showQuickFileHistory", @@ -5308,19 +6410,19 @@ }, { "command": "gitlens.addAuthors", - "title": "Add Co-authors", + "title": "Add Co-authors...", "category": "GitLens", "icon": "$(person-add)" }, { "command": "gitlens.connectRemoteProvider", - "title": "Connect to Remote", + "title": "Connect Remote Integration", "category": "GitLens", "icon": "$(plug)" }, { "command": "gitlens.disconnectRemoteProvider", - "title": "Disconnect from Remote", + "title": "Disconnect Remote Integration", "category": "GitLens", "icon": "$(gitlens-unplug)" }, @@ -5387,12 +6489,36 @@ "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.copyDeepLinkToFile", + "title": "Copy Link to File", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.copyDeepLinkToFileAtRevision", + "title": "Copy Link to File at Revision...", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.copyDeepLinkToLines", + "title": "Copy Link to Code", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.copyDeepLinkToRepo", "title": "Copy Link to Repository", "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.copyDeepLinkToWorkspace", + "title": "Copy Link to Workspace", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.copyDeepLinkToTag", "title": "Copy Link to Tag", @@ -5411,6 +6537,18 @@ "category": "GitLens", "icon": "$(globe)" }, + { + "command": "gitlens.views.openBranchOnRemote", + "title": "Open Branch on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.views.openBranchOnRemote.multi", + "title": "Open Branches on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.openCurrentBranchOnRemote", "title": "Open Current Branch on Remote", @@ -5429,12 +6567,36 @@ "category": "GitLens", "icon": "$(globe)" }, + { + "command": "gitlens.views.openCommitOnRemote", + "title": "Open Commit on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.views.openCommitOnRemote.multi", + "title": "Open Commits on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.copyRemoteCommitUrl", "title": "Copy Remote Commit URL", "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.views.copyRemoteCommitUrl", + "title": "Copy Remote Commit URL", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.views.copyRemoteCommitUrl.multi", + "title": "Copy Remote Commit URLs", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.openComparisonOnRemote", "title": "Open Comparison on Remote", @@ -5500,30 +6662,6 @@ "icon": "$(gitlens-open-revision)", "category": "GitLens" }, - { - "command": "gitlens.openAutolinkUrl", - "title": "Open Autolink URL", - "category": "GitLens", - "icon": "$(globe)" - }, - { - "command": "gitlens.copyAutolinkUrl", - "title": "Copy Autolink URL", - "category": "GitLens", - "icon": "$(copy)" - }, - { - "command": "gitlens.openIssueOnRemote", - "title": "Open Issue on Remote", - "category": "GitLens", - "icon": "$(globe)" - }, - { - "command": "gitlens.copyRemoteIssueUrl", - "title": "Copy Issue URL", - "category": "GitLens", - "icon": "$(copy)" - }, { "command": "gitlens.openPullRequestOnRemote", "title": "Open Pull Request on Remote", @@ -5559,21 +6697,21 @@ "title": "Open File at Revision", "category": "GitLens", "icon": "$(gitlens-open-revision)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.openRevisionFileInDiffLeft", "title": "Open File at Revision", "category": "GitLens", "icon": "$(gitlens-open-revision)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.openRevisionFileInDiffRight", "title": "Open File at Revision", "category": "GitLens", "icon": "$(gitlens-open-revision)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ " }, { "command": "gitlens.openWorkingFile", @@ -5595,7 +6733,14 @@ }, { "command": "gitlens.stashApply", - "title": "Apply Stash", + "title": "Apply a Stash...", + "category": "GitLens", + "icon": "$(gitlens-stash-pop)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.stash.apply", + "title": "Apply Stash...", "category": "GitLens", "icon": "$(gitlens-stash-pop)", "enablement": "!operationInProgress" @@ -5607,6 +6752,13 @@ "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.stash.delete.multi", + "title": "Drop Stashes...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.stash.rename", "title": "Rename Stash...", @@ -5616,14 +6768,14 @@ }, { "command": "gitlens.stashSave", - "title": "Stash All Changes", + "title": "Stash All Changes...", "category": "GitLens", "icon": "$(gitlens-stash-save)", "enablement": "!operationInProgress" }, { "command": "gitlens.stashSaveFiles", - "title": "Stash Changes", + "title": "Stash Changes...", "category": "GitLens", "icon": "$(gitlens-stash-save)", "enablement": "!operationInProgress" @@ -5638,21 +6790,6 @@ "title": "Open All Changes (difftool)", "category": "GitLens" }, - { - "command": "gitlens.resetAvatarCache", - "title": "Reset Avatar Cache", - "category": "GitLens" - }, - { - "command": "gitlens.resetSuppressedWarnings", - "title": "Reset Suppressed Warnings", - "category": "GitLens" - }, - { - "command": "gitlens.resetTrackedUsage", - "title": "Reset Tracked Usage", - "category": "GitLens" - }, { "command": "gitlens.inviteToLiveShare", "title": "Invite to Live Share", @@ -5664,28 +6801,28 @@ "title": "Browse Repository from Revision", "category": "GitLens", "icon": "$(folder-opened)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.browseRepoAtRevisionInNewWindow", "title": "Browse Repository from Revision in New Window", "category": "GitLens", "icon": "$(folder-opened)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.browseRepoBeforeRevision", "title": "Browse Repository from Before Revision", "category": "GitLens", "icon": "$(folder-opened)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.browseRepoBeforeRevisionInNewWindow", "title": "Browse Repository from Before Revision in New Window", "category": "GitLens", "icon": "$(folder-opened)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.views.browseRepoAtRevision", @@ -5715,21 +6852,21 @@ "command": "gitlens.fetchRepositories", "title": "Fetch", "category": "GitLens", - "icon": "$(sync)", + "icon": "$(gitlens-repo-fetch)", "enablement": "!operationInProgress" }, { "command": "gitlens.pullRepositories", "title": "Pull", "category": "GitLens", - "icon": "$(arrow-down)", + "icon": "$(gitlens-repo-pull)", "enablement": "!operationInProgress" }, { "command": "gitlens.pushRepositories", "title": "Push", "category": "GitLens", - "icon": "$(arrow-up)", + "icon": "$(gitlens-repo-push)", "enablement": "!operationInProgress" }, { @@ -5789,6 +6926,36 @@ "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.views.copyAsMarkdown", + "title": "Copy as Markdown", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.views.copyUrl", + "title": "Copy URL", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.views.copyUrl.multi", + "title": "Copy URLs", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.views.openUrl", + "title": "Open URL", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.views.openUrl.multi", + "title": "Open URLs", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.views.pruneRemote", "title": "Prune", @@ -5799,7 +6966,7 @@ "command": "gitlens.views.fetch", "title": "Fetch", "category": "GitLens", - "icon": "$(sync)", + "icon": "$(gitlens-repo-fetch)", "enablement": "!operationInProgress" }, { @@ -5820,21 +6987,21 @@ "command": "gitlens.views.pull", "title": "Pull", "category": "GitLens", - "icon": "$(arrow-down)", + "icon": "$(gitlens-repo-pull)", "enablement": "!operationInProgress" }, { "command": "gitlens.views.push", "title": "Push", "category": "GitLens", - "icon": "$(arrow-up)", + "icon": "$(gitlens-repo-push)", "enablement": "!operationInProgress" }, { "command": "gitlens.views.pushWithForce", "title": "Push (force)", "category": "GitLens", - "icon": "$(gitlens-arrow-up-force)", + "icon": "$(gitlens-repo-force-push)", "enablement": "!operationInProgress" }, { @@ -5842,6 +7009,11 @@ "title": "Open in Terminal", "category": "GitLens" }, + { + "command": "gitlens.views.openInIntegratedTerminal", + "title": "Open in Integrated Terminal", + "category": "GitLens" + }, { "command": "gitlens.views.setAsDefault", "title": "Set as Default", @@ -5886,12 +7058,24 @@ "category": "GitLens", "icon": "$(star-empty)" }, + { + "command": "gitlens.views.star.multi", + "title": "Add to Favorites", + "category": "GitLens", + "icon": "$(star-empty)" + }, { "command": "gitlens.views.unstar", "title": "Remove from Favorites", "category": "GitLens", "icon": "$(star-full)" }, + { + "command": "gitlens.views.unstar.multi", + "title": "Remove from Favorites", + "category": "GitLens", + "icon": "$(star-full)" + }, { "command": "gitlens.views.openDirectoryDiff", "title": "Open Directory Compare", @@ -5939,11 +7123,23 @@ { "command": "gitlens.views.openChangedFileDiffs", "title": "Open All Changes", + "icon": "$(diff-multiple)", "category": "GitLens" }, { "command": "gitlens.views.openChangedFileDiffsWithWorking", "title": "Open All Changes with Working Tree", + "icon": "$(diff-multiple)", + "category": "GitLens" + }, + { + "command": "gitlens.views.openChangedFileDiffsIndividually", + "title": "Open All Changes, Individually", + "category": "GitLens" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithWorkingIndividually", + "title": "Open All Changes with Working Tree, Individually", "category": "GitLens" }, { @@ -5969,7 +7165,18 @@ }, { "command": "gitlens.views.compareAncestryWithWorking", - "title": "Compare Ancestry with Working Tree", + "title": "Compare Common Base with Working Tree", + "category": "GitLens" + }, + { + "command": "gitlens.views.compareWithMergeBase", + "title": "Compare with Common Base", + "category": "GitLens" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithMergeBase", + "title": "Open All Changes with Common Base", + "icon": "$(diff-multiple)", "category": "GitLens" }, { @@ -6011,7 +7218,7 @@ }, { "command": "gitlens.views.addAuthors", - "title": "Add Co-authors", + "title": "Add Co-authors...", "category": "GitLens", "icon": "$(person-add)" }, @@ -6022,11 +7229,10 @@ "icon": "$(person-add)" }, { - "command": "gitlens.views.title.applyStash", - "title": "Apply a Stash...", + "command": "gitlens.views.addAuthor.multi", + "title": "Add as Co-authors", "category": "GitLens", - "icon": "$(gitlens-stash-pop)", - "enablement": "!operationInProgress" + "icon": "$(person-add)" }, { "command": "gitlens.views.createWorktree", @@ -6037,7 +7243,7 @@ }, { "command": "gitlens.ghpr.views.openOrCreateWorktree", - "title": "Open Worktree for Pull Request via GitLens...", + "title": "Checkout Pull Request in Worktree (GitLens)...", "category": "GitLens", "enablement": "!operationInProgress" }, @@ -6055,6 +7261,13 @@ "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.deleteWorktree.multi", + "title": "Delete Worktrees...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.openWorktree", "title": "Open Worktree", @@ -6067,6 +7280,12 @@ "category": "GitLens", "icon": "$(empty-window)" }, + { + "command": "gitlens.views.openWorktreeInNewWindow.multi", + "title": "Open Worktrees in New Window", + "category": "GitLens", + "icon": "$(empty-window)" + }, { "command": "gitlens.views.revealRepositoryInExplorer", "title": "Reveal in File Explorer", @@ -6083,6 +7302,12 @@ "category": "GitLens", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.cherryPick.multi", + "title": "Cherry Pick Commits...", + "category": "GitLens", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.createBranch", "title": "Create Branch...", @@ -6104,6 +7329,13 @@ "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.deleteBranch.multi", + "title": "Delete Branches...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.renameBranch", "title": "Rename Branch...", @@ -6131,6 +7363,13 @@ "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.deleteTag.multi", + "title": "Delete Tags...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.mergeBranchInto", "title": "Merge Branch into Current Branch...", @@ -6141,7 +7380,7 @@ "command": "gitlens.views.pushToCommit", "title": "Push to Commit...", "category": "GitLens", - "icon": "$(arrow-up)", + "icon": "$(gitlens-repo-push)", "enablement": "!operationInProgress" }, { @@ -6224,11 +7463,33 @@ "icon": "$(git-pull-request)" }, { - "command": "gitlens.views.clearNode", - "title": "Clear", + "command": "gitlens.views.openPullRequestChanges", + "title": "Open Pull Request Changes", + "category": "GitLens", + "icon": "$(diff-multiple)" + }, + { + "command": "gitlens.views.openPullRequestComparison", + "title": "Compare Pull Request", + "category": "GitLens", + "icon": "$(compare-changes)" + }, + { + "command": "gitlens.views.clearComparison", + "title": "Clear Comparison", "category": "GitLens", "icon": "$(close)" }, + { + "command": "gitlens.views.clearReviewed", + "title": "Clear Reviewed Files", + "category": "GitLens" + }, + { + "command": "gitlens.views.collapseNode", + "title": "Collapse", + "category": "GitLens" + }, { "command": "gitlens.views.dismissNode", "title": "Dismiss", @@ -6350,6 +7611,18 @@ "category": "GitLens", "icon": "$(refresh)" }, + { + "command": "gitlens.views.patchDetails.close", + "title": "Close Patch", + "category": "GitLens", + "icon": "$(close)" + }, + { + "command": "gitlens.views.patchDetails.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, { "command": "gitlens.views.commits.copy", "title": "Copy", @@ -6380,17 +7653,27 @@ "icon": "$(list-flat)" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", - "title": "View Only My Commits", + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "title": "Filter Commits by Author...", "category": "GitLens", "icon": "$(filter)" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "title": "View All Commits", + "command": "gitlens.views.commits.setCommitsFilterOff", + "title": "Clear Filter", "category": "GitLens", "icon": "$(filter-filled)" }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOff", + "title": "Hide Merge Commits", + "category": "GitLens" + }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOn", + "title": "Show Merge Commits", + "category": "GitLens" + }, { "command": "gitlens.views.commits.setShowAvatarsOn", "title": "Show Avatars", @@ -6470,6 +7753,16 @@ "title": "Hide Avatars", "category": "GitLens" }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOff", + "title": "Hide Merge Commits", + "category": "GitLens" + }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOn", + "title": "Show Merge Commits", + "category": "GitLens" + }, { "command": "gitlens.views.contributors.setShowStatisticsOn", "title": "Show Statistics", @@ -6480,6 +7773,57 @@ "title": "Hide Statistics", "category": "GitLens" }, + { + "command": "gitlens.views.drafts.copy", + "title": "Copy", + "category": "GitLens" + }, + { + "command": "gitlens.views.drafts.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.drafts.info", + "title": "Learn about Cloud Patches...", + "category": "GitLens", + "icon": "$(info)" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOn", + "title": "Show Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOff", + "title": "Hide Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.drafts.create", + "title": "Create Cloud Patch...", + "category": "GitLens", + "icon": "$(add)" + }, + { + "command": "gitlens.views.drafts.delete", + "title": "Delete Cloud Patch...", + "category": "GitLens", + "icon": "$(trash)" + }, + { + "command": "gitlens.views.draft.open", + "title": "Open", + "category": "GitLens", + "icon": "$(eye)" + }, + { + "command": "gitlens.views.draft.openOnWeb", + "title": "Open on gitkraken.dev", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.views.fileHistory.changeBase", "title": "Change Base...", @@ -6526,14 +7870,12 @@ { "command": "gitlens.views.fileHistory.setRenameFollowingOn", "title": "Follow Renames", - "category": "GitLens", - "enablement": "!config.gitlens.advanced.fileHistoryShowAllBranches" + "category": "GitLens" }, { "command": "gitlens.views.fileHistory.setRenameFollowingOff", "title": "Don't Follow Renames", - "category": "GitLens", - "enablement": "!config.gitlens.advanced.fileHistoryShowAllBranches" + "category": "GitLens" }, { "command": "gitlens.views.fileHistory.setShowAllBranchesOn", @@ -6545,6 +7887,16 @@ "title": "View History for Current Branch Only", "category": "GitLens" }, + { + "command": "gitlens.views.fileHistory.setShowMergeCommitsOn", + "title": "Show Merge Commits", + "category": "GitLens" + }, + { + "command": "gitlens.views.fileHistory.setShowMergeCommitsOff", + "title": "Hide Merge Commits", + "category": "GitLens" + }, { "command": "gitlens.views.fileHistory.setShowAvatarsOn", "title": "Show Avatars", @@ -6557,7 +7909,7 @@ }, { "command": "gitlens.views.graph.openInTab", - "title": "Open in Editor Area", + "title": "Open in Editor", "category": "GitLens", "icon": "$(link-external)" }, @@ -6624,6 +7976,51 @@ "title": "Hide Avatars", "category": "GitLens" }, + { + "command": "gitlens.views.pullRequest.close", + "title": "Close", + "category": "GitLens", + "icon": "$(close)" + }, + { + "command": "gitlens.views.pullRequest.copy", + "title": "Copy", + "category": "GitLens" + }, + { + "command": "gitlens.views.pullRequest.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToAuto", + "title": "View Files as Auto", + "category": "GitLens", + "icon": "$(list-tree)" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToList", + "title": "View Files as List", + "category": "GitLens", + "icon": "$(gitlens-list-auto)" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToTree", + "title": "View Files as Tree", + "category": "GitLens", + "icon": "$(list-flat)" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOn", + "title": "Show Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOff", + "title": "Hide Avatars", + "category": "GitLens" + }, { "command": "gitlens.views.remotes.copy", "title": "Copy", @@ -6890,13 +8287,25 @@ "command": "gitlens.views.searchAndCompare.setFilesLayoutToList", "title": "View Files as List", "category": "GitLens", - "icon": "$(gitlens-list-auto)" + "icon": "$(gitlens-list-auto)" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesLayoutToTree", + "title": "View Files as Tree", + "category": "GitLens", + "icon": "$(list-flat)" + }, + { + "command": "gitlens.views.setResultsCommitsFilterAuthors", + "title": "Filter Commits by Author...", + "category": "GitLens", + "icon": "$(filter)" }, { - "command": "gitlens.views.searchAndCompare.setFilesLayoutToTree", - "title": "View Files as Tree", + "command": "gitlens.views.setResultsCommitsFilterOff", + "title": "Clear Filter", "category": "GitLens", - "icon": "$(list-flat)" + "icon": "$(filter-filled)" }, { "command": "gitlens.views.searchAndCompare.setShowAvatarsOn", @@ -7031,7 +8440,7 @@ }, { "command": "gitlens.views.workspaces.info", - "title": "Learn more about GitKraken Workspaces...", + "title": "Learn about GitKraken Workspaces...", "category": "GitLens", "icon": "$(info)" }, @@ -7192,14 +8601,14 @@ "category": "GitLens" }, { - "command": "gitlens.focus.refresh", + "command": "gitlens.launchpad.refresh", "title": "Refresh", "category": "GitLens", "icon": "$(refresh)" }, { "command": "gitlens.graph.switchToEditorLayout", - "title": "Prefer Commit Graph in Editor Area", + "title": "Prefer Commit Graph in Editor", "category": "GitLens", "enablement": "config.gitlens.graph.layout != editor" }, @@ -7213,21 +8622,21 @@ "command": "gitlens.graph.push", "title": "Push", "category": "GitLens", - "icon": "$(arrow-up)", + "icon": "$(gitlens-repo-push)", "enablement": "!operationInProgress" }, { "command": "gitlens.graph.pull", "title": "Pull", "category": "GitLens", - "icon": "$(arrow-down)", + "icon": "$(gitlens-repo-pull)", "enablement": "!operationInProgress" }, { "command": "gitlens.graph.fetch", "title": "Fetch", "category": "GitLens", - "icon": "$(sync)", + "icon": "$(gitlens-repo-fetch)", "enablement": "!operationInProgress" }, { @@ -7380,9 +8789,15 @@ "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.graph.copyRemoteCommitUrl.multi", + "title": "Copy Remote Commit URLs", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.graph.showInDetailsView", - "title": "Open Details", + "title": "Inspect Details", "category": "GitLens", "icon": "$(eye)" }, @@ -7397,6 +8812,12 @@ "category": "GitLens", "icon": "$(globe)" }, + { + "command": "gitlens.graph.openCommitOnRemote.multi", + "title": "Open Commits on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.graph.rebaseOntoCommit", "title": "Rebase Current Branch onto Commit...", @@ -7442,15 +8863,15 @@ "enablement": "!operationInProgress" }, { - "command": "gitlens.graph.saveStash", - "title": "Stash All Changes", + "command": "gitlens.graph.stash.save", + "title": "Stash All Changes...", "category": "GitLens", "icon": "$(gitlens-stash-save)", "enablement": "!operationInProgress" }, { - "command": "gitlens.graph.applyStash", - "title": "Apply Stash", + "command": "gitlens.graph.stash.apply", + "title": "Apply Stash...", "category": "GitLens", "icon": "$(gitlens-stash-pop)", "enablement": "!operationInProgress" @@ -7503,6 +8924,24 @@ "category": "GitLens", "icon": "$(git-pull-request-create)" }, + { + "command": "gitlens.graph.openPullRequest", + "title": "Open Pull Request", + "category": "GitLens", + "icon": "$(git-pull-request)" + }, + { + "command": "gitlens.graph.openPullRequestChanges", + "title": "Open Pull Request Changes", + "category": "GitLens", + "icon": "$(diff-multiple)" + }, + { + "command": "gitlens.graph.openPullRequestComparison", + "title": "Compare Pull Request", + "category": "GitLens", + "icon": "$(compare-changes)" + }, { "command": "gitlens.graph.openPullRequestOnRemote", "title": "Open Pull Request on Remote", @@ -7511,7 +8950,18 @@ }, { "command": "gitlens.graph.compareAncestryWithWorking", - "title": "Compare Ancestry with Working Tree", + "title": "Compare Common Base with Working Tree", + "category": "GitLens" + }, + { + "command": "gitlens.graph.compareWithMergeBase", + "title": "Compare with Common Base", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileDiffsWithMergeBase", + "title": "Open All Changes with Common Base", + "icon": "$(diff-multiple)", "category": "GitLens" }, { @@ -7539,11 +8989,23 @@ { "command": "gitlens.graph.openChangedFileDiffs", "title": "Open All Changes", + "icon": "$(diff-multiple)", "category": "GitLens" }, { "command": "gitlens.graph.openChangedFileDiffsWithWorking", "title": "Open All Changes with Working Tree", + "icon": "$(diff-multiple)", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileDiffsIndividually", + "title": "Open All Changes Individually", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileDiffsWithWorkingIndividually", + "title": "Open All Changes with Working Tree Individually", "category": "GitLens" }, { @@ -7705,6 +9167,21 @@ "title": "Hide Tag Markers", "category": "GitLens" }, + { + "command": "gitlens.graph.scrollMarkerPullRequestOn", + "title": "Show Pull Request Markers", + "category": "GitLens" + }, + { + "command": "gitlens.graph.scrollMarkerPullRequestOff", + "title": "Hide Pull Request Markers", + "category": "GitLens" + }, + { + "command": "gitlens.graph.shareAsCloudPatch", + "title": "Share as Cloud Patch...", + "category": "GitLens" + }, { "command": "gitlens.timeline.refresh", "title": "Refresh", @@ -7909,8 +9386,8 @@ "fontCharacter": "\\f11a" } }, - "gitlens-arrow-up-force": { - "description": "arrow-up-force icon", + "gitlens-repo-force-push": { + "description": "repo-force-push icon", "default": { "fontPath": "dist/glicons.woff2", "fontCharacter": "\\f11b" @@ -7985,26 +9462,136 @@ "fontPath": "dist/glicons.woff2", "fontCharacter": "\\f125" } + }, + "gitlens-confirm-checked": { + "description": "confirm-checked icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f126" + } + }, + "gitlens-confirm-unchecked": { + "description": "confirm-unchecked icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f127" + } + }, + "gitlens-cloud-patch": { + "description": "cloud-patch icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f128" + } + }, + "gitlens-cloud-patch-share": { + "description": "cloud-patch-share icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f129" + } + }, + "gitlens-inspect": { + "description": "inspect icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f12a" + } + }, + "gitlens-repository-filled": { + "description": "repository-filled icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f12b" + } + }, + "gitlens-gitlens-filled": { + "description": "gitlens-filled icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f12c" + } + }, + "gitlens-code-suggestion": { + "description": "code-suggestion icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f12d" + } + }, + "gitlens-diff-multiple": { + "description": "diff-multiple icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f12e" + } + }, + "gitlens-diff-single": { + "description": "diff-single icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f12f" + } + }, + "gitlens-repo-fetch": { + "description": "repo-fetch icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f130" + } + }, + "gitlens-repo-pull": { + "description": "repo-pull icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f131" + } + }, + "gitlens-repo-push": { + "description": "repo-push icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f132" + } + }, + "gitlens-provider-jira": { + "description": "provider-jira icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f133" + } } }, "menus": { "commandPalette": [ { - "command": "gitlens.plus.loginOrSignUp", + "command": "gitlens.plus.login", "when": "!gitlens:plus" }, { "command": "gitlens.plus.logout", "when": "true" }, + { + "command": "gitlens.plus.signUp", + "when": "!gitlens:plus" + }, { "command": "gitlens.plus.startPreviewTrial", "when": "!gitlens:plus" }, + { + "command": "gitlens.plus.reactivateProTrial", + "when": "gitlens:plus:state == 5" + }, { "command": "gitlens.plus.manage", "when": "gitlens:plus" }, + { + "command": "gitlens.plus.cloudIntegrations.manage", + "when": "gitlens:plus" + }, { "command": "gitlens.plus.hide", "when": "config.gitlens.plusFeatures.enabled" @@ -8014,16 +9601,52 @@ "when": "!config.gitlens.plusFeatures.enabled" }, { - "command": "gitlens.plus.reset", - "when": "gitlens:debugging" + "command": "gitlens.plus.refreshRepositoryAccess", + "when": "gitlens:enabled" }, { - "command": "gitlens.plus.resetRepositoryAccess", - "when": "gitlens:enabled" + "command": "gitlens.gk.switchOrganization", + "when": "gitlens:gk:hasOrganizations" }, { - "command": "gitlens.plus.refreshRepositoryAccess", - "when": "gitlens:enabled" + "command": "gitlens.showPatchDetailsPage", + "when": "gitlens:enabled && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled" + }, + { + "command": "gitlens.applyPatchFromClipboard", + "when": "gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.copyWorkingChangesToWorktree", + "when": "gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.graph.copyWorkingChangesToWorktree", + "when": "false" + }, + { + "command": "gitlens.createPatch", + "when": "false && gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.createCloudPatch", + "when": "gitlens:enabled && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled" + }, + { + "command": "gitlens.shareAsCloudPatch", + "when": "gitlens:enabled && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled" + }, + { + "command": "gitlens.openCloudPatch", + "when": "false" + }, + { + "command": "gitlens.openPatch", + "when": "false && gitlens:enabled" }, { "command": "gitlens.timeline.refresh", @@ -8045,14 +9668,30 @@ "command": "gitlens.showContributorsView", "when": "gitlens:enabled" }, + { + "command": "gitlens.showDraftsView", + "when": "gitlens:enabled && gitlens:gk:organization:drafts:enabled" + }, { "command": "gitlens.showFileHistoryView", "when": "gitlens:enabled" }, + { + "command": "gitlens.showLaunchpad", + "when": "gitlens:prerelease" + }, { "command": "gitlens.showFocusPage", "when": "gitlens:enabled" }, + { + "command": "gitlens.launchpad.split", + "when": "gitlens:enabled && config.gitlens.launchpad.allowMultiple" + }, + { + "command": "gitlens.launchpad.indicator.toggle", + "when": "gitlens:enabled" + }, { "command": "gitlens.showGraph", "when": "gitlens:enabled" @@ -8061,6 +9700,10 @@ "command": "gitlens.showGraphPage", "when": "gitlens:enabled" }, + { + "command": "gitlens.graph.split", + "when": "gitlens:enabled && config.gitlens.graph.allowMultiple" + }, { "command": "gitlens.showGraphView", "when": "gitlens:enabled" @@ -8106,55 +9749,59 @@ "when": "gitlens:enabled" }, { - "command": "gitlens.showSettingsPage#views", + "command": "gitlens.showSettingsPage!views", "when": "false" }, { - "command": "gitlens.showSettingsPage#branches-view", + "command": "gitlens.showSettingsPage!file-annotations", "when": "false" }, { - "command": "gitlens.showSettingsPage#commits-view", + "command": "gitlens.showSettingsPage!branches-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#contributors-view", + "command": "gitlens.showSettingsPage!commits-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#file-history-view", + "command": "gitlens.showSettingsPage!contributors-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#line-history-view", + "command": "gitlens.showSettingsPage!file-history-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#remotes-view", + "command": "gitlens.showSettingsPage!line-history-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#repositories-view", + "command": "gitlens.showSettingsPage!remotes-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#search-compare-view", + "command": "gitlens.showSettingsPage!repositories-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#stashes-view", + "command": "gitlens.showSettingsPage!search-compare-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#tags-view", + "command": "gitlens.showSettingsPage!stashes-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#worktrees-view", + "command": "gitlens.showSettingsPage!tags-view", "when": "false" }, { - "command": "gitlens.showSettingsPage#commit-graph", + "command": "gitlens.showSettingsPage!worktrees-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!commit-graph", "when": "false" }, { @@ -8167,8 +9814,16 @@ }, { "command": "gitlens.showTimelinePage", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showInTimeline", "when": "gitlens:enabled && gitlens:activeFileStatus =~ /tracked/" }, + { + "command": "gitlens.timeline.split", + "when": "gitlens:enabled && config.gitlens.visualHistory.allowMultiple" + }, { "command": "gitlens.showTimelineView", "when": "gitlens:enabled" @@ -8201,21 +9856,17 @@ "command": "gitlens.diffDirectoryWithHead", "when": "gitlens:enabled && !gitlens:hasVirtualFolders" }, - { - "command": "gitlens.diffWithRevisionFrom", - "when": "gitlens:activeFileStatus =~ /tracked/" - }, { "command": "gitlens.diffWithNext", - "when": "gitlens:activeFileStatus =~ /revision/ && !isInDiffEditor" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && !isInDiffEditor" }, { "command": "gitlens.diffWithNextInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffEditor && !isInDiffRightEditor" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor && !isInDiffRightEditor" }, { "command": "gitlens.diffWithNextInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffRightEditor" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffRightEditor" }, { "command": "gitlens.diffWithPrevious", @@ -8233,13 +9884,25 @@ "command": "gitlens.diffLineWithPrevious", "when": "gitlens:activeFileStatus =~ /blameable/" }, + { + "command": "gitlens.diffFolderWithRevision", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.diffFolderWithRevisionFrom", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, { "command": "gitlens.diffWithRevision", "when": "gitlens:activeFileStatus =~ /tracked/" }, + { + "command": "gitlens.diffWithRevisionFrom", + "when": "gitlens:activeFileStatus =~ /tracked/" + }, { "command": "gitlens.diffWithWorking", - "when": "gitlens:activeFileStatus =~ /revision/" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithWorkingInDiffLeft", @@ -8271,90 +9934,186 @@ }, { "command": "gitlens.toggleFileBlame", - "when": "gitlens:activeFileStatus =~ /blameable/" + "when": "gitlens:activeFileStatus =~ /blameable/ || config.gitlens.blame.toggleMode == window" + }, + { + "command": "gitlens.toggleFileBlameInDiffLeft", + "when": "false" + }, + { + "command": "gitlens.toggleFileBlameInDiffRight", + "when": "false" + }, + { + "command": "gitlens.annotations.nextChange", + "when": "false" + }, + { + "command": "gitlens.annotations.previousChange", + "when": "false" + }, + { + "command": "gitlens.clearFileAnnotations", + "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus =~ /computed\\b/" + }, + { + "command": "gitlens.computingFileAnnotations", + "when": "false" + }, + { + "command": "gitlens.toggleFileHeatmap", + "when": "gitlens:activeFileStatus =~ /blameable/ || config.gitlens.heatmap.toggleMode == window" + }, + { + "command": "gitlens.toggleFileHeatmapInDiffLeft", + "when": "false" + }, + { + "command": "gitlens.toggleFileHeatmapInDiffRight", + "when": "false" + }, + { + "command": "gitlens.toggleFileChanges", + "when": "(gitlens:activeFileStatus =~ /blameable/ || config.gitlens.changes.toggleMode == window) && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.toggleFileChangesOnly", + "when": "false" + }, + { + "command": "gitlens.toggleLineBlame", + "when": "!gitlens:disabled" + }, + { + "command": "gitlens.toggleCodeLens", + "when": "!gitlens:disabled && !gitlens:disabledToggleCodeLens" + }, + { + "command": "gitlens.gitCommands", + "when": "!gitlens:disabled" + }, + { + "command": "gitlens.gitCommands.branch", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.branch.create", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.branch.delete", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.branch.prune", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.branch.rename", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.checkout", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.cherryPick", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.history", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.merge", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.rebase", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleFileBlameInDiffLeft", - "when": "false" + "command": "gitlens.gitCommands.remote", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleFileBlameInDiffRight", - "when": "false" + "command": "gitlens.gitCommands.remote.add", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.clearFileAnnotations", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed" + "command": "gitlens.gitCommands.remote.prune", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.computingFileAnnotations", - "when": "false" + "command": "gitlens.gitCommands.remote.remove", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleFileHeatmap", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.gitCommands.reset", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleFileHeatmapInDiffLeft", - "when": "false" + "command": "gitlens.gitCommands.revert", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleFileHeatmapInDiffRight", - "when": "false" + "command": "gitlens.gitCommands.show", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleFileChanges", - "when": "gitlens:activeFileStatus =~ /blameable/ && !gitlens:hasVirtualFolders" + "command": "gitlens.gitCommands.stash", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleFileChangesOnly", - "when": "false" + "command": "gitlens.gitCommands.stash.drop", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleLineBlame", - "when": "!gitlens:disabled" + "command": "gitlens.gitCommands.stash.list", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.toggleCodeLens", - "when": "!gitlens:disabled && !gitlens:disabledToggleCodeLens" + "command": "gitlens.gitCommands.stash.pop", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands", - "when": "!gitlens:disabled" + "command": "gitlens.gitCommands.stash.push", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.branch", + "command": "gitlens.gitCommands.stash.rename", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.cherryPick", + "command": "gitlens.gitCommands.status", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.merge", + "command": "gitlens.gitCommands.switch", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.rebase", + "command": "gitlens.gitCommands.tag", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.reset", + "command": "gitlens.gitCommands.tag.create", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.revert", + "command": "gitlens.gitCommands.tag.delete", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.switch", + "command": "gitlens.gitCommands.worktree", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.tag", + "command": "gitlens.gitCommands.worktree.create", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.gitCommands.worktree", + "command": "gitlens.gitCommands.worktree.delete", "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { @@ -8363,7 +10122,7 @@ }, { "command": "gitlens.switchAIModel", - "when": "gitlens:enabled" + "when": "gitlens:enabled && gitlens:gk:organization:ai:enabled" }, { "command": "gitlens.switchMode", @@ -8427,7 +10186,7 @@ }, { "command": "gitlens.showQuickRevisionDetails", - "when": "gitlens:activeFileStatus =~ /revision/" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.showQuickRevisionDetailsInDiffLeft", @@ -8467,11 +10226,11 @@ }, { "command": "gitlens.connectRemoteProvider", - "when": "config.gitlens.integrations.enabled && gitlens:hasRichRemotes && !gitlens:hasConnectedRemotes" + "when": "config.gitlens.integrations.enabled && gitlens:repos:withHostingIntegrations && !gitlens:repos:withHostingIntegrationsConnected" }, { "command": "gitlens.disconnectRemoteProvider", - "when": "config.gitlens.integrations.enabled && gitlens:hasRichRemotes && gitlens:hasConnectedRemotes" + "when": "config.gitlens.integrations.enabled && gitlens:repos:withHostingIntegrationsConnected" }, { "command": "gitlens.copyCurrentBranch", @@ -8503,7 +10262,7 @@ }, { "command": "gitlens.openBranchesOnRemote", - "when": "gitlens:hasRemotes" + "when": "gitlens:repos:withRemotes" }, { "command": "gitlens.copyRemoteBranchesUrl", @@ -8511,11 +10270,19 @@ }, { "command": "gitlens.openBranchOnRemote", - "when": "gitlens:hasRemotes" + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.views.openBranchOnRemote", + "when": "false" + }, + { + "command": "gitlens.views.openBranchOnRemote.multi", + "when": "false" }, { "command": "gitlens.openCurrentBranchOnRemote", - "when": "gitlens:hasRemotes" + "when": "gitlens:repos:withRemotes" }, { "command": "gitlens.copyDeepLinkToBranch", @@ -8529,6 +10296,18 @@ "command": "gitlens.copyDeepLinkToComparison", "when": "false" }, + { + "command": "gitlens.copyDeepLinkToFile", + "when": "false" + }, + { + "command": "gitlens.copyDeepLinkToFileAtRevision", + "when": "false" + }, + { + "command": "gitlens.copyDeepLinkToLines", + "when": "false" + }, { "command": "gitlens.copyDeepLinkToRepo", "when": "gitlens:enabled" @@ -8537,40 +10316,44 @@ "command": "gitlens.copyDeepLinkToTag", "when": "false" }, + { + "command": "gitlens.copyDeepLinkToWorkspace", + "when": "false" + }, { "command": "gitlens.copyRemoteBranchUrl", "when": "false" }, { "command": "gitlens.openCommitOnRemote", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:activeFileStatus =~ /remotes/" + "when": "gitlens:repos:withRemotes" }, { - "command": "gitlens.copyRemoteCommitUrl", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "command": "gitlens.views.openCommitOnRemote", + "when": "false" }, { - "command": "gitlens.openComparisonOnRemote", + "command": "gitlens.views.openCommitOnRemote.multi", "when": "false" }, { - "command": "gitlens.copyRemoteComparisonUrl", - "when": "false" + "command": "gitlens.copyRemoteCommitUrl", + "when": "gitlens:repos:withRemotes" }, { - "command": "gitlens.openAutolinkUrl", + "command": "gitlens.views.copyRemoteCommitUrl", "when": "false" }, { - "command": "gitlens.copyAutolinkUrl", + "command": "gitlens.views.copyRemoteCommitUrl.multi", "when": "false" }, { - "command": "gitlens.openIssueOnRemote", + "command": "gitlens.openComparisonOnRemote", "when": "false" }, { - "command": "gitlens.copyRemoteIssueUrl", + "command": "gitlens.copyRemoteComparisonUrl", "when": "false" }, { @@ -8583,7 +10366,7 @@ }, { "command": "gitlens.openAssociatedPullRequestOnRemote", - "when": "gitlens:hasRemotes" + "when": "gitlens:repos:withRemotes" }, { "command": "gitlens.openFileFromRemote", @@ -8591,11 +10374,11 @@ }, { "command": "gitlens.openFileOnRemote", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "when": "gitlens:repos:withRemotes" }, { "command": "gitlens.copyRemoteFileUrlToClipboard", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "when": "gitlens:repos:withRemotes" }, { "command": "gitlens.copyRemoteFileUrlWithoutRange", @@ -8603,11 +10386,11 @@ }, { "command": "gitlens.openFileOnRemoteFrom", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "when": "gitlens:repos:withRemotes" }, { "command": "gitlens.copyRemoteFileUrlFrom", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "when": "gitlens:repos:withRemotes" }, { "command": "gitlens.openBlamePriorToChange", @@ -8623,7 +10406,7 @@ }, { "command": "gitlens.openRepoOnRemote", - "when": "gitlens:hasRemotes" + "when": "gitlens:repos:withRemotes" }, { "command": "gitlens.copyRemoteRepositoryUrl", @@ -8631,7 +10414,7 @@ }, { "command": "gitlens.openRevisionFile", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffEditor" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor" }, { "command": "gitlens.openRevisionFileInDiffLeft", @@ -8643,7 +10426,7 @@ }, { "command": "gitlens.openWorkingFile", - "when": "gitlens:activeFileStatus =~ /revision/" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.openWorkingFileInDiffLeft", @@ -8657,10 +10440,18 @@ "command": "gitlens.stashApply", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, + { + "command": "gitlens.views.stash.apply", + "when": "false" + }, { "command": "gitlens.views.stash.delete", "when": "false" }, + { + "command": "gitlens.views.stash.delete.multi", + "when": "false" + }, { "command": "gitlens.views.stash.rename", "when": "false" @@ -8673,37 +10464,25 @@ "command": "gitlens.stashSaveFiles", "when": "false" }, - { - "command": "gitlens.resetAvatarCache", - "when": "gitlens:enabled" - }, - { - "command": "gitlens.resetSuppressedWarnings", - "when": "gitlens:enabled" - }, - { - "command": "gitlens.resetTrackedUsage", - "when": "gitlens:enabled" - }, { "command": "gitlens.inviteToLiveShare", "when": "false" }, { "command": "gitlens.browseRepoAtRevision", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /revision/" + "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.browseRepoAtRevisionInNewWindow", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /revision/" + "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.browseRepoBeforeRevision", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /revision/" + "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.browseRepoBeforeRevisionInNewWindow", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /revision/" + "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.views.browseRepoAtRevision", @@ -8723,15 +10502,15 @@ }, { "command": "gitlens.fetchRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { "command": "gitlens.pullRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { "command": "gitlens.pushRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { "command": "gitlens.views.addRemote", @@ -8769,6 +10548,26 @@ "command": "gitlens.views.copy", "when": "false" }, + { + "command": "gitlens.views.copyAsMarkdown", + "when": "false" + }, + { + "command": "gitlens.views.copyUrl", + "when": "false" + }, + { + "command": "gitlens.views.copyUrl.multi", + "when": "false" + }, + { + "command": "gitlens.views.openUrl", + "when": "false" + }, + { + "command": "gitlens.views.openUrl.multi", + "when": "false" + }, { "command": "gitlens.views.pruneRemote", "when": "false" @@ -8801,6 +10600,10 @@ "command": "gitlens.views.openInTerminal", "when": "false" }, + { + "command": "gitlens.views.openInIntegratedTerminal", + "when": "false" + }, { "command": "gitlens.views.setAsDefault", "when": "false" @@ -8829,10 +10632,18 @@ "command": "gitlens.views.star", "when": "false" }, + { + "command": "gitlens.views.star.multi", + "when": "false" + }, { "command": "gitlens.views.unstar", "when": "false" }, + { + "command": "gitlens.views.unstar.multi", + "when": "false" + }, { "command": "gitlens.views.openChanges", "when": "false" @@ -8873,6 +10684,14 @@ "command": "gitlens.views.openChangedFileDiffsWithWorking", "when": "false" }, + { + "command": "gitlens.views.openChangedFileDiffsIndividually", + "when": "false" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithWorkingIndividually", + "when": "false" + }, { "command": "gitlens.views.openChangedFileRevisions", "when": "false" @@ -8893,6 +10712,14 @@ "command": "gitlens.views.compareAncestryWithWorking", "when": "false" }, + { + "command": "gitlens.views.compareWithMergeBase", + "when": "false" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithMergeBase", + "when": "false" + }, { "command": "gitlens.views.compareWithHead", "when": "false" @@ -8930,7 +10757,7 @@ "when": "false" }, { - "command": "gitlens.views.title.applyStash", + "command": "gitlens.views.addAuthor.multi", "when": "false" }, { @@ -8949,6 +10776,10 @@ "command": "gitlens.views.deleteWorktree", "when": "false" }, + { + "command": "gitlens.views.deleteWorktree.multi", + "when": "false" + }, { "command": "gitlens.views.openWorktree", "when": "false" @@ -8957,6 +10788,10 @@ "command": "gitlens.views.openWorktreeInNewWindow", "when": "false" }, + { + "command": "gitlens.views.openWorktreeInNewWindow.multi", + "when": "false" + }, { "command": "gitlens.views.revealRepositoryInExplorer", "when": "false" @@ -8977,6 +10812,10 @@ "command": "gitlens.views.deleteBranch", "when": "false" }, + { + "command": "gitlens.views.deleteBranch.multi", + "when": "false" + }, { "command": "gitlens.views.renameBranch", "when": "false" @@ -8985,6 +10824,10 @@ "command": "gitlens.views.cherryPick", "when": "false" }, + { + "command": "gitlens.views.cherryPick.multi", + "when": "false" + }, { "command": "gitlens.views.mergeBranchInto", "when": "false" @@ -9041,6 +10884,10 @@ "command": "gitlens.views.deleteTag", "when": "false" }, + { + "command": "gitlens.views.deleteTag.multi", + "when": "false" + }, { "command": "gitlens.views.setBranchComparisonToWorking", "when": "false" @@ -9058,7 +10905,23 @@ "when": "false" }, { - "command": "gitlens.views.clearNode", + "command": "gitlens.views.openPullRequestChanges", + "when": "false" + }, + { + "command": "gitlens.views.openPullRequestComparison", + "when": "false" + }, + { + "command": "gitlens.views.clearComparison", + "when": "false" + }, + { + "command": "gitlens.views.clearReviewed", + "when": "false" + }, + { + "command": "gitlens.views.collapseNode", "when": "false" }, { @@ -9149,6 +11012,14 @@ "command": "gitlens.views.commitDetails.refresh", "when": "false" }, + { + "command": "gitlens.views.patchDetails.close", + "when": "false" + }, + { + "command": "gitlens.views.patchDetails.refresh", + "when": "false" + }, { "command": "gitlens.views.commits.copy", "when": "false" @@ -9170,11 +11041,19 @@ "when": "false" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "when": "false" + }, + { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "false" + }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOff", "when": "false" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", + "command": "gitlens.views.commits.setShowMergeCommitsOn", "when": "false" }, { @@ -9237,6 +11116,14 @@ "command": "gitlens.views.contributors.setShowAvatarsOff", "when": "false" }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOff", + "when": "false" + }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOn", + "when": "false" + }, { "command": "gitlens.views.contributors.setShowStatisticsOn", "when": "false" @@ -9245,6 +11132,42 @@ "command": "gitlens.views.contributors.setShowStatisticsOff", "when": "false" }, + { + "command": "gitlens.views.drafts.copy", + "when": "false" + }, + { + "command": "gitlens.views.drafts.refresh", + "when": "false" + }, + { + "command": "gitlens.views.drafts.info", + "when": "false" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOn", + "when": "false" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOff", + "when": "false" + }, + { + "command": "gitlens.views.drafts.create", + "when": "false" + }, + { + "command": "gitlens.views.drafts.delete", + "when": "false" + }, + { + "command": "gitlens.views.draft.open", + "when": "false" + }, + { + "command": "gitlens.views.draft.openOnWeb", + "when": "false" + }, { "command": "gitlens.views.fileHistory.changeBase", "when": "false" @@ -9289,6 +11212,14 @@ "command": "gitlens.views.fileHistory.setShowAllBranchesOff", "when": "false" }, + { + "command": "gitlens.views.fileHistory.setShowMergeCommitsOn", + "when": "false" + }, + { + "command": "gitlens.views.fileHistory.setShowMergeCommitsOff", + "when": "false" + }, { "command": "gitlens.views.fileHistory.setShowAvatarsOn", "when": "false" @@ -9345,6 +11276,38 @@ "command": "gitlens.views.lineHistory.setShowAvatarsOff", "when": "false" }, + { + "command": "gitlens.views.pullRequest.close", + "when": "false" + }, + { + "command": "gitlens.views.pullRequest.copy", + "when": "false" + }, + { + "command": "gitlens.views.pullRequest.refresh", + "when": "false" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToAuto", + "when": "false" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToList", + "when": "false" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToTree", + "when": "false" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOn", + "when": "false" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOff", + "when": "false" + }, { "command": "gitlens.views.remotes.copy", "when": "false" @@ -9549,6 +11512,14 @@ "command": "gitlens.views.searchAndCompare.setFilesLayoutToTree", "when": "false" }, + { + "command": "gitlens.views.setResultsCommitsFilterAuthors", + "when": "false" + }, + { + "command": "gitlens.views.setResultsCommitsFilterOff", + "when": "false" + }, { "command": "gitlens.views.searchAndCompare.setShowAvatarsOn", "when": "false" @@ -9794,7 +11765,7 @@ "when": "false" }, { - "command": "gitlens.focus.refresh", + "command": "gitlens.launchpad.refresh", "when": "false" }, { @@ -9873,6 +11844,10 @@ "command": "gitlens.graph.copyRemoteCommitUrl", "when": "false" }, + { + "command": "gitlens.graph.copyRemoteCommitUrl.multi", + "when": "false" + }, { "command": "gitlens.graph.showInDetailsView", "when": "false" @@ -9881,6 +11856,10 @@ "command": "gitlens.graph.openCommitOnRemote", "when": "false" }, + { + "command": "gitlens.graph.openCommitOnRemote.multi", + "when": "false" + }, { "command": "gitlens.graph.rebaseOntoCommit", "when": "false" @@ -9910,11 +11889,11 @@ "when": "false" }, { - "command": "gitlens.graph.saveStash", + "command": "gitlens.graph.stash.save", "when": "false" }, { - "command": "gitlens.graph.applyStash", + "command": "gitlens.graph.stash.apply", "when": "false" }, { @@ -9942,15 +11921,35 @@ "when": "false" }, { - "command": "gitlens.graph.createPullRequest", + "command": "gitlens.graph.createPullRequest", + "when": "false" + }, + { + "command": "gitlens.graph.openPullRequest", + "when": "false" + }, + { + "command": "gitlens.graph.openPullRequestChanges", + "when": "false" + }, + { + "command": "gitlens.graph.openPullRequestComparison", + "when": "false" + }, + { + "command": "gitlens.graph.openPullRequestOnRemote", + "when": "false" + }, + { + "command": "gitlens.graph.compareAncestryWithWorking", "when": "false" }, { - "command": "gitlens.graph.openPullRequestOnRemote", + "command": "gitlens.graph.compareWithMergeBase", "when": "false" }, { - "command": "gitlens.graph.compareAncestryWithWorking", + "command": "gitlens.graph.openChangedFileDiffsWithMergeBase", "when": "false" }, { @@ -9977,6 +11976,14 @@ "command": "gitlens.graph.openChangedFileDiffsWithWorking", "when": "false" }, + { + "command": "gitlens.graph.openChangedFileDiffsIndividually", + "when": "false" + }, + { + "command": "gitlens.graph.openChangedFileDiffsWithWorkingIndividually", + "when": "false" + }, { "command": "gitlens.graph.openChangedFileRevisions", "when": "false" @@ -10097,21 +12104,33 @@ "command": "gitlens.graph.scrollMarkerTagOff", "when": "false" }, + { + "command": "gitlens.graph.scrollMarkerPullRequestOn", + "when": "false" + }, + { + "command": "gitlens.graph.scrollMarkerPullRequestOff", + "when": "false" + }, + { + "command": "gitlens.graph.shareAsCloudPatch", + "when": "false" + }, { "command": "gitlens.enableDebugLogging", "when": "config.gitlens.outputLevel != debug" }, { "command": "gitlens.disableDebugLogging", - "when": "config.gitlens.outputLevel != errors" + "when": "config.gitlens.outputLevel == debug" }, { "command": "gitlens.generateCommitMessage", - "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:ai:enabled && config.gitlens.ai.experimental.generateCommitMessage.enabled" }, { "command": "gitlens.resetAIKey", - "when": "gitlens:enabled" + "when": "gitlens:enabled && gitlens:gk:organization:ai:enabled" } ], "editor/context": [ @@ -10127,7 +12146,7 @@ }, { "submenu": "gitlens/editor/context/openOn", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /remotes/ && config.gitlens.menus.editor.remote && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "when": "editorTextFocus && gitlens:repos:withRemotes && config.gitlens.menus.editor.remote && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", "group": "1_z_gitlens_open@2" }, { @@ -10171,12 +12190,27 @@ "command": "gitlens.copyMessageToClipboard", "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", "group": "3_gitlens@2" + }, + { + "command": "gitlens.copyDeepLinkToLines", + "when": "editorTextFocus && editorHasSelection && config.gitlens.menus.editor.clipboard && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.copyDeepLinkToFile", + "when": "editorTextFocus && config.gitlens.menus.editor.clipboard && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "group": "1_gitlens@3" + }, + { + "command": "gitlens.copyDeepLinkToFileAtRevision", + "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "group": "1_gitlens@4" } ], "editor/lineNumber/context": [ { "submenu": "gitlens/editor/lineNumber/context/share", - "when": "gitlens:hasRemotes && config.gitlens.menus.editorGutter.share && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "when": "gitlens:repos:withRemotes && config.gitlens.menus.editorGutter.share && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", "group": "2_gitlens@2" }, { @@ -10186,54 +12220,58 @@ }, { "submenu": "gitlens/editor/lineNumber/context/openOn", - "when": "gitlens:hasRemotes && config.gitlens.menus.editorGutter.remote && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "when": "gitlens:repos:withRemotes && config.gitlens.menus.editorGutter.remote && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", "group": "3_gitlens@2" } ], "editor/title": [ + { + "command": "gitlens.openPatch", + "when": "false && editorLangId == diff" + }, { "command": "gitlens.diffWithWorking", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && !isInDiffEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && !isInDiffEditor", "group": "navigation@-99" }, { "command": "gitlens.diffWithWorkingInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffEditor && !isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@-99" }, { "command": "gitlens.diffWithWorkingInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffRightEditor", "group": "navigation@-99" }, { "command": "gitlens.openWorkingFile", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme == git && !isInDiffEditor", + "when": "gitlens:enabled && resourceScheme == git && !isInDiffEditor", "group": "navigation@-98" }, { "command": "gitlens.openWorkingFile", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && !isInDiffEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && !isInDiffEditor", "group": "navigation@-98" }, { "command": "gitlens.openWorkingFileInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffEditor && !isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@-98" }, { "command": "gitlens.openWorkingFileInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffRightEditor", "group": "navigation@-98" }, { "command": "gitlens.openRevisionFileInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffEditor && !isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@-97" }, { "command": "gitlens.openRevisionFileInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffRightEditor", "group": "navigation@-97" }, { @@ -10309,44 +12347,59 @@ }, { "command": "gitlens.computingFileAnnotations", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computing && config.gitlens.menus.editorGroup.blame", + "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus =~ /computing\\b/ && config.gitlens.menus.editorGroup.blame", "group": "navigation@100" }, { "command": "gitlens.clearFileAnnotations", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed && config.gitlens.menus.editorGroup.blame", + "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus =~ /computed\\b/ && config.gitlens.menus.editorGroup.blame", "group": "navigation@100" }, { "command": "gitlens.timeline.refresh", - "when": "gitlens:webview:timeline:active", + "when": "activeWebviewPanelId === gitlens.timeline", "group": "navigation@-99" }, { "command": "gitlens.graph.refresh", - "when": "gitlens:webview:graph:active", + "when": "activeWebviewPanelId === gitlens.graph", "group": "navigation@-99" }, { "submenu": "gitlens/graph/configuration", - "when": "gitlens:webview:graph:active", + "when": "activeWebviewPanelId === gitlens.graph", "group": "navigation@-98" }, { - "command": "gitlens.focus.refresh", - "when": "gitlens:webview:focus:active", + "command": "gitlens.launchpad.refresh", + "when": "activeWebviewPanelId === gitlens.focus", "group": "navigation@-98" + }, + { + "command": "gitlens.launchpad.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.focus && config.gitlens.launchpad.allowMultiple", + "group": "navigation@-97" + }, + { + "command": "gitlens.graph.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.graph && config.gitlens.graph.allowMultiple", + "group": "navigation@-97" + }, + { + "command": "gitlens.timeline.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.timeline && config.gitlens.visualHistory.allowMultiple", + "group": "navigation@-97" } ], "editor/title/context": [ { "command": "gitlens.copyRemoteFileUrlWithoutRange", - "when": "gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.editorTab.clipboard && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "when": "gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.editorTab.clipboard && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", "group": "1_cutcopypaste@100" }, { "command": "gitlens.copyRemoteFileUrlFrom", - "when": "gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.editorTab.clipboard && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "when": "gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.editorTab.clipboard && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", "group": "1_cutcopypaste@101" }, { @@ -10361,24 +12414,39 @@ }, { "submenu": "gitlens/editor/openOn", - "when": "gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.editorTab.remote && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", + "when": "gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.editorTab.remote && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", "group": "2_a_gitlens_open@2" }, { "submenu": "gitlens/editor/history", "when": "gitlens:enabled && config.gitlens.menus.editorTab.history && isFileSystemResource && resourceScheme =~ /^(?!output$|vscode-(?!remote|vfs$)).*$/", "group": "2_a_gitlens_open_file@1" + }, + { + "command": "gitlens.launchpad.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.focus && config.gitlens.launchpad.allowMultiple", + "group": "6_split_in_group_gitlens@2" + }, + { + "command": "gitlens.graph.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.graph && config.gitlens.graph.allowMultiple", + "group": "6_split_in_group_gitlens@2" + }, + { + "command": "gitlens.timeline.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.timeline && config.gitlens.visualHistory.allowMultiple", + "group": "6_split_in_group_gitlens@2" } ], "explorer/context": [ { "submenu": "gitlens/explorer/changes", - "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled && config.gitlens.menus.explorer.compare", + "when": "!explorerResourceIsRoot && gitlens:enabled && config.gitlens.menus.explorer.compare", "group": "4_t_gitlens@0" }, { "submenu": "gitlens/explorer/openOn", - "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.explorer.remote", + "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.explorer.remote", "group": "4_t_gitlens@1" }, { @@ -10388,12 +12456,12 @@ }, { "command": "gitlens.copyRemoteFileUrlWithoutRange", - "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.explorer.clipboard", + "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.explorer.clipboard", "group": "6_copypath@100" }, { "command": "gitlens.copyRemoteFileUrlFrom", - "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.explorer.clipboard", + "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.explorer.clipboard", "group": "6_copypath@101" } ], @@ -10421,9 +12489,19 @@ "group": "4_gitlens@1" }, { - "command": "gitlens.generateCommitMessage", - "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.menus.scmRepository.generateCommitMessage", + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && config.gitlens.menus.scmRepository.patch", "group": "4_gitlens@2" + }, + { + "command": "gitlens.shareAsCloudPatch", + "when": "gitlens:enabled && scmProvider == git && config.gitlens.menus.scmRepository.patch", + "group": "4_gitlens@3" + }, + { + "command": "gitlens.generateCommitMessage", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:ai:enabled && config.gitlens.ai.experimental.generateCommitMessage.enabled && config.gitlens.menus.scmRepository.generateCommitMessage", + "group": "4_gitlens@4" } ], "menuBar/edit/copy": [ @@ -10446,6 +12524,11 @@ } ], "scm/title": [ + { + "command": "gitlens.stashSave", + "when": "gitlens:enabled && config.gitlens.menus.scmRepositoryInline.stash && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && scmProvider == git", + "group": "navigation@-1001" + }, { "command": "gitlens.showGraph", "when": "gitlens:enabled && config.gitlens.menus.scmRepositoryInline.graph && gitlens:plus:enabled && scmProvider == git && scmProviderRootUri not in gitlens:plus:disallowedRepos", @@ -10457,14 +12540,24 @@ "group": "2_z_gitlens@1" }, { - "command": "gitlens.generateCommitMessage", - "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && scmProvider == git && config.gitlens.menus.scmRepository.generateCommitMessage", + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && config.gitlens.menus.scmRepository.patch", "group": "2_z_gitlens@2" }, + { + "command": "gitlens.shareAsCloudPatch", + "when": "gitlens:enabled && scmProvider == git && config.gitlens.menus.scmRepository.patch", + "group": "2_z_gitlens@3" + }, + { + "command": "gitlens.generateCommitMessage", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:ai:enabled && config.gitlens.ai.experimental.generateCommitMessage.enabled && scmProvider == git && config.gitlens.menus.scmRepository.generateCommitMessage", + "group": "2_z_gitlens@4" + }, { "command": "gitlens.showGraph", "when": "gitlens:enabled && config.gitlens.menus.scmRepository.graph && gitlens:plus:enabled && scmProvider == git && scmProviderRootUri not in gitlens:plus:disallowedRepos", - "group": "2_z_gitlens@3" + "group": "2_z_gitlens@5" } ], "scm/resourceGroup/context": [ @@ -10480,7 +12573,7 @@ }, { "submenu": "gitlens/scm/resourceGroup/changes", - "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.compare", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmGroup.compare", "group": "2_gitlens@1" }, { @@ -10496,7 +12589,29 @@ { "command": "gitlens.openOnlyChangedFiles", "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.openClose", - "group": "3_gitlens@2" + "group": "3_gitlens@3" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.patch", + "group": "7_cutcopypaste@97" + }, + { + "command": "gitlens.shareAsCloudPatch", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.patch", + "group": "7_cutcopypaste@98" + } + ], + "scm/resourceFolder/context": [ + { + "submenu": "gitlens/scm/resourceFolder/changes", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.compare", + "group": "2_gitlens@1" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.patch", + "group": "7_cutcopypaste@97" } ], "scm/resourceState/context": [ @@ -10512,7 +12627,7 @@ }, { "submenu": "gitlens/scm/resourceState/openOn", - "when": "gitlens:enabled && gitlens:hasRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.remote", + "when": "gitlens:enabled && gitlens:repos:withRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.remote", "group": "navigation" }, { @@ -10530,16 +12645,21 @@ "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.share", "group": "7_a_gitlens_share@1" }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmItem.patch", + "group": "7_cutcopypaste@97" + }, { "command": "gitlens.copyRelativePathToClipboard", - "when": "gitlens:enabled && gitlens:hasRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.clipboard", + "when": "gitlens:enabled && gitlens:repos:withRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.clipboard", "group": "7_cutcopypaste@98" } ], "timeline/item/context": [ { "command": "gitlens.openCommitOnRemote", - "when": "false && gitlens:enabled && gitlens:hasRemotes && timelineItem =~ /git:file:commit\\b/", + "when": "false && gitlens:enabled && gitlens:repos:withRemotes && timelineItem =~ /git:file:commit\\b/", "group": "inline@99", "alt": "gitlens.copyRemoteCommitUrl" } @@ -10617,17 +12737,17 @@ }, { "command": "gitlens.pushRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", "group": "navigation@1" }, { "command": "gitlens.pullRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", "group": "navigation@2" }, { "command": "gitlens.fetchRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", "group": "navigation@3" }, { @@ -10646,8 +12766,18 @@ "group": "navigation@99" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:myCommitsOnly", + "command": "gitlens.views.patchDetails.refresh", + "when": "view =~ /^gitlens\\.views\\.patchDetails/", + "group": "navigation@98" + }, + { + "command": "gitlens.views.patchDetails.close", + "when": "view =~ /^gitlens\\.views\\.patchDetails/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:filtered", "group": "navigation@50" }, { @@ -10656,29 +12786,39 @@ "group": "navigation@99" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", - "when": "view =~ /^gitlens\\.views\\.commits/ && !gitlens:views:commits:myCommitsOnly", + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:filtered", "group": "3_gitlens@0" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:myCommitsOnly", - "group": "3_gitlens@0" + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "when": "view =~ /^gitlens\\.views\\.commits/", + "group": "3_gitlens@1" + }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && !gitlens:views:commits:hideMergeCommits", + "group": "3_gitlens@2" + }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOn", + "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:hideMergeCommits", + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setFilesLayoutToAuto", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == tree", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setFilesLayoutToList", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == auto", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setFilesLayoutToTree", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == list", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setShowAvatarsOn", @@ -10730,30 +12870,40 @@ "when": "view =~ /^gitlens\\.views\\.contributors/", "group": "navigation@99" }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOff", + "when": "view =~ /^gitlens\\.views\\.contributors/ && !gitlens:views:contributors:hideMergeCommits", + "group": "3_gitlens@0" + }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOn", + "when": "view =~ /^gitlens\\.views\\.contributors/ && gitlens:views:contributors:hideMergeCommits", + "group": "3_gitlens@0" + }, { "command": "gitlens.views.contributors.setShowAllBranchesOn", "when": "view =~ /^gitlens\\.views\\.contributors/ && !config.gitlens.views.contributors.showAllBranches", - "group": "3_gitlens@0" + "group": "3_gitlens@1" }, { "command": "gitlens.views.contributors.setShowAllBranchesOff", "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.showAllBranches", - "group": "3_gitlens@0" + "group": "3_gitlens@1" }, { "command": "gitlens.views.contributors.setFilesLayoutToAuto", "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == tree", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.contributors.setFilesLayoutToList", "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == auto", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.contributors.setFilesLayoutToTree", "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == list", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.contributors.setShowAvatarsOn", @@ -10775,6 +12925,31 @@ "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.showStatistics", "group": "5_gitlens@1" }, + { + "command": "gitlens.views.drafts.refresh", + "when": "view =~ /^gitlens\\.views\\.drafts/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.drafts.create", + "when": "view =~ /^gitlens\\.views\\.drafts/ && gitlens:plus", + "group": "navigation@1" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOn", + "when": "view =~ /^gitlens\\.views\\.drafts/ && !config.gitlens.views.drafts.avatars", + "group": "5_gitlens@0" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOff", + "when": "view =~ /^gitlens\\.views\\.drafts/ && config.gitlens.views.drafts.avatars", + "group": "5_gitlens@0" + }, + { + "command": "gitlens.views.drafts.info", + "when": "view =~ /^gitlens\\.views\\.drafts/", + "group": "8_info@1" + }, { "command": "gitlens.views.fileHistory.setEditorFollowingOn", "when": "view =~ /^gitlens\\.views\\.fileHistory/ && gitlens:views:fileHistory:canPin && !gitlens:views:fileHistory:editorFollowing", @@ -10807,28 +12982,33 @@ }, { "command": "gitlens.views.fileHistory.setRenameFollowingOn", - "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && config.gitlens.advanced.fileHistoryShowAllBranches", + "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryFollowsRenames", "group": "3_gitlens@1" }, { - "command": "gitlens.views.fileHistory.setRenameFollowingOn", - "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryShowAllBranches && !config.gitlens.advanced.fileHistoryFollowsRenames", + "command": "gitlens.views.fileHistory.setRenameFollowingOff", + "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && config.gitlens.advanced.fileHistoryFollowsRenames", "group": "3_gitlens@1" }, { - "command": "gitlens.views.fileHistory.setRenameFollowingOff", - "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryShowAllBranches && config.gitlens.advanced.fileHistoryFollowsRenames", - "group": "3_gitlens@1" + "command": "gitlens.views.fileHistory.setShowMergeCommitsOn", + "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryShowMergeCommits", + "group": "3_gitlens@2" + }, + { + "command": "gitlens.views.fileHistory.setShowMergeCommitsOff", + "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && config.gitlens.advanced.fileHistoryShowMergeCommits", + "group": "3_gitlens@2" }, { "command": "gitlens.views.fileHistory.setShowAllBranchesOn", "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryShowAllBranches", - "group": "3_gitlens@2" + "group": "3_gitlens@3" }, { "command": "gitlens.views.fileHistory.setShowAllBranchesOff", "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && config.gitlens.advanced.fileHistoryShowAllBranches", - "group": "3_gitlens@2" + "group": "3_gitlens@3" }, { "command": "gitlens.views.fileHistory.setShowAvatarsOn", @@ -10905,6 +13085,41 @@ "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /gitlens\\.views\\.remotes/", "group": "navigation@1" }, + { + "command": "gitlens.views.pullRequest.refresh", + "when": "view =~ /^gitlens\\.views\\.pullRequest/", + "group": "navigation@98" + }, + { + "command": "gitlens.views.pullRequest.close", + "when": "view =~ /gitlens\\.views\\.pullRequest/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToAuto", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && config.gitlens.views.pullRequest.files.layout == tree", + "group": "navigation@50" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToList", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && config.gitlens.views.pullRequest.files.layout == auto", + "group": "navigation@50" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToTree", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && config.gitlens.views.pullRequest.files.layout == list", + "group": "navigation@50" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOn", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && !config.gitlens.views.pullRequest.avatars", + "group": "5_gitlens@0" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOff", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && config.gitlens.views.pullRequest.avatars", + "group": "5_gitlens@0" + }, { "command": "gitlens.views.remotes.setLayoutToList", "when": "view =~ /gitlens\\.views\\.remotes/ && config.gitlens.views.remotes.branches.layout == tree", @@ -10957,17 +13172,17 @@ }, { "command": "gitlens.pushRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", "group": "navigation@1" }, { "command": "gitlens.pullRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", "group": "navigation@2" }, { "command": "gitlens.fetchRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", "group": "navigation@3" }, { @@ -11061,7 +13276,7 @@ "group": "navigation@10" }, { - "command": "gitlens.views.title.applyStash", + "command": "gitlens.stashApply", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.stashes/", "group": "navigation@11" }, @@ -11226,67 +13441,87 @@ "group": "navigation@-98" }, { - "command": "gitlens.showSettingsPage#branches-view", + "command": "gitlens.showSettingsPage!branches-view", "when": "view =~ /^gitlens\\.views\\.branches/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#commits-view", + "command": "gitlens.showSettingsPage!commits-view", "when": "view =~ /^gitlens\\.views\\.commits/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#contributors-view", + "command": "gitlens.showSettingsPage!contributors-view", "when": "view =~ /^gitlens\\.views\\.contributors/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#file-history-view", + "command": "gitlens.showSettingsPage!file-history-view", "when": "view =~ /^gitlens\\.views\\.fileHistory/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#line-history-view", + "command": "gitlens.showSettingsPage!line-history-view", "when": "view =~ /^gitlens\\.views\\.lineHistory/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#remotes-view", + "command": "gitlens.showSettingsPage!remotes-view", "when": "view =~ /^gitlens\\.views\\.remotes/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#repositories-view", + "command": "gitlens.showSettingsPage!repositories-view", "when": "view =~ /^gitlens\\.views\\.repositories/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#search-compare-view", + "command": "gitlens.showSettingsPage!search-compare-view", "when": "view =~ /^gitlens\\.views\\.searchAndCompare\\b/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#stashes-view", + "command": "gitlens.showSettingsPage!stashes-view", "when": "view =~ /^gitlens\\.views\\.stashes/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#tags-view", + "command": "gitlens.showSettingsPage!tags-view", "when": "view =~ /^gitlens\\.views\\.tags/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#worktrees-view", + "command": "gitlens.showSettingsPage!worktrees-view", "when": "view =~ /^gitlens\\.views\\.worktrees/", "group": "9_gitlens@1" } ], "view/item/context": [ { - "command": "gitlens.plus.loginOrSignUp", + "command": "gitlens.plus.login", "when": "viewItem == gitlens:message:signin", "group": "inline@1" }, + { + "command": "gitlens.views.draft.openOnWeb", + "when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus", + "group": "inline@99" + }, + { + "command": "gitlens.views.draft.open", + "when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.views.draft.openOnWeb", + "when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.drafts.delete", + "when": "viewItem =~ /gitlens:draft\\b(?=.*?\\b\\+mine\\b)/ && gitlens:plus", + "group": "6_gitlens_actions@1" + }, { "command": "gitlens.views.workspaces.convert", "when": "viewItem =~ /gitlens:repositories\\b(?=.*?\\b\\+workspaces\\b)/ && gitlens:plus", @@ -11320,42 +13555,42 @@ }, { "command": "gitlens.views.workspaces.addRepos", - "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.workspaces.locateAllRepos", - "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)(?!.*?\\b\\+empty\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)(?!.*?\\b\\+empty\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.workspaces.addReposFromLinked", - "when": "viewItem =~ /gitlens:repositories\\b(?=.*?\\b\\+linked\\b)(?=.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repositories\\b(?=.*?\\b\\+linked\\b)(?=.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@3" }, { "command": "gitlens.views.workspaces.createLocal", - "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+empty\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+empty\\b)/", "group": "2_gitlens_quickopen@3" }, { "command": "gitlens.views.workspaces.openLocal", - "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/", "group": "2_gitlens_quickopen@4" }, { "command": "gitlens.views.workspaces.openLocalNewWindow", - "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/", "group": "2_gitlens_quickopen@5" }, { "command": "gitlens.views.workspaces.changeAutoAddSetting", - "when": "viewItem =~ /(gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)|gitlens:repositories\\b(?=.*?\\b\\+linked\\b))/", + "when": "!listMultiSelection && viewItem =~ /(gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)|gitlens:repositories\\b(?=.*?\\b\\+linked\\b))/", "group": "2_gitlens_quickopen@6" }, { "command": "gitlens.views.workspaces.delete", - "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", "group": "6_gitlens_actions@1" }, { @@ -11386,28 +13621,27 @@ }, { "command": "gitlens.views.switchToAnotherBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.openBranchesOnRemote", - "when": "viewItem =~ /gitlens:branches\\b(?=.*?\\b\\+remotes\\b)/", - "group": "2_gitlens_quickopen@1", - "alt": "gitlens.copyRemoteBranchesUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:branches\\b(?=.*?\\b\\+remotes\\b)/", + "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOn", - "when": "view =~ /^gitlens\\.views\\.repositories/ && viewItem =~ /gitlens:branches\\b/ && !config.gitlens.views.repositories.branches.showBranchComparison", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.repositories/ && viewItem =~ /gitlens:branches\\b/ && !config.gitlens.views.repositories.branches.showBranchComparison", "group": "8_gitlens_toggles@1" }, { "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOff", - "when": "view =~ /^gitlens\\.views\\.repositories/ && viewItem =~ /gitlens:branches\\b/ && config.gitlens.views.repositories.branches.showBranchComparison", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.repositories/ && viewItem =~ /gitlens:branches\\b/ && config.gitlens.views.repositories.branches.showBranchComparison", "group": "8_gitlens_toggles@1" }, { @@ -11422,44 +13656,44 @@ }, { "command": "gitlens.views.publishBranch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)(?!.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)(?!.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", "group": "inline@8" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", "group": "inline@8" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", "group": "inline@8" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", "group": "inline@8" }, { "command": "gitlens.views.createPullRequest", - "when": "gitlens:hasRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)(?!.*?\\b\\+closed\\b)/", "group": "inline@9" }, { "command": "gitlens.views.undoCommit", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", - "group": "inline@95" + "group": "inline@78" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", - "group": "inline@96", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "group": "inline@79", "alt": "gitlens.views.pushWithForce" }, { "command": "gitlens.views.pushToCommit", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", - "group": "inline@96" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", + "group": "inline@80" }, { "command": "gitlens.views.compareWithHead", @@ -11483,145 +13717,174 @@ "group": "inline@98" }, { - "command": "gitlens.openBranchOnRemote", + "command": "gitlens.views.openBranchOnRemote", "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", "group": "inline@99", "alt": "gitlens.copyRemoteBranchUrl" }, { "command": "gitlens.views.switchToAnotherBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.switchToBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.publishBranch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)(?!.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)(?!.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@3" }, { "command": "gitlens.views.mergeBranchInto", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@4" }, { "command": "gitlens.views.rebaseOntoBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@5" }, { "command": "gitlens.views.rebaseOntoUpstream", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@5" }, { "command": "gitlens.views.renameBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@6" }, { "command": "gitlens.views.deleteBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@7" + }, + { + "command": "gitlens.views.deleteBranch.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@7" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions_@7" }, { "command": "gitlens.views.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions_@8" }, { "command": "gitlens.views.createWorktree", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions_@9" }, { "command": "gitlens.views.createPullRequest", - "when": "gitlens:hasRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions_@10" }, { - "command": "gitlens.openBranchOnRemote", - "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", - "group": "2_gitlens_quickopen@1", - "alt": "gitlens.copyRemoteBranchUrl" + "command": "gitlens.views.openBranchOnRemote", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "group": "2_gitlens_quickopen@1" }, { - "command": "gitlens.views.openDirectoryDiffWithWorking", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|tag)\\b/", + "command": "gitlens.views.openBranchOnRemote.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "group": "2_gitlens_quickopen@1" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithMergeBase", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", "group": "3_gitlens_explore@11" }, + { + "command": "gitlens.views.openDirectoryDiffWithWorking", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|tag)\\b/", + "group": "3_gitlens_explore@12" + }, { "command": "gitlens.views.compareWithUpstream", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+tracking\\b)/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+tracking\\b)/", "group": "4_gitlens_compare@1" }, { "command": "gitlens.views.compareWithHead", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch\\b(?!.*?\\b\\+current\\b)|commit\\b|stash\\b|tag\\b)/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch\\b(?!.*?\\b\\+current\\b)|commit\\b|stash\\b|tag\\b)/", "group": "4_gitlens_compare@2" }, { "command": "gitlens.views.compareWithWorking", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "4_gitlens_compare@3" }, { - "command": "gitlens.views.compareAncestryWithWorking", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "command": "gitlens.views.compareWithMergeBase", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", "group": "4_gitlens_compare@4" }, + { + "command": "gitlens.views.compareAncestryWithWorking", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "4_gitlens_compare@5" + }, { "command": "gitlens.views.compareWithSelected", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/ && gitlens:views:canCompare", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/ && gitlens:views:canCompare", "group": "4_gitlens_compare@98" }, { "command": "gitlens.views.selectForCompare", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "4_gitlens_compare@99" }, { "command": "gitlens.views.compareFileWithSelected", - "when": "viewItem =~ /gitlens:file\\b/ && gitlens:views:canCompare:file", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/ && gitlens:views:canCompare:file", "group": "4_gitlens_compare@98" }, { "command": "gitlens.views.selectFileForCompare", - "when": "viewItem =~ /gitlens:file\\b(?!.*?\\b\\+conflicted\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b(?!.*?\\b\\+conflicted\\b)/", "group": "4_gitlens_compare@99" }, { "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions@1" + }, + { + "command": "gitlens.views.star.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions@1" }, { "command": "gitlens.views.unstar", - "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions@1" + }, + { + "command": "gitlens.views.unstar.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions@1" }, { @@ -11631,7 +13894,7 @@ }, { "command": "gitlens.views.addAuthors", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributors\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributors\\b/", "group": "1_gitlens_actions@1" }, { @@ -11651,14 +13914,24 @@ }, { "command": "gitlens.inviteToLiveShare", - "when": "gitlens:vsls && gitlens:vsls != guest && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && gitlens:vsls && gitlens:vsls != guest && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.addAuthor", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.addAuthor.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@2" }, + { + "command": "gitlens.views.copyAsMarkdown", + "when": "viewItem =~ /gitlens:contributor\\b/", + "group": "7_gitlens_cutcopypaste@2" + }, { "command": "gitlens.copyShaToClipboard", "when": "viewItem =~ /gitlens:commit\\b/", @@ -11666,120 +13939,165 @@ "alt": "gitlens.copyMessageToClipboard" }, { - "command": "gitlens.openCommitOnRemote", - "when": "viewItem =~ /gitlens:commit\\b/ && gitlens:hasRemotes", + "command": "gitlens.views.openCommitOnRemote", + "when": "viewItem =~ /gitlens:commit\\b/ && gitlens:repos:withRemotes", "group": "inline@99", - "alt": "gitlens.copyRemoteCommitUrl" + "alt": "gitlens.views.copyRemoteCommitUrl" }, { "command": "gitlens.views.cherryPick", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+(current|rebase)\\b)/", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.views.cherryPick.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+(current|rebase)\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.undoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.pushToCommit", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.revert", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@3" }, { "command": "gitlens.views.resetToCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@4" }, { "command": "gitlens.views.resetToTip", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@4" }, { "command": "gitlens.views.resetCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@5" }, { "command": "gitlens.views.rebaseOntoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@6" }, { "command": "gitlens.views.switchToCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@7" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions_1@1" }, { - "command": "gitlens.views.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "command": "gitlens.createPatch", + "when": "!listMultiSelection && false && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(commit|stash)\\b/", "group": "1_gitlens_actions_1@2" }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(commit|stash)\\b/", + "group": "7_gitlens_cutcopypaste@97" + }, + { + "command": "gitlens.createCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:(commit|stash)\\b/", + "group": "1_gitlens_actions_1@3" + }, + { + "command": "gitlens.views.createTag", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens_actions_1@4" + }, + { + "command": "gitlens.views.openChangedFileDiffs", + "when": "!listMultiSelection && viewItem =~ /gitlens:(compare:results(?!:)\\b(?!.*?\\b\\+filtered\\b)|commit|stash|results:files|status-branch:files|status:upstream:(ahead|behind))\\b/ && config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "inline@90", + "alt": "gitlens.views.openChangedFileDiffsWithWorking" + }, { "submenu": "gitlens/commit/changes", - "when": "viewItem =~ /gitlens:(commit|stash|results:files)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(compare:results(?!:)\\b(?!.*?\\b\\+filtered\\b)|commit|stash|results:files|status-branch:files|status:upstream:(ahead|behind))\\b/", "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.showInDetailsView", - "when": "viewItem =~ /gitlens:(commit|stash)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|stash)\\b/", "group": "3_gitlens_explore@0" }, { "command": "gitlens.showInCommitGraph", - "when": "viewItem =~ /gitlens:(branch|commit|stash|tag)\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b(?!.*?\\b\\+closed\\b)/", "group": "3_gitlens_explore@1" }, { "command": "gitlens.revealCommitInView", - "when": "view =~ /gitlens\\.views\\.(?!commits|branches\\b)/ && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.(?!commits|branches\\b)/ && viewItem =~ /gitlens:commit\\b/", "group": "3_gitlens_explore@2" }, { - "command": "gitlens.openCommitOnRemote", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:commit\\b/", - "group": "3_gitlens_explore@2", - "alt": "gitlens.copyRemoteCommitUrl" + "command": "gitlens.views.openCommitOnRemote", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:commit\\b/", + "group": "3_gitlens_explore@2" + }, + { + "command": "gitlens.views.openCommitOnRemote.multi", + "when": "listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:commit\\b/", + "group": "3_gitlens_explore@2" }, { "submenu": "gitlens/share", - "when": "viewItem =~ /gitlens:(branch|commit|compare:results(?!:)|remote|repo-folder|repository|stash|tag|file\\b(?=.*?\\b\\+committed\\b))\\b/", + "when": "viewItem =~ /gitlens:(branch|commit|compare:(branch(?=.*?\\b\\+comparing\\b)|results(?!:))|remote|repo-folder|repository|stash|status:upstream|tag|workspace|file\\b(?=.*?\\b\\+committed\\b))\\b/", "group": "7_gitlens_a_share@1" }, { "command": "gitlens.copyRelativePathToClipboard", - "when": "viewItem =~ /gitlens:file\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/", "group": "7_gitlens_cutcopypaste@2" }, { "command": "gitlens.copyShaToClipboard", - "when": "(viewItem =~ /gitlens:(commit|stash)\\b/) || (viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && view =~ /gitlens\\.views\\.(file|line)History/)", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|stash)\\b/", "group": "7_gitlens_cutcopypaste@3" }, { "command": "gitlens.copyMessageToClipboard", - "when": "(viewItem =~ /gitlens:(commit|stash)\\b/) || (viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && view =~ /gitlens\\.views\\.(file|line)History/)", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|stash)\\b/", "group": "7_gitlens_cutcopypaste@4" }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "group": "7_gitlens_cutcopypaste@3" + }, + { + "command": "gitlens.copyShaToClipboard", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && view =~ /gitlens\\.views\\.(file|line)History/", + "group": "7_gitlens_cutcopypaste@97" + }, + { + "command": "gitlens.copyMessageToClipboard", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && view =~ /gitlens\\.views\\.(file|line)History/", + "group": "7_gitlens_cutcopypaste@98" + }, { "submenu": "gitlens/commit/copy", - "when": "viewItem =~ /gitlens:(branch|commit|remote|repo-folder|repository|stash|tag|file\\b(?=.*?\\b\\+committed\\b))\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|commit|remote|repo-folder|repository|stash|tag|file\\b(?=.*?\\b\\+committed\\b))\\b/", "group": "7_gitlens_cutcopypaste@10" }, { @@ -11787,30 +14105,19 @@ "when": "viewItem =~ /gitlens:(history:(file|line)|status:file)\\b/", "group": "inline@1" }, - { - "command": "gitlens.views.undoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+HEAD\\b)/", - "group": "inline@-2" - }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", "group": "inline@-1" }, { "command": "gitlens.views.pushToCommit", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", "group": "inline@-1" }, - { - "command": "gitlens.views.openFile", - "when": "view =~ /gitlens\\.views\\.(?!(fileHistory|lineHistory)\\b)/ && viewItem =~ /gitlens:file(:results|\\b(?=.*?\\b\\+(committed|stashed)\\b))/", - "group": "inline@1", - "alt": "gitlens.views.openFileRevision" - }, { "command": "gitlens.views.openFileRevision", - "when": "view =~ /gitlens\\.views\\.(fileHistory|lineHistory)\\b/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+(committed|stashed)\\b)/", + "when": "viewItem =~ /gitlens:file(:results|\\b(?=.*?\\b\\+(committed|stashed)\\b))/", "group": "inline@1", "alt": "gitlens.views.openFile" }, @@ -11847,146 +14154,168 @@ }, { "command": "gitlens.openFileOnRemote", - "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:hasRemotes", + "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:repos:withRemotes", "group": "inline@99", "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, { "command": "gitlens.views.stageFile", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+unstaged\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+unstaged\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.unstageFile", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+staged\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+staged\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.stashSaveFiles", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+(un)?staged\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+(un)?staged\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.openChanges", - "when": "viewItem =~ /gitlens:file\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/", "group": "2_gitlens_quickopen@1" }, { "submenu": "gitlens/commit/file/changes", - "when": "viewItem =~ /gitlens:file\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/", "group": "2_gitlens_quickopen@2" }, + { + "command": "gitlens.showInDetailsView", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.(fileHistory|lineHistory\\b)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "group": "2_gitlens_quickopen@3" + }, { "command": "gitlens.views.openFile", - "when": "viewItem =~ /gitlens:(file|history:(file|line)|status:file)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(file|history:(file|line)|status:file)\\b/", "group": "2_gitlens_quickopen_file@3" }, { "command": "gitlens.views.openFileRevision", - "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+(committed|stashed)\\b)|:results)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+(committed|stashed)\\b)|:results)/", "group": "2_gitlens_quickopen_file@4" }, { "command": "gitlens.openFileOnRemote", - "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:hasRemotes", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:repos:withRemotes", "group": "2_gitlens_quickopen_file@5", "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, { "submenu": "gitlens/commit/file/commit", - "when": "view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "3_gitlens_explore@1" }, { "submenu": "gitlens/commit/file/history", - "when": "viewItem =~ /gitlens:file\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/", "group": "3_gitlens_explore@2" }, { "command": "gitlens.views.compareWithHead", - "when": "!gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "4_gitlens_compare@2" }, { "command": "gitlens.views.compareWithWorking", - "when": "!gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "4_gitlens_compare@3" }, { "command": "gitlens.views.applyChanges", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+stashed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+stashed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.restore", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+stashed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+stashed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.applyChanges", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results\\b)/", "group": "8_gitlens_actions@1" }, { "command": "gitlens.views.restore", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results\\b)/", "group": "8_gitlens_actions@2" }, { "command": "gitlens.openFileOnRemote", - "when": "viewItem =~ /gitlens:(history:(file|line)|status:file)\\b/ && gitlens:hasRemotes", + "when": "!listMultiSelection && viewItem =~ /gitlens:(history:(file|line)|status:file)\\b/ && gitlens:repos:withRemotes", "group": "5_gitlens_open@2", "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, { - "command": "gitlens.showSettingsPage#autolinks", + "command": "gitlens.connectRemoteProvider", + "when": "config.gitlens.integrations.enabled && viewItem =~ /gitlens:autolinked:items\\b/ && gitlens:repos:withHostingIntegrations && !gitlens:repos:withHostingIntegrationsConnected", + "group": "inline@98" + }, + { + "command": "gitlens.showSettingsPage!autolinks", "when": "viewItem =~ /gitlens:autolinked:items\\b/", "group": "inline@99" }, { - "command": "gitlens.showSettingsPage#autolinks", - "when": "viewItem =~ /gitlens:autolinked:items\\b/", - "group": "1_gitlens_actions@99" + "command": "gitlens.connectRemoteProvider", + "when": "config.gitlens.integrations.enabled && viewItem =~ /gitlens:autolinked:items\\b/ && gitlens:repos:withHostingIntegrations && !gitlens:repos:withHostingIntegrationsConnected", + "group": "6_gitlens_actions@1" }, { - "command": "gitlens.openIssueOnRemote", - "when": "viewItem =~ /gitlens:autolinked:issue\\b/", + "command": "gitlens.showSettingsPage!autolinks", + "when": "!listMultiSelection && viewItem =~ /gitlens:autolinked:items\\b/", + "group": "8_gitlens_actions@99" + }, + { + "command": "gitlens.views.openUrl", + "when": "viewItem =~ /gitlens:autolinked:item\\b/", "group": "inline@99", - "alt": "gitlens.copyRemoteIssueUrl" + "alt": "gitlens.views.copyUrl" }, { - "command": "gitlens.openIssueOnRemote", - "when": "viewItem =~ /gitlens:autolinked:issue\\b/", - "group": "1_gitlens_actions@99", - "alt": "gitlens.copyRemoteIssueUrl" + "command": "gitlens.views.openUrl", + "when": "!listMultiSelection && viewItem =~ /gitlens:autolinked:item\\b/", + "group": "1_gitlens_actions@99" }, { - "command": "gitlens.copyRemoteIssueUrl", - "when": "viewItem =~ /gitlens:autolinked:issue\\b/", - "group": "7_gitlens_cutcopypaste@1" + "command": "gitlens.views.openUrl.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:autolinked:item\\b/", + "group": "1_gitlens_actions@99" }, { - "command": "gitlens.openAutolinkUrl", - "when": "viewItem =~ /gitlens:autolinked:item\\b/", - "group": "inline@99", - "alt": "gitlens.copyAutolinkUrl" + "command": "gitlens.views.copyUrl", + "when": "!listMultiSelection && viewItem =~ /gitlens:autolinked:item\\b/", + "group": "7_gitlens_cutcopypaste@1" }, { - "command": "gitlens.openAutolinkUrl", - "when": "viewItem =~ /gitlens:autolinked:item\\b/", - "group": "1_gitlens_actions@99", - "alt": "gitlens.copyAutolinkUrl" + "command": "gitlens.views.copyUrl.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:autolinked:item\\b/", + "group": "7_gitlens_cutcopypaste@1" }, { - "command": "gitlens.copyAutolinkUrl", + "command": "gitlens.views.copyAsMarkdown", "when": "viewItem =~ /gitlens:autolinked:item\\b/", - "group": "7_gitlens_cutcopypaste@1" + "group": "7_gitlens_cutcopypaste@2" }, { "command": "gitlens.views.openPullRequest", "when": "gitlens:action:openPullRequest > 1 && viewItem =~ /gitlens:pullrequest\\b/", "group": "inline@1" }, + { + "command": "gitlens.views.openPullRequestChanges", + "when": "viewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/ && config.multiDiffEditor.experimental.enabled", + "group": "inline@2" + }, + { + "command": "gitlens.views.openPullRequestComparison", + "when": "viewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/", + "group": "inline@3" + }, { "command": "gitlens.openPullRequestOnRemote", "when": "viewItem =~ /gitlens:pullrequest\\b/", @@ -11994,19 +14323,33 @@ "alt": "gitlens.copyRemotePullRequestUrl" }, { - "command": "gitlens.views.openPullRequest", - "when": "gitlens:action:openPullRequest > 1 && viewItem =~ /gitlens:pullrequest\\b/", + "command": "gitlens.views.openPullRequestChanges", + "when": "!listMultiSelection && viewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/ && config.multiDiffEditor.experimental.enabled", "group": "1_gitlens_actions@1" }, + { + "command": "gitlens.views.openPullRequest", + "when": "!listMultiSelection && gitlens:action:openPullRequest > 1 && viewItem =~ /gitlens:pullrequest\\b/", + "group": "1_gitlens_actions@98" + }, { "command": "gitlens.openPullRequestOnRemote", - "when": "viewItem =~ /gitlens:pullrequest\\b/", - "group": "1_gitlens_actions@99", - "alt": "gitlens.copyRemotePullRequestUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:pullrequest\\b/", + "group": "1_gitlens_actions@99" + }, + { + "command": "gitlens.showInCommitGraph", + "when": "!listMultiSelection && viewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/", + "group": "3_gitlens_explore@1" + }, + { + "command": "gitlens.views.openPullRequestComparison", + "when": "!listMultiSelection && viewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/", + "group": "4_gitlens_compare@1" }, { "command": "gitlens.copyRemotePullRequestUrl", - "when": "viewItem =~ /gitlens:pullrequest\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:pullrequest\\b/", "group": "7_gitlens_cutcopypaste@1" }, { @@ -12026,7 +14369,7 @@ }, { "command": "gitlens.views.addRemote", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remotes\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remotes\\b/", "group": "1_gitlens_actions@1" }, { @@ -12052,54 +14395,52 @@ }, { "command": "gitlens.views.fetch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pruneRemote", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.openRepoOnRemote", - "when": "viewItem =~ /gitlens:remote\\b/", - "group": "5_gitlens_open@1", - "alt": "gitlens.copyRemoteRepositoryUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:remote\\b/", + "group": "5_gitlens_open@1" }, { "command": "gitlens.openBranchesOnRemote", - "when": "viewItem =~ /gitlens:remote\\b/", - "group": "5_gitlens_open@2", - "alt": "gitlens.copyRemoteBranchesUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:remote\\b/", + "group": "5_gitlens_open@2" }, { "command": "gitlens.views.removeRemote", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", "group": "6_gitlens_terminal@1" }, { "command": "gitlens.views.setAsDefault", - "when": "viewItem =~ /gitlens:remote\\b(?!.*?\\b\\+default\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:remote\\b(?!.*?\\b\\+default\\b)/", "group": "8_gitlens_actions@1" }, { "command": "gitlens.views.unsetAsDefault", - "when": "viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+default\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+default\\b)/", "group": "8_gitlens_actions@1" }, { "command": "gitlens.connectRemoteProvider", - "when": "config.gitlens.integrations.enabled && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+disconnected\\b)/", + "when": "!listMultiSelection && config.gitlens.integrations.enabled && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+disconnected\\b)/", "group": "8_gitlens_actions@2" }, { "command": "gitlens.disconnectRemoteProvider", - "when": "config.gitlens.integrations.enabled && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+connected\\b)/", + "when": "!listMultiSelection && config.gitlens.integrations.enabled && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+connected\\b)/", "group": "8_gitlens_actions@2" }, { "submenu": "gitlens/commit/browse", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|file\\b(?=.*?\\b\\+committed\\b)|stash|tag)\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|file\\b(?=.*?\\b\\+committed\\b)|stash|tag)\\b/", "group": "3_gitlens_explore@10" }, { @@ -12109,12 +14450,12 @@ }, { "command": "gitlens.views.workspaces.repo.locate", - "when": "viewItem =~ /gitlens:workspaceMissingRepository\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspaceMissingRepository\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.workspaces.repo.remove", - "when": "viewItem =~ /gitlens:workspaceMissingRepository\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspaceMissingRepository\\b/", "group": "6_gitlens_actions@1" }, { @@ -12125,48 +14466,48 @@ }, { "command": "gitlens.views.workspaces.repo.open", - "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", "group": "0_1gitlens_actions@1" }, { "command": "gitlens.views.workspaces.repo.openInNewWindow", - "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", "group": "0_1gitlens_actions@2" }, { "command": "gitlens.views.workspaces.repo.addToWindow", - "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", "group": "0_1gitlens_actions@3" }, { "command": "gitlens.views.revealRepositoryInExplorer", - "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", "group": "0_2gitlens_actions@1" }, { "command": "gitlens.views.workspaces.repo.locate", - "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/", "group": "0_2gitlens_actions@2" }, { "command": "gitlens.views.workspaces.repo.remove", - "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/", "group": "0_3gitlens_actions@1" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "inline@96", "alt": "gitlens.views.pushWithForce" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "inline@97" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "inline@98" }, { @@ -12181,84 +14522,98 @@ }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pushWithForce", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.openInTerminal", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", "group": "2_gitlens_quickopen@1" }, { - "command": "gitlens.openRepoOnRemote", - "when": "viewItem =~ /gitlens:repository\\b/ && gitlens:hasRemotes", - "group": "2_gitlens_quickopen@2", - "alt": "gitlens.copyRemoteRepositoryUrl" + "command": "gitlens.views.openInIntegratedTerminal", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "group": "2_gitlens_quickopen@2" }, { "command": "gitlens.views.revealRepositoryInExplorer", - "when": "viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", - "group": "2_gitlens_quickopen@2" + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", + "group": "2_gitlens_quickopen@3" + }, + { + "command": "gitlens.openRepoOnRemote", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b/ && gitlens:repos:withRemotes", + "group": "2_gitlens_quickopen@4" }, { "command": "gitlens.showCommitSearch", - "when": "viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b/", "group": "3_gitlens_explore@1" }, { "command": "gitlens.stashSave", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", "group": "1_gitlens_actions_1@1" }, { "command": "gitlens.stashApply", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", "group": "1_gitlens_actions_1@2" }, { "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+(starred|workspace)\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+(starred|workspace)\\b)/", + "group": "8_gitlens_actions_@1" + }, + { + "command": "gitlens.views.star.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+(starred|workspace)\\b)/", "group": "8_gitlens_actions_@1" }, { "command": "gitlens.views.unstar", - "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions_@1" + }, + { + "command": "gitlens.views.unstar.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions_@1" }, { "command": "gitlens.views.closeRepository", - "when": "viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "8_gitlens_actions_@2" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", "group": "inline@96", "alt": "gitlens.views.pushWithForce" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+behind\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+behind\\b)/", "group": "inline@97" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", "group": "inline@98" }, { @@ -12276,154 +14631,197 @@ "when": "viewItem =~ /gitlens:repo-folder\\b/ && gitlens:plus:enabled", "group": "inline@100" }, + { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+filtered\\b)/ && gitlens:views:commits:filtered", + "group": "inline@101" + }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pushWithForce", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.openInTerminal", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "2_gitlens_quickopen@1" }, + { + "command": "gitlens.views.openInIntegratedTerminal", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "group": "2_gitlens_quickopen@2" + }, { "command": "gitlens.openRepoOnRemote", - "when": "viewItem =~ /gitlens:repo-folder\\b/ && gitlens:hasRemotes", - "group": "2_gitlens_quickopen@2", - "alt": "gitlens.copyRemoteRepositoryUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b/ && gitlens:repos:withRemotes", + "group": "2_gitlens_quickopen@3" }, { "command": "gitlens.showGraph", - "when": "viewItem =~ /gitlens:repo-folder\\b/ && gitlens:plus:enabled", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b/ && gitlens:plus:enabled", "group": "3_gitlens_explore@1" }, { "command": "gitlens.showCommitSearch", - "when": "viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b/", "group": "3_gitlens_explore@2" }, { "command": "gitlens.stashSave", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions_1@1" }, { "command": "gitlens.stashApply", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions_1@2" }, { "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions_@1" + }, + { + "command": "gitlens.views.star.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions_@1" }, { "command": "gitlens.views.unstar", - "when": "viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions_@1" + }, + { + "command": "gitlens.views.unstar.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions_@1" }, { "command": "gitlens.views.closeRepository", - "when": "viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b/", "group": "8_gitlens_actions_@2" }, + { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+filtered\\b)/ && gitlens:views:commits:filtered", + "group": "8_gitlens_filter_@1" + }, + { + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b/", + "group": "8_gitlens_filter_@2" + }, { "command": "gitlens.views.publishRepository", - "when": "!gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", - "group": "inline@1" + "when": "!gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:(missing|none)/", + "group": "inline@99" }, { "command": "gitlens.views.addRemote", - "when": "!gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", + "when": "!gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", "group": "inline@2" }, { "command": "gitlens.views.publishBranch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", - "group": "inline@1" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:(missing|none)/", + "group": "inline@99" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:ahead/", - "group": "inline@1", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:ahead/", + "group": "inline@99", "alt": "gitlens.views.pushWithForce" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:behind/", - "group": "inline@1" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:behind/", + "group": "inline@99" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:(?!none)/", - "group": "inline@2" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:(?!(missing|none))/", + "group": "inline@98" }, { "command": "gitlens.views.createPullRequest", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:action:createPullRequest && viewItem =~ /gitlens:status:upstream:(?!none)/", - "group": "inline@3" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:action:createPullRequest && viewItem =~ /gitlens:status:upstream:same/", + "group": "inline@1" }, { - "command": "gitlens.openBranchOnRemote", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:(?!none)/", - "group": "inline@99", + "command": "gitlens.views.openBranchOnRemote", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:same/", + "group": "inline@97", "alt": "gitlens.copyRemoteBranchUrl" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:ahead", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:ahead", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pushWithForce", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:ahead", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:ahead", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:behind", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:behind", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:(?!none)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:(?!(missing|none))/", "group": "1_gitlens_actions@3" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream/", "group": "1_gitlens_secondary_actions@1" }, { "command": "gitlens.views.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream/", "group": "1_gitlens_secondary_actions@2" }, { "command": "gitlens.views.createPullRequest", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:action:createPullRequest && viewItem =~ /gitlens:status:upstream:(?!none)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:action:createPullRequest && viewItem =~ /gitlens:status:upstream:(?!(missing|none))/", "group": "1_gitlens_secondary_actions@3" }, { - "command": "gitlens.openBranchOnRemote", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:(?!none)/", - "group": "2_gitlens_quickopen@1", - "alt": "gitlens.copyRemoteBranchUrl" + "command": "gitlens.views.openBranchOnRemote", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:status:upstream:(?!(missing|none))/", + "group": "2_gitlens_quickopen_remote@1" + }, + { + "command": "gitlens.views.openInTerminal", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream\\b/", + "group": "2_gitlens_quickopen_terminal@1" + }, + { + "command": "gitlens.views.openInIntegratedTerminal", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream\\b/", + "group": "2_gitlens_quickopen_terminal@2" + }, + { + "command": "gitlens.copyRemoteBranchUrl", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:status:upstream:(?!(missing|none))/", + "group": "7_gitlens_cutcopypaste@1" }, { "command": "gitlens.views.dismissNode", @@ -12431,14 +14829,14 @@ "group": "inline@99" }, { - "command": "gitlens.views.clearNode", + "command": "gitlens.views.clearComparison", "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", "group": "inline@99" }, { "command": "gitlens.views.editNode", "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", - "group": "inline@98" + "group": "inline@96" }, { "command": "gitlens.views.setBranchComparisonToWorking", @@ -12451,59 +14849,79 @@ "group": "inline@2" }, { - "command": "gitlens.views.editNode", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", + "command": "gitlens.views.setBranchComparisonToWorking", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+branch\\b)/", "group": "1_gitlens@1" }, { - "command": "gitlens.views.setBranchComparisonToWorking", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+branch\\b)/", - "group": "1_gitlens@2" + "command": "gitlens.views.setBranchComparisonToBranch", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+working\\b)/", + "group": "1_gitlens@1" }, { - "command": "gitlens.views.setBranchComparisonToBranch", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+working\\b)/", + "command": "gitlens.views.editNode", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", "group": "1_gitlens@2" }, + { + "command": "gitlens.views.clearReviewed", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", + "group": "1_gitlens@3" + }, + { + "command": "gitlens.views.clearComparison", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", + "group": "1_gitlens@4" + }, { "command": "gitlens.views.branches.setShowBranchComparisonOff", - "when": "view =~ /gitlens\\.views\\.branches\\b/ && viewItem =~ /gitlens:compare:branch\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.branches\\b/ && viewItem =~ /gitlens:compare:branch\\b/", "group": "8_gitlens_toggles@1" }, { "command": "gitlens.views.commits.setShowBranchComparisonOff", - "when": "view =~ /gitlens\\.views\\.commits\\b/ && viewItem =~ /gitlens:compare:branch\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.commits\\b/ && viewItem =~ /gitlens:compare:branch\\b/", "group": "8_gitlens_toggles@1" }, { "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOff", - "when": "view =~ /gitlens\\.views\\.repositories\\b/ && viewItem =~ /gitlens:compare:branch(?!.*?\\b\\+root\\b)\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.repositories\\b/ && viewItem =~ /gitlens:compare:branch(?!.*?\\b\\+root\\b)\\b/", "group": "8_gitlens_toggles@1" }, { "command": "gitlens.views.repositories.setShowSectionOff", - "when": "view =~ /gitlens\\.views\\.repositories\\b/ && viewItem =~ /gitlens:(compare:branch(?=.*?\\b\\+root\\b)|branches|branch(?=.*?\\b\\+commits\\b)|reflog|remotes|stashes|status:upstream|tags)\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.repositories\\b/ && viewItem =~ /gitlens:(compare:branch(?=.*?\\b\\+root\\b)|branches|branch(?=.*?\\b\\+commits\\b)|reflog|remotes|stashes|status:upstream|tags)\\b/", "group": "8_gitlens_toggles@99" }, - { - "command": "gitlens.views.clearNode", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", - "group": "9_gitlens@1" - }, { "command": "gitlens.views.searchAndCompare.swapComparison", "when": "viewItem =~ /gitlens:compare:results(?!:)\\b/", - "group": "inline@1" + "group": "inline@96" }, { - "submenu": "gitlens/view/searchAndCompare/comparison/filter", + "submenu": "gitlens/comparison/results/files/filter/inline", "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?!.*?\\b\\+filtered\\b)/", - "group": "inline@1" + "group": "inline@99" }, { - "submenu": "gitlens/view/searchAndCompare/comparison/filtered", + "submenu": "gitlens/comparison/results/files/filtered/inline", "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?=.*?\\b\\+filtered\\b)/", - "group": "inline@1" + "group": "inline@99" + }, + { + "command": "gitlens.views.clearReviewed", + "when": "!listMultiSelection && viewItem =~ /gitlens:results:files\\b/", + "group": "1_gitlens@1" + }, + { + "submenu": "gitlens/comparison/results/files/filter", + "when": "!listMultiSelection && viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?!.*?\\b\\+filtered\\b)/", + "group": "1_gitlens@2" + }, + { + "submenu": "gitlens/comparison/results/files/filtered", + "when": "!listMultiSelection && viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?=.*?\\b\\+filtered\\b)/", + "group": "1_gitlens@2" }, { "command": "gitlens.views.refreshNode", @@ -12515,15 +14933,55 @@ "when": "viewItem =~ /gitlens:search:results(?!:)\\b/", "group": "inline@97" }, + { + "command": "gitlens.views.setResultsCommitsFilterOff", + "when": "viewItem =~ /gitlens:compare:(results(?!:)|branch)\\b(?=.*?\\b\\+filtered\\b)/", + "group": "inline@96" + }, { "command": "gitlens.views.searchAndCompare.swapComparison", - "when": "viewItem =~ /gitlens:compare:results(?!:)\\b(?!.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results(?!:)\\b(?!.*?\\b\\+working\\b)/", "group": "1_gitlens_actions@2" }, + { + "command": "gitlens.views.clearReviewed", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results(?!:)/", + "group": "1_gitlens_actions@3" + }, + { + "command": "gitlens.createPatch", + "when": "!listMultiSelection && false && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "1_gitlens_secondary_actions@1" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "7_gitlens_cutcopypaste@97" + }, + { + "command": "gitlens.createCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "1_gitlens_secondary_actions@2" + }, + { + "command": "gitlens.openComparisonOnRemote", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "2_gitlens_quickopen@1 && gitlens:repos:withRemotes" + }, { "command": "gitlens.views.openDirectoryDiff", - "when": "viewItem =~ /gitlens:compare:results(?!:)\\b/", - "group": "2_gitlens_quickopen@1" + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "2_gitlens_quickopen@2" + }, + { + "command": "gitlens.views.setResultsCommitsFilterOff", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:(results(?!:)|branch)\\b(?=.*?\\b\\+filtered\\b)/", + "group": "7_gitlens_filter@1" + }, + { + "command": "gitlens.views.setResultsCommitsFilterAuthors", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:(results(?!:)|branch)\\b/", + "group": "7_gitlens_filter@2" }, { "command": "gitlens.views.editNode", @@ -12532,20 +14990,19 @@ }, { "command": "gitlens.views.editNode", - "when": "viewItem =~ /gitlens:search:results(?!:)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:search:results(?!:)\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.openComparisonOnRemote", - "when": "viewItem =~ /gitlens:compare:results:commits\\b/", + "when": "viewItem =~ /gitlens:compare:results:commits\\b/ && gitlens:repos:withRemotes", "group": "inline@99", "alt": "gitlens.copyRemoteComparisonUrl" }, { "command": "gitlens.openComparisonOnRemote", - "when": "viewItem =~ /gitlens:compare:results:commits\\b/", - "group": "3_gitlens_explore@0", - "alt": "gitlens.copyRemoteComparisonUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results:commits\\b/ && gitlens:repos:withRemotes", + "group": "3_gitlens_explore@0" }, { "command": "gitlens.stashSave", @@ -12559,16 +15016,16 @@ }, { "command": "gitlens.stashSave", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /^gitlens:(stashes|status:files)$/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /^gitlens:(stashes|status:files)$/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.stashApply", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stashes", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stashes", "group": "1_gitlens_actions@2" }, { - "command": "gitlens.stashApply", + "command": "gitlens.views.stash.apply", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", "group": "inline@1" }, @@ -12583,18 +15040,23 @@ "group": "inline@99" }, { - "command": "gitlens.stashApply", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", + "command": "gitlens.views.stash.apply", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.stash.rename", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.stash.delete", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", + "group": "1_gitlens_actions@3" + }, + { + "command": "gitlens.views.stash.delete.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", "group": "1_gitlens_actions@3" }, { @@ -12604,7 +15066,7 @@ }, { "command": "gitlens.views.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tags\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tags\\b/", "group": "1_gitlens_actions@1" }, { @@ -12614,17 +15076,22 @@ }, { "command": "gitlens.views.switchToTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tag\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tag\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.deleteTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:tag", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:tag", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.deleteTag.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:tag", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tag\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tag\\b/", "group": "1_gitlens_actions@3" }, { @@ -12634,7 +15101,7 @@ }, { "command": "gitlens.views.createWorktree", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:worktrees\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:worktrees\\b/", "group": "1_gitlens_actions@1" }, { @@ -12651,78 +15118,88 @@ }, { "command": "gitlens.views.openWorktree", - "when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/", "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.views.openWorktree", - "when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1", "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.views.openWorktreeInNewWindow", - "when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/", "group": "2_gitlens_quickopen@2" }, { "command": "gitlens.views.openWorktreeInNewWindow", - "when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1", + "group": "2_gitlens_quickopen@2" + }, + { + "command": "gitlens.views.openWorktreeInNewWindow.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:worktree\\b/", "group": "2_gitlens_quickopen@2" }, { "command": "gitlens.views.revealWorktreeInExplorer", - "when": "viewItem =~ /gitlens:worktree\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b/", "group": "3_gitlens@1" }, { "command": "gitlens.views.deleteWorktree", - "when": "!gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+(active|main)\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+(active|main)\\b)/", + "group": "6_gitlens_actions@1" + }, + { + "command": "gitlens.views.deleteWorktree.multi", + "when": "listMultiSelection && !gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+(active|main)\\b)/", "group": "6_gitlens_actions@1" }, { "command": "gitlens.views.stageDirectory", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", "group": "inline@1" }, { "command": "gitlens.views.unstageDirectory", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", "group": "inline@2" }, { "command": "gitlens.views.stageDirectory", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.unstageDirectory", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.copy", - "when": "viewItem =~ /gitlens:(?=(autolinked:issue|branch|commit|contributor|folder|history:line|pullrequest|remote|repository|repo-folder|stash|tag|workspace|worktree)\\b)/", + "when": "viewItem =~ /gitlens:(?=(autolinked:item\\b|branch|commit|contributor|file(?!.*?\\b\\+(staged|unstaged))\\b|folder|history:line|pullrequest|remote|repository|repo-folder|search:results|stash|tag|workspace|worktree)\\b)/", "group": "7_gitlens_cutcopypaste@1" }, { - "command": "gitlens.views.copy", - "when": "viewItem =~ /gitlens:file(?!.*?\\b\\+(staged|unstaged))\\b/", - "group": "7_gitlens_cutcopypaste@1" + "command": "gitlens.views.dismissNode", + "when": "!listMultiSelection && viewItem =~ /gitlens:(compare:picker:ref|(compare|search):results(?!:)\\b)\\b(?!:(commits|files))/", + "group": "1_gitlens_actions@98" }, { - "command": "gitlens.views.dismissNode", - "when": "viewItem =~ /gitlens:(compare:picker:ref|(compare|search):results(?!:)\\b)\\b(?!:(commits|files))/", - "group": "8_gitlens_actions@98" + "command": "gitlens.views.collapseNode", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|compare|folder|results|search|status|tag)\\b/", + "group": "9_gitlens@1" }, { "command": "gitlens.views.expandNode", - "when": "viewItem =~ /gitlens:(branch|compare|folder|results|search|status)\\b/", - "group": "9_gitlens@1" + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|compare|folder|results|search|status|tag)\\b/", + "group": "9_gitlens@2" }, { "command": "gitlens.views.refreshNode", "when": "viewItem =~ /gitlens:(?!(file|message|date-marker)\\b)/", - "group": "9_gitlens@99" + "group": "9_gitlens_z@99" }, { "command": "gitlens.views.loadAllChildren", @@ -12731,17 +15208,17 @@ }, { "command": "gitlens.views.loadAllChildren", - "when": "viewItem =~ /gitlens:pager\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:pager\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.setShowRelativeDateMarkersOff", - "when": "viewItem == gitlens:date-marker && config.gitlens.views.showRelativeDateMarkers", + "when": "!listMultiSelection && viewItem == gitlens:date-marker && config.gitlens.views.showRelativeDateMarkers", "group": "1_gitlens@0" }, { "command": "gitlens.ghpr.views.openOrCreateWorktree", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/ && config.gitlens.menus.ghpr.worktree", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/ && config.gitlens.menus.ghpr.worktree", "group": "2_gitlens@1" } ], @@ -12758,22 +15235,22 @@ }, { "command": "gitlens.graph.publishBranch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)(?!.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)(?!.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.graph.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.graph.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.graph.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@3" }, { @@ -12818,7 +15295,7 @@ }, { "command": "gitlens.graph.createPullRequest", - "when": "gitlens:hasRemotes && gitlens:action:createPullRequest && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "when": "gitlens:repos:withRemotes && gitlens:action:createPullRequest && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", "group": "1_gitlens_actions_@10" }, { @@ -12843,28 +15320,27 @@ }, { "command": "gitlens.graph.openBranchOnRemote", - "when": "gitlens:hasRemotes && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", - "group": "2_gitlens_quickopen@1", - "alt": "gitlens.copyRemoteBranchUrl" + "when": "gitlens:repos:withRemotes && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.graph.cherryPick", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?!.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.graph.undoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.graph.revert", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@3" }, { "command": "gitlens.graph.resetToCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions@4" }, { @@ -12874,85 +15350,109 @@ }, { "command": "gitlens.graph.resetCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions@5" }, { "command": "gitlens.graph.rebaseOntoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions@6" }, { "command": "gitlens.graph.switchToCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions@7" }, { "command": "gitlens.graph.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions_1@1" }, + { + "command": "gitlens.createPatch", + "when": "!listMultiSelection && false && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(commit|stash)\\b/", + "group": "1_gitlens_actions_1@2" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(commit|stash)\\b/", + "group": "7_cutcopypaste@97" + }, + { + "command": "gitlens.createCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && webviewItem =~ /gitlens:(commit|stash)\\b/", + "group": "1_gitlens_actions_1@3" + }, { "command": "gitlens.graph.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", - "group": "1_gitlens_actions_1@2" + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens_actions_1@4" }, { "submenu": "gitlens/graph/commit/changes", - "when": "webviewItem =~ /gitlens:(commit|stash|wip)\\b/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(commit|stash|wip)\\b/", "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.graph.showInDetailsView", - "when": "webviewItem =~ /gitlens:(commit|stash|wip)\\b/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(commit|stash|wip)\\b/", "group": "3_gitlens_explore@0" }, { "command": "gitlens.graph.openSCM", - "when": "webviewItem == gitlens:wip", + "when": "!listMultiSelection && webviewItem == gitlens:wip", "group": "3_gitlens_explore@1" }, { "command": "gitlens.graph.openCommitOnRemote", - "when": "gitlens:hasRemotes && webviewItem =~ /gitlens:commit\\b/", - "group": "3_gitlens_explore@2", - "alt": "gitlens.copyRemoteCommitUrl" + "when": "!listMultiSelection && gitlens:repos:withRemotes && webviewItem =~ /gitlens:commit\\b/", + "group": "3_gitlens_explore@2" + }, + { + "command": "gitlens.graph.openCommitOnRemote.multi", + "when": "listMultiSelection && gitlens:repos:withRemotes && webviewItems =~ /gitlens:commit\\b/", + "group": "3_gitlens_explore@2" }, { "submenu": "gitlens/share", - "when": "webviewItem =~ /gitlens:(branch|commit|compare:results(?!:)|stash|tag)\\b/", + "when": "webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "7_gitlens_a_share@1" }, { "command": "gitlens.graph.copySha", - "when": "webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "7_gitlens_cutcopypaste@2" }, { "command": "gitlens.graph.copyMessage", - "when": "webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "7_gitlens_cutcopypaste@3" }, { - "command": "gitlens.graph.applyStash", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", + "command": "gitlens.graph.stash.apply", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", "group": "1_gitlens_actions@1" }, { "command": "gitlens.graph.stash.rename", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", "group": "1_gitlens_actions@2" }, { "command": "gitlens.graph.stash.delete", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", "group": "1_gitlens_actions@3" }, { - "command": "gitlens.graph.saveStash", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:wip", + "command": "gitlens.graph.stash.save", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:wip", "group": "1_gitlens_actions@3" }, + { + "command": "gitlens.graph.copyWorkingChangesToWorktree", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:wip", + "group": "1_gitlens_actions@4" + }, { "command": "gitlens.graph.switchToTag", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:tag\\b/", @@ -12973,10 +15473,25 @@ "when": "webviewItem =~ /gitlens:tag\\b/", "group": "8_gitlens_actions@10" }, + { + "command": "gitlens.graph.openPullRequestChanges", + "when": "webviewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/ && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.graph.openPullRequest", + "when": "gitlens:action:openPullRequest > 1 && webviewItem =~ /gitlens:pullrequest\\b/", + "group": "1_gitlens_actions@98" + }, { "command": "gitlens.graph.openPullRequestOnRemote", "when": "webviewItem =~ /gitlens:pullrequest\\b/", - "group": "1_gitlens_actions@1" + "group": "1_gitlens_actions@99" + }, + { + "command": "gitlens.graph.openPullRequestComparison", + "when": "webviewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/", + "group": "4_gitlens_compare@1" }, { "command": "gitlens.graph.push", @@ -12993,6 +15508,11 @@ "when": "webviewItem =~ /gitlens:upstreamStatus\\b/", "group": "1_gitlens_actions@3" }, + { + "command": "gitlens.graph.openChangedFileDiffsWithMergeBase", + "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "3_gitlens_explore@11" + }, { "command": "gitlens.graph.compareWithUpstream", "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+tracking\\b)/", @@ -13000,19 +15520,24 @@ }, { "command": "gitlens.graph.compareWithHead", - "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(branch\\b(?!.*?\\b\\+current\\b)|commit\\b|stash\\b|tag\\b)/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(branch\\b(?!.*?\\b\\+current\\b)|commit\\b|stash\\b|tag\\b)/", "group": "4_gitlens_compare@2" }, { "command": "gitlens.graph.compareWithWorking", - "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "4_gitlens_compare@3" }, { - "command": "gitlens.graph.compareAncestryWithWorking", + "command": "gitlens.graph.compareWithMergeBase", "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", "group": "4_gitlens_compare@4" }, + { + "command": "gitlens.graph.compareAncestryWithWorking", + "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "4_gitlens_compare@5" + }, { "command": "gitlens.graph.addAuthor", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", @@ -13147,97 +15672,137 @@ "command": "gitlens.copyMessageToClipboard", "when": "viewItem =~ /gitlens:(?!(commit|file|remote|repo-folder|repository|stash)\\b)/", "group": "1_gitlens@3" + }, + { + "command": "gitlens.copyRemoteBranchUrl", + "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "group": "1_gitlens@4" } ], "gitlens/share": [ + { + "command": "gitlens.shareAsCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:((commit|stash|compare:results(?!:)|)\\b|file\\b(?=.*?\\b\\+committed\\b))/", + "group": "1_a_gitlens@1" + }, + { + "command": "gitlens.graph.shareAsCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && webviewItem =~ /gitlens:(commit|stash)\\b/", + "group": "1_a_gitlens@1" + }, { "command": "gitlens.copyDeepLinkToBranch", - "when": "viewItem =~ /gitlens:branch\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch\\b(?=.*?\\b\\+(remote|tracking)\\b)|status:upstream(?!:none))\\b/", "group": "1_gitlens@50" }, { "command": "gitlens.graph.copyDeepLinkToBranch", - "when": "webviewItem =~ /gitlens:branch\\b/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)/", "group": "1_gitlens@50" }, { "command": "gitlens.copyRemoteBranchUrl", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:branch\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)/", "group": "2_gitlens@50" }, { "command": "gitlens.graph.copyRemoteBranchUrl", - "when": "gitlens:hasRemotes && webviewItem =~ /gitlens:branch\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)/", "group": "2_gitlens@50" }, { "command": "gitlens.copyDeepLinkToCommit", - "when": "viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", "group": "1_gitlens@25" }, { "command": "gitlens.graph.copyDeepLinkToCommit", - "when": "webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens@25" }, { "command": "gitlens.copyDeepLinkToComparison", - "when": "viewItem =~ /gitlens:compare:results(?!:)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:(branch(?=.*?\\b\\+comparing\\b)|results(?!:))\\b/", + "group": "1_gitlens@25" + }, + { + "command": "gitlens.copyDeepLinkToFile", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:repos:withRemotes", + "group": "1_gitlens@26" + }, + { + "command": "gitlens.copyDeepLinkToFileAtRevision", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:repos:withRemotes", + "group": "1_gitlens@27" + }, + { + "command": "gitlens.copyDeepLinkToWorkspace", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b/", "group": "1_gitlens@25" }, { "command": "gitlens.copyRemoteFileUrlWithoutRange", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:(file\\b(?=.*?\\b\\+committed\\b)|history:(file|line)|status:file)\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(file\\b(?=.*?\\b\\+committed\\b)|history:(file|line)|status:file)\\b/", "group": "2_gitlens@1" }, { "command": "gitlens.copyRemoteFileUrlFrom", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:(file\\b(?=.*?\\b\\+committed\\b)|history:(file|line)|status:file)\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(file\\b(?=.*?\\b\\+committed\\b)|history:(file|line)|status:file)\\b/", "group": "2_gitlens@2" }, { "command": "gitlens.copyRemoteFileUrlWithoutRange", - "when": "gitlens:enabled && gitlens:hasRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/", + "when": "!listMultiSelection && gitlens:enabled && gitlens:repos:withRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/", "group": "2_gitlens@1" }, { "command": "gitlens.copyRemoteFileUrlFrom", - "when": "gitlens:enabled && gitlens:hasRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/", + "when": "!listMultiSelection && gitlens:enabled && gitlens:repos:withRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/", "group": "2_gitlens@2" }, { - "command": "gitlens.copyRemoteCommitUrl", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", + "command": "gitlens.views.copyRemoteCommitUrl", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", + "group": "2_gitlens@25" + }, + { + "command": "gitlens.views.copyRemoteCommitUrl.multi", + "when": "listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", "group": "2_gitlens@25" }, { "command": "gitlens.graph.copyRemoteCommitUrl", - "when": "gitlens:hasRemotes && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && webviewItem =~ /gitlens:commit\\b/", + "group": "2_gitlens@25" + }, + { + "command": "gitlens.graph.copyRemoteCommitUrl.multi", + "when": "listMultiSelection && gitlens:repos:withRemotes && webviewItems =~ /gitlens:commit\\b/", "group": "2_gitlens@25" }, { "command": "gitlens.copyDeepLinkToRepo", - "when": "viewItem =~ /gitlens:(remote|repo-folder|repository)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch\\b(?=.*?\\b\\+(remote|tracking)\\b)|remote|repo-folder|repository|status:upstream(?!:none))\\b/", "group": "1_gitlens@99" }, { "command": "gitlens.copyDeepLinkToTag", - "when": "viewItem =~ /gitlens:tag\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:tag\\b/", "group": "1_gitlens@50" }, { "command": "gitlens.graph.copyDeepLinkToTag", - "when": "webviewItem =~ /gitlens:tag\\b/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:tag\\b/", "group": "1_gitlens@50" }, { "command": "gitlens.graph.copyDeepLinkToRepo", - "when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+remote\\b)(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)/", "group": "1_gitlens@99" }, { "command": "gitlens.copyRemoteRepositoryUrl", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:(remote|repo-folder|repository)\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(remote|repo-folder|repository)\\b/", "group": "2_gitlens@99" } ], @@ -13247,9 +15812,19 @@ "group": "1_gitlens@1" }, { - "command": "gitlens.views.openChangedFileDiffsWithWorking", + "command": "gitlens.views.openChangedFileDiffsIndividually", + "when": "config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", "group": "1_gitlens@2" }, + { + "command": "gitlens.views.openChangedFileDiffsWithWorking", + "group": "1_gitlens@3" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithWorkingIndividually", + "when": "config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens@4" + }, { "command": "gitlens.views.openChangedFiles", "group": "2_gitlens@1" @@ -13268,10 +15843,20 @@ "command": "gitlens.graph.openChangedFileDiffs", "group": "1_gitlens@1" }, + { + "command": "gitlens.graph.openChangedFileDiffsIndividually", + "when": "config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens@2" + }, { "command": "gitlens.graph.openChangedFileDiffsWithWorking", "when": "webviewItem != gitlens:wip", - "group": "1_gitlens@2" + "group": "1_gitlens@3" + }, + { + "command": "gitlens.graph.openChangedFileDiffsWithWorkingIndividually", + "when": "config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens@4" }, { "command": "gitlens.graph.openChangedFiles", @@ -13298,8 +15883,8 @@ "group": "navigation@2" }, { - "command": "gitlens.openCommitOnRemote", - "when": "view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && gitlens:hasRemotes", + "command": "gitlens.views.openCommitOnRemote", + "when": "view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && gitlens:repos:withRemotes", "group": "navigation@3", "alt": "gitlens.copyRemoteCommitUrl" }, @@ -13309,14 +15894,19 @@ "group": "navigation@4" }, { - "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "command": "gitlens.views.undoCommit", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@0" }, + { + "command": "gitlens.views.push", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "group": "1_gitlens_actions@1" + }, { "command": "gitlens.views.pushToCommit", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", - "group": "1_gitlens_actions@0" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", + "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.revert", @@ -13348,10 +15938,20 @@ "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "1_gitlens_secondary_actions@1" }, + { + "command": "gitlens.createPatch", + "when": "false && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "group": "1_gitlens_secondary_actions@2" + }, + { + "command": "gitlens.createCloudPatch", + "when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "group": "1_gitlens_secondary_actions@3" + }, { "command": "gitlens.views.createTag", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", - "group": "1_gitlens_secondary_actions@2" + "group": "1_gitlens_secondary_actions@4" } ], "gitlens/commit/file/changes": [ @@ -13396,7 +15996,7 @@ "group": "1_gitlens@1" }, { - "command": "gitlens.showTimelinePage", + "command": "gitlens.showInTimeline", "group": "1_gitlens@2" }, { @@ -13444,6 +16044,11 @@ "command": "gitlens.toggleFileChanges", "when": "gitlens:activeFileStatus =~ /blameable/ && !gitlens:hasVirtualFolders", "group": "2_gitlens@3" + }, + { + "command": "gitlens.showSettingsPage!file-annotations", + "when": "gitlens:activeFileStatus =~ /blameable/", + "group": "8_gitlens@1" } ], "gitlens/editor/context/changes": [ @@ -13475,27 +16080,32 @@ "group": "2_gitlens@4" }, { - "command": "gitlens.showLineCommitInView", + "command": "gitlens.showQuickCommitFileDetails", "group": "3_gitlens@1" }, { - "command": "gitlens.showQuickCommitFileDetails", + "command": "gitlens.showLineCommitInView", "group": "3_gitlens@2" }, { - "command": "gitlens.showQuickRevisionDetails", - "when": "gitlens:activeFileStatus =~ /revision/ && !isInDiffEditor", + "command": "gitlens.showCommitsInView", + "when": "editorTextFocus && editorHasSelection", "group": "3_gitlens@2" }, + { + "command": "gitlens.showQuickRevisionDetails", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && !isInDiffEditor", + "group": "3_gitlens_1@1" + }, { "command": "gitlens.showQuickRevisionDetailsInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffEditor && !isInDiffRightEditor", - "group": "3_gitlens@2" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor && !isInDiffRightEditor", + "group": "3_gitlens_1@1" }, { "command": "gitlens.showQuickRevisionDetailsInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffRightEditor", - "group": "3_gitlens@2" + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffRightEditor", + "group": "3_gitlens_1@1" } ], "gitlens/editor/context/openOn": [ @@ -13540,7 +16150,7 @@ "group": "1_gitlens@1" }, { - "command": "gitlens.showTimelinePage", + "command": "gitlens.showInTimeline", "group": "1_gitlens@2" }, { @@ -13570,11 +16180,11 @@ "group": "1_gitlens@2" }, { - "command": "gitlens.showLineCommitInView", + "command": "gitlens.showQuickCommitFileDetails", "group": "3_gitlens@1" }, { - "command": "gitlens.showQuickCommitFileDetails", + "command": "gitlens.showLineCommitInView", "group": "3_gitlens@2" } ], @@ -13598,28 +16208,54 @@ "gitlens/editor/lineNumber/context/share": [ { "command": "gitlens.copyRemoteFileUrlToClipboard", - "group": "1_gitlens@2" + "group": "1_gitlens_remote@2" }, { "command": "gitlens.copyRemoteFileUrlFrom", - "group": "1_gitlens@3" + "group": "1_gitlens_remote@3" }, { "command": "gitlens.copyRemoteCommitUrl", - "group": "1_gitlens_commit@1" + "group": "1_gitlens_remote_commit@1" + }, + { + "command": "gitlens.copyDeepLinkToLines", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.copyDeepLinkToFile", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.copyDeepLinkToFileAtRevision", + "when": "gitlens:activeFileStatus =~ /blameable/", + "group": "1_gitlens@3" } ], "gitlens/explorer/changes": [ { "command": "gitlens.diffWithPrevious", + "when": "!explorerResourceIsFolder", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.diffFolderWithRevision", + "when": "!gitlens:hasVirtualFolders && explorerResourceIsFolder", "group": "1_gitlens@1" }, + { + "command": "gitlens.diffFolderWithRevisionFrom", + "when": "!gitlens:hasVirtualFolders && explorerResourceIsFolder", + "group": "1_gitlens@2" + }, { "command": "gitlens.diffWithRevision", + "when": "!explorerResourceIsFolder", "group": "1_gitlens@2" }, { "command": "gitlens.diffWithRevisionFrom", + "when": "!explorerResourceIsFolder", "group": "1_gitlens@3" } ], @@ -13635,7 +16271,7 @@ "group": "1_gitlens@1" }, { - "command": "gitlens.showTimelinePage", + "command": "gitlens.showInTimeline", "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled", "group": "1_gitlens@2" }, @@ -13667,7 +16303,7 @@ "group": "1_gitlens@1" }, { - "command": "gitlens.showSettingsPage#commit-graph", + "command": "gitlens.showSettingsPage!commit-graph", "group": "9_gitlens@1" } ], @@ -13711,23 +16347,45 @@ "command": "gitlens.graph.scrollMarkerTagOff", "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:tags:enabled\\b/", "group": "4_settings@4" + }, + { + "command": "gitlens.graph.scrollMarkerPullRequestOn", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:pullRequests:disabled\\b/", + "group": "4_settings@5" + }, + { + "command": "gitlens.graph.scrollMarkerPullRequestOff", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:pullRequests:enabled\\b/", + "group": "4_settings@5" } ], "gitlens/scm/resourceGroup/changes": [ { "command": "gitlens.externalDiffAll", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", - "group": "1_gitlens@1" + "group": "2_gitlens@1" }, { "command": "gitlens.diffDirectoryWithHead", "when": "!gitlens:hasVirtualFolders", - "group": "1_gitlens@2" + "group": "2_gitlens@2" }, { "command": "gitlens.diffDirectory", "when": "!gitlens:hasVirtualFolders", - "group": "1_gitlens@3" + "group": "2_gitlens@3" + } + ], + "gitlens/scm/resourceFolder/changes": [ + { + "command": "gitlens.diffFolderWithRevision", + "when": "!gitlens:hasVirtualFolders", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.diffFolderWithRevisionFrom", + "when": "!gitlens:hasVirtualFolders", + "group": "1_gitlens@2" } ], "gitlens/scm/resourceState/changes": [ @@ -13753,7 +16411,7 @@ "group": "1_gitlens@1" }, { - "command": "gitlens.showTimelinePage", + "command": "gitlens.showInTimeline", "group": "1_gitlens@2" }, { @@ -13877,7 +16535,41 @@ "group": "navigation@11" } ], - "gitlens/view/searchAndCompare/comparison/filter": [ + "gitlens/comparison/results/files/filter": [ + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOff", + "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filtered\\b)/", + "group": "navigation@1" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOnLeft", + "when1": "viewItem =~ /gitlens:results:files\\b(?!.*?\\b\\+filtered~left\\b)/", + "group": "navigation_1@1" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOnRight", + "when1": "viewItem =~ /gitlens:results:files\\b(?!.*?\\b\\+filtered~right\\b)/", + "group": "navigation_1@2" + } + ], + "gitlens/comparison/results/files/filter/inline": [ + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOff", + "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filtered\\b)/", + "group": "navigation@1" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOnLeft", + "when1": "viewItem =~ /gitlens:results:files\\b(?!.*?\\b\\+filtered~left\\b)/", + "group": "navigation_1@1" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOnRight", + "when1": "viewItem =~ /gitlens:results:files\\b(?!.*?\\b\\+filtered~right\\b)/", + "group": "navigation_1@2" + } + ], + "gitlens/comparison/results/files/filtered": [ { "command": "gitlens.views.searchAndCompare.setFilesFilterOff", "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filtered\\b)/", @@ -13894,7 +16586,7 @@ "group": "navigation_1@2" } ], - "gitlens/view/searchAndCompare/comparison/filtered": [ + "gitlens/comparison/results/files/filtered/inline": [ { "command": "gitlens.views.searchAndCompare.setFilesFilterOff", "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filtered\\b)/", @@ -13944,10 +16636,7 @@ { "id": "gitlens/editor/annotations", "label": "File Annotations", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "id": "gitlens/editor/context/changes", @@ -14006,6 +16695,10 @@ "id": "gitlens/scm/resourceGroup/changes", "label": "Open Changes" }, + { + "id": "gitlens/scm/resourceFolder/changes", + "label": "Open Changes with" + }, { "id": "gitlens/scm/resourceState/changes", "label": "Open Changes with" @@ -14032,13 +16725,23 @@ "icon": "$(add)" }, { - "id": "gitlens/view/searchAndCompare/comparison/filter", - "label": "Filter", + "id": "gitlens/comparison/results/files/filter", + "label": "Filter Files", + "icon": "$(filter)" + }, + { + "id": "gitlens/comparison/results/files/filter/inline", + "label": "Filter Files", "icon": "$(filter)" }, { - "id": "gitlens/view/searchAndCompare/comparison/filtered", - "label": "Filter", + "id": "gitlens/comparison/results/files/filtered", + "label": "Filter Files", + "icon": "$(filter-filled)" + }, + { + "id": "gitlens/comparison/results/files/filtered/inline", + "label": "Filter Files", "icon": "$(filter-filled)" } ], @@ -14098,7 +16801,7 @@ { "command": "gitlens.key.escape", "key": "escape", - "when": "gitlens:key:escape && editorTextFocus && !findWidgetVisible && !quickFixWidgetVisible && !renameInputVisible && !suggestWidgetVisible && !isInEmbeddedEditor" + "when": "gitlens:key:escape && editorTextFocus && !findWidgetVisible && !quickFixWidgetVisible && !renameInputVisible && !suggestWidgetVisible && !referenceSearchVisible && !codeActionMenuVisible && !parameterHintsVisible && !isInEmbeddedEditor" }, { "command": "gitlens.gitCommands", @@ -14213,46 +16916,46 @@ { "command": "gitlens.diffWithNext", "key": "alt+.", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && !isInDiffEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && !isInDiffEditor" }, { "command": "gitlens.diffWithNext", "key": "ctrl+shift+g .", "mac": "cmd+alt+g .", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && !isInDiffEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && !isInDiffEditor" }, { "command": "gitlens.diffWithNextInDiffLeft", "key": "alt+.", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && isInDiffEditor && !isInDiffRightEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor && !isInDiffRightEditor" }, { "command": "gitlens.diffWithNextInDiffLeft", "key": "ctrl+shift+g .", "mac": "cmd+alt+g .", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && isInDiffEditor && !isInDiffRightEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor && !isInDiffRightEditor" }, { "command": "gitlens.diffWithNextInDiffRight", "key": "alt+.", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && isInDiffRightEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffRightEditor" }, { "command": "gitlens.diffWithNextInDiffRight", "key": "ctrl+shift+g .", "mac": "cmd+alt+g .", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && isInDiffRightEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffRightEditor" }, { "command": "gitlens.diffWithWorking", "key": "shift+alt+.", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /revision/" + "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithWorking", "key": "ctrl+shift+g shift+.", "mac": "cmd+alt+g shift+.", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /revision/" + "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffLineWithPrevious", @@ -14289,6 +16992,12 @@ "mac": "cmd+c", "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.contributors/" }, + { + "command": "gitlens.views.drafts.copy", + "key": "ctrl+c", + "mac": "cmd+c", + "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.drafts/" + }, { "command": "gitlens.views.fileHistory.copy", "key": "ctrl+c", @@ -14301,6 +17010,12 @@ "mac": "cmd+c", "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.lineHistory/" }, + { + "command": "gitlens.views.pullRequest.copy", + "key": "ctrl+c", + "mac": "cmd+c", + "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.pullRequest/" + }, { "command": "gitlens.views.remotes.copy", "key": "ctrl+c", @@ -14361,9 +17076,9 @@ "authority": "*", "formatting": { "label": "${path} (${query.ref})", - "separator": "/", - "workspaceSuffix": "GitLens", - "stripPathStartingSeparator": true + "normalizeDriveLetter": true, + "tildify": true, + "workspaceSuffix": "GitLens" } } ], @@ -14378,6 +17093,11 @@ "id": "gitlensInspect", "title": "GitLens Inspect", "icon": "$(gitlens-gitlens-inspect)" + }, + { + "id": "gitlensPatch", + "title": "GitLens Patch", + "icon": "$(gitlens-cloud-patch)" } ], "panel": [ @@ -14404,24 +17124,45 @@ "contents": "Compare a with another \n\n[Compare References...](command:gitlens.views.searchAndCompare.selectForCompare)", "when": "!gitlens:hasVirtualFolders" }, + { + "view": "gitlens.views.drafts", + "contents": "Cloud Patches ᴘʀᴇᴠɪᴇᴡ — easily and securely share code with your teammates or other developers, accessible from anywhere, streamlining your workflow with better collaboration." + }, + { + "view": "gitlens.views.drafts", + "contents": "[Create Cloud Patch](command:gitlens.views.drafts.create)", + "when": "gitlens:plus" + }, + { + "view": "gitlens.views.drafts", + "contents": "[Start Pro Trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22cloud-patches%22%7D)\n\nStart your free 7-day Pro trial to try Cloud Patches and other Pro features, or [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22cloud-patches%22%7D).", + "when": "!gitlens:plus" + }, + { + "view": "gitlens.views.drafts", + "contents": "Preview feature ☁️ — requires an account and may require a paid plan in the future." + }, { "view": "gitlens.views.workspaces", - "contents": "Workspaces allow you to easily group and manage multiple repositories together, accessible from anywhere, streamlining your workflow.\n\nCreate workspaces just for yourself or share (coming soon in GitLens) them with your team for faster onboarding and better collaboration." + "contents": "Workspaces ᴘʀᴇᴠɪᴇᴡ — group and manage multiple repositories together, accessible from anywhere, streamlining your workflow.\n\nCreate workspaces just for yourself or share (coming soon in GitLens) them with your team for faster onboarding and better collaboration." }, { "view": "gitlens.views.workspaces", - "contents": "[Create Cloud Workspace](command:gitlens.views.workspaces.create)\n\n☁️ Access is based on your plan, e.g. Free, Pro, etc", + "contents": "[Create Cloud Workspace](command:gitlens.views.workspaces.create)", "when": "gitlens:plus" }, { "view": "gitlens.views.workspaces", - "contents": "[Start Free Pro Trial](command:gitlens.plus.loginOrSignUp)\n\nStart a free 7-day Pro trial to use GitKraken Workspaces, or [sign in](command:gitlens.plus.loginOrSignUp).\n☁️ Requires an account and access is based on your plan, e.g. Free, Pro, etc", + "contents": "[Start Pro Trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22workspaces%22%7D)\n\nStart your free 7-day Pro trial to try GitKraken (GK) Workspaces and other Pro features, or [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22workspaces%22%7D).", "when": "!gitlens:plus" }, + { + "view": "gitlens.views.workspaces", + "contents": "Preview feature ☁️ — requires an account and may require a paid plan in the future." + }, { "view": "gitlens.views.worktrees", - "contents": "Worktrees help you multitask by minimizing the context switching between branches, allowing you to easily work on different branches of a repository simultaneously.\n\nYou can create multiple working trees, each of which can be opened in individual windows or all together in a single workspace.", - "when": "!gitlens:plus:required || gitlens:plus:state == 0" + "contents": "[Worktrees](https://help.gitkraken.com/gitlens/side-bar/#worktrees-view%e2%9c%a8) ᴾᴿᴼ — minimize context switching by allowing you to work on multiple branches simultaneously." }, { "view": "gitlens.views.worktrees", @@ -14430,23 +17171,32 @@ }, { "view": "gitlens.views.worktrees", - "contents": "You must verify your email before you can continue.\n\n[Resend verification email](command:gitlens.plus.resendVerification)\n\n[Refresh verification status](command:gitlens.plus.validate)", + "contents": "[Resend Verification Email](command:gitlens.plus.resendVerification?%7B%22source%22%3A%22worktrees%22%7D)\n\nYou must verify your email before you can continue or [recheck Status](command:gitlens.plus.validate?%7B%22source%22%3A%22worktrees%22%7D).", "when": "gitlens:plus:state == -1" }, { "view": "gitlens.views.worktrees", - "contents": "[Preview Pro](command:gitlens.plus.startPreviewTrial)\n\nPreview Pro for 3 days, or [sign in](command:gitlens.plus.loginOrSignUp) to start a full 7-day Pro trial.\n✨ A trial or paid plan is required to use this on privately hosted repos.", + "contents": "[Continue](command:gitlens.plus.startPreviewTrial?%7B%22source%22%3A%22worktrees%22%7D)\n\nContinuing gives you 3 days to preview Workrees and other local Pro features for 3 days. [Start 7-day Pro trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22worktrees%22%7D) or [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22worktrees%22%7D) for full access to Pro features.", "when": "gitlens:plus:required && gitlens:plus:state == 0" }, { "view": "gitlens.views.worktrees", - "contents": "Your 3-day Pro preview has ended, start a free Pro trial to get an additional 7 days, or [sign in](command:gitlens.plus.loginOrSignUp).\n\n[Start Free Pro Trial](command:gitlens.plus.loginOrSignUp)\n✨ A trial or paid plan is required to use this on privately hosted repos.", + "contents": "[Start Pro Trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22worktrees%22%7D)\n\nStart your free 7-day Pro trial to try Worktrees and other Pro features, or [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22worktrees%22%7D).", "when": "gitlens:plus:required && gitlens:plus:state == 2" }, { "view": "gitlens.views.worktrees", - "contents": "Your Pro trial has ended, please upgrade to continue to use this on privately hosted repos.\n\n[Upgrade to Pro](command:gitlens.plus.purchase)\n✨ A paid plan is required to use this on privately hosted repos.", + "contents": "[Upgrade to Pro](command:gitlens.plus.upgrade?%7B%22source%22%3A%22worktrees%22%7D)\n\nYour Pro trial has ended. Please upgrade for full access to Worktrees and other Pro features.\nSpecial: 50% off first seat of Pro — only $4/month!", "when": "gitlens:plus:required && gitlens:plus:state == 4" + }, + { + "view": "gitlens.views.worktrees", + "contents": "[Continue](command:gitlens.plus.reactivateProTrial?%7B%22source%22%3A%22worktrees%22%7D)\n\nReactivate your Pro trial and experience Worktrees and all the new Pro features — free for another 7 days!", + "when": "gitlens:plus:required && gitlens:plus:state == 5" + }, + { + "view": "gitlens.views.worktrees", + "contents": "Pro feature — requires a paid plan for use on privately-hosted repos." } ], "views": { @@ -14455,16 +17205,25 @@ "type": "webview", "id": "gitlens.views.home", "name": "Home", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-gitlens)", "initialSize": 6, "visibility": "visible" }, + { + "id": "gitlens.views.drafts", + "name": "Cloud Patches", + "when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled", + "contextualTitle": "GitLens", + "icon": "$(gitlens-cloud-patch)", + "initialSize": 2, + "visibility": "visible" + }, { "id": "gitlens.views.workspaces", - "name": "GitKraken Workspaces", + "name": "GK Workspaces", "when": "!gitlens:untrusted && !gitlens:hasVirtualFolders", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-workspaces-view)", "initialSize": 2, "visibility": "visible" @@ -14473,7 +17232,7 @@ "type": "webview", "id": "gitlens.views.account", "name": "GitKraken Account", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-gitlens)", "initialSize": 1, "visibility": "collapsed" @@ -14483,18 +17242,27 @@ { "type": "webview", "id": "gitlens.views.commitDetails", - "name": "Commit Details", + "name": "Inspect", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-commit-view)", "initialSize": 6, "visibility": "visible" }, + { + "id": "gitlens.views.pullRequest", + "name": "Pull Request", + "when": "!gitlens:disabled && gitlens:views:pullRequest:visible", + "contextualTitle": "GitLens", + "icon": "$(git-pull-request)", + "initialSize": 1, + "visibility": "visible" + }, { "id": "gitlens.views.lineHistory", "name": "Line History", "when": "!gitlens:disabled && !gitlens:hasVirtualFolders", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-history-view)", "initialSize": 1, "visibility": "collapsed" @@ -14503,7 +17271,7 @@ "id": "gitlens.views.fileHistory", "name": "File History", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-history-view)", "initialSize": 2, "visibility": "visible" @@ -14513,7 +17281,7 @@ "id": "gitlens.views.timeline", "name": "Visual File History", "when": "!gitlens:disabled && gitlens:plus:enabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(graph-scatter)", "initialSize": 1, "visibility": "visible" @@ -14522,7 +17290,7 @@ "id": "gitlens.views.searchAndCompare", "name": "Search & Compare", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-search-view)", "initialSize": 2, "visibility": "visible" @@ -14534,7 +17302,7 @@ "id": "gitlens.views.graph", "name": "Graph", "when": "!gitlens:disabled && gitlens:plus:enabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-graph)", "initialSize": 4, "visibility": "visible" @@ -14544,18 +17312,29 @@ "id": "gitlens.views.graphDetails", "name": "Graph Details", "when": "!gitlens:disabled && gitlens:plus:enabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-commit-view)", "initialSize": 1, "visibility": "visible" } ], + "gitlensPatch": [ + { + "type": "webview", + "id": "gitlens.views.patchDetails", + "name": "Patch", + "when": "!gitlens:untrusted && config.gitlens.cloudPatches.enabled && gitlens:views:patchDetails:mode", + "contextualTitle": "GitLens", + "icon": "$(gitlens-cloud-patch)", + "initialSize": 24 + } + ], "scm": [ { "id": "gitlens.views.repositories", "name": "Repositories", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-repositories-view)", "visibility": "hidden" }, @@ -14563,7 +17342,7 @@ "id": "gitlens.views.commits", "name": "Commits", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-commits-view)", "visibility": "visible" }, @@ -14571,7 +17350,7 @@ "id": "gitlens.views.branches", "name": "Branches", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-branches-view)", "visibility": "collapsed" }, @@ -14579,7 +17358,7 @@ "id": "gitlens.views.remotes", "name": "Remotes", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-remotes-view)", "visibility": "collapsed" }, @@ -14587,7 +17366,7 @@ "id": "gitlens.views.stashes", "name": "Stashes", "when": "!gitlens:disabled && !gitlens:hasVirtualFolders", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-stashes-view)", "visibility": "collapsed" }, @@ -14595,7 +17374,7 @@ "id": "gitlens.views.tags", "name": "Tags", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-tags-view)", "visibility": "collapsed" }, @@ -14603,7 +17382,7 @@ "id": "gitlens.views.worktrees", "name": "Worktrees", "when": "!gitlens:disabled && !gitlens:hasVirtualFolders && gitlens:plus:enabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-worktrees-view)", "visibility": "collapsed" }, @@ -14611,7 +17390,7 @@ "id": "gitlens.views.contributors", "name": "Contributors", "when": "!gitlens:disabled", - "contextualTitle": "GL", + "contextualTitle": "GitLens", "icon": "$(gitlens-contributors-view)", "visibility": "collapsed" } @@ -14619,117 +17398,113 @@ }, "walkthroughs": [ { - "id": "gitlens.welcome", + "id": "welcome", "title": "Get Started with GitLens", "description": "Discover and personalize features that supercharge your Git experience", "steps": [ { - "id": "gitlens.welcome.tutorial", - "title": "Welcome and Tutorial", - "description": "Open the Welcome experience to quickly get started and discover the many powerful GitLens features.\n\n[Open Welcome](command:gitlens.showWelcomePage \"Opens GitLens Welcome\")\nOr, sit back and watch the our Getting Started video.\n\n[Watch Tutorial Video](https://www.youtube.com/watch?v=UQPb73Zz9qk \"Watch the Getting Started video\")\n💡 **Want more control?** Use the interactive [GitLens Settings](command:gitlens.showSettingsPage \"Opens GitLens Settings\") editor to customize GitLens to meet your needs.", + "id": "get-started", + "title": "Welcome & Overview", + "description": "Quickly [get started](command:gitlens.showWelcomePage \"Opens GitLens Welcome\") and discover the many powerful GitLens features, or sit back and watch our [tutorial video](https://www.youtube.com/watch?v=UQPb73Zz9qk \"Watch the Getting Started Tutorial video\").\n\n**Side Bar & Panel Overview**\n\n$(gitlens-gitlens-inspect)  **GitLens Inspect** — an x-ray into your code's history. Offers contextual insights & details focused on what you're currently working on.\n\n[Open GitLens Inspect](command:workbench.view.extension.gitlensInspect)\n\n$(gitlens-gitlens)  **GitLens** — quick access to many GitLens features. And the home of our team and collaboration services.\n\n[Open GitLens](command:workbench.view.extension.gitlens)\n\n$(source-control) **Source Control** — packed with additional features for working with, exploring, and managing your repositories.\n\n[Open Source Control](command:workbench.view.scm)\n\n$(layout-panel)  **(Bottom) Panel** — access to the powerful Commit Graph and its dedicated details view.\n\n[Open Commit Graph](command:gitlens.showGraphView)\n💡 While our views are arranged for focus and productivity, you can easily drag them around to suit your needs. Use the [Reset Views Layout](command:workbench.action.quickOpen?%22>GitLens%3A%20Reset%20Views%20Layout%22) command to quickly get back to the default layout.\n💡 **Want more control?** Use the interactive [GitLens Settings](command:gitlens.showSettingsPage \"Opens GitLens Settings\") editor to customize GitLens to meet your needs.", "media": { - "markdown": "walkthroughs/welcome/tutorial.md" + "markdown": "walkthroughs/welcome/get-started.md" } }, { - "id": "gitlens.welcome.sidebars", - "title": "Discover the many powerful views", - "description": "Our views are arranged for focus and productivity, although you can easily drag them around to suit your needs.\n\n$(gitlens-gitlens-inspect)  **GitLens Inspect**\nAn x-ray or developer tools inspector into your code, focused on providing contextual information and insights to what you're actively working on.\n\n[Open GitLens Inspect](command:workbench.view.extension.gitlensInspect)\n\n$(gitlens-gitlens)  **GitLens**\nQuick access to many GitLens features. Also the home of GitKraken teams and collaboration services (e.g. GitKraken Workspaces), help, and support.\n\n[Open GitLens](command:workbench.view.extension.gitlens)\n\n$(source-control) **Source Control**\nShows additional views that are focused on exploring and managing your repositories.\n\n[Open Source Control](command:workbench.view.scm)\n\n$(layout-panel)  **(Bottom) Panel**\nConvenient and easy access to the Commit Graph with a dedicated details view.\n\n[Open Commit Graph](command:gitlens.showGraph)\n💡 Use the [Reset Views Layout](command:workbench.action.quickOpen?%22>GitLens%3A%20Reset%20Views%20Layout%22) command to quickly get back to the default layout.", + "id": "core-features", + "title": "Discover Core Features", + "description": "**Inline blame** and status bar blame provide historical context about line changes.\n💡 Hover over annotations to reveal rich details & actions.\n**Git CodeLens** adds contextual and actionable authorship information at the top of each file and at the beginning of each block of code.\n💡 Use the [Toggle Line Blame](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20Line%20Blame%22) and [Toggle Git CodeLens](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20Git%20CodeLens%22) commands.\n**File annotations**, visual indicators that augment your editor, provide insights into authorship, recent changes, or a heatmap. Annotations can be toggled on-demand for individual files or holistically.\n💡 Use the [Toggle File Blame](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Blame%22), [Toggle File Changes](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Changes%22), and [Toggle File Heatmap](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Heatmap%22) commands on an active file.\n\n**Navigate revision history** with just a click of a button at the top of any file and compare changes over time.", "media": { - "markdown": "walkthroughs/welcome/side-bars.md" + "altText": "Illustrations of Inline Blame, Codelens, File Annotations and Revision Navigation", + "svg": "walkthroughs/welcome/core-features.svg" } }, { - "id": "gitlens.welcome.currentLineBlame", - "title": "See who made which changes at a glance", - "description": "**Inline blame** and status bar blame provide historical context about line changes.\n**Git CodeLens** adds contextual and actionable authorship information at the top of each file and at the beginning of each block of code.\n**Hover** over blame annotations to reveal rich details and actions.\n💡 Use the [Toggle Line Blame](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20Line%20Blame%22) and [Toggle Git CodeLens](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20Git%20CodeLens%22) commands from the Command Palette to turn the annotations on and off.", + "id": "pro-features", + "title": "Power-up with Pro", + "description": "Unlock the full power of GitLens with [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and get access to the full [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links).\n\n[Start Pro Trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22walkthrough%22%7D)\n\nAlready have an account? [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22walkthrough%22%7D)\n\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "markdown": "walkthroughs/welcome/blame.md" - } + "markdown": "walkthroughs/welcome/pro-features.md" + }, + "when": "gitlens:plus:state >= 0 && gitlens:plus:state <= 2" }, { - "id": "gitlens.welcome.fileAnnotations", - "title": "Get more context with file annotations", - "description": "Toggle on-demand whole file annotations to see authorship, recent changes, and a heatmap. Annotations are rendered as visual indicators directly in the editor.\n💡 **On an active file**, use the [Toggle File Blame](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Blame%22), [Toggle File Changes](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Changes%22), and [Toggle File Heatmap](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Heatmap%22) commands from the Command Palette to turn the annotations on and off.", + "id": "pro-trial", + "title": "Get Started with Pro", + "description": "During your trial, you have access to all [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and to the full [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links). Be sure to take full advantage of these powerful features.\n\n[Upgrade to Pro](command:gitlens.plus.upgrade?%7B%22source%22%3A%22walkthrough%22%7D)\n\n💡Special: 50% off first seat of Pro — only $4/month!\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "markdown": "walkthroughs/welcome/file-annotations.md" - } + "markdown": "walkthroughs/welcome/pro-trial.md" + }, + "when": "gitlens:plus:state == 3" }, { - "id": "gitlens.welcome.revisionHistory", - "title": "Effortlessly navigate revision history", - "description": "With just a click of a button, you can navigate backwards and forwards through the history of any file.\nCompare changes over time and see the revision history of the whole file or an individual line.", + "id": "pro-upgrade", + "title": "Upgrade to Pro", + "description": "Your Pro trial has ended. Please upgrade for full access to all [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and to the full [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links).\n\n[Upgrade to Pro](command:gitlens.plus.upgrade?%7B%22source%22%3A%22walkthrough%22%7D)\n\n💡Special: 50% off first seat of Pro — only $4/month!\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "markdown": "walkthroughs/welcome/revision-history.md" - } + "markdown": "walkthroughs/welcome/pro-upgrade.md" + }, + "when": "gitlens:plus:state == 4" }, { - "id": "gitlens.welcome.commitGraph", - "title": "Visualize with the Commit Graph ✨", - "description": "Easily visualize your repository and keep track of all work in progress.\nUse the rich commit search to find exactly what you're looking for. It's powerful filters allow you to search by a specific commit, message, author, a changed file or files, or even a specific code change.\n\n[Open Commit Graph](command:gitlens.showGraph)\n\n💡Quickly toggle the Graph via the [Toggle Commit Graph](command:gitlens.toggleGraph) command.\n💡Maximize the Graph via the [Toggle Maximized Commit Graph](command:gitlens.toggleMaximizedGraph) command.", + "id": "pro-reactivate", + "title": "Reactivate Pro Power-up", + "description": "Reactivate your Pro trial and experience all the new [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and the full [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links) — free for another 7 days!.\n\n[Reactivate Pro Trial](command:gitlens.plus.reactivateProTrial?%7B%22source%22%3A%22walkthrough%22%7D)\n\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "altText": "Illustrations of the Commit Graph view", - "svg": "walkthroughs/welcome/commit-graph.svg" - } + "markdown": "walkthroughs/welcome/pro-reactivate.md" + }, + "when": "gitlens:plus:state == 5" }, { - "id": "gitlens.welcome.workspaces", - "title": "Work smarter with GitKraken Workspaces ☁️ and Focus ✨", - "description": "GitKraken Workspaces allow you to easily group and manage multiple repositories together, accessible from anywhere, streamlining your workflow.\nCreate workspaces just for yourself or share (coming soon in GitLens) them with your team for faster onboarding and better collaboration.\n\n[Open Workspaces](command:gitlens.showWorkspacesView)\n\nThe Focus view brings all of your GitHub pull requests and issues into a unified actionable view to help to you more easily juggle work in progress, pending work, reviews, and more.\nQuickly see if anything requires your attention while keeping you focused.\n\n[Open Focus](command:gitlens.showFocusPage)", + "id": "pro-paid", + "title": "Powered-up with Pro", + "description": "You have the full power of GitLens with [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and the [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links).\n\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "altText": "Illustrations of Focus and Workspaces view", - "svg": "walkthroughs/welcome/workspaces-focus.svg" - } + "markdown": "walkthroughs/welcome/pro-paid.md" + }, + "when": "gitlens:plus:state == 6" }, { - "id": "gitlens.welcome.hostingServiceIntegrations", - "title": "Integrate with Git hosting services", - "description": "Simplify your workflow and quickly gain insights with automatic linking of issues and pull requests across multiple Git hosting services including GitHub, GitHub Enterprise ✨, GitLab, GitLab self-managed ✨, Gitea, Gerrit, Google Source, Bitbucket, Bitbucket Server, Azure DevOps, and custom servers.\n\nAll integration provide automatic linking, while rich integrations with GitHub & GitLab offer detailed hover information for autolinks, and correlations between pull requests, branches, and commits, as well as user avatars for added context.\n\n**Define your own autolinks**\nUse autolinks to linkify external references, like Jira issues or Zendesk tickets, in commit messages.\n\n[Configure Autolinks](command:gitlens.showSettingsPage?%22autolinks%22)", + "id": "visualize", + "title": "Visualize with Commit Graph & Visual File History", + "description": "**Commit Graph**\nEasily visualize your repository and keep track of all work in progress.\nUse the rich commit search to find exactly what you're looking for. Its powerful filters allow you to search by a specific commit, message, author, a changed file or files, or even a specific code change. [Learn more](https://gitkraken.com/solutions/commit-graph?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n[Open Commit Graph](command:gitlens.showGraph)\n\n💡Quickly toggle the Graph via the [Toggle Commit Graph](command:gitlens.toggleGraph) command or maximize it using the [Toggle Maximized Commit Graph](command:gitlens.toggleMaximizedGraph) command.\n**Visual File History**\nVisualize the evolution of a file and quickly identify when the most impactful changes were made and by whom\n\n[Open Visual File History](command:gitlens.showTimelineView)", "media": { - "markdown": "walkthroughs/welcome/integrations.md" + "altText": "Illustrations of the Commit Graph & Visual File History", + "svg": "walkthroughs/welcome/visualize.svg" } }, { - "id": "gitlens.welcome.labs", - "title": "Experiment with GitKraken Labs $(beaker)", - "description": "GitKraken Labs is our incubator for experimentation and exploration with the community to gather early reactions and feedback. Below are some of our current experiments.\n**Explain Commit (AI)**\nUse the Explain panel on the [Commit Details view](command:gitlens.showCommitDetailsView) to leverage AI to help you understand the changes introduced by a commit.\n**Automatically Generate Commit Message (AI)**\nUse the [Generate Commit Message](command:workbench.action.quickOpen?%22>GitLens%3A%20Generate%20Commit%20Message%22) command from the Source Control view's context menu to automatically generate a commit message for your staged changes by leveraging AI.", + "id": "launchpad", + "title": "Unblock your team with Launchpad", + "description": "**Launchpad** ᴘʀᴇᴠɪᴇᴡ brings all of your GitHub pull requests into a unified, actionable list to better track work in progress, pending work, reviews, and more. Stay focused and take action on the most important items to keep your team unblocked. [Learn more](https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n[Open Launchpad](command:gitlens.showLaunchpad?%7B%22source%22%3A%22walkthrough%22%7D)", "media": { - "markdown": "walkthroughs/welcome/labs.md" + "altText": "Illustrations of Launchpad", + "svg": "walkthroughs/welcome/launchpad-quick.svg" } }, { - "id": "gitlens.welcome.preview", - "title": "Previewing GitLens Pro", - "description": "During your preview, you have access to ✨ features on privately hosted repos. [Learn more](https://www.gitkraken.com/gitlens/pro-features)\n\n[Start Free Pro Trial](command:gitlens.plus.loginOrSignUp)\n\nStart a free Pro trial to get an additional 7 days.", - "media": { - "markdown": "walkthroughs/welcome/preview.md" - }, - "when": "gitlens:plus:state == 1" - }, - { - "id": "gitlens.welcome.trial", - "title": "Trialing GitLens Pro", - "description": "During your trial, you have access to ✨ features on privately hosted repos and ☁️ features based on the Pro plan. [Learn more](https://www.gitkraken.com/gitlens/pro-features)\n\n[Upgrade to Pro](command:gitlens.plus.purchase)", + "id": "code-collab", + "title": "Collaborate with Code Suggest & Cloud Patches", + "description": "**Code Suggest** ᴘʀᴇᴠɪᴇᴡ\n\nLiberate your code reviews from GitHub's restrictive, comment-only feedback style. Like suggesting changes on a Google-doc, suggest code changes from where you're already coding — your IDE and on anything in your project, not just on the lines of code changed in the PR. [Learn more](https://gitkraken.com/solutions/code-suggest?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n**Cloud Patches** ᴘʀᴇᴠɪᴇᴡ\n\nEasily and securely share code changes with your teammates or other developers by creating a Cloud Patch from your WIP, commit or stash and sharing the generated link. Use Cloud Patches to collaborate early for feedback on direction, approach, and more, to minimize rework and streamline your workflow. [Learn more](https://gitkraken.com/solutions/cloud-patches?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n[Open Cloud Patches](command:gitlens.showDraftsView)", "media": { - "markdown": "walkthroughs/welcome/trial.md" - }, - "when": "gitlens:plus:state == 3" + "altText": "Illustrations of Code Suggest & Cloud Patches", + "image": "walkthroughs/welcome/code-collab.png" + } }, { - "id": "gitlens.welcome.services", - "title": "Power-up with GitKraken Cloud Services", - "description": "Sign up for access to our developer productivity and collaboration services, e.g. Workspaces, or [sign in](command:gitlens.plus.loginOrSignUp).\n\n[Sign Up](command:gitlens.plus.loginOrSignUp)", + "id": "integrations", + "title": "Integrate with Git Hosting & Issue Services", + "description": "GitLens automatically detects patterns in commit messages to generate autolinks to pull requests and issues for Git hosting services including GitHub, GitLab, Gitea, Gerrit, Google Source, Bitbucket, Azure DevOps, and custom servers.\n\n[Configure autolinks](command:gitlens.showSettingsPage!autolinks) for custom pattern-matching with other services.\n\n**Rich Integrations with GitHub, GitLab, and Jira**\nConnect [GitHub](command:gitlens.connectRemoteProvider), [GitLab](command:gitlens.connectRemoteProvider), and [Jira](command:gitlens.plus.cloudIntegrations.manage?%7B%22integrationId%22%3A%22jira%22%2C%22source%22%3A%22walkthrough%22%2C%22detail%22%3A%7B%22action%22%3A%22connect%22%2C%22integration%22%3A%22jira%22%7D%7D) integrations to enhance autolinks with more data available via APIs, associate branches and commits with PRs, and review pull requests within VS Code.", "media": { - "markdown": "walkthroughs/welcome/services.md" - }, - "when": "gitlens:plus:state >= 0 && gitlens:plus:state <= 2" + "markdown": "walkthroughs/welcome/integrations.md" + } }, { - "id": "gitlens.welcome.additional", + "id": "more", "title": "And More!", "description": "**Rebase got you down?**\nEasily visualize and configure interactive rebase operations with the intuitive and user-friendly Interactive Rebase Editor\n\n**Trouble remembering Git commands?**\nNow you don't have to with the Git Command Palette — a guided, step-by-step experience to many common Git commands.\n\n[Open Git Command Palette](command:gitlens.gitCommands)\n\n**Terminal your jam?**\nQuickly jump to more details on commits, branches, tags, and more with autolinks in the Integrated Terminal.\n\n**Not sure what you are looking for?**\nOpen the Command Palette and explore the many available commands.\n\n[Open GitLens Commands](command:workbench.action.quickOpen?%22>GitLens%3A%22)", "media": { - "markdown": "walkthroughs/welcome/additional-features.md" + "markdown": "walkthroughs/welcome/more-features.md" } } ] @@ -14740,6 +17515,7 @@ "analyze:bundle": "webpack --mode production --env analyzeBundle", "analyze:deps": "webpack --env analyzeDeps", "build": "webpack --mode development", + "build:quick": "webpack --mode development --env skipLint", "build:extension": "webpack --mode development --config-name extension:node", "build:extension:browser": "webpack --mode development --config-name extension:webworker", "build:webviews": "webpack --mode development --config-name webviews", @@ -14751,17 +17527,20 @@ "clean": "npx rimraf dist out .vscode-test .vscode-test-web .eslintcache* tsconfig*.tsbuildinfo", "copy:images": "webpack --config webpack.config.images.js", "graph:link": "yarn link @gitkraken/gitkraken-components", - "graph:link:main": "pushd \"../GitKrakenComponents\" && yarn link && popd && yarn link @gitkraken/gitkraken-components", + "graph:link:main": "pushd \"../GitKrakenComponents\" && yarn link && popd && yarn graph:link", "graph:unlink": "yarn unlink @gitkraken/gitkraken-components && yarn install --force", - "icons:apply": "node ./scripts/applyIconsContribution.js", + "graph:unlink:main": "yarn graph:unlink && pushd \"../GitKrakenComponents\" && yarn unlink && popd", + "icons:apply": "node ./scripts/applyIconsContribution.mjs", "icons:svgo": "svgo -q -f ./images/icons/ --config svgo.config.js", - "lint": "eslint \"src/**/*.ts?(x)\"", - "lint:webviews": "eslint \"src/webviews/apps/**/*.ts?(x)\" --fix", + "lint": "yarn run lint:clear-cache && eslint \"src/**/*.ts?(x)\"", + "lint:webviews": "yarn run lint:clear-cache && eslint \"src/webviews/apps/**/*.ts?(x)\"", + "lint:clear-cache": "npx rimraf .eslintcache", "package": "vsce package --yarn", "package-pre": "yarn run patch-pre && yarn run package --pre-release", "patch-pre": "node ./scripts/applyPreReleasePatch.js", "prep-release": "node ./scripts/prep-release.js", - "pretty": "prettier --config .prettierrc --log-level warn --write .", + "pretty": "prettier --config .prettierrc --write .", + "pretty:check": "prettier --config .prettierrc --check .", "pub": "vsce publish --yarn", "pub-pre": "vsce publish --yarn --pre-release", "rebuild": "yarn run reset && yarn run build", @@ -14774,30 +17553,35 @@ "-watch:tests": "webpack --watch -c webpack.config.test.js --mode development", "web": "vscode-test-web --extensionDevelopmentPath=. --folder-uri=vscode-vfs://github/gitkraken/vscode-gitlens", "web:serve": "node -e \"const p = require('path'); const h = require('os').homedir(); require('child_process').execSync('npx serve --cors -l 5000 --ssl-cert '+p.resolve(h, 'certs/localhost.pem')+' --ssl-key '+p.resolve(h, 'certs/localhost-key.pem'), { stdio: 'inherit' })\"", - "update-dts": "pushd \"src/@types\" && npx vscode-dts dev && popd", - "update-dts:master": "pushd \"src/@types\" && npx vscode-dts master && popd", + "update-dts": "pushd \"src/@types\" && npx @vscode/dts dev && popd", + "update-dts:main": "pushd \"src/@types\" && npx @vscode/dts main && popd", "update-emoji": "node ./scripts/generateEmojiShortcodeMap.mjs", "update-licenses": "node ./scripts/generateLicenses.mjs", "-pretest": "yarn run build:tests", "vscode:prepublish": "yarn run bundle" }, "dependencies": { - "@gitkraken/gitkraken-components": "10.1.25", - "@gitkraken/shared-web-components": "^0.1.1-rc.6", - "@microsoft/fast-element": "1.12.0", - "@microsoft/fast-react-wrapper": "0.3.19", - "@octokit/graphql": "7.0.2", - "@octokit/request": "8.1.2", - "@opentelemetry/api": "1.6.0", - "@opentelemetry/exporter-trace-otlp-http": "0.43.0", - "@opentelemetry/sdk-trace-base": "1.17.0", - "@vscode/codicons": "0.0.33", - "@vscode/webview-ui-toolkit": "1.2.2", + "@gitkraken/gitkraken-components": "10.5.1", + "@gitkraken/provider-apis": "0.22.9", + "@gitkraken/shared-web-components": "0.1.1-rc.15", + "@lit/react": "1.0.5", + "@microsoft/fast-element": "1.13.0", + "@octokit/graphql": "8.1.1", + "@octokit/request": "9.1.1", + "@octokit/types": "13.5.0", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "0.52.0", + "@opentelemetry/sdk-trace-base": "1.25.0", + "@shoelace-style/shoelace": "2.15.1", + "@vscode/codicons": "0.0.36", + "@vscode/webview-ui-toolkit": "1.4.0", "ansi-regex": "6.0.1", - "billboard.js": "3.9.4", + "billboard.js": "3.12.4", + "fast-string-truncated-width": "1.1.0", "https-proxy-agent": "5.0.1", "iconv-lite": "0.6.3", - "lit": "2.8.0", + "lit": "3.1.4", + "marked": "12.0.2", "node-fetch": "2.7.0", "os-browserify": "0.3.0", "path-browserify": "1.0.1", @@ -14806,63 +17590,65 @@ "sortablejs": "1.15.0" }, "devDependencies": { - "@types/mocha": "10.0.1", - "@types/node": "16.11.47", - "@types/react": "17.0.47", - "@types/react-dom": "17.0.17", - "@types/sortablejs": "1.15.2", - "@types/vscode": "1.80.0", - "@typescript-eslint/eslint-plugin": "6.7.3", - "@typescript-eslint/parser": "6.7.3", - "@vscode/test-electron": "2.3.4", - "@vscode/test-web": "0.0.45", - "@vscode/vsce": "2.21.0", + "@eamodio/eslint-lite-webpack-plugin": "0.0.8", + "@swc/core": "1.6.7", + "@twbs/fantasticon": "3.0.0", + "@types/mocha": "10.0.7", + "@types/node": "18.15.0", + "@types/react": "17.0.80", + "@types/react-dom": "17.0.21", + "@types/sortablejs": "1.15.8", + "@types/vscode": "1.82.0", + "@typescript-eslint/eslint-plugin": "7.15.0", + "@typescript-eslint/parser": "7.15.0", + "@vscode/test-electron": "2.4.0", + "@vscode/test-web": "0.0.56", + "@vscode/vsce": "2.29.0", "circular-dependency-plugin": "5.2.2", "clean-webpack-plugin": "4.0.0", - "concurrently": "8.2.1", - "copy-webpack-plugin": "11.0.0", + "concurrently": "8.2.2", + "copy-webpack-plugin": "12.0.2", "csp-html-webpack-plugin": "5.1.0", - "css-loader": "6.8.1", - "css-minimizer-webpack-plugin": "5.0.1", - "cssnano-preset-advanced": "6.0.1", - "esbuild": "0.19.3", - "esbuild-loader": "4.0.2", - "esbuild-sass-plugin": "2.15.0", - "eslint": "8.50.0", + "css-loader": "7.1.2", + "css-minimizer-webpack-plugin": "7.0.0", + "cssnano-preset-advanced": "7.0.4", + "esbuild": "0.23.0", + "esbuild-loader": "4.2.0", + "esbuild-sass-plugin": "3.3.1", + "eslint": "8.57.0", "eslint-cli": "1.1.1", - "eslint-config-prettier": "9.0.0", "eslint-import-resolver-typescript": "3.6.1", "eslint-plugin-anti-trojan-source": "1.1.1", - "eslint-plugin-import": "2.28.1", - "eslint-plugin-lit": "1.9.1", - "eslint-plugin-wc": "2.0.4", - "fantasticon": "1.2.3", + "eslint-plugin-import": "2.29.1", + "eslint-plugin-lit": "1.14.0", + "eslint-plugin-wc": "2.1.0", "fork-ts-checker-webpack-plugin": "6.5.3", - "glob": "10.3.10", - "html-loader": "4.2.0", - "html-webpack-plugin": "5.5.3", - "image-minimizer-webpack-plugin": "3.8.3", - "license-checker-rseidelsohn": "4.2.8", + "glob": "10.4.2", + "html-loader": "5.0.0", + "html-webpack-plugin": "5.6.0", + "image-minimizer-webpack-plugin": "4.0.2", + "license-checker-rseidelsohn": "4.3.0", "lz-string": "1.5.0", - "mini-css-extract-plugin": "2.7.6", - "mocha": "10.2.0", - "prettier": "3.0.3", - "sass": "1.68.0", - "sass-loader": "13.3.2", + "mini-css-extract-plugin": "2.9.0", + "mocha": "10.6.0", + "prettier": "3.1.0", + "sass": "1.77.6", + "sass-loader": "14.2.1", "schema-utils": "4.2.0", "sharp": "0.32.6", - "svgo": "3.0.2", - "terser-webpack-plugin": "5.3.9", - "ts-loader": "9.4.4", - "tsc-alias": "1.8.8", - "typescript": "5.2.2", - "webpack": "5.88.2", - "webpack-bundle-analyzer": "4.9.1", + "svgo": "3.3.2", + "terser-webpack-plugin": "5.3.10", + "ts-loader": "9.5.1", + "tsc-alias": "1.8.10", + "typescript": "5.5.3", + "webpack": "5.92.1", + "webpack-bundle-analyzer": "4.10.2", "webpack-cli": "5.1.4", "webpack-node-externals": "3.0.0", "webpack-require-from": "1.8.6" }, "resolutions": { + "esbuild": "0.23.0", "iconv-lite": "0.6.3", "node-fetch": "2.7.0", "semver-regex": "4.0.5" diff --git a/scripts/applyIconsContribution.js b/scripts/applyIconsContribution.js deleted file mode 100644 index b015a20887e9d..0000000000000 --- a/scripts/applyIconsContribution.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const fs = require('fs'); - -// Update the icons contribution point in package.json -const package = require('../package.json'); -const icons = require('../dist/icons-contribution.json').icons; -if (JSON.stringify(package.contributes.icons) !== JSON.stringify(icons)) { - package.contributes.icons = icons; - const packageJSON = `${JSON.stringify(package, undefined, '\t')}\n`; - fs.writeFileSync('./package.json', packageJSON); -} - -fs.rmSync('./dist/icons-contribution.json'); - -// Update the scss file -const newScss = fs.readFileSync('./dist/glicons.scss', 'utf8'); -const scss = fs.readFileSync('./src/webviews/apps/shared/glicons.scss', 'utf8'); -if (scss !== newScss) { - fs.writeFileSync('./src/webviews/apps/shared/glicons.scss', newScss); -} - -fs.rmSync('./dist/glicons.scss'); diff --git a/scripts/applyIconsContribution.mjs b/scripts/applyIconsContribution.mjs new file mode 100644 index 0000000000000..91501351b5c0a --- /dev/null +++ b/scripts/applyIconsContribution.mjs @@ -0,0 +1,32 @@ +import fs from 'fs'; + +const packageJSONPromises = Promise.all([ + import('../package.json', { assert: { type: 'json' } }), + import('../dist/icons-contribution.json', { assert: { type: 'json' } }), +]); + +const scssPromises = Promise.all([ + fs.promises.readFile('./dist/glicons.scss', 'utf8'), + fs.promises.readFile('./src/webviews/apps/shared/glicons.scss', 'utf8'), +]); + +let pending = []; + +// Update the icons contribution point in package.json +const [{ default: packageJSON }, { default: icons }] = await packageJSONPromises; + +if (JSON.stringify(packageJSON.contributes.icons) !== JSON.stringify(icons.icons)) { + packageJSON.contributes.icons = icons; + const json = `${JSON.stringify(packageJSON, undefined, '\t')}\n`; + pending.push(fs.promises.writeFile('./package.json', json)); +} + +// Update the scss file +const [newScss, scss] = await scssPromises; + +if (scss !== newScss) { + pending.push(fs.promises.writeFile('./src/webviews/apps/shared/glicons.scss', newScss)); +} + +pending.push(fs.promises.rm('./dist/icons-contribution.json'), fs.promises.rm('./dist/glicons.scss')); +await Promise.allSettled(pending); diff --git a/scripts/generateLicenses.mjs b/scripts/generateLicenses.mjs index 2c432cf813e7f..c5f75d63cded3 100644 --- a/scripts/generateLicenses.mjs +++ b/scripts/generateLicenses.mjs @@ -2,7 +2,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import * as fs from 'fs'; import * as path from 'path'; -import fetch from 'node-fetch'; import * as checker from 'license-checker-rseidelsohn'; /** @typedef { { licenses: string; repository: string; licenseFile: string } } PackageInfo **/ diff --git a/scripts/prep-release.js b/scripts/prep-release.js index 7fdd63b061253..9808bf21943f3 100644 --- a/scripts/prep-release.js +++ b/scripts/prep-release.js @@ -46,7 +46,7 @@ rl.question(`Enter the new version number (format x.x.x, current is ${currentVer // Add the new version header below the ## [Unreleased] header data = data.replace('## [Unreleased]', newVersionHeader); - const unreleasedLink = match[0].replace(/\/compare\/v(.+?)\.\.\.HEAD/, `\/compare\/v${version}...HEAD`); + const unreleasedLink = match[0].replace(/\/compare\/v(.+?)\.\.\.HEAD/, `/compare/v${version}...HEAD`); // Update the [unreleased]: line data = data.replace(match[0], `${unreleasedLink}\n${newVersionLink}`); diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index e35c9cf295267..3e49dce7e0681 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,6 +15,9 @@ export declare global { export type ExtractSome = Omit & { [P in K]-?: Extract }; export type RequireSome = Omit & { [P in K]-?: T[P] }; + export type RequireSomeWithProps = Omit & { + [P in K]-?: RequireSome; + }; export type AllNonNullable = { [P in keyof T]-?: NonNullable }; export type SomeNonNullable = Omit & { [P in K]-?: NonNullable }; @@ -25,7 +28,12 @@ export declare global { export type Prefix

= T extends `${P}${S}${infer R}` ? R : never; + + export type Replace = Omit & { [P in K]: R }; + export type StartsWith

= T extends `${P}${S}${string}` ? T : never; + + export type UnwrapCustomEvent = T extends CustomEvent ? U : never; } diff --git a/src/@types/lib.textEncoder.d.ts b/src/@types/lib.textEncoder.d.ts deleted file mode 100644 index 9c7bd7903edf2..0000000000000 --- a/src/@types/lib.textEncoder.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Define TextEncoder + TextDecoder globals for both browser and node runtimes -// See: https://github.com/microsoft/TypeScript/issues/31535 - -declare let TextDecoder: typeof import('util').TextDecoder; -declare let TextEncoder: typeof import('util').TextEncoder; diff --git a/src/@types/vscode.git.d.ts b/src/@types/vscode.git.d.ts index 59b1052d25cdc..aa5d8bf9a29b9 100644 --- a/src/@types/vscode.git.d.ts +++ b/src/@types/vscode.git.d.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, Event, ProviderResult, Uri, Command } from 'vscode'; - import { GitErrorCodes, RefType, Status, ForcePushMode } from '../@types/vscode.git.enums'; export interface Git { @@ -94,6 +93,10 @@ export interface LogOptions { /** Max number of log entries to retrieve. If not specified, the default is 32. */ readonly maxEntries?: number; readonly path?: string; + /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ + readonly range?: string; + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; } export interface CommitOptions { @@ -106,7 +109,13 @@ export interface CommitOptions { requireUserConfig?: boolean; useEditor?: boolean; verbose?: boolean; - postCommitCommand?: string; + /** + * string - execute the specified command after the commit operation + * undefined - execute the command specified in git.postCommitCommand + * after the commit operation + * null - do not execute any command after the commit operation + */ + postCommitCommand?: string | null; } export interface FetchOptions { @@ -117,11 +126,19 @@ export interface FetchOptions { depth?: number; } -export interface BranchQuery { - readonly remote?: boolean; - readonly pattern?: string; - readonly count?: number; +export interface InitOptions { + defaultBranch?: string; +} + +export interface RefQuery { readonly contains?: string; + readonly count?: number; + readonly pattern?: string; + readonly sort?: 'alphabetically' | 'committerdate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; } export interface Repository { @@ -164,9 +181,12 @@ export interface Repository { createBranch(name: string, checkout: boolean, ref?: string): Promise; deleteBranch(name: string, force?: boolean): Promise; getBranch(name: string): Promise; - getBranches(query: BranchQuery): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + getBranchBase(name: string): Promise; setBranchUpstream(name: string, upstream: string): Promise; + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + getMergeBase(ref1: string, ref2: string): Promise; tag(name: string, upstream: string): Promise; @@ -233,6 +253,31 @@ export interface PushErrorHandler { ): Promise; } +export interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): BranchProtection[]; +} + +export interface CommitMessageProvider { + readonly title: string; + readonly icon?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + provideCommitMessage( + repository: Repository, + changes: string[], + cancellationToken?: CancellationToken, + ): Promise; +} + export type APIState = 'uninitialized' | 'initialized'; export interface PublishEvent { @@ -251,14 +296,16 @@ export interface API { toGitUri(uri: Uri, ref: string): Uri; getRepository(uri: Uri): Repository | null; - init(root: Uri): Promise; - openRepository?(root: Uri): Promise; + init(root: Uri, options?: InitOptions): Promise; + openRepository(root: Uri): Promise; registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; registerCredentialsProvider(provider: CredentialsProvider): Disposable; registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; + registerCommitMessageProvider(provider: CommitMessageProvider): Disposable; } export interface GitExtension { diff --git a/src/@types/vscode.git.enums.ts b/src/@types/vscode.git.enums.ts index 1e87702ad4e51..862663af9013a 100644 --- a/src/@types/vscode.git.enums.ts +++ b/src/@types/vscode.git.enums.ts @@ -6,6 +6,7 @@ export const enum ForcePushMode { Force, ForceWithLease, + ForceWithLeaseIfIncludes, } export const enum RefType { @@ -26,6 +27,8 @@ export const enum Status { UNTRACKED, IGNORED, INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, ADDED_BY_US, ADDED_BY_THEM, @@ -48,6 +51,8 @@ export const enum GitErrorCodes { StashConflict = 'StashConflict', UnmergedChanges = 'UnmergedChanges', PushRejected = 'PushRejected', + ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected', + ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected', RemoteConnectionError = 'RemoteConnectionError', DirtyWorkTree = 'DirtyWorkTree', CantOpenResource = 'CantOpenResource', @@ -73,4 +78,7 @@ export const enum GitErrorCodes { NoPathFound = 'NoPathFound', UnknownPath = 'UnknownPath', EmptyCommitMessage = 'EmptyCommitMessage', + BranchFastForwardRejected = 'BranchFastForwardRejected', + BranchNotYetBorn = 'BranchNotYetBorn', + TagConflict = 'TagConflict', } diff --git a/src/@types/vscode.git.resources.d.ts b/src/@types/vscode.git.resources.d.ts index 125055f596d1c..44e91a5df2016 100644 --- a/src/@types/vscode.git.resources.d.ts +++ b/src/@types/vscode.git.resources.d.ts @@ -1,5 +1,6 @@ -import { Status as ScmStatus } from '../@types/vscode.git.d.ts'; -import { ScmResourceGroupType } from '../@types/vscode.git.resources.enums'; +import type { SourceControlResourceState } from 'vscode'; +import type { Status as ScmStatus } from '../@types/vscode.git.enums.ts'; +import type { ScmResourceGroupType } from '../@types/vscode.git.resources.enums'; export interface ScmResource extends SourceControlResourceState { readonly resourceGroupType?: ScmResourceGroupType; diff --git a/src/@types/vscode.proposed.languageModels.d.ts b/src/@types/vscode.proposed.languageModels.d.ts new file mode 100644 index 0000000000000..b2d732fd1f550 --- /dev/null +++ b/src/@types/vscode.proposed.languageModels.d.ts @@ -0,0 +1,324 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/206265 + + /** + * Represents the role of a chat message. This is either the user or the assistant. + */ + export enum LanguageModelChatMessageRole { + /** + * The user role, e.g the human interacting with a language model. + */ + User = 1, + + /** + * The assistant role, e.g. the language model generating responses. + */ + Assistant = 2, + } + + /** + * Represents a message in a chat. Can assume different roles, like user or assistant. + */ + export class LanguageModelChatMessage { + /** + * Utility to create a new user message. + * + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + static User(content: string, name?: string): LanguageModelChatMessage; + + /** + * Utility to create a new assistant message. + * + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + static Assistant(content: string, name?: string): LanguageModelChatMessage; + + /** + * The role of this message. + */ + role: LanguageModelChatMessageRole; + + /** + * The content of this message. + */ + content: string; + + /** + * The optional name of a user for this message. + */ + name: string | undefined; + + /** + * Create a new user message. + * + * @param role The role of the message. + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + constructor(role: LanguageModelChatMessageRole, content: string, name?: string); + } + + /** + * Represents a language model response. + * + * @see {@link LanguageModelAccess.chatRequest} + */ + export interface LanguageModelChatResponse { + /** + * An async iterable that is a stream of text chunks forming the overall response. + * + * *Note* that this stream will error when during data receiving an error occurs. Consumers of + * the stream should handle the errors accordingly. + * + * @example + * ```ts + * try { + * // consume stream + * for await (const chunk of response.stream) { + * console.log(chunk); + * } + * + * } catch(e) { + * // stream ended with an error + * console.error(e); + * } + * ``` + * + * To cancel the stream, the consumer can {@link CancellationTokenSource.cancel cancel} the token that was used to make the request + * or break from the for-loop. + */ + text: AsyncIterable; + } + + /** + * Represents a language model for making chat requests. + * + * @see {@link lm.selectChatModels} + */ + export interface LanguageModelChat { + /** + * Human-readable name of the language model. + */ + readonly name: string; + + /** + * Opaque identifier of the language model. + */ + readonly id: string; + + /** + * A well-know identifier of the vendor of the language model, a sample is `copilot`, but + * values are defined by extensions contributing chat models and need to be looked up with them. + */ + readonly vendor: string; + + /** + * Opaque family-name of the language model. Values might be `gpt-3.5-turbo`, `gpt4`, `phi2`, or `llama` + * but they are defined by extensions contributing languages and subject to change. + */ + readonly family: string; + + /** + * Opaque version string of the model. This is defined by the extension contributing the language model + * and subject to change. + */ + readonly version: string; + + /** + * The maximum number of tokens that can be sent to the model in a single request. + */ + readonly maxInputTokens: number; + + /** + * Make a chat request using a language model. + * + * *Note* that language model use may be subject to access restrictions and user consent. Calling this function + * for the first time (for a extension) will show a consent dialog to the user and because of that this function + * must _only be called in response to a user action!_ Extension can use {@link LanguageModelAccessInformation.canSendRequest} + * to check if they have the necessary permissions to make a request. + * + * This function will return a rejected promise if making a request to the language model is not + * possible. Reasons for this can be: + * + * - user consent not given, see {@link LanguageModelError.NoPermissions `NoPermissions`} + * - model does not exist anymore, see {@link LanguageModelError.NotFound `NotFound`} + * - quota limits exceeded, see {@link LanguageModelError.Blocked `Blocked`} + * - other issues in which case extension must check {@link LanguageModelError.cause `LanguageModelError.cause`} + * + * @param messages An array of message instances. + * @param options Options that control the request. + * @param token A cancellation token which controls the request. See {@link CancellationTokenSource} for how to create one. + * @returns A thenable that resolves to a {@link LanguageModelChatResponse}. The promise will reject when the request couldn't be made. + */ + sendRequest( + messages: LanguageModelChatMessage[], + options?: LanguageModelChatRequestOptions, + token?: CancellationToken, + ): Thenable; + + /** + * Count the number of tokens in a message using the model specific tokenizer-logic. + + * @param text A string or a message instance. + * @param token Optional cancellation token. See {@link CancellationTokenSource} for how to create one. + * @returns A thenable that resolves to the number of tokens. + */ + countTokens(text: string | LanguageModelChatMessage, token?: CancellationToken): Thenable; + } + + /** + * Describes how to select language models for chat requests. + * + * @see {@link lm.selectChatModels} + */ + export interface LanguageModelChatSelector { + /** + * A vendor of language models. + * @see {@link LanguageModelChat.vendor} + */ + vendor?: string; + + /** + * A family of language models. + * @see {@link LanguageModelChat.family} + */ + family?: string; + + /** + * The version of a language model. + * @see {@link LanguageModelChat.version} + */ + version?: string; + + /** + * The identifier of a language model. + * @see {@link LanguageModelChat.id} + */ + id?: string; + } + + /** + * An error type for language model specific errors. + * + * Consumers of language models should check the code property to determine specific + * failure causes, like `if(someError.code === vscode.LanguageModelError.NotFound.name) {...}` + * for the case of referring to an unknown language model. For unspecified errors the `cause`-property + * will contain the actual error. + */ + export class LanguageModelError extends Error { + /** + * The requestor does not have permissions to use this + * language model + */ + static NoPermissions(message?: string): LanguageModelError; + + /** + * The requestor is blocked from using this language model. + */ + static Blocked(message?: string): LanguageModelError; + + /** + * The language model does not exist. + */ + static NotFound(message?: string): LanguageModelError; + + /** + * A code that identifies this error. + * + * Possible values are names of errors, like {@linkcode LanguageModelError.NotFound NotFound}, + * or `Unknown` for unspecified errors from the language model itself. In the latter case the + * `cause`-property will contain the actual error. + */ + readonly code: string; + } + + /** + * Options for making a chat request using a language model. + * + * @see {@link LanguageModelChat.sendRequest} + */ + export interface LanguageModelChatRequestOptions { + /** + * A human-readable message that explains why access to a language model is needed and what feature is enabled by it. + */ + justification?: string; + + /** + * A set of options that control the behavior of the language model. These options are specific to the language model + * and need to be lookup in the respective documentation. + */ + modelOptions?: { [name: string]: any }; + } + + /** + * Namespace for language model related functionality. + */ + export namespace lm { + /** + * An event that is fired when the set of available chat models changes. + */ + export const onDidChangeChatModels: Event; + + /** + * Select chat models by a {@link LanguageModelChatSelector selector}. This can yield in multiple or no chat models and + * extensions must handle these cases, esp. when no chat model exists, gracefully. + * + * ```ts + * + * const models = await vscode.lm.selectChatModels({family: 'gpt-3.5-turbo'})!; + * if (models.length > 0) { + * const [first] = models; + * const response = await first.sendRequest(...) + * // ... + * } else { + * // NO chat models available + * } + * ``` + * + * *Note* that extensions can hold-on to the results returned by this function and use them later. However, when the + * {@link onDidChangeChatModels}-event is fired the list of chat models might have changed and extensions should re-query. + * + * @param selector A chat model selector. When omitted all chat models are returned. + * @returns An array of chat models, can be empty! + */ + export function selectChatModels(selector?: LanguageModelChatSelector): Thenable; + } + + /** + * Represents extension specific information about the access to language models. + */ + export interface LanguageModelAccessInformation { + /** + * An event that fires when access information changes. + */ + onDidChange: Event; + + /** + * Checks if a request can be made to a language model. + * + * *Note* that calling this function will not trigger a consent UI but just checks for a persisted state. + * + * @param chat A language model chat object. + * @return `true` if a request can be made, `false` if not, `undefined` if the language + * model does not exist or consent hasn't been asked for. + */ + canSendRequest(chat: LanguageModelChat): boolean | undefined; + } + + export interface ExtensionContext { + /** + * An object that keeps information about how this extension can use language models. + * + * @see {@link lm.sendChatRequest} + */ + readonly languageModelAccessInformation: LanguageModelAccessInformation; + } +} diff --git a/src/ai/aiProviderService.ts b/src/ai/aiProviderService.ts index f41cc6fa44b81..56c9b16ccc34b 100644 --- a/src/ai/aiProviderService.ts +++ b/src/ai/aiProviderService.ts @@ -1,98 +1,316 @@ -import type { Disposable, MessageItem, ProgressOptions } from 'vscode'; -import { Uri, window } from 'vscode'; -import type { AIProviders } from '../constants'; +import type { CancellationToken, Disposable, MessageItem, ProgressOptions, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { AIModels, AIProviders, SupportedAIModels } from '../constants'; import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; import { assertsCommitHasFullDetails, isCommit } from '../git/models/commit'; -import { uncommittedStaged } from '../git/models/constants'; +import { uncommitted, uncommittedStaged } from '../git/models/constants'; import type { GitRevisionReference } from '../git/models/reference'; import type { Repository } from '../git/models/repository'; import { isRepository } from '../git/models/repository'; +import { showAIModelPicker } from '../quickpicks/aiModelPicker'; import { configuration } from '../system/configuration'; +import { getSettledValue } from '../system/promise'; import type { Storage } from '../system/storage'; +import { supportedInVSCodeVersion } from '../system/utils'; import { AnthropicProvider } from './anthropicProvider'; +import { GeminiProvider } from './geminiProvider'; import { OpenAIProvider } from './openaiProvider'; +import type { VSCodeAIModels } from './vscodeProvider'; +import { isVSCodeAIModel, VSCodeAIProvider } from './vscodeProvider'; + +export interface AIModel< + Provider extends AIProviders = AIProviders, + Model extends AIModels = AIModels, +> { + readonly id: Model; + readonly name: string; + readonly maxTokens: number; + readonly provider: { + id: Provider; + name: string; + }; + + readonly default?: boolean; + readonly hidden?: boolean; +} + +interface AIProviderConstructor { + new (container: Container): AIProvider; +} + +const _supportedProviderTypes = new Map([ + ['openai', OpenAIProvider], + ['anthropic', AnthropicProvider], + ['gemini', GeminiProvider], +]); +if (supportedInVSCodeVersion('language-models')) { + _supportedProviderTypes.set('vscode', VSCodeAIProvider); +} -export interface AIProvider extends Disposable { - readonly id: AIProviders; +export interface AIProvider extends Disposable { + readonly id: Provider; readonly name: string; - generateCommitMessage(diff: string, options?: { context?: string }): Promise; - explainChanges(message: string, diff: string): Promise; + getModels(): Promise>[]>; + + explainChanges( + model: AIModel>, + message: string, + diff: string, + options?: { cancellation?: CancellationToken }, + ): Promise; + generateCommitMessage( + model: AIModel>, + diff: string, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise; + generateDraftMessage( + model: AIModel>, + diff: string, + options?: { cancellation?: CancellationToken; context?: string; codeSuggestion?: boolean }, + ): Promise; } export class AIProviderService implements Disposable { private _provider: AIProvider | undefined; + private _model: AIModel | undefined; - private get provider() { - const providerId = configuration.get('ai.experimental.provider'); - if (providerId === this._provider?.id) return this._provider; + constructor(private readonly container: Container) {} + dispose() { this._provider?.dispose(); + } - if (providerId === 'anthropic') { - this._provider = new AnthropicProvider(this.container); - } else { - this._provider = new OpenAIProvider(this.container); + get currentProviderId() { + return this._provider?.id; + } + + private getConfiguredModel(): { provider: AIProviders; model: AIModels } | undefined { + const qualifiedModelId = configuration.get('ai.experimental.model') ?? undefined; + if (qualifiedModelId != null) { + let [providerId, modelId] = qualifiedModelId.split(':') as [AIProviders, AIModels]; + if (providerId != null && this.supports(providerId)) { + if (modelId != null) { + return { provider: providerId, model: modelId }; + } else if (providerId === 'vscode') { + modelId = configuration.get('ai.experimental.vscode.model') as VSCodeAIModels; + if (modelId != null) { + // Model ids are in the form of `vendor:family` + if (/^(.+):(.+)$/.test(modelId)) { + return { provider: providerId, model: modelId }; + } + } + } + } } + return undefined; + } - return this._provider; + async getModels(): Promise { + const providers = [..._supportedProviderTypes.values()].map(p => new p(this.container)); + const models = await Promise.allSettled(providers.map(p => p.getModels())); + return models.flatMap(m => getSettledValue(m, [])); } - constructor(private readonly container: Container) {} + private async getModel(options?: { force?: boolean; silent?: boolean }): Promise { + const cfg = this.getConfiguredModel(); + if (!options?.force && cfg?.provider != null && cfg?.model != null) { + const model = await this.getOrUpdateModel(cfg.provider, cfg.model); + if (model != null) return model; + } - dispose() { - this._provider?.dispose(); + if (options?.silent) return undefined; + + const pick = await showAIModelPicker(this.container, cfg); + if (pick == null) return undefined; + + return this.getOrUpdateModel(pick.model); } - get providerId() { - return this.provider?.id; + private getOrUpdateModel(model: AIModel): Promise; + private getOrUpdateModel(providerId: T, modelId: AIModels): Promise; + private async getOrUpdateModel( + modelOrProviderId: AIModel | AIProviders, + modelId?: AIModels, + ): Promise { + let providerId: AIProviders; + let model: AIModel | undefined; + if (typeof modelOrProviderId === 'string') { + providerId = modelOrProviderId; + } else { + model = modelOrProviderId; + providerId = model.provider.id; + } + + let changed = false; + + if (providerId !== this._provider?.id) { + changed = true; + this._provider?.dispose(); + + const type = _supportedProviderTypes.get(providerId); + if (type == null) { + this._provider = undefined; + this._model = undefined; + + return undefined; + } + + this._provider = new type(this.container); + } + + if (model == null) { + if (modelId != null && modelId === this._model?.id) { + model = this._model; + } else { + changed = true; + + model = (await this._provider.getModels())?.find(m => m.id === modelId); + if (model == null) { + this._model = undefined; + + return undefined; + } + } + } else if (model.id !== this._model?.id) { + changed = true; + } + + if (changed) { + if (isVSCodeAIModel(model)) { + await configuration.updateEffective(`ai.experimental.model`, 'vscode'); + await configuration.updateEffective(`ai.experimental.vscode.model`, model.id); + } else { + await configuration.updateEffective( + `ai.experimental.model`, + `${model.provider.id}:${model.id}` as SupportedAIModels, + ); + } + } + + this._model = model; + return model; } - public async generateCommitMessage( - repoPath: string | Uri, - options?: { context?: string; progress?: ProgressOptions }, + async generateCommitMessage( + changes: string[], + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, ): Promise; - public async generateCommitMessage( + async generateCommitMessage( + repoPath: Uri, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + ): Promise; + async generateCommitMessage( repository: Repository, - options?: { context?: string; progress?: ProgressOptions }, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, ): Promise; - public async generateCommitMessage( - repoOrPath: string | Uri | Repository, - options?: { context?: string; progress?: ProgressOptions }, + async generateCommitMessage( + changesOrRepoOrPath: string[] | Repository | Uri, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + ): Promise { + const changes: string | undefined = await this.getChanges(changesOrRepoOrPath); + if (changes == null) return undefined; + + const model = await this.getModel(); + if (model == null) return undefined; + + const provider = this._provider!; + + const confirmed = await confirmAIProviderToS(model, this.container.storage); + if (!confirmed) return undefined; + if (options?.cancellation?.isCancellationRequested) return undefined; + + if (options?.progress != null) { + return window.withProgress(options.progress, async () => + provider.generateCommitMessage(model, changes, { + cancellation: options?.cancellation, + context: options?.context, + }), + ); + } + return provider.generateCommitMessage(model, changes, { + cancellation: options?.cancellation, + context: options?.context, + }); + } + + async generateDraftMessage( + changesOrRepoOrPath: string[] | Repository | Uri, + options?: { + cancellation?: CancellationToken; + context?: string; + progress?: ProgressOptions; + codeSuggestion?: boolean; + }, ): Promise { - const repository = isRepository(repoOrPath) ? repoOrPath : this.container.git.getRepository(repoOrPath); - if (repository == null) throw new Error('Unable to find repository'); + const changes: string | undefined = await this.getChanges(changesOrRepoOrPath); + if (changes == null) return undefined; - const diff = await this.container.git.getDiff(repository.uri, uncommittedStaged); - if (diff == null) throw new Error('No staged changes to generate a commit message from.'); + const model = await this.getModel(); + if (model == null) return undefined; - const provider = this.provider; + const provider = this._provider!; - const confirmed = await confirmAIProviderToS(provider, this.container.storage); + const confirmed = await confirmAIProviderToS(model, this.container.storage); if (!confirmed) return undefined; + if (options?.cancellation?.isCancellationRequested) return undefined; if (options?.progress != null) { return window.withProgress(options.progress, async () => - provider.generateCommitMessage(diff.contents, { context: options?.context }), + provider.generateDraftMessage(model, changes, { + cancellation: options?.cancellation, + context: options?.context, + codeSuggestion: options?.codeSuggestion, + }), ); } - return provider.generateCommitMessage(diff.contents, { context: options?.context }); + return provider.generateDraftMessage(model, changes, { + cancellation: options?.cancellation, + context: options?.context, + codeSuggestion: options?.codeSuggestion, + }); + } + + private async getChanges( + changesOrRepoOrPath: string[] | Repository | Uri, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + ): Promise { + let changes: string; + if (Array.isArray(changesOrRepoOrPath)) { + changes = changesOrRepoOrPath.join('\n'); + } else { + const repository = isRepository(changesOrRepoOrPath) + ? changesOrRepoOrPath + : this.container.git.getRepository(changesOrRepoOrPath); + if (repository == null) throw new Error('Unable to find repository'); + + let diff = await this.container.git.getDiff(repository.uri, uncommittedStaged); + if (!diff?.contents) { + diff = await this.container.git.getDiff(repository.uri, uncommitted); + if (!diff?.contents) throw new Error('No changes to generate a commit message from.'); + } + if (options?.cancellation?.isCancellationRequested) return undefined; + + changes = diff.contents; + } + + return changes; } async explainCommit( repoPath: string | Uri, sha: string, - options?: { progress?: ProgressOptions }, + options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, ): Promise; async explainCommit( commit: GitRevisionReference | GitCommit, - options?: { progress?: ProgressOptions }, + options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, ): Promise; async explainCommit( commitOrRepoPath: string | Uri | GitRevisionReference | GitCommit, shaOrOptions?: string | { progress?: ProgressOptions }, - options?: { progress?: ProgressOptions }, + options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, ): Promise { let commit: GitCommit | undefined; if (typeof commitOrRepoPath === 'string' || commitOrRepoPath instanceof Uri) { @@ -110,11 +328,14 @@ export class AIProviderService implements Disposable { if (commit == null) throw new Error('Unable to find commit'); const diff = await this.container.git.getDiff(commit.repoPath, commit.sha); - if (diff == null) throw new Error('No changes found to explain.'); + if (!diff?.contents) throw new Error('No changes found to explain.'); + + const model = await this.getModel(); + if (model == null) return undefined; - const provider = this.provider; + const provider = this._provider!; - const confirmed = await confirmAIProviderToS(provider, this.container.storage); + const confirmed = await confirmAIProviderToS(model, this.container.storage); if (!confirmed) return undefined; if (!commit.hasFullDetails()) { @@ -124,35 +345,94 @@ export class AIProviderService implements Disposable { if (options?.progress != null) { return window.withProgress(options.progress, async () => - provider.explainChanges(commit!.message!, diff.contents), + provider.explainChanges(model, commit.message, diff.contents, { + cancellation: options?.cancellation, + }), ); } - return provider.explainChanges(commit.message, diff.contents); + return provider.explainChanges(model, commit.message, diff.contents, { + cancellation: options?.cancellation, + }); } - reset() { - const { providerId } = this; - if (providerId == null) return; + async reset(all?: boolean) { + let { _provider: provider } = this; + if (provider == null) { + // If we have no provider, try to get the current model (which will load the provider) + await this.getModel({ silent: true }); + provider = this._provider; + } - void this.container.storage.deleteSecret(`gitlens.${providerId}.key`); + const resetCurrent: MessageItem = { title: `Reset Current` }; + const resetAll: MessageItem = { title: 'Reset All' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + + let result; + if (all) { + result = resetAll; + } else if (provider == null) { + result = await window.showInformationMessage( + `Do you want to reset all of the stored AI keys?`, + { modal: true }, + resetAll, + cancel, + ); + } else { + result = await window.showInformationMessage( + `Do you want to reset the stored key for the current provider (${provider.name}) or reset all of the stored AI keys?`, + { modal: true }, + resetCurrent, + resetAll, + cancel, + ); + } + + if (provider != null && result === resetCurrent) { + void env.clipboard.writeText((await this.container.storage.getSecret(`gitlens.${provider.id}.key`)) ?? ''); + void this.container.storage.deleteSecret(`gitlens.${provider.id}.key`); + + void this.container.storage.delete(`confirm:ai:tos:${provider.id}`); + void this.container.storage.deleteWorkspace(`confirm:ai:tos:${provider.id}`); + } else if (result === resetAll) { + const keys = []; + for (const [providerId] of _supportedProviderTypes) { + keys.push(await this.container.storage.getSecret(`gitlens.${providerId}.key`)); + } + void env.clipboard.writeText(keys.join('\n')); + + for (const [providerId] of _supportedProviderTypes) { + void this.container.storage.deleteSecret(`gitlens.${providerId}.key`); + } + + void this.container.storage.deleteWithPrefix(`confirm:ai:tos`); + void this.container.storage.deleteWorkspaceWithPrefix(`confirm:ai:tos`); + } + } + + supports(provider: AIProviders | string) { + return _supportedProviderTypes.has(provider as AIProviders); + } - void this.container.storage.delete(`confirm:ai:tos:${providerId}`); - void this.container.storage.deleteWorkspace(`confirm:ai:tos:${providerId}`); + async switchModel() { + void (await this.getModel({ force: true })); } } -async function confirmAIProviderToS(provider: AIProvider, storage: Storage): Promise { +async function confirmAIProviderToS( + model: AIModel>, + storage: Storage, +): Promise { const confirmed = - storage.get(`confirm:ai:tos:${provider.id}`, false) || - storage.getWorkspace(`confirm:ai:tos:${provider.id}`, false); + storage.get(`confirm:ai:tos:${model.provider.id}`, false) || + storage.getWorkspace(`confirm:ai:tos:${model.provider.id}`, false); if (confirmed) return true; - const accept: MessageItem = { title: 'Yes' }; + const accept: MessageItem = { title: 'Continue' }; const acceptWorkspace: MessageItem = { title: 'Always for this Workspace' }; const acceptAlways: MessageItem = { title: 'Always' }; - const decline: MessageItem = { title: 'No', isCloseAffordance: true }; + const decline: MessageItem = { title: 'Cancel', isCloseAffordance: true }; const result = await window.showInformationMessage( - `This GitLens experimental feature requires sending a diff of the code changes to ${provider.name}. This may contain sensitive information.\n\nDo you want to continue?`, + `GitLens experimental AI features require sending a diff of the code changes to ${model.provider.name} for analysis. This may contain sensitive information.\n\nDo you want to continue?`, { modal: true }, accept, acceptWorkspace, @@ -163,14 +443,98 @@ async function confirmAIProviderToS(provider: AIProvider, storage: Storage): Pro if (result === accept) return true; if (result === acceptWorkspace) { - void storage.storeWorkspace(`confirm:ai:tos:${provider.id}`, true); + void storage.storeWorkspace(`confirm:ai:tos:${model.provider.id}`, true); return true; } if (result === acceptAlways) { - void storage.store(`confirm:ai:tos:${provider.id}`, true); + void storage.store(`confirm:ai:tos:${model.provider.id}`, true); return true; } return false; } + +export function getMaxCharacters(model: AIModel, outputLength: number): number { + const tokensPerCharacter = 3.1; + const max = model.maxTokens * tokensPerCharacter - outputLength / tokensPerCharacter; + return Math.floor(max - max * 0.1); +} + +export async function getApiKey( + storage: Storage, + provider: { id: AIProviders; name: string; validator: (value: string) => boolean; url: string }, +): Promise { + let apiKey = await storage.getSecret(`gitlens.${provider.id}.key`); + if (!apiKey) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: `Open the ${provider.name} API Key Page`, + }; + + apiKey = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(value => { + if (value && !provider.validator(value)) { + input.validationMessage = `Please enter a valid ${provider.name} API key`; + return; + } + input.validationMessage = undefined; + }), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value || !provider.validator(value)) { + input.validationMessage = `Please enter a valid ${provider.name} API key`; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal(Uri.parse(provider.url)); + } + }), + ); + + input.password = true; + input.title = `Connect to ${provider.name}`; + input.placeholder = `Please enter your ${provider.name} API key to use this feature`; + input.prompt = `Enter your [${provider.name} API Key](${provider.url} "Get your ${provider.name} API key")`; + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!apiKey) return undefined; + + void storage.storeSecret(`gitlens.${provider.id}.key`, apiKey); + } + + return apiKey; +} + +export function extractDraftMessage( + message: string, + splitter = '\n\n', +): { title: string; description: string | undefined } { + const firstBreak = message.indexOf(splitter) ?? 0; + const title = firstBreak > -1 ? message.substring(0, firstBreak) : message; + const description = firstBreak > -1 ? message.substring(firstBreak + splitter.length) : undefined; + + return { + title: title, + description: description, + }; +} diff --git a/src/ai/anthropicProvider.ts b/src/ai/anthropicProvider.ts index f5eeac82c73e3..45528054b1696 100644 --- a/src/ai/anthropicProvider.ts +++ b/src/ai/anthropicProvider.ts @@ -1,225 +1,496 @@ -import type { Disposable, QuickInputButton } from 'vscode'; -import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { CancellationToken } from 'vscode'; +import { window } from 'vscode'; import { fetch } from '@env/fetch'; import type { Container } from '../container'; +import { CancellationError } from '../errors'; import { configuration } from '../system/configuration'; import type { Storage } from '../system/storage'; -import { supportedInVSCodeVersion } from '../system/utils'; -import type { AIProvider } from './aiProviderService'; +import type { AIModel, AIProvider } from './aiProviderService'; +import { getApiKey as getApiKeyCore, getMaxCharacters } from './aiProviderService'; +import { cloudPatchMessageSystemPrompt, codeSuggestMessageSystemPrompt, commitMessageSystemPrompt } from './prompts'; + +const provider = { id: 'anthropic', name: 'Anthropic' } as const; +type LegacyModels = Extract; +type SupportedModels = Exclude; +type LegacyModel = AIModel; +type SupportedModel = AIModel; + +function isLegacyModel(model: AnthropicModel): model is LegacyModel { + return model.id === 'claude-instant-1' || model.id === 'claude-2'; +} -export class AnthropicProvider implements AIProvider { - readonly id = 'anthropic'; - readonly name = 'Anthropic'; +function isSupportedModel(model: AnthropicModel): model is SupportedModel { + return !isLegacyModel(model); +} - private get model(): AnthropicModels { - return configuration.get('ai.experimental.anthropic.model') || 'claude-v1'; - } +export type AnthropicModels = + | 'claude-instant-1' + | 'claude-2' + | 'claude-2.1' + | 'claude-3-opus-20240229' + | 'claude-3-sonnet-20240229' + | 'claude-3-5-sonnet-20240620' + | 'claude-3-haiku-20240307'; + +type AnthropicModel = AIModel; + +const models: AnthropicModel[] = [ + { + id: 'claude-3-opus-20240229', + name: 'Claude 3 Opus', + maxTokens: 200000, + provider: provider, + }, + { + id: 'claude-3-5-sonnet-20240620', + name: 'Claude 3.5 Sonnet', + maxTokens: 200000, + provider: provider, + }, + { + id: 'claude-3-sonnet-20240229', + name: 'Claude 3 Sonnet', + maxTokens: 200000, + provider: provider, + }, + { + id: 'claude-3-haiku-20240307', + name: 'Claude 3 Haiku', + maxTokens: 200000, + provider: provider, + default: true, + }, + { id: 'claude-2.1', name: 'Claude 2.1', maxTokens: 200000, provider: provider }, + { id: 'claude-2', name: 'Claude 2.0', maxTokens: 100000, provider: provider }, + { + id: 'claude-instant-1', + name: 'Claude Instant', + maxTokens: 100000, + provider: provider, + }, +]; + +export class AnthropicProvider implements AIProvider { + readonly id = provider.id; + readonly name = provider.name; constructor(private readonly container: Container) {} dispose() {} - async generateCommitMessage(diff: string, options?: { context?: string }): Promise { + getModels(): Promise[]> { + return Promise.resolve(models); + } + + async generateMessage( + model: AnthropicModel, + diff: string, + promptConfig: { + systemPrompt: string; + customPrompt: string; + contextName: string; + }, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { const apiKey = await getApiKey(this.container.storage); if (apiKey == null) return undefined; - const model = this.model; - const maxCodeCharacters = getMaxCharacters(model); + try { + let result: string; + let maxCodeCharacters: number; + + if (!isSupportedModel(model)) { + [result, maxCodeCharacters] = await this.makeLegacyRequest( + model as LegacyModel, + apiKey, + max => { + const code = diff.substring(0, max); + let prompt = `\n\nHuman: ${promptConfig.systemPrompt}\n\nHuman: Here is the code diff to use to generate the ${promptConfig.contextName}:\n\n${code}\n`; + if (options?.context) { + prompt += `\nHuman: Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:\n\n${options.context}\n`; + } + if (promptConfig.customPrompt) { + prompt += `\nHuman: ${promptConfig.customPrompt}\n`; + } + prompt += '\nAssistant:'; + return prompt; + }, + 4096, + options?.cancellation, + ); + } else { + [result, maxCodeCharacters] = await this.makeRequest( + model, + apiKey, + promptConfig.systemPrompt, + max => { + const code = diff.substring(0, max); + const message: Message = { + role: 'user', + content: [ + { + type: 'text', + text: `Here is the code diff to use to generate the ${promptConfig.contextName}:`, + }, + { + type: 'text', + text: code, + }, + ], + }; + if (options?.context) { + message.content.push( + { + type: 'text', + text: `Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:`, + }, + { + type: 'text', + text: options.context, + }, + ); + } + if (promptConfig.customPrompt) { + message.content.push({ + type: 'text', + text: promptConfig.customPrompt, + }); + } + return [message]; + }, + 4096, + options?.cancellation, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the Anthropic's limits.`, + ); + } - const code = diff.substring(0, maxCodeCharacters); - if (diff.length > maxCodeCharacters) { - void window.showWarningMessage( - `The diff of the staged changes had to be truncated to ${maxCodeCharacters} characters to fit within the Anthropic's limits.`, - ); + return result; + } catch (ex) { + throw new Error(`Unable to generate ${promptConfig.contextName}: ${ex.message}`); } + } - let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + async generateDraftMessage( + model: AnthropicModel, + diff: string, + options?: { cancellation?: CancellationToken; context?: string; codeSuggestion?: boolean }, + ): Promise { + let customPrompt = + options?.codeSuggestion === true + ? configuration.get('experimental.generateCodeSuggestionMessagePrompt') + : configuration.get('experimental.generateCloudPatchMessagePrompt'); if (!customPrompt.endsWith('.')) { customPrompt += '.'; } - let prompt = - "\n\nHuman: You are an AI programming assistant tasked with writing a meaningful commit message by summarizing code changes.\n- Follow the user's instructions carefully & to the letter!\n- Don't repeat yourself or make anything up!\n- Minimize any other prose."; - prompt += `\n${customPrompt}\n- Avoid phrases like "this commit", "this change", etc.`; - prompt += '\n\nAssistant: OK'; - if (options?.context) { - prompt += `\n\nHuman: Use "${options.context}" to help craft the commit message.\n\nAssistant: OK`; - } - prompt += `\n\nHuman: Write a meaningful commit message for the following code changes:\n\n${code}`; - prompt += '\n\nAssistant:'; - - const request: AnthropicCompletionRequest = { - model: model, - prompt: prompt, - stream: false, - max_tokens_to_sample: 5000, - stop_sequences: ['\n\nHuman:'], - }; - const rsp = await this.fetch(apiKey, request); - if (!rsp.ok) { - let json; - try { - json = (await rsp.json()) as { error: { type: string; message: string } } | undefined; - } catch {} - - debugger; - throw new Error( - `Unable to generate commit message: (${this.name}:${rsp.status}) ${ - json?.error.message || rsp.statusText - })`, - ); + return this.generateMessage( + model, + diff, + { + systemPrompt: + options?.codeSuggestion === true ? codeSuggestMessageSystemPrompt : cloudPatchMessageSystemPrompt, + customPrompt: customPrompt, + contextName: + options?.codeSuggestion === true + ? 'code suggestion title and description' + : 'cloud patch title and description', + }, + options, + ); + } + + async generateCommitMessage( + model: AnthropicModel, + diff: string, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; } - const data: AnthropicCompletionResponse = await rsp.json(); - const message = data.completion.trim(); - return message; + return this.generateMessage( + model, + diff, + { + systemPrompt: commitMessageSystemPrompt, + customPrompt: customPrompt, + contextName: 'commit message', + }, + options, + ); } - async explainChanges(message: string, diff: string): Promise { + async explainChanges( + model: AnthropicModel, + message: string, + diff: string, + options?: { cancellation?: CancellationToken }, + ): Promise { const apiKey = await getApiKey(this.container.storage); if (apiKey == null) return undefined; - const model = this.model; - const maxCodeCharacters = getMaxCharacters(model); + const systemPrompt = `You are an advanced AI programming assistant tasked with summarizing code changes into an explanation that is both easy to understand and meaningful. Construct an explanation that: +- Concisely synthesizes meaningful information from the provided code diff +- Incorporates any additional context provided by the user to understand the rationale behind the code changes +- Places the emphasis on the 'why' of the change, clarifying its benefits or addressing the problem that necessitated the change, beyond just detailing the 'what' has changed - const code = diff.substring(0, maxCodeCharacters); - if (diff.length > maxCodeCharacters) { - void window.showWarningMessage( - `The diff of the commit changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, - ); - } +Do not make any assumptions or invent details that are not supported by the code diff or the user-provided context.`; - let prompt = - "\n\nHuman: You are an AI programming assistant tasked with providing an easy to understand but detailed explanation of a commit by summarizing the code changes while also using the commit message as additional context and framing.\nDon't make anything up!"; - prompt += `\nUse the following user-provided commit message, which should provide some explanation to why these changes where made, when attempting to generate the rich explanation:\n\n${message}`; - prompt += '\n\nAssistant: OK'; - prompt += `\n\nHuman: Explain the following code changes:\n\n${code}`; - prompt += '\n\nAssistant:'; - - const request: AnthropicCompletionRequest = { - model: model, - prompt: prompt, - stream: false, - max_tokens_to_sample: 5000, - stop_sequences: ['\n\nHuman:'], - }; - - const rsp = await this.fetch(apiKey, request); - if (!rsp.ok) { - let json; - try { - json = (await rsp.json()) as { error: { type: string; message: string } } | undefined; - } catch {} - - debugger; - throw new Error( - `Unable to explain commit: (${this.name}:${rsp.status}) ${json?.error.message || rsp.statusText})`, - ); - } + try { + let result: string; + let maxCodeCharacters: number; - const data: AnthropicCompletionResponse = await rsp.json(); - const summary = data.completion.trim(); - return summary; - } + if (!isSupportedModel(model)) { + [result, maxCodeCharacters] = await this.makeLegacyRequest( + model as LegacyModel, + apiKey, + max => { + const code = diff.substring(0, max); + return `\n\nHuman: ${systemPrompt} - private fetch(apiKey: string, request: AnthropicCompletionRequest) { - return fetch('https://api.anthropic.com/v1/complete', { - headers: { - Accept: 'application/json', - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'X-API-Key': apiKey, - 'anthropic-version': '2023-06-01', - }, - method: 'POST', - body: JSON.stringify(request), - }); - } -} +Human: Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation: -async function getApiKey(storage: Storage): Promise { - let apiKey = await storage.getSecret('gitlens.anthropic.key'); - if (!apiKey) { - const input = window.createInputBox(); - input.ignoreFocusOut = true; +${message} - const disposables: Disposable[] = []; +Human: Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes: - try { - const infoButton: QuickInputButton = { - iconPath: new ThemeIcon(`link-external`), - tooltip: 'Open the Anthropic API Key Page', - }; +${code} - apiKey = await new Promise(resolve => { - disposables.push( - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(value => { - if (value && !/(?:sk-)?[a-zA-Z0-9-_]{32,}/.test(value)) { - input.validationMessage = 'Please enter a valid Anthropic API key'; - return; - } - input.validationMessage = undefined; - }), - input.onDidAccept(() => { - const value = input.value.trim(); - if (!value || !/(?:sk-)?[a-zA-Z0-9-_]{32,}/.test(value)) { - input.validationMessage = 'Please enter a valid Anthropic API key'; - return; - } +Human: Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase. And please don't explain how you arrived at the explanation, just provide the explanation. +Assistant:`; + }, + 4096, + options?.cancellation, + ); + } else { + [result, maxCodeCharacters] = await this.makeRequest( + model, + apiKey, + systemPrompt, + max => { + const code = diff.substring(0, max); + return [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation:', + }, + { + type: 'text', + text: message, + }, + { + type: 'text', + text: 'Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes:', + }, + { + type: 'text', + text: code, + }, + { + type: 'text', + text: `Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase. And please don't explain how you arrived at the explanation, just provide the explanation`, + }, + ], + }, + ]; + }, + 4096, + options?.cancellation, + ); + } - resolve(value); - }), - input.onDidTriggerButton(e => { - if (e === infoButton) { - void env.openExternal(Uri.parse('https://console.anthropic.com/account/keys')); - } - }), + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the Anthropic's limits.`, ); + } - input.password = true; - input.title = 'Connect to Anthropic'; - input.placeholder = 'Please enter your Anthropic API key to use this feature'; - input.prompt = supportedInVSCodeVersion('input-prompt-links') - ? 'Enter your [Anthropic API Key](https://console.anthropic.com/account/keys "Get your Anthropic API key")' - : 'Enter your Anthropic API Key'; - input.buttons = [infoButton]; + return result; + } catch (ex) { + throw new Error(`Unable to explain changes: ${ex.message}`); + } + } + + private fetch( + model: SupportedModel, + apiKey: string, + request: AnthropicMessageRequest, + cancellation: CancellationToken | undefined, + ): ReturnType; + private fetch( + model: LegacyModel, + apiKey: string, + request: AnthropicCompletionRequest, + cancellation: CancellationToken | undefined, + ): ReturnType; + private async fetch( + model: AnthropicModel, + apiKey: string, + request: AnthropicMessageRequest | AnthropicCompletionRequest, + cancellation: CancellationToken | undefined, + ): ReturnType { + let aborter: AbortController | undefined; + if (cancellation != null) { + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter?.abort()); + } - input.show(); + try { + return await fetch(getUrl(model), { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + 'anthropic-version': '2023-06-01', + }, + method: 'POST', + body: JSON.stringify(request), }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); + } catch (ex) { + if (ex.name === 'AbortError') throw new CancellationError(ex); + throw ex; } + } - if (!apiKey) return undefined; + private async makeRequest( + model: SupportedModel, + apiKey: string, + system: string, + messages: (maxCodeCharacters: number) => Message[], + maxTokens: number, + cancellation: CancellationToken | undefined, + ): Promise<[result: string, maxCodeCharacters: number]> { + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 2600); + + while (true) { + const request: AnthropicMessageRequest = { + model: model.id, + messages: messages(maxCodeCharacters), + system: system, + stream: false, + max_tokens: maxTokens, + }; + const rsp = await this.fetch(model, apiKey, request, cancellation); + if (!rsp.ok) { + let json; + try { + json = (await rsp.json()) as AnthropicError | undefined; + } catch {} + + debugger; + + if ( + retries++ < 2 && + json?.error?.type === 'invalid_request_error' && + json?.error?.message?.includes('prompt is too long') + ) { + maxCodeCharacters -= 500 * retries; + continue; + } + + throw new Error(`(${this.name}:${rsp.status}) ${json?.error?.message || rsp.statusText})`); + } + + const data: AnthropicMessageResponse = await rsp.json(); + const result = data.content + .map(c => c.text) + .join('\n') + .trim(); + return [result, maxCodeCharacters]; + } + } - void storage.storeSecret('gitlens.anthropic.key', apiKey); + private async makeLegacyRequest( + model: LegacyModel, + apiKey: string, + prompt: (maxCodeCharacters: number) => string, + maxTokens: number, + cancellation: CancellationToken | undefined, + ): Promise<[result: string, maxCodeCharacters: number]> { + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 2600); + + while (true) { + const request: AnthropicCompletionRequest = { + model: model.id, + prompt: prompt(maxCodeCharacters), + stream: false, + max_tokens_to_sample: maxTokens, + }; + const rsp = await this.fetch(model, apiKey, request, cancellation); + if (!rsp.ok) { + let json; + try { + json = (await rsp.json()) as AnthropicError | undefined; + } catch {} + + debugger; + + if ( + retries++ < 2 && + json?.error?.type === 'invalid_request_error' && + json?.error?.message?.includes('prompt is too long') + ) { + maxCodeCharacters -= 500 * retries; + continue; + } + + throw new Error(`(${this.name}:${rsp.status}) ${json?.error?.message || rsp.statusText})`); + } + + const data: AnthropicCompletionResponse = await rsp.json(); + const result = data.completion.trim(); + return [result, maxCodeCharacters]; + } } +} - return apiKey; +async function getApiKey(storage: Storage): Promise { + return getApiKeyCore(storage, { + id: provider.id, + name: provider.name, + validator: v => /(?:sk-)?[a-zA-Z0-9-_]{32,}/.test(v), + url: 'https://console.anthropic.com/account/keys', + }); } -function getMaxCharacters(model: AnthropicModels): number { - if (model === 'claude-2' || model === 'claude-v1-100k' || model === 'claude-instant-v1-100k') { - return 135000; - } - return 12000; +function getUrl(model: AnthropicModel): string { + return isLegacyModel(model) ? 'https://api.anthropic.com/v1/complete' : 'https://api.anthropic.com/v1/messages'; +} + +interface AnthropicError { + type: 'error'; + error: { + type: + | 'invalid_request_error' + | 'authentication_error' + | 'permission_error' + | 'not_found_error' + | 'rate_limit_error' + | 'api_error' + | 'overloaded_error'; + message: string; + }; } -export type AnthropicModels = - | 'claude-v1' - | 'claude-v1-100k' - | 'claude-instant-v1' - | 'claude-instant-v1-100k' - | 'claude-2'; interface AnthropicCompletionRequest { - model: string; + model: Extract; prompt: string; stream: boolean; max_tokens_to_sample: number; - stop_sequences: string[]; + stop_sequences?: string[]; temperature?: number; top_k?: number; @@ -235,3 +506,46 @@ interface AnthropicCompletionResponse { exception: string | null; log_id: string; } + +interface Message { + role: 'user' | 'assistant'; + content: ( + | { type: 'text'; text: string } + | { + type: 'image'; + source: { + type: 'base64'; + media_type: `image/${'jpeg' | 'png' | 'gif' | 'webp'}`; + data: string; + }; + } + )[]; +} + +interface AnthropicMessageRequest { + model: SupportedModels; + messages: Message[]; + system?: string; + + max_tokens: number; + metadata?: object; + stop_sequences?: string[]; + stream?: boolean; + temperature?: number; + top_p?: number; + top_k?: number; +} + +interface AnthropicMessageResponse { + id: string; + type: 'message'; + role: 'assistant'; + content: { type: 'text'; text: string }[]; + model: string; + stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence'; + stop_sequence: string | null; + usage: { + input_tokens: number; + output_tokens: number; + }; +} diff --git a/src/ai/geminiProvider.ts b/src/ai/geminiProvider.ts new file mode 100644 index 0000000000000..3681f8b6d311f --- /dev/null +++ b/src/ai/geminiProvider.ts @@ -0,0 +1,349 @@ +import type { CancellationToken } from 'vscode'; +import { window } from 'vscode'; +import { fetch } from '@env/fetch'; +import type { Container } from '../container'; +import { CancellationError } from '../errors'; +import { configuration } from '../system/configuration'; +import type { Storage } from '../system/storage'; +import type { AIModel, AIProvider } from './aiProviderService'; +import { getApiKey as getApiKeyCore, getMaxCharacters } from './aiProviderService'; +import { cloudPatchMessageSystemPrompt, codeSuggestMessageSystemPrompt, commitMessageSystemPrompt } from './prompts'; + +const provider = { id: 'gemini', name: 'Google' } as const; + +export type GeminiModels = 'gemini-1.0-pro' | 'gemini-1.5-pro-latest' | 'gemini-1.5-flash-latest'; +type GeminiModel = AIModel; +const models: GeminiModel[] = [ + { + id: 'gemini-1.5-pro-latest', + name: 'Gemini 1.5 Pro', + maxTokens: 1048576, + provider: provider, + default: true, + }, + { + id: 'gemini-1.5-flash-latest', + name: 'Gemini 1.5 Flash', + maxTokens: 1048576, + provider: provider, + }, + { + id: 'gemini-1.0-pro', + name: 'Gemini 1.0 Pro', + maxTokens: 30720, + provider: provider, + }, +]; + +export class GeminiProvider implements AIProvider { + readonly id = provider.id; + readonly name = provider.name; + + constructor(private readonly container: Container) {} + + dispose() {} + + getModels(): Promise[]> { + return Promise.resolve(models); + } + + async generateMessage( + model: GeminiModel, + diff: string, + promptConfig: { + systemPrompt: string; + customPrompt: string; + contextName: string; + }, + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + }, + ): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + // const retries = 0; + const maxCodeCharacters = getMaxCharacters(model, 2600); + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const request: GenerateContentRequest = { + systemInstruction: { + parts: [ + { + text: promptConfig.systemPrompt, + }, + ], + }, + contents: [ + { + role: 'user', + parts: [ + { + text: `Here is the code diff to use to generate the ${promptConfig.contextName}:\n\n${code}`, + }, + ...(options?.context + ? [ + { + text: `Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:\n\n${options.context}`, + }, + ] + : []), + { + text: promptConfig.customPrompt, + }, + ], + }, + ], + }; + + const rsp = await this.fetch(model.id, apiKey, request, options?.cancellation); + if (!rsp.ok) { + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + debugger; + + // if (retries++ < 2 && json?.error?.code === 'context_length_exceeded') { + // maxCodeCharacters -= 500 * retries; + // continue; + // } + + throw new Error( + `Unable to generate ${promptConfig.contextName}: (${this.name}:${rsp.status}) ${ + json?.error?.message || rsp.statusText + }`, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the Gemini's limits.`, + ); + } + + const data: GenerateContentResponse = await rsp.json(); + const message = data.candidates[0].content.parts[0].text.trim(); + return message; + } + } + + async generateDraftMessage( + model: GeminiModel, + diff: string, + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + codeSuggestion?: boolean | undefined; + }, + ): Promise { + let customPrompt = + options?.codeSuggestion === true + ? configuration.get('experimental.generateCodeSuggestionMessagePrompt') + : configuration.get('experimental.generateCloudPatchMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + { + systemPrompt: + options?.codeSuggestion === true ? codeSuggestMessageSystemPrompt : cloudPatchMessageSystemPrompt, + customPrompt: customPrompt, + contextName: + options?.codeSuggestion === true + ? 'code suggestion title and description' + : 'cloud patch title and description', + }, + options, + ); + } + + async generateCommitMessage( + model: GeminiModel, + diff: string, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + { + systemPrompt: commitMessageSystemPrompt, + customPrompt: customPrompt, + contextName: 'commit message', + }, + options, + ); + } + + async explainChanges( + model: GeminiModel, + message: string, + diff: string, + options?: { cancellation?: CancellationToken }, + ): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + // const retries = 0; + const maxCodeCharacters = getMaxCharacters(model, 3000); + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const request: GenerateContentRequest = { + systemInstruction: { + parts: [ + { + text: `You are an advanced AI programming assistant tasked with summarizing code changes into an explanation that is both easy to understand and meaningful. Construct an explanation that: +- Concisely synthesizes meaningful information from the provided code diff +- Incorporates any additional context provided by the user to understand the rationale behind the code changes +- Places the emphasis on the 'why' of the change, clarifying its benefits or addressing the problem that necessitated the change, beyond just detailing the 'what' has changed + +Do not make any assumptions or invent details that are not supported by the code diff or the user-provided context.`, + }, + ], + }, + contents: [ + { + role: 'user', + parts: [ + { + text: `Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation:\n\n${message}`, + }, + { + text: `Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes:\n\n${code}`, + }, + { + text: `Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase.`, + }, + ], + }, + ], + }; + + const rsp = await this.fetch(model.id, apiKey, request, options?.cancellation); + if (!rsp.ok) { + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + debugger; + + // if (retries++ < 2 && json?.error?.code === 'context_length_exceeded') { + // maxCodeCharacters -= 500 * retries; + // continue; + // } + + throw new Error( + `Unable to explain changes: (${this.name}:${rsp.status}) ${json?.error?.message || rsp.statusText}`, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the Gemini's limits.`, + ); + } + + const data: GenerateContentResponse = await rsp.json(); + const summary = data.candidates[0].content.parts[0].text.trim(); + return summary; + } + } + + private async fetch( + model: GeminiModels, + apiKey: string, + request: GenerateContentRequest, + cancellation: CancellationToken | undefined, + ) { + let aborter: AbortController | undefined; + if (cancellation != null) { + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter?.abort()); + } + + try { + return await fetch(getUrl(model), { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-goog-api-key': apiKey, + }, + method: 'POST', + body: JSON.stringify(request), + signal: aborter?.signal, + }); + } catch (ex) { + if (ex.name === 'AbortError') throw new CancellationError(ex); + + throw ex; + } + } +} + +async function getApiKey(storage: Storage): Promise { + return getApiKeyCore(storage, { + id: provider.id, + name: provider.name, + validator: () => true, + url: 'https://aistudio.google.com/app/apikey', + }); +} + +function getUrl(model: GeminiModels): string { + return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; +} + +interface Content { + parts: Part[]; + role?: 'model' | 'user'; +} + +type Part = TextPart; +interface TextPart { + text: string; +} + +interface GenerationConfig { + stopSequences?: string[]; + candidateCount?: number; + maxOutputTokens?: number; + temperature?: number; + topP?: number; + topK?: number; +} + +interface GenerateContentRequest { + contents: Content[]; + systemInstruction?: Content; + generationConfig?: GenerationConfig; +} + +interface Candidate { + content: Content; + finishReason?: 'FINISH_REASON_UNSPECIFIED' | 'STOP' | 'MAX_TOKENS' | 'SAFETY' | 'RECITATION' | 'OTHER'; + safetyRatings: any[]; + citationMetadata: any; + tokenCount: number; + index: number; +} + +interface GenerateContentResponse { + candidates: Candidate[]; + promptFeedback: { + blockReason: 'BLOCK_REASON_UNSPECIFIED' | 'SAFETY' | 'OTHER'; + safetyRatings: any[]; + }; +} diff --git a/src/ai/openaiProvider.ts b/src/ai/openaiProvider.ts index 68fda386d1086..b152434a3b2c5 100644 --- a/src/ai/openaiProvider.ts +++ b/src/ai/openaiProvider.ts @@ -1,246 +1,414 @@ -import type { Disposable, QuickInputButton } from 'vscode'; -import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { CancellationToken } from 'vscode'; +import { window } from 'vscode'; import { fetch } from '@env/fetch'; import type { Container } from '../container'; +import { CancellationError } from '../errors'; import { configuration } from '../system/configuration'; import type { Storage } from '../system/storage'; -import { supportedInVSCodeVersion } from '../system/utils'; -import type { AIProvider } from './aiProviderService'; +import type { AIModel, AIProvider } from './aiProviderService'; +import { getApiKey as getApiKeyCore, getMaxCharacters } from './aiProviderService'; +import { cloudPatchMessageSystemPrompt, codeSuggestMessageSystemPrompt, commitMessageSystemPrompt } from './prompts'; -export class OpenAIProvider implements AIProvider { - readonly id = 'openai'; - readonly name = 'OpenAI'; +const provider = { id: 'openai', name: 'OpenAI' } as const; - private get model(): OpenAIModels { - return configuration.get('ai.experimental.openai.model') || 'gpt-3.5-turbo'; - } +export type OpenAIModels = + | 'gpt-4o' + | 'gpt-4-turbo' + | 'gpt-4-turbo-2024-04-09' + | 'gpt-4-turbo-preview' + | 'gpt-4-0125-preview' + | 'gpt-4-1106-preview' + | 'gpt-4' + | 'gpt-4-0613' + | 'gpt-4-32k' + | 'gpt-4-32k-0613' + | 'gpt-3.5-turbo' + | 'gpt-3.5-turbo-0125' + | 'gpt-3.5-turbo-1106' + | 'gpt-3.5-turbo-16k'; + +type OpenAIModel = AIModel; +const models: OpenAIModel[] = [ + { + id: 'gpt-4o', + name: 'GPT-4 Omni', + maxTokens: 128000, + provider: provider, + default: true, + }, + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo with Vision', + maxTokens: 128000, + provider: provider, + }, + { + id: 'gpt-4-turbo-2024-04-09', + name: 'GPT-4 Turbo Preview (2024-04-09)', + maxTokens: 128000, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-turbo-preview', + name: 'GPT-4 Turbo Preview', + maxTokens: 128000, + provider: provider, + }, + { + id: 'gpt-4-0125-preview', + name: 'GPT-4 0125 Preview', + maxTokens: 128000, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-1106-preview', + name: 'GPT-4 1106 Preview', + maxTokens: 128000, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4', + name: 'GPT-4', + maxTokens: 8192, + provider: provider, + }, + { + id: 'gpt-4-0613', + name: 'GPT-4 0613', + maxTokens: 8192, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-32k', + name: 'GPT-4 32k', + maxTokens: 32768, + provider: provider, + }, + { + id: 'gpt-4-32k-0613', + name: 'GPT-4 32k 0613', + maxTokens: 32768, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo', + name: 'GPT-3.5 Turbo', + maxTokens: 16385, + provider: provider, + }, + { + id: 'gpt-3.5-turbo-0125', + name: 'GPT-3.5 Turbo 0125', + maxTokens: 16385, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo-1106', + name: 'GPT-3.5 Turbo 1106', + maxTokens: 16385, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo-16k', + name: 'GPT-3.5 Turbo 16k', + maxTokens: 16385, + provider: provider, + hidden: true, + }, +]; + +export class OpenAIProvider implements AIProvider { + readonly id = provider.id; + readonly name = provider.name; constructor(private readonly container: Container) {} dispose() {} + getModels(): Promise[]> { + return Promise.resolve(models); + } + private get url(): string { - return configuration.get('ai.experimental.openai.url') ?? 'https://api.openai.com/v1/chat/completions'; + return configuration.get('ai.experimental.openai.url') || 'https://api.openai.com/v1/chat/completions'; } - async generateCommitMessage(diff: string, options?: { context?: string }): Promise { + async generateMessage( + model: OpenAIModel, + diff: string, + promptConfig: { + systemPrompt: string; + customPrompt: string; + contextName: string; + }, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { const apiKey = await getApiKey(this.container.storage); if (apiKey == null) return undefined; - const model = this.model; - const maxCodeCharacters = getMaxCharacters(model); + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 2600); + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const request: OpenAIChatCompletionRequest = { + model: model.id, + messages: [ + { + role: 'system', + content: promptConfig.systemPrompt, + }, + { + role: 'user', + content: `Here is the code diff to use to generate the ${promptConfig.contextName}:\n\n${code}`, + }, + ...(options?.context + ? [ + { + role: 'user' as const, + content: `Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:\n\n${options.context}`, + }, + ] + : []), + { + role: 'user', + content: promptConfig.customPrompt, + }, + ], + }; + + const rsp = await this.fetch(apiKey, request, options?.cancellation); + if (!rsp.ok) { + if (rsp.status === 404) { + throw new Error( + `Unable to generate ${promptConfig.contextName}: Your API key doesn't seem to have access to the selected '${model.id}' model`, + ); + } + if (rsp.status === 429) { + throw new Error( + `Unable to generate ${promptConfig.contextName}: (${this.name}:${rsp.status}) Too many requests (rate limit exceeded) or your API key is associated with an expired trial`, + ); + } + + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + debugger; + + if (retries++ < 2 && json?.error?.code === 'context_length_exceeded') { + maxCodeCharacters -= 500 * retries; + continue; + } + + throw new Error( + `Unable to generate ${promptConfig.contextName}: (${this.name}:${rsp.status}) ${ + json?.error?.message || rsp.statusText + }`, + ); + } - const code = diff.substring(0, maxCodeCharacters); - if (diff.length > maxCodeCharacters) { - void window.showWarningMessage( - `The diff of the staged changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, - ); + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, + ); + } + + const data: OpenAIChatCompletionResponse = await rsp.json(); + const message = data.choices[0].message.content.trim(); + return message; } + } - let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + async generateDraftMessage( + model: OpenAIModel, + diff: string, + options?: { + cancellation?: CancellationToken; + context?: string; + codeSuggestion?: boolean | undefined; + }, + ): Promise { + let customPrompt = + options?.codeSuggestion === true + ? configuration.get('experimental.generateCodeSuggestionMessagePrompt') + : configuration.get('experimental.generateCloudPatchMessagePrompt'); if (!customPrompt.endsWith('.')) { customPrompt += '.'; } - const request: OpenAIChatCompletionRequest = { - model: model, - messages: [ - { - role: 'system', - content: - "You are an AI programming assistant tasked with writing a meaningful commit message by summarizing code changes.\n\n- Follow the user's instructions carefully & to the letter!\n- Don't repeat yourself or make anything up!\n- Minimize any other prose.", - }, - { - role: 'user', - content: `${customPrompt}\n- Avoid phrases like "this commit", "this change", etc.`, - }, - ], - }; + return this.generateMessage( + model, + diff, + { + systemPrompt: + options?.codeSuggestion === true ? codeSuggestMessageSystemPrompt : cloudPatchMessageSystemPrompt, + customPrompt: customPrompt, + contextName: + options?.codeSuggestion === true + ? 'code suggestion title and description' + : 'cloud patch title and description', + }, + options, + ); + } - if (options?.context) { - request.messages.push({ - role: 'user', - content: `Use "${options.context}" to help craft the commit message.`, - }); - } - request.messages.push({ - role: 'user', - content: `Write a meaningful commit message for the following code changes:\n\n${code}`, - }); - - const rsp = await this.fetch(apiKey, request); - if (!rsp.ok) { - debugger; - if (rsp.status === 429) { - throw new Error( - `Unable to generate commit message: (${this.name}:${rsp.status}) Too many requests (rate limit exceeded) or your API key is associated with an expired trial`, - ); - } - throw new Error(`Unable to generate commit message: (${this.name}:${rsp.status}) ${rsp.statusText}`); + async generateCommitMessage( + model: OpenAIModel, + diff: string, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; } - const data: OpenAIChatCompletionResponse = await rsp.json(); - const message = data.choices[0].message.content.trim(); - return message; + return this.generateMessage( + model, + diff, + { + systemPrompt: commitMessageSystemPrompt, + customPrompt: customPrompt, + contextName: 'commit message', + }, + options, + ); } - async explainChanges(message: string, diff: string): Promise { + async explainChanges( + model: OpenAIModel, + message: string, + diff: string, + options?: { cancellation?: CancellationToken }, + ): Promise { const apiKey = await getApiKey(this.container.storage); if (apiKey == null) return undefined; - const model = this.model; - const maxCodeCharacters = getMaxCharacters(model); - - const code = diff.substring(0, maxCodeCharacters); - if (diff.length > maxCodeCharacters) { - void window.showWarningMessage( - `The diff of the commit changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, - ); - } + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 3000); + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const request: OpenAIChatCompletionRequest = { + model: model.id, + messages: [ + { + role: 'system', + content: `You are an advanced AI programming assistant tasked with summarizing code changes into an explanation that is both easy to understand and meaningful. Construct an explanation that: +- Concisely synthesizes meaningful information from the provided code diff +- Incorporates any additional context provided by the user to understand the rationale behind the code changes +- Places the emphasis on the 'why' of the change, clarifying its benefits or addressing the problem that necessitated the change, beyond just detailing the 'what' has changed + +Do not make any assumptions or invent details that are not supported by the code diff or the user-provided context.`, + }, + { + role: 'user', + content: `Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation:\n\n${message}`, + }, + { + role: 'user', + content: `Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes:\n\n${code}`, + }, + { + role: 'user', + content: + 'Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase.', + }, + ], + }; - const request: OpenAIChatCompletionRequest = { - model: model, - messages: [ - { - role: 'system', - content: - "You are an AI programming assistant tasked with providing an easy to understand but detailed explanation of a commit by summarizing the code changes while also using the commit message as additional context and framing.\n\n- Don't make anything up!", - }, - { - role: 'user', - content: `Use the following user-provided commit message, which should provide some explanation to why these changes where made, when attempting to generate the rich explanation:\n\n${message}`, - }, - { - role: 'assistant', - content: 'OK', - }, - { - role: 'user', - content: `Explain the following code changes:\n\n${code}`, - }, - ], - }; + const rsp = await this.fetch(apiKey, request, options?.cancellation); + if (!rsp.ok) { + if (rsp.status === 404) { + throw new Error( + `Unable to explain changes: Your API key doesn't seem to have access to the selected '${model.id}' model`, + ); + } + if (rsp.status === 429) { + throw new Error( + `Unable to explain changes: (${this.name}:${rsp.status}) Too many requests (rate limit exceeded) or your API key is associated with an expired trial`, + ); + } + + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + debugger; + + if (retries++ < 2 && json?.error?.code === 'context_length_exceeded') { + maxCodeCharacters -= 500 * retries; + continue; + } - const rsp = await this.fetch(apiKey, request); - if (!rsp.ok) { - debugger; - if (rsp.status === 404) { throw new Error( - `Unable to explain commit: Your API key doesn't seem to have access to the selected '${model}' model`, + `Unable to explain changes: (${this.name}:${rsp.status}) ${json?.error?.message || rsp.statusText}`, ); } - if (rsp.status === 429) { - throw new Error( - `Unable to explain commit: (${this.name}:${rsp.status}) Too many requests (rate limit exceeded) or your API key is associated with an expired trial`, + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, ); } - throw new Error(`Unable to explain commit: (${this.name}:${rsp.status}) ${rsp.statusText}`); - } - const data: OpenAIChatCompletionResponse = await rsp.json(); - const summary = data.choices[0].message.content.trim(); - return summary; + const data: OpenAIChatCompletionResponse = await rsp.json(); + const summary = data.choices[0].message.content.trim(); + return summary; + } } - private fetch(apiKey: string, request: OpenAIChatCompletionRequest) { + private async fetch( + apiKey: string, + request: OpenAIChatCompletionRequest, + cancellation: CancellationToken | undefined, + ) { const url = this.url; const isAzure = url.includes('.azure.com'); - return fetch(url, { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(isAzure ? { 'api-key': apiKey } : { Authorization: `Bearer ${apiKey}` }), - }, - method: 'POST', - body: JSON.stringify(request), - }); - } -} -async function getApiKey(storage: Storage): Promise { - let openaiApiKey = await storage.getSecret('gitlens.openai.key'); - if (!openaiApiKey) { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; + let aborter: AbortController | undefined; + if (cancellation != null) { + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter?.abort()); + } try { - const infoButton: QuickInputButton = { - iconPath: new ThemeIcon(`link-external`), - tooltip: 'Open the OpenAI API Key Page', - }; - - openaiApiKey = await new Promise(resolve => { - disposables.push( - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(value => { - if (value && !/(?:sk-)?[a-zA-Z0-9]{32,}/.test(value)) { - input.validationMessage = 'Please enter a valid OpenAI API key'; - return; - } - input.validationMessage = undefined; - }), - input.onDidAccept(() => { - const value = input.value.trim(); - if (!value || !/(?:sk-)?[a-zA-Z0-9]{32,}/.test(value)) { - input.validationMessage = 'Please enter a valid OpenAI API key'; - return; - } - - resolve(value); - }), - input.onDidTriggerButton(e => { - if (e === infoButton) { - void env.openExternal(Uri.parse('https://platform.openai.com/account/api-keys')); - } - }), - ); - - input.password = true; - input.title = 'Connect to OpenAI'; - input.placeholder = 'Please enter your OpenAI API key to use this feature'; - input.prompt = supportedInVSCodeVersion('input-prompt-links') - ? 'Enter your [OpenAI API Key](https://platform.openai.com/account/api-keys "Get your OpenAI API key")' - : 'Enter your OpenAI API Key'; - input.buttons = [infoButton]; - - input.show(); + return fetch(url, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(isAzure ? { 'api-key': apiKey } : { Authorization: `Bearer ${apiKey}` }), + }, + method: 'POST', + body: JSON.stringify(request), + signal: aborter?.signal, }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - - if (!openaiApiKey) return undefined; + } catch (ex) { + if (ex.name === 'AbortError') throw new CancellationError(ex); - void storage.storeSecret('gitlens.openai.key', openaiApiKey); + throw ex; + } } - - return openaiApiKey; } -function getMaxCharacters(model: OpenAIModels): number { - switch (model) { - case 'gpt-4-32k': - case 'gpt-4-32k-0613': - return 43000; - case 'gpt-3.5-turbo-16k': - return 21000; - default: - return 12000; - } +async function getApiKey(storage: Storage): Promise { + return getApiKeyCore(storage, { + id: provider.id, + name: provider.name, + validator: v => /(?:sk-)?[a-zA-Z0-9]{32,}/.test(v), + url: 'https://platform.openai.com/account/api-keys', + }); } -export type OpenAIModels = - | 'gpt-3.5-turbo' - | 'gpt-3.5-turbo-16k' - | 'gpt-3.5-turbo-0613' - | 'gpt-4' - | 'gpt-4-0613' - | 'gpt-4-32k' - | 'gpt-4-32k-0613'; - interface OpenAIChatCompletionRequest { model: OpenAIModels; messages: { role: 'system' | 'user' | 'assistant'; content: string }[]; diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts new file mode 100644 index 0000000000000..d04d582f39f38 --- /dev/null +++ b/src/ai/prompts.ts @@ -0,0 +1,29 @@ +export const commitMessageSystemPrompt = `You are an advanced AI programming assistant tasked with summarizing code changes into a concise and meaningful commit message. Compose a commit message that: +- Strictly synthesizes meaningful information from the provided code diff +- Utilizes any additional user-provided context to comprehend the rationale behind the code changes +- Is clear and brief, with an informal yet professional tone, and without superfluous descriptions +- Avoids unnecessary phrases such as "this commit", "this change", and the like +- Avoids direct mention of specific code identifiers, names, or file names, unless they are crucial for understanding the purpose of the changes +- Most importantly emphasizes the 'why' of the change, its benefits, or the problem it addresses rather than only the 'what' that changed + +Follow the user's instructions carefully, don't repeat yourself, don't include the code in the output, or make anything up!`; + +export const cloudPatchMessageSystemPrompt = `You are an advanced AI programming assistant tasked with summarizing code changes into a concise and meaningful title with an optional description. Compose a title with an optional description that: +- Strictly synthesizes meaningful information from the provided code diff +- Utilizes any additional user-provided context to comprehend the rationale behind the code changes +- Is clear and brief, with an informal yet professional tone, and without superfluous descriptions +- Avoids unnecessary phrases such as "this commit", "this change", and the like +- Avoids direct mention of specific code identifiers, names, or file names, unless they are crucial for understanding the purpose of the changes +- Most importantly emphasizes the 'why' of the change, its benefits, or the problem it addresses rather than only the 'what' that changed + +Follow the user's instructions carefully, don't repeat yourself, don't include the code in the output, or make anything up!`; + +export const codeSuggestMessageSystemPrompt = `You are an advanced AI programming assistant tasked with summarizing code changes into a concise and meaningful code review title with an optional description. Compose a code review title with an optional description that: +- Strictly synthesizes meaningful information from the provided code diff +- Utilizes any additional user-provided context to comprehend the rationale behind the code changes +- Is clear and brief, with an informal yet professional tone, and without superfluous descriptions +- Avoids unnecessary phrases such as "this commit", "this change", and the like +- Avoids direct mention of specific code identifiers, names, or file names, unless they are crucial for understanding the purpose of the changes +- Most importantly emphasizes the 'why' of the change, its benefits, or the problem it addresses rather than only the 'what' that changed + +Follow the user's instructions carefully, don't repeat yourself, don't include the code in the output, or make anything up!`; diff --git a/src/ai/vscodeProvider.ts b/src/ai/vscodeProvider.ts new file mode 100644 index 0000000000000..319e4284fcd49 --- /dev/null +++ b/src/ai/vscodeProvider.ts @@ -0,0 +1,290 @@ +import type { CancellationToken, LanguageModelChat, LanguageModelChatSelector } from 'vscode'; +import { CancellationTokenSource, LanguageModelChatMessage, lm, window } from 'vscode'; +import type { Container } from '../container'; +import { configuration } from '../system/configuration'; +import { capitalize } from '../system/string'; +import type { AIModel, AIProvider } from './aiProviderService'; +import { getMaxCharacters } from './aiProviderService'; +import { cloudPatchMessageSystemPrompt, codeSuggestMessageSystemPrompt, commitMessageSystemPrompt } from './prompts'; + +const provider = { id: 'vscode', name: 'VS Code Provided' } as const; + +export type VSCodeAIModels = `${string}:${string}`; +type VSCodeAIModel = AIModel & { vendor: string; selector: LanguageModelChatSelector }; +export function isVSCodeAIModel(model: AIModel): model is AIModel { + return model.provider.id === provider.id; +} + +export class VSCodeAIProvider implements AIProvider { + readonly id = provider.id; + + private _name: string | undefined; + get name() { + return this._name ?? provider.name; + } + + constructor(private readonly container: Container) {} + + dispose() {} + + async getModels(): Promise[]> { + const models = await lm.selectChatModels(); + return models.map(getModelFromChatModel); + } + + private async getChatModel(model: VSCodeAIModel): Promise { + const models = await lm.selectChatModels(model.selector); + return models?.[0]; + } + + async generateMessage( + model: VSCodeAIModel, + diff: string, + promptConfig: { + systemPrompt: string; + customPrompt: string; + contextName: string; + }, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + const chatModel = await this.getChatModel(model); + if (chatModel == null) return undefined; + + let cancellation; + let cancellationSource; + if (options?.cancellation == null) { + cancellationSource = new CancellationTokenSource(); + cancellation = cancellationSource.token; + } else { + cancellation = options.cancellation; + } + + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 2600) - 1000; // TODO: Use chatModel.countTokens + + try { + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const messages: LanguageModelChatMessage[] = [ + LanguageModelChatMessage.User(promptConfig.systemPrompt), + LanguageModelChatMessage.User( + `Here is the code diff to use to generate the ${promptConfig.contextName}:\n\n${code}`, + ), + ...(options?.context + ? [ + LanguageModelChatMessage.User( + `Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:\n\n${options.context}`, + ), + ] + : []), + LanguageModelChatMessage.User(promptConfig.customPrompt), + ]; + + try { + const rsp = await chatModel.sendRequest(messages, {}, cancellation); + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within ${getPossessiveForm( + model.provider.name, + )} limits.`, + ); + } + + let message = ''; + for await (const fragment of rsp.text) { + message += fragment; + } + + return message.trim(); + } catch (ex) { + debugger; + + let message = ex instanceof Error ? ex.message : String(ex); + + if (ex instanceof Error && 'cause' in ex && ex.cause instanceof Error) { + message += `\n${ex.cause.message}`; + + if (retries++ < 2 && ex.cause.message.includes('exceeds token limit')) { + maxCodeCharacters -= 500 * retries; + continue; + } + } + + throw new Error( + `Unable to generate commit message: (${getPossessiveForm(model.provider.name)}:${ + ex.code + }) ${message}`, + ); + } + } + } finally { + cancellationSource?.dispose(); + } + } + + async generateDraftMessage( + model: VSCodeAIModel, + diff: string, + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + codeSuggestion?: boolean | undefined; + }, + ): Promise { + let customPrompt = + options?.codeSuggestion === true + ? configuration.get('experimental.generateCodeSuggestionMessagePrompt') + : configuration.get('experimental.generateCloudPatchMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + { + systemPrompt: + options?.codeSuggestion === true ? codeSuggestMessageSystemPrompt : cloudPatchMessageSystemPrompt, + customPrompt: customPrompt, + contextName: + options?.codeSuggestion === true + ? 'code suggestion title and description' + : 'cloud patch title and description', + }, + options != null + ? { + cancellation: options.cancellation, + context: options.context, + } + : undefined, + ); + } + + async generateCommitMessage( + model: VSCodeAIModel, + diff: string, + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + }, + ): Promise { + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + { + systemPrompt: commitMessageSystemPrompt, + customPrompt: customPrompt, + contextName: 'commit message', + }, + options, + ); + } + + async explainChanges( + model: VSCodeAIModel, + message: string, + diff: string, + options?: { cancellation?: CancellationToken }, + ): Promise { + const chatModel = await this.getChatModel(model); + if (chatModel == null) return undefined; + + let cancellation; + let cancellationSource; + if (options?.cancellation == null) { + cancellationSource = new CancellationTokenSource(); + cancellation = cancellationSource.token; + } else { + cancellation = options.cancellation; + } + + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 3000) - 1000; + + try { + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const messages: LanguageModelChatMessage[] = [ + LanguageModelChatMessage.User(`You are an advanced AI programming assistant tasked with summarizing code changes into an explanation that is both easy to understand and meaningful. Construct an explanation that: +- Concisely synthesizes meaningful information from the provided code diff +- Incorporates any additional context provided by the user to understand the rationale behind the code changes +- Places the emphasis on the 'why' of the change, clarifying its benefits or addressing the problem that necessitated the change, beyond just detailing the 'what' has changed + +Do not make any assumptions or invent details that are not supported by the code diff or the user-provided context.`), + LanguageModelChatMessage.User( + `Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation:\n\n${message}`, + ), + LanguageModelChatMessage.User( + `Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes:\n\n${code}`, + ), + LanguageModelChatMessage.User( + 'Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase.', + ), + ]; + + try { + const rsp = await chatModel.sendRequest(messages, {}, cancellation); + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within ${getPossessiveForm( + model.provider.name, + )} limits.`, + ); + } + + let summary = ''; + for await (const fragment of rsp.text) { + summary += fragment; + } + + return summary.trim(); + } catch (ex) { + debugger; + let message = ex instanceof Error ? ex.message : String(ex); + + if (ex instanceof Error && 'cause' in ex && ex.cause instanceof Error) { + message += `\n${ex.cause.message}`; + + if (retries++ < 2 && ex.cause.message.includes('exceeds token limit')) { + maxCodeCharacters -= 500 * retries; + continue; + } + } + + throw new Error( + `Unable to explain changes: (${getPossessiveForm(model.provider.name)}:${ex.code}) ${message}`, + ); + } + } + } finally { + cancellationSource?.dispose(); + } + } +} + +function getModelFromChatModel(model: LanguageModelChat): VSCodeAIModel { + return { + id: `${model.vendor}:${model.family}`, + name: `${capitalize(model.vendor)} ${model.name}`, + vendor: model.vendor, + selector: { + vendor: model.vendor, + family: model.family, + }, + maxTokens: model.maxInputTokens, + provider: { id: provider.id, name: capitalize(model.vendor) }, + }; +} + +function getPossessiveForm(name: string) { + return name.endsWith('s') ? `${name}'` : `${name}'s`; +} diff --git a/src/annotations/annotationProvider.ts b/src/annotations/annotationProvider.ts index 702ebf7cdd342..c832db4cbb7b5 100644 --- a/src/annotations/annotationProvider.ts +++ b/src/annotations/annotationProvider.ts @@ -1,22 +1,21 @@ -import type { - DecorationOptions, - Range, - TextDocument, - TextEditor, - TextEditorDecorationType, - TextEditorSelectionChangeEvent, - Uri, -} from 'vscode'; +import type { TextEditor, TextEditorDecorationType, TextEditorSelectionChangeEvent } from 'vscode'; import { Disposable, window } from 'vscode'; import type { FileAnnotationType } from '../config'; +import type { Container } from '../container'; import { setContext } from '../system/context'; import { Logger } from '../system/logger'; -import type { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { Decoration } from './annotations'; export type AnnotationStatus = 'computing' | 'computed'; export interface AnnotationContext { - selection?: { sha?: string; line?: undefined } | { sha?: undefined; line?: number } | false; + selection?: { sha?: string; line?: never } | { sha?: never; line?: number } | false; +} + +export interface AnnotationState { + recompute?: boolean; + restoring?: boolean; } export type TextEditorCorrelationKey = string; @@ -27,23 +26,16 @@ export function getEditorCorrelationKey(editor: TextEditor | undefined): TextEdi export abstract class AnnotationProviderBase implements Disposable { - annotationContext: TContext | undefined; - correlationKey: TextEditorCorrelationKey; - document: TextDocument; - status: AnnotationStatus | undefined; - - private decorations: - | { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] | DecorationOptions[] }[] - | undefined; + private decorations: Decoration[] | undefined; protected disposable: Disposable; constructor( + protected readonly container: Container, public readonly annotationType: FileAnnotationType, - public editor: TextEditor, - protected readonly trackedDocument: TrackedDocument, + editor: TextEditor, + protected readonly trackedDocument: TrackedGitDocument, ) { - this.correlationKey = getEditorCorrelationKey(this.editor); - this.document = this.editor.document; + this.editor = editor; this.disposable = Disposable.from( window.onDidChangeTextEditorSelection(this.onTextEditorSelectionChanged, this), @@ -56,36 +48,102 @@ export abstract class AnnotationProviderBase { + if (this.status === value) return; + + this._status = value; + if (editor != null && editor === window.activeTextEditor) { + await setContext('gitlens:annotationStatus', this.statusContextValue); + } + } + private onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent) { - if (this.document !== e.textEditor.document) return; + if (this.editor.document !== e.textEditor.document) return; - void this.selection({ line: e.selections[0].active.line }); + void this.selection?.({ line: e.selections[0].active.line }); } - get editorUri(): Uri | undefined { - return this.editor?.document?.uri; + canReuse(_context?: TContext): boolean { + return true; } clear() { + const decorations = this.decorations; + this.decorations = undefined; this.annotationContext = undefined; - this.status = undefined; + void this.setStatus(undefined, this.editor); + if (this.editor == null) return; - if (this.decorations?.length) { - for (const d of this.decorations) { + if (decorations?.length) { + for (const d of decorations) { try { this.editor.setDecorations(d.decorationType, []); + if (d.dispose) { + d.decorationType.dispose(); + } } catch {} } - - this.decorations = undefined; } } - mustReopen(_context?: TContext): boolean { + nextChange?(): void; + previousChange?(): void; + + async provideAnnotation(context?: TContext, state?: AnnotationState): Promise { + void this.setStatus('computing', this.editor); + + try { + this.annotationContext = context; + + if (await this.onProvideAnnotation(context, state)) { + void this.setStatus('computed', this.editor); + await this.selection?.( + state?.restoring ? { line: this.editor.selection.active.line } : context?.selection, + ); + return true; + } + } catch (ex) { + Logger.error(ex); + } + + void this.setStatus(undefined, this.editor); return false; } + protected abstract onProvideAnnotation(context?: TContext, state?: AnnotationState): Promise; + refresh(replaceDecorationTypes: Map) { if (this.editor == null || !this.decorations?.length) return; @@ -105,19 +163,19 @@ export abstract class AnnotationProviderBase { - this.status = 'computing'; - try { - if (await this.onProvideAnnotation(context)) { - this.status = 'computed'; - return true; - } - } catch (ex) { - Logger.error(ex); - } + selection?(selection?: TContext['selection']): Promise; + validate?(): boolean | Promise; - this.status = undefined; - return false; - } - - protected abstract onProvideAnnotation(context?: TContext): Promise; + protected setDecorations(decorations: Decoration[]) { + if (this.decorations?.length) { + // If we have no new decorations, just completely clear the old ones + if (!decorations?.length) { + this.clear(); - abstract selection(selection?: TContext['selection']): Promise; + return; + } - protected setDecorations( - decorations: { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] | DecorationOptions[] }[], - ) { - if (this.decorations?.length) { - this.clear(); + // Only remove the decorations that are no longer needed + const remove = this.decorations.filter( + decoration => !decorations.some(d => d.decorationType.key === decoration.decorationType.key), + ); + for (const d of remove) { + try { + this.editor.setDecorations(d.decorationType, []); + if (d.dispose) { + d.decorationType.dispose(); + } + } catch {} + } } this.decorations = decorations; - if (this.decorations?.length) { - for (const d of this.decorations) { + if (decorations?.length) { + for (const d of decorations) { this.editor.setDecorations(d.decorationType, d.rangesOrOptions); } } } - - abstract validate(): Promise; } diff --git a/src/annotations/annotations.ts b/src/annotations/annotations.ts index 8844a84f3f428..e6ac46e8cddd1 100644 --- a/src/annotations/annotations.ts +++ b/src/annotations/annotations.ts @@ -8,7 +8,7 @@ import type { } from 'vscode'; import { OverviewRulerLane, ThemeColor, Uri, window } from 'vscode'; import type { Config } from '../config'; -import type { Colors, CoreConfiguration } from '../constants'; +import type { Colors } from '../constants'; import { GlyphChars } from '../constants'; import type { CommitFormatOptions } from '../git/formatters/commitFormatter'; import { CommitFormatter } from '../git/formatters/commitFormatter'; @@ -16,6 +16,7 @@ import type { GitCommit } from '../git/models/commit'; import { scale, toRgba } from '../system/color'; import { configuration } from '../system/configuration'; import { getWidth, interpolate, pad } from '../system/string'; +import type { BlameFontOptions } from './gutterBlameAnnotationProvider'; export interface ComputedHeatmap { coldThresholdTimestamp: number; @@ -24,6 +25,12 @@ export interface ComputedHeatmap { computeOpacity(date: Date): number; } +export type Decoration = { + decorationType: TextEditorDecorationType; + rangesOrOptions: T; + dispose?: boolean; +}; + interface RenderOptions extends DecorationInstanceRenderOptions, ThemableDecorationRenderOptions, @@ -93,7 +100,7 @@ export function addOrUpdateGutterHeatmapDecoration( date: Date, heatmap: ComputedHeatmap, range: Range, - map: Map, + map: Map>, ) { const [r, g, b, a] = getHeatmapColor(date, heatmap); @@ -113,7 +120,7 @@ export function addOrUpdateGutterHeatmapDecoration( gutterIconPath: gutter ? Uri.parse( `data:image/svg+xml,${encodeURIComponent( - ``, + ``, )}`, ) : undefined, @@ -122,6 +129,7 @@ export function addOrUpdateGutterHeatmapDecoration( overviewRulerColor: scrollbar ? `rgba(${r},${g},${b},${a * 0.7})` : undefined, }), rangesOrOptions: [range], + dispose: true, }; map.set(key, colorDecoration); } else { @@ -159,6 +167,7 @@ export function getGutterRenderOptions( avatars: boolean, format: string, options: CommitFormatOptions, + fontOptions: BlameFontOptions, ): RenderOptions { // Get the character count of all the tokens, assuming there there is a cap (bail if not) let chars = 0; @@ -192,7 +201,7 @@ export function getGutterRenderOptions( let width; if (chars >= 0) { - const spacing = configuration.getAny('editor.letterSpacing'); + const spacing = configuration.getCore('editor.letterSpacing'); if (spacing != null && spacing !== 0) { width = `calc(${chars}ch + ${Math.round(chars * spacing) + (avatars ? 13 : -6)}px)`; } else { @@ -205,13 +214,15 @@ export function getGutterRenderOptions( borderStyle: borderStyle, borderWidth: borderWidth, color: new ThemeColor('gitlens.gutterForegroundColor' satisfies Colors), - fontWeight: 'normal', - fontStyle: 'normal', + fontWeight: fontOptions.weight ?? 'normal', + fontStyle: fontOptions.style ?? 'normal', height: '100%', margin: '0 26px -1px 0', textDecoration: `${separateLines ? 'overline solid rgba(0, 0, 0, .2)' : 'none'};box-sizing: border-box${ avatars ? ';padding: 0 0 0 18px' : '' - }`, + }${fontOptions.family ? `;font-family: ${fontOptions.family}` : ''}${ + fontOptions.size ? `;font-size: ${fontOptions.size}px` : '' + };`, width: width, uncommittedColor: new ThemeColor('gitlens.gutterUncommittedForegroundColor' satisfies Colors), }; @@ -223,6 +234,7 @@ export function getInlineDecoration( // editorLine: number, format: string, formatOptions?: CommitFormatOptions, + fontOptions?: BlameFontOptions, scrollable: boolean = true, ): Partial { // TODO: Enable this once there is better caching @@ -243,10 +255,12 @@ export function getInlineDecoration( backgroundColor: new ThemeColor('gitlens.trailingLineBackgroundColor' satisfies Colors), color: new ThemeColor('gitlens.trailingLineForegroundColor' satisfies Colors), contentText: pad(message.replace(/ /g, GlyphChars.Space), 1, 1), - fontWeight: 'normal', - fontStyle: 'normal', + fontWeight: fontOptions?.weight ?? 'normal', + fontStyle: fontOptions?.style ?? 'normal', // Pull the decoration out of the document flow if we want to be scrollable - textDecoration: `none;${scrollable ? '' : ' position: absolute;'}`, + textDecoration: `none${scrollable ? '' : ';position: absolute'}${ + fontOptions?.family ? `;font-family: ${fontOptions.family}` : '' + }${fontOptions?.size ? `;font-size: ${fontOptions.size}px` : ''};`, }, }, }; diff --git a/src/annotations/autolinks.ts b/src/annotations/autolinks.ts index 6e7b440c41d17..a488b28673973 100644 --- a/src/annotations/autolinks.ts +++ b/src/annotations/autolinks.ts @@ -6,15 +6,17 @@ import type { Container } from '../container'; import type { IssueOrPullRequest } from '../git/models/issue'; import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/models/issue'; import type { GitRemote } from '../git/models/remote'; -import type { RemoteProviderReference } from '../git/models/remoteProvider'; -import type { RichRemoteProvider } from '../git/remotes/richRemoteProvider'; -import type { MaybePausedResult } from '../system/cancellation'; +import type { ProviderReference } from '../git/models/remoteProvider'; +import type { ResourceDescriptor } from '../plus/integrations/integration'; +import type { IntegrationId } from '../plus/integrations/providers/models'; +import { IssueIntegrationId } from '../plus/integrations/providers/models'; import { configuration } from '../system/configuration'; import { fromNow } from '../system/date'; import { debug } from '../system/decorators/log'; import { encodeUrl } from '../system/encoding'; import { join, map } from '../system/iterable'; import { Logger } from '../system/logger'; +import type { MaybePausedResult } from '../system/promise'; import { capitalize, encodeHtmlWeak, escapeMarkdown, escapeRegex, getSuperscript } from '../system/string'; const emptyAutolinkMap = Object.freeze(new Map()); @@ -22,7 +24,7 @@ const emptyAutolinkMap = Object.freeze(new Map()); const numRegex = //g; export interface Autolink { - provider?: RemoteProviderReference; + provider?: ProviderReference; id: string; prefix: string; title?: string; @@ -30,6 +32,18 @@ export interface Autolink { type?: AutolinkType; description?: string; + + descriptor?: ResourceDescriptor; + tokenize?: + | (( + text: string, + outputFormat: 'html' | 'markdown' | 'plaintext', + tokenMapping: Map, + enrichedAutolinks?: Map, + prs?: Set, + footnotes?: Map, + ) => string) + | null; } export type EnrichedAutolink = [ @@ -58,6 +72,7 @@ export function serializeAutolink(value: Autolink): Autolink { url: value.url, type: value.type, description: value.description, + descriptor: value.descriptor, }; return serialized; } @@ -93,6 +108,8 @@ export interface DynamicAutolinkReference { parse: (text: string, autolinks: Map) => void; } +export const supportedAutolinkIntegrations = [IssueIntegrationId.Jira]; + function isDynamic(ref: AutolinkReference | DynamicAutolinkReference): ref is DynamicAutolinkReference { return !('prefix' in ref) && !('url' in ref); } @@ -133,27 +150,61 @@ export class Autolinks implements Disposable { ignoreCase: a.ignoreCase, type: a.type, description: a.description, + descriptor: a.descriptor, })) ?? []; } } - getAutolinks(message: string, remote?: GitRemote): Map; - // eslint-disable-next-line @typescript-eslint/unified-signatures - getAutolinks(message: string, remote: GitRemote, options?: { excludeCustom?: boolean }): Map; + async getAutolinks(message: string, remote?: GitRemote): Promise>; + async getAutolinks( + message: string, + remote: GitRemote, + // eslint-disable-next-line @typescript-eslint/unified-signatures + options?: { excludeCustom?: boolean }, + ): Promise>; @debug({ args: { 0: '', 1: false, }, }) - getAutolinks(message: string, remote?: GitRemote, options?: { excludeCustom?: boolean }): Map { + async getAutolinks( + message: string, + remote?: GitRemote, + options?: { excludeCustom?: boolean }, + ): Promise> { const refsets: [ - RemoteProviderReference | undefined, + ProviderReference | undefined, (AutolinkReference | DynamicAutolinkReference)[] | CacheableAutolinkReference[], ][] = []; - if (remote?.provider?.autolinks?.length) { - refsets.push([remote.provider, remote.provider.autolinks]); + // Connected integration autolinks + await Promise.allSettled( + supportedAutolinkIntegrations.map(async integrationId => { + const integration = await this.container.integrations.get(integrationId); + const autoLinks = await integration.autolinks(); + if (autoLinks.length) { + refsets.push([integration, autoLinks]); + } + }), + ); + + // Remote-specific autolinks and remote integration autolinks + if (remote?.provider != null) { + const autoLinks = []; + const integrationAutolinks = await (await remote.getIntegration())?.autolinks(); + if (integrationAutolinks?.length) { + autoLinks.push(...integrationAutolinks); + } + if (remote?.provider?.autolinks.length) { + autoLinks.push(...remote.provider.autolinks); + } + + if (autoLinks.length) { + refsets.push([remote.provider, autoLinks]); + } } + + // Custom-configured autolinks if (this._references.length && (remote?.provider == null || !options?.excludeCustom)) { refsets.push([undefined, this._references]); } @@ -189,6 +240,7 @@ export class Autolinks implements Disposable { type: ref.type, description: ref.description?.replace(numRegex, num), + descriptor: ref.descriptor, }); } while (true); } @@ -197,6 +249,15 @@ export class Autolinks implements Disposable { return autolinks; } + getAutolinkEnrichableId(autolink: Autolink): string { + switch (autolink.provider?.id) { + case IssueIntegrationId.Jira: + return `${autolink.prefix}${autolink.id}`; + default: + return autolink.id; + } + } + async getEnrichedAutolinks( message: string, remote: GitRemote | undefined, @@ -217,36 +278,42 @@ export class Autolinks implements Disposable { remote: GitRemote | undefined, ): Promise | undefined> { if (typeof messageOrAutolinks === 'string') { - messageOrAutolinks = this.getAutolinks(messageOrAutolinks, remote); + messageOrAutolinks = await this.getAutolinks(messageOrAutolinks, remote); } if (messageOrAutolinks.size === 0) return undefined; - let provider: RichRemoteProvider | undefined; - if (remote?.hasRichIntegration()) { - ({ provider } = remote); - const connected = remote.provider.maybeConnected ?? (await remote.provider.isConnected()); + let integration = await remote?.getIntegration(); + if (integration != null) { + const connected = integration.maybeConnected ?? (await integration.isConnected()); if (!connected) { - provider = undefined; + integration = undefined; } } - return new Map( - map( - messageOrAutolinks, - ([id, link]) => - [ - id, - [ - provider != null && - link.provider?.id === provider.id && - link.provider?.domain === provider.domain - ? provider.getIssueOrPullRequest(id) - : undefined, - link, - ] satisfies EnrichedAutolink, - ] as const, - ), - ); + const enrichedAutolinks = new Map(); + for (const [id, link] of messageOrAutolinks) { + let linkIntegration = link.provider + ? await this.container.integrations.get(link.provider.id as IntegrationId) + : undefined; + if (linkIntegration != null) { + const connected = linkIntegration.maybeConnected ?? (await linkIntegration.isConnected()); + if (!connected) { + linkIntegration = undefined; + } + } + const issueOrPullRequestPromise = + remote?.provider != null && + integration != null && + link.provider?.id === integration.id && + link.provider?.domain === integration.domain + ? integration.getIssueOrPullRequest(link.descriptor ?? remote.provider.repoDesc, id) + : link.descriptor != null + ? linkIntegration?.getIssueOrPullRequest(link.descriptor, this.getAutolinkEnrichableId(link)) + : undefined; + enrichedAutolinks.set(id, [issueOrPullRequestPromise, link]); + } + + return enrichedAutolinks; } @debug({ @@ -272,27 +339,44 @@ export class Autolinks implements Disposable { const tokenMapping = new Map(); - for (const ref of this._references) { - if (this.ensureAutolinkCached(ref)) { - if (ref.tokenize != null) { - text = ref.tokenize(text, outputFormat, tokenMapping, enrichedAutolinks, prs, footnotes); + if (enrichedAutolinks?.size) { + for (const [, [, link]] of enrichedAutolinks) { + if (this.ensureAutolinkCached(link)) { + if (link.tokenize != null) { + text = link.tokenize(text, outputFormat, tokenMapping, enrichedAutolinks, prs, footnotes); + } + } + } + } else { + for (const ref of this._references) { + if (this.ensureAutolinkCached(ref)) { + if (ref.tokenize != null) { + text = ref.tokenize(text, outputFormat, tokenMapping, enrichedAutolinks, prs, footnotes); + } } } - } - if (remotes != null && remotes.length !== 0) { - remotes = [...remotes].sort((a, b) => { - const aConnected = a.provider?.maybeConnected; - const bConnected = b.provider?.maybeConnected; - return aConnected !== bConnected ? (aConnected ? -1 : bConnected ? 1 : 0) : 0; - }); - for (const r of remotes) { - if (r.provider == null) continue; - - for (const ref of r.provider.autolinks) { - if (this.ensureAutolinkCached(ref)) { - if (ref.tokenize != null) { - text = ref.tokenize(text, outputFormat, tokenMapping, enrichedAutolinks, prs, footnotes); + if (remotes != null && remotes.length !== 0) { + remotes = [...remotes].sort((a, b) => { + const aConnected = a.maybeIntegrationConnected; + const bConnected = b.maybeIntegrationConnected; + return aConnected !== bConnected ? (aConnected ? -1 : bConnected ? 1 : 0) : 0; + }); + for (const r of remotes) { + if (r.provider == null) continue; + + for (const ref of r.provider.autolinks) { + if (this.ensureAutolinkCached(ref)) { + if (ref.tokenize != null) { + text = ref.tokenize( + text, + outputFormat, + tokenMapping, + enrichedAutolinks, + prs, + footnotes, + ); + } } } } @@ -315,8 +399,8 @@ export class Autolinks implements Disposable { } private ensureAutolinkCached( - ref: CacheableAutolinkReference | DynamicAutolinkReference, - ): ref is CacheableAutolinkReference | DynamicAutolinkReference { + ref: CacheableAutolinkReference | DynamicAutolinkReference | Autolink, + ): ref is CacheableAutolinkReference | DynamicAutolinkReference | Autolink { if (isDynamic(ref)) return true; if (!ref.prefix || !ref.url) return false; if (ref.tokenize !== undefined || ref.tokenize === null) return true; @@ -374,7 +458,7 @@ export class Autolinks implements Disposable { )} **${issueTitle}**](${url}${title}")\\\n${GlyphChars.Space.repeat( 5, )}${linkText} ${issue.state} ${fromNow( - issue.closedDate ?? issue.date, + issue.closedDate ?? issue.createdDate, )}`, ); } @@ -382,7 +466,7 @@ export class Autolinks implements Disposable { title += `\n${GlyphChars.Dash.repeat( 2, )}\n${issueTitleQuoteEscaped}\n${capitalize(issue.state)}, ${fromNow( - issue.closedDate ?? issue.date, + issue.closedDate ?? issue.createdDate, )}`; } } else if (footnotes != null && !prs?.has(num)) { @@ -446,7 +530,7 @@ export class Autolinks implements Disposable { )} ${issueTitle}
${GlyphChars.Space.repeat( 5, )}${linkText} ${issue.state} ${fromNow( - issue.closedDate ?? issue.date, + issue.closedDate ?? issue.createdDate, )}`, ); } @@ -454,7 +538,7 @@ export class Autolinks implements Disposable { title += `\n${GlyphChars.Dash.repeat( 2, )}\n${issueTitleQuoteEscaped}\n${capitalize(issue.state)}, ${fromNow( - issue.closedDate ?? issue.date, + issue.closedDate ?? issue.createdDate, )}`; } } else if (footnotes != null && !prs?.has(num)) { @@ -495,7 +579,7 @@ export class Autolinks implements Disposable { : `${issueResult.value.title} ${GlyphChars.Dot} ${capitalize( issueResult.value.state, )}, ${fromNow( - issueResult.value.closedDate ?? issueResult.value.date, + issueResult.value.closedDate ?? issueResult.value.createdDate, )}` }`, ); diff --git a/src/annotations/blameAnnotationProvider.ts b/src/annotations/blameAnnotationProvider.ts index 3facb384c196f..ae51bc1156537 100644 --- a/src/annotations/blameAnnotationProvider.ts +++ b/src/annotations/blameAnnotationProvider.ts @@ -8,26 +8,26 @@ import type { GitCommit } from '../git/models/commit'; import { changesMessage, detailsMessage } from '../hovers/hovers'; import { configuration } from '../system/configuration'; import { log } from '../system/decorators/log'; -import type { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; import { AnnotationProviderBase } from './annotationProvider'; import type { ComputedHeatmap } from './annotations'; import { getHeatmapColors } from './annotations'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase { protected blame: Promise; protected hoverProviderDisposable: Disposable | undefined; constructor( + container: Container, annotationType: FileAnnotationType, editor: TextEditor, - trackedDocument: TrackedDocument, - protected readonly container: Container, + trackedDocument: TrackedGitDocument, ) { - super(annotationType, editor, trackedDocument); + super(container, annotationType, editor, trackedDocument); - this.blame = this.container.git.getBlame(this.trackedDocument.uri, editor.document); + this.blame = container.git.getBlame(this.trackedDocument.uri, editor.document); if (editor.document.isDirty) { trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); @@ -42,14 +42,17 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase super.clear(); } - async validate(): Promise { + override async validate(): Promise { const blame = await this.blame; - return blame != null && blame.lines.length !== 0; + return Boolean(blame?.lines.length); } - protected async getBlame(): Promise { + protected async getBlame(force?: boolean): Promise { + if (force) { + this.blame = this.container.git.getBlame(this.trackedDocument.uri, this.editor.document); + } const blame = await this.blame; - if (blame == null || blame.lines.length === 0) return undefined; + if (!blame?.lines.length) return undefined; return blame; } @@ -106,10 +109,10 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase Array.isArray(lookupTable) ? lookupTable : unified - ? lookupTable.hot.concat(lookupTable.cold) - : date.getTime() < coldThresholdTimestamp - ? lookupTable.cold - : lookupTable.hot; + ? lookupTable.hot.concat(lookupTable.cold) + : date.getTime() < coldThresholdTimestamp + ? lookupTable.cold + : lookupTable.hot; const computeRelativeAge = (date: Date, lookup: number[]) => { const time = date.getTime(); @@ -141,8 +144,9 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase return; } + this.hoverProviderDisposable?.dispose(); this.hoverProviderDisposable = languages.registerHoverProvider( - { pattern: this.document.uri.fsPath }, + { pattern: this.editor.document.uri.fsPath }, { provideHover: (document: TextDocument, position: Position, token: CancellationToken) => this.provideHover(providers, document, position, token), @@ -158,7 +162,7 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase ): Promise { if (configuration.get('hovers.annotations.over') !== 'line' && position.character !== 0) return undefined; - if (this.document.uri.toString() !== document.uri.toString()) return undefined; + if (this.editor.document.uri.toString() !== document.uri.toString()) return undefined; const blame = await this.getBlame(); if (blame == null) return undefined; diff --git a/src/annotations/fileAnnotationController.ts b/src/annotations/fileAnnotationController.ts index ba787da453d36..91989bf0e0908 100644 --- a/src/annotations/fileAnnotationController.ts +++ b/src/annotations/fileAnnotationController.ts @@ -23,35 +23,29 @@ import { import type { AnnotationsToggleMode, FileAnnotationType } from '../config'; import type { Colors, CoreColors } from '../constants'; import type { Container } from '../container'; +import { registerCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; +import { debug, log } from '../system/decorators/log'; import { once } from '../system/event'; +import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; import { find } from '../system/iterable'; import type { KeyboardScope } from '../system/keyboard'; -import { Logger } from '../system/logger'; import { basename } from '../system/path'; import { isTextEditor } from '../system/utils'; import type { DocumentBlameStateChangeEvent, + DocumentDirtyIdleTriggerEvent, DocumentDirtyStateChangeEvent, - GitDocumentState, -} from '../trackers/gitDocumentTracker'; +} from '../trackers/documentTracker'; import type { AnnotationContext, AnnotationProviderBase, TextEditorCorrelationKey } from './annotationProvider'; import { getEditorCorrelationKey } from './annotationProvider'; import type { ChangesAnnotationContext } from './gutterChangesAnnotationProvider'; -export type AnnotationClearReason = - | 'User' - | 'BlameabilityChanged' - | 'ColumnChanged' - | 'Disposing' - | 'DocumentChanged' - | 'DocumentClosed'; - export const Decorations = { gutterBlameAnnotation: window.createTextEditorDecorationType({ - rangeBehavior: DecorationRangeBehavior.ClosedOpen, + rangeBehavior: DecorationRangeBehavior.OpenOpen, textDecoration: 'none', }), gutterBlameHighlight: undefined as TextEditorDecorationType | undefined, @@ -93,7 +87,6 @@ export class FileAnnotationController implements Disposable { Decorations.changesLineAddedAnnotation?.dispose(); Decorations.changesLineDeletedAnnotation?.dispose(); - this._annotationsDisposable?.dispose(); this._disposable?.dispose(); } @@ -108,6 +101,16 @@ export class FileAnnotationController implements Disposable { this.updateDecorations(false); } + if (configuration.changed(e, 'fileAnnotations.dismissOnEscape')) { + if (configuration.get('fileAnnotations.dismissOnEscape')) { + if (window.visibleTextEditors.some(e => this.getProvider(e))) { + void this.attachKeyboardHook(); + } + } else { + void this.detachKeyboardHook(); + } + } + let toggleMode; if (configuration.changed(e, 'blame.toggleMode')) { toggleMode = configuration.get('blame.toggleMode'); @@ -177,26 +180,47 @@ export class FileAnnotationController implements Disposable { void setContext('gitlens:annotationStatus', undefined); void this.detachKeyboardHook(); } else { - void setContext('gitlens:annotationStatus', provider.status); + void setContext('gitlens:annotationStatus', provider.statusContextValue); void this.attachKeyboardHook(); } } - private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { + private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { + const editor = window.activeTextEditor; + if (editor == null) return; + // Only care if we are becoming un-blameable - if (e.blameable) return; + if (e.blameable) { + if (configuration.get('fileAnnotations.preserveWhileEditing')) { + this.restore(editor); + } + + return; + } + + void this.clearCore(getEditorCorrelationKey(editor)); + } + + private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { + if (!e.document.isBlameable || !configuration.get('fileAnnotations.preserveWhileEditing')) return; const editor = window.activeTextEditor; if (editor == null) return; - void this.clear(editor, 'BlameabilityChanged'); + this.restore(editor); } - private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { + private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { for (const [key, p] of this._annotationProviders) { - if (!e.document.is(p.document)) continue; + if (!e.document.is(p.editor.document)) continue; - void this.clearCore(key, 'DocumentChanged'); + if (configuration.get('fileAnnotations.preserveWhileEditing')) { + if (!e.dirty) { + this.restore(e.editor); + } + } else if (e.dirty) { + void this.clearCore(key); + } } } @@ -204,9 +228,9 @@ export class FileAnnotationController implements Disposable { if (!this.container.git.isTrackable(document.uri)) return; for (const [key, p] of this._annotationProviders) { - if (p.document !== document) continue; + if (p.editor.document !== document) continue; - void this.clearCore(key, 'DocumentClosed'); + void this.clearCore(key); } } @@ -221,17 +245,17 @@ export class FileAnnotationController implements Disposable { ); if (fuzzyProvider == null) return; - void this.clearCore(fuzzyProvider.correlationKey, 'ColumnChanged'); + void this.clearCore(fuzzyProvider.correlationKey); return; } - void provider.restore(e.textEditor); + provider.restore(e.textEditor); } private onVisibleTextEditorsChanged(editors: readonly TextEditor[]) { for (const e of editors) { - void this.getProvider(e)?.restore(e); + this.getProvider(e)?.restore(e, false); } } @@ -245,27 +269,30 @@ export class FileAnnotationController implements Disposable { return this._toggleModes.get(annotationType) ?? 'file'; } - clear(editor: TextEditor, reason: AnnotationClearReason = 'User') { - if (this.isInWindowToggle()) { - return this.clearAll(); - } + @log({ args: { 0: e => e?.document.uri.toString(true) } }) + clear(editor: TextEditor) { + if (this.isInWindowToggle()) return this.clearAll(); - return this.clearCore(getEditorCorrelationKey(editor), reason); + return this.clearCore(getEditorCorrelationKey(editor), true); } + @log() async clearAll() { this._windowAnnotationType = undefined; + for (const [key] of this._annotationProviders) { - await this.clearCore(key, 'Disposing'); + await this.clearCore(key, true); } + + this.unsubscribe(); } async getAnnotationType(editor: TextEditor | undefined): Promise { const provider = this.getProvider(editor); if (provider == null) return undefined; - const trackedDocument = await this.container.tracker.get(editor!.document); - if (trackedDocument == null || !trackedDocument.isBlameable) return undefined; + const trackedDocument = await this.container.documentTracker.get(editor!.document); + if (!trackedDocument?.isBlameable) return undefined; return provider.annotationType; } @@ -275,8 +302,32 @@ export class FileAnnotationController implements Disposable { return this._annotationProviders.get(getEditorCorrelationKey(editor)); } + private debouncedRestores = new WeakMap>(); + + private restore(editor: TextEditor, recompute?: boolean) { + const provider = this.getProvider(editor); + if (provider == null) return; + + let debouncedRestore = this.debouncedRestores.get(editor); + if (debouncedRestore == null) { + debouncedRestore = debounce((editor: TextEditor) => { + this.debouncedRestores.delete(editor); + provider.restore(editor, recompute ?? true); + }, 500); + this.debouncedRestores.set(editor, debouncedRestore); + } + + debouncedRestore(editor); + } + async show(editor: TextEditor | undefined, type: FileAnnotationType, context?: AnnotationContext): Promise; async show(editor: TextEditor | undefined, type: 'changes', context?: ChangesAnnotationContext): Promise; + @log({ + args: { + 0: e => e?.document.uri.toString(true), + 2: false, + }, + }) async show( editor: TextEditor | undefined, type: FileAnnotationType, @@ -300,18 +351,22 @@ export class FileAnnotationController implements Disposable { void this.show(e, type); } } + + if (editor == null) { + this.subscribe(); + return false; + } } if (editor == null) return false; // || editor.viewColumn == null) return false; this._editor = editor; - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); + const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document); if (!trackedDocument.isBlameable) return false; const currentProvider = this.getProvider(editor); if (currentProvider?.annotationType === type) { await currentProvider.provideAnnotation(context); - await currentProvider.selection(context?.selection); return true; } @@ -324,7 +379,7 @@ export class FileAnnotationController implements Disposable { const provider = await computingAnnotations; if (editor === this._editor) { - await setContext('gitlens:annotationStatus', provider?.status); + await setContext('gitlens:annotationStatus', provider?.statusContextValue); } return computingAnnotations; @@ -346,6 +401,12 @@ export class FileAnnotationController implements Disposable { context?: ChangesAnnotationContext, on?: boolean, ): Promise; + @log({ + args: { + 0: e => e?.document.uri.toString(true), + 2: false, + }, + }) async toggle( editor: TextEditor | undefined, type: FileAnnotationType, @@ -353,22 +414,29 @@ export class FileAnnotationController implements Disposable { on?: boolean, ): Promise { if (editor != null && this._toggleModes.get(type) === 'file') { - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); + const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document); if ((type === 'changes' && !trackedDocument.isTracked) || !trackedDocument.isBlameable) { return false; } } const provider = this.getProvider(editor); - if (provider == null) return this.show(editor, type, context); + if (provider == null) { + if (editor == null && this.isInWindowToggle()) { + await this.clearAll(); + return false; + } + + return this.show(editor, type, context); + } - const reopen = provider.annotationType !== type || provider.mustReopen(context); + const reopen = provider.annotationType !== type || !provider.canReuse(context); if (on === true && !reopen) return true; if (this.isInWindowToggle()) { await this.clearAll(); } else { - await this.clearCore(provider.correlationKey, 'User'); + await this.clearCore(provider.correlationKey, true); } if (!reopen) return false; @@ -376,7 +444,21 @@ export class FileAnnotationController implements Disposable { return this.show(editor, type, context); } + @log() + nextChange() { + const provider = this.getProvider(window.activeTextEditor); + provider?.nextChange?.(); + } + + @log() + previousChange() { + const provider = this.getProvider(window.activeTextEditor); + provider?.previousChange?.(); + } + private async attachKeyboardHook() { + if (!configuration.get('fileAnnotations.dismissOnEscape')) return; + // Allows pressing escape to exit the annotations if (this._keyboardScope == null) { this._keyboardScope = await this.container.keyboard.beginScope({ @@ -385,7 +467,7 @@ export class FileAnnotationController implements Disposable { const e = this._editor; if (e == null) return undefined; - await this.clear(e, 'User'); + await this.clear(e); return undefined; }, }, @@ -393,25 +475,22 @@ export class FileAnnotationController implements Disposable { } } - private async clearCore(key: TextEditorCorrelationKey, reason: AnnotationClearReason) { + @log() + private async clearCore(key: TextEditorCorrelationKey, force?: boolean) { const provider = this._annotationProviders.get(key); if (provider == null) return; - Logger.log(`${reason}:`, `Clear annotations for ${key}`); - this._annotationProviders.delete(key); provider.dispose(); - if (this._annotationProviders.size === 0 || key === getEditorCorrelationKey(this._editor)) { + if (!this._annotationProviders.size || key === getEditorCorrelationKey(this._editor)) { await setContext('gitlens:annotationStatus', undefined); await this.detachKeyboardHook(); } - if (this._annotationProviders.size === 0) { - Logger.log('Remove all listener registrations for annotations'); - - this._annotationsDisposable?.dispose(); - this._annotationsDisposable = undefined; + if (!this._annotationProviders.size && (force || !this.isInWindowToggle())) { + this._windowAnnotationType = undefined; + this.unsubscribe(); } this._onDidToggleAnnotations.fire(); @@ -455,49 +534,40 @@ export class FileAnnotationController implements Disposable { // Allows pressing escape to exit the annotations await this.attachKeyboardHook(); - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); + const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document); let provider: AnnotationProviderBase | undefined = undefined; switch (type) { case 'blame': { const { GutterBlameAnnotationProvider } = await import( - /* webpackChunkName: "annotations-blame" */ './gutterBlameAnnotationProvider' + /* webpackChunkName: "annotations" */ './gutterBlameAnnotationProvider' ); - provider = new GutterBlameAnnotationProvider(editor, trackedDocument, this.container); + provider = new GutterBlameAnnotationProvider(this.container, editor, trackedDocument); break; } case 'changes': { const { GutterChangesAnnotationProvider } = await import( - /* webpackChunkName: "annotations-changes" */ './gutterChangesAnnotationProvider' + /* webpackChunkName: "annotations" */ './gutterChangesAnnotationProvider' ); - provider = new GutterChangesAnnotationProvider(editor, trackedDocument, this.container); + provider = new GutterChangesAnnotationProvider(this.container, editor, trackedDocument); break; } case 'heatmap': { const { GutterHeatmapBlameAnnotationProvider } = await import( - /* webpackChunkName: "annotations-heatmap" */ './gutterHeatmapBlameAnnotationProvider' + /* webpackChunkName: "annotations" */ './gutterHeatmapBlameAnnotationProvider' ); - provider = new GutterHeatmapBlameAnnotationProvider(editor, trackedDocument, this.container); + provider = new GutterHeatmapBlameAnnotationProvider(this.container, editor, trackedDocument); break; } } - if (provider == null || !(await provider.validate())) return undefined; + if (provider == null || (await provider.validate?.()) === false) return undefined; if (currentProvider != null) { - await this.clearCore(currentProvider.correlationKey, 'User'); + await this.clearCore(currentProvider.correlationKey, true); } - if (this._annotationsDisposable == null && this._annotationProviders.size === 0) { - Logger.log('Add listener registrations for annotations'); - - this._annotationsDisposable = Disposable.from( - window.onDidChangeActiveTextEditor(debounce(this.onActiveTextEditorChanged, 50), this), - window.onDidChangeTextEditorViewColumn(this.onTextEditorViewColumnChanged, this), - window.onDidChangeVisibleTextEditors(debounce(this.onVisibleTextEditorsChanged, 50), this), - workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this), - this.container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), - this.container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), - ); + if (this._annotationProviders.size === 0) { + this.subscribe(); } this._annotationProviders.set(provider.correlationKey, provider); @@ -506,11 +576,42 @@ export class FileAnnotationController implements Disposable { return provider; } - await this.clearCore(provider.correlationKey, 'Disposing'); + await this.clearCore(provider.correlationKey, true); return undefined; } + @debug({ + singleLine: true, + if: function () { + return this._annotationsDisposable == null; + }, + }) + private subscribe() { + this._annotationsDisposable ??= Disposable.from( + window.onDidChangeActiveTextEditor(debounce(this.onActiveTextEditorChanged, 50), this), + window.onDidChangeTextEditorViewColumn(this.onTextEditorViewColumnChanged, this), + window.onDidChangeVisibleTextEditors(debounce(this.onVisibleTextEditorsChanged, 50), this), + workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this), + this.container.documentTracker.onDidChangeBlameState(this.onBlameStateChanged, this), + this.container.documentTracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), + this.container.documentTracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), + registerCommand('gitlens.annotations.nextChange', () => this.nextChange()), + registerCommand('gitlens.annotations.previousChange', () => this.previousChange()), + ); + } + + @debug({ + singleLine: true, + if: function () { + return this._annotationsDisposable != null; + }, + }) + private unsubscribe() { + this._annotationsDisposable?.dispose(); + this._annotationsDisposable = undefined; + } + private updateDecorations(refresh: boolean) { const previous = refresh ? Object.entries(Decorations) : (undefined! as []); @@ -577,7 +678,7 @@ export class FileAnnotationController implements Disposable { `data:image/svg+xml,${encodeURIComponent( ``, + )})' x='13' y='0' width='3' height='18'/>`, )}`, ) : undefined, @@ -596,7 +697,7 @@ export class FileAnnotationController implements Disposable { `data:image/svg+xml,${encodeURIComponent( ``, + )})' x='13' y='0' width='3' height='18'/>`, )}`, ) : undefined, diff --git a/src/annotations/gutterBlameAnnotationProvider.ts b/src/annotations/gutterBlameAnnotationProvider.ts index f1e4c06c685e2..8e9ef08b6dbbf 100644 --- a/src/annotations/gutterBlameAnnotationProvider.ts +++ b/src/annotations/gutterBlameAnnotationProvider.ts @@ -1,11 +1,10 @@ import type { DecorationOptions, TextEditor, ThemableDecorationAttachmentRenderOptions } from 'vscode'; import { Range } from 'vscode'; -import type { FileAnnotationType, GravatarDefaultStyle } from '../config'; +import type { GravatarDefaultStyle } from '../config'; import { GlyphChars } from '../constants'; import type { Container } from '../container'; import type { CommitFormatOptions } from '../git/formatters/commitFormatter'; import { CommitFormatter } from '../git/formatters/commitFormatter'; -import type { GitBlame } from '../git/models/blame'; import type { GitCommit } from '../git/models/commit'; import { filterMap } from '../system/array'; import { configuration } from '../system/configuration'; @@ -15,18 +14,24 @@ import { getLogScope } from '../system/logger.scope'; import { maybeStopWatch } from '../system/stopwatch'; import type { TokenOptions } from '../system/string'; import { getTokensFromTemplate, getWidth } from '../system/string'; -import type { GitDocumentState } from '../trackers/gitDocumentTracker'; -import type { TrackedDocument } from '../trackers/trackedDocument'; -import type { AnnotationContext } from './annotationProvider'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { AnnotationContext, AnnotationState } from './annotationProvider'; import { applyHeatmap, getGutterDecoration, getGutterRenderOptions } from './annotations'; import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; import { Decorations } from './fileAnnotationController'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) + +export interface BlameFontOptions { + family: string; + size: number; + style: string; + weight: string; +} export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { - constructor(editor: TextEditor, trackedDocument: TrackedDocument, container: Container) { - super('blame', editor, trackedDocument, container); + constructor(container: Container, editor: TextEditor, trackedDocument: TrackedGitDocument) { + super(container, 'blame', editor, trackedDocument); } override clear() { @@ -40,15 +45,13 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { } @log() - async onProvideAnnotation(context?: AnnotationContext, _type?: FileAnnotationType): Promise { + override async onProvideAnnotation(context?: AnnotationContext, state?: AnnotationState): Promise { const scope = getLogScope(); - this.annotationContext = context; - - const blame = await this.getBlame(); + const blame = await this.getBlame(state?.recompute); if (blame == null) return false; - const sw = maybeStopWatch(scope); + using sw = maybeStopWatch(scope); const cfg = configuration.get('blame'); @@ -72,10 +75,24 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { tokenOptions: tokenOptions, }; + const fontOptions: BlameFontOptions = { + family: configuration.get('blame.fontFamily'), + size: configuration.get('blame.fontSize'), + style: configuration.get('blame.fontStyle'), + weight: configuration.get('blame.fontWeight'), + }; + const avatars = cfg.avatars; const gravatarDefault = configuration.get('defaultGravatarsStyle'); const separateLines = cfg.separateLines; - const renderOptions = getGutterRenderOptions(separateLines, cfg.heatmap, cfg.avatars, cfg.format, options); + const renderOptions = getGutterRenderOptions( + separateLines, + cfg.heatmap, + cfg.avatars, + cfg.format, + options, + fontOptions, + ); const decorationOptions = []; const decorationsMap = new Map(); @@ -91,6 +108,8 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { computedHeatmap = this.getComputedHeatmap(blame); } + let emptyLine: string | undefined; + for (const l of blame.lines) { // editor lines are 0-based const editorLine = l.line - 1; @@ -102,17 +121,24 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { gutter = { ...gutter }; if (cfg.compact && !compacted) { + // Since the line length is the same just generate a single new empty line + if (emptyLine == null) { + emptyLine = GlyphChars.Space.repeat(getWidth(gutter.renderOptions!.before!.contentText!)); + } + // Since we are wiping out the contextText make sure to copy the objects gutter.renderOptions = { before: { ...gutter.renderOptions!.before, - contentText: GlyphChars.Space.repeat(getWidth(gutter.renderOptions!.before!.contentText!)), + contentText: emptyLine, }, }; if (separateLines) { gutter.renderOptions.before!.textDecoration = `none;box-sizing: border-box${ avatars ? ';padding: 0 0 0 18px' : '' + }${fontOptions.family ? `;font-family: ${fontOptions.family}` : ''}${ + fontOptions.size ? `;font-size: ${fontOptions.size}px` : '' }`; } @@ -176,13 +202,11 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { } @log({ args: false }) - async selection(selection?: AnnotationContext['selection'], blame?: GitBlame): Promise { + override async selection(selection?: AnnotationContext['selection']): Promise { if (selection === false || Decorations.gutterBlameHighlight == null) return; - if (blame == null) { - blame = await this.blame; - if (!blame?.lines.length) return; - } + const blame = await this.blame; + if (!blame?.lines.length) return; let sha: string | undefined = undefined; if (selection?.sha != null) { diff --git a/src/annotations/gutterChangesAnnotationProvider.ts b/src/annotations/gutterChangesAnnotationProvider.ts index 0ae6605d30e09..568c47cf0a70e 100644 --- a/src/annotations/gutterChangesAnnotationProvider.ts +++ b/src/annotations/gutterChangesAnnotationProvider.ts @@ -1,11 +1,4 @@ -import type { - CancellationToken, - DecorationOptions, - Disposable, - TextDocument, - TextEditor, - TextEditorDecorationType, -} from 'vscode'; +import type { CancellationToken, DecorationOptions, Disposable, TextDocument, TextEditor } from 'vscode'; import { Hover, languages, Position, Range, Selection, TextEditorRevealType } from 'vscode'; import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; @@ -14,13 +7,15 @@ import { localChangesMessage } from '../hovers/hovers'; import { configuration } from '../system/configuration'; import { log } from '../system/decorators/log'; import { getLogScope } from '../system/logger.scope'; +import { getSettledValue } from '../system/promise'; import { maybeStopWatch } from '../system/stopwatch'; -import type { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; -import type { AnnotationContext } from './annotationProvider'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { AnnotationContext, AnnotationState } from './annotationProvider'; import { AnnotationProviderBase } from './annotationProvider'; +import type { Decoration } from './annotations'; import { Decorations } from './fileAnnotationController'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) export interface ChangesAnnotationContext extends AnnotationContext { sha?: string; @@ -28,19 +23,16 @@ export interface ChangesAnnotationContext extends AnnotationContext { } export class GutterChangesAnnotationProvider extends AnnotationProviderBase { - private state: { commit: GitCommit | undefined; diffs: GitDiffFile[] } | undefined; private hoverProviderDisposable: Disposable | undefined; + private sortedHunkStarts: number[] | undefined; + private state: { commit: GitCommit | undefined; diffs: GitDiffFile[] } | undefined; - constructor( - editor: TextEditor, - trackedDocument: TrackedDocument, - private readonly container: Container, - ) { - super('changes', editor, trackedDocument); + constructor(container: Container, editor: TextEditor, trackedDocument: TrackedGitDocument) { + super(container, 'changes', editor, trackedDocument); } - override mustReopen(context?: ChangesAnnotationContext): boolean { - return this.annotationContext?.sha !== context?.sha || this.annotationContext?.only !== context?.only; + override canReuse(context?: ChangesAnnotationContext): boolean { + return !(this.annotationContext?.sha !== context?.sha || this.annotationContext?.only !== context?.only); } override clear() { @@ -52,23 +44,58 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase { - return Promise.resolve(); - } + override nextChange() { + if (this.sortedHunkStarts == null) return; - validate(): Promise { - return Promise.resolve(true); + let nextLine = -1; + const currentLine = this.editor.selection.active.line; + for (const line of this.sortedHunkStarts) { + if (line > currentLine) { + nextLine = line; + break; + } + } + + if (nextLine === -1) { + nextLine = this.sortedHunkStarts[0]; + } + + if (nextLine > 0) { + this.editor.selection = new Selection(nextLine, 0, nextLine, 0); + this.editor.revealRange( + new Range(nextLine, 0, nextLine, 0), + TextEditorRevealType.InCenterIfOutsideViewport, + ); + } } - @log() - async onProvideAnnotation(context?: ChangesAnnotationContext): Promise { - const scope = getLogScope(); + override previousChange() { + if (this.sortedHunkStarts == null) return; - if (this.mustReopen(context)) { - this.clear(); + let previousLine = -1; + const currentLine = this.editor.selection.active.line; + for (const line of this.sortedHunkStarts) { + if (line >= currentLine) break; + + previousLine = line; } - this.annotationContext = context; + if (previousLine === -1) { + previousLine = this.sortedHunkStarts[this.sortedHunkStarts.length - 1]; + } + + if (previousLine > 0) { + this.editor.selection = new Selection(previousLine, 0, previousLine, 0); + this.editor.revealRange( + new Range(previousLine, 0, previousLine, 0), + TextEditorRevealType.InCenterIfOutsideViewport, + ); + } + } + + @log() + override async onProvideAnnotation(context?: ChangesAnnotationContext, state?: AnnotationState): Promise { + const scope = getLogScope(); let ref1 = this.trackedDocument.uri.sha; let ref2 = context?.sha != null && context.sha !== ref1 ? `${context.sha}^` : undefined; @@ -129,18 +156,19 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase(d?: T): d is T => Boolean(d)); + ) + .map(d => getSettledValue(d)) + .filter((d?: T): d is T => Boolean(d)); if (!diffs?.length) return false; - const sw = maybeStopWatch(scope); + using sw = maybeStopWatch(scope); - const decorationsMap = new Map< - string, - { decorationType: TextEditorDecorationType; rangesOrOptions: DecorationOptions[] } - >(); + const decorationsMap = new Map>(); // If we want to only show changes from the specified sha, get the blame so we can compare with "visible" shas const blame = @@ -170,6 +197,8 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase hunk.current.count) { - state = 'removed'; - } else { - count--; - continue; - } - } else { - count--; - continue; - } - } else if (hunkLine.current?.state === 'added') { - if (hunkLine.previous?.state === 'removed') { - state = 'changed'; - } else { - state = 'added'; - } - } else if (hunkLine?.current.state === 'removed') { - // Check if there are more deleted lines than added lines show a deleted indicator - if (hunk.previous.count > hunk.current.count) { - state = 'removed'; - } else { - count--; - continue; - } - } else { - state = 'changed'; - } - - let decoration = decorationsMap.get(state); + let decoration = decorationsMap.get(hunkLine.state); if (decoration == null) { decoration = { - decorationType: (state === 'added' + decorationType: (hunkLine.state === 'added' ? Decorations.changesLineAddedAnnotation - : state === 'removed' - ? Decorations.changesLineDeletedAnnotation - : Decorations.changesLineChangedAnnotation)!, + : hunkLine.state === 'removed' + ? Decorations.changesLineDeletedAnnotation + : Decorations.changesLineChangedAnnotation)!, rangesOrOptions: [{ range: range }], }; - decorationsMap.set(state, decoration); + decorationsMap.set(hunkLine.state, decoration); } else { decoration.rangesOrOptions.push({ range: range }); } @@ -258,6 +251,8 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase a - b); + sw?.restart({ suffix: ' to compute recent changes annotations' }); if (decorationsMap.size) { @@ -265,7 +260,7 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase this.provideHover(document, position, token), @@ -302,7 +298,7 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase hunk.current.count; + const hasMoreDeletedLines = /*hunk.state === 'changed' &&*/ hunk.previous.count > hunk.current.count; if ( position.line >= hunk.current.position.start - 1 && position.line <= hunk.current.position.end - (hasMoreDeletedLines ? 0 : 1) diff --git a/src/annotations/gutterHeatmapBlameAnnotationProvider.ts b/src/annotations/gutterHeatmapBlameAnnotationProvider.ts index 09e6881b03998..0013b2e2bda39 100644 --- a/src/annotations/gutterHeatmapBlameAnnotationProvider.ts +++ b/src/annotations/gutterHeatmapBlameAnnotationProvider.ts @@ -1,37 +1,31 @@ -import type { TextEditor, TextEditorDecorationType } from 'vscode'; +import type { TextEditor } from 'vscode'; import { Range } from 'vscode'; -import type { FileAnnotationType } from '../config'; import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; import { log } from '../system/decorators/log'; import { getLogScope } from '../system/logger.scope'; import { maybeStopWatch } from '../system/stopwatch'; -import type { GitDocumentState } from '../trackers/gitDocumentTracker'; -import type { TrackedDocument } from '../trackers/trackedDocument'; -import type { AnnotationContext } from './annotationProvider'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { AnnotationContext, AnnotationState } from './annotationProvider'; +import type { Decoration } from './annotations'; import { addOrUpdateGutterHeatmapDecoration } from './annotations'; import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; export class GutterHeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase { - constructor(editor: TextEditor, trackedDocument: TrackedDocument, container: Container) { - super('heatmap', editor, trackedDocument, container); + constructor(container: Container, editor: TextEditor, trackedDocument: TrackedGitDocument) { + super(container, 'heatmap', editor, trackedDocument); } @log() - async onProvideAnnotation(context?: AnnotationContext, _type?: FileAnnotationType): Promise { + override async onProvideAnnotation(context?: AnnotationContext, state?: AnnotationState): Promise { const scope = getLogScope(); - this.annotationContext = context; - - const blame = await this.getBlame(); + const blame = await this.getBlame(state?.recompute); if (blame == null) return false; - const sw = maybeStopWatch(scope); + using sw = maybeStopWatch(scope); - const decorationsMap = new Map< - string, - { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] } - >(); + const decorationsMap = new Map>(); const computedHeatmap = this.getComputedHeatmap(blame); let commit: GitCommit | undefined; @@ -61,8 +55,4 @@ export class GutterHeatmapBlameAnnotationProvider extends BlameAnnotationProvide // this.registerHoverProviders(configuration.get('hovers.annotations')); return true; } - - selection(_selection?: AnnotationContext['selection']): Promise { - return Promise.resolve(); - } } diff --git a/src/annotations/lineAnnotationController.ts b/src/annotations/lineAnnotationController.ts index 5a976e1ab4897..3c87175ddf5b3 100644 --- a/src/annotations/lineAnnotationController.ts +++ b/src/annotations/lineAnnotationController.ts @@ -5,27 +5,27 @@ import type { Container } from '../container'; import { CommitFormatter } from '../git/formatters/commitFormatter'; import type { PullRequest } from '../git/models/pullRequest'; import { detailsMessage } from '../hovers/hovers'; -import type { MaybePausedResult } from '../system/cancellation'; -import { pauseOnCancelOrTimeoutMap } from '../system/cancellation'; import { configuration } from '../system/configuration'; import { debug, log } from '../system/decorators/log'; import { once } from '../system/event'; import { debounce } from '../system/function'; import { Logger } from '../system/logger'; import { getLogScope, setLogScopeExit } from '../system/logger.scope'; -import { getSettledValue } from '../system/promise'; +import type { MaybePausedResult } from '../system/promise'; +import { getSettledValue, pauseOnCancelOrTimeoutMap } from '../system/promise'; import { isTextEditor } from '../system/utils'; -import type { GitLineState, LinesChangeEvent } from '../trackers/gitLineTracker'; +import type { LinesChangeEvent, LineState } from '../trackers/lineTracker'; import { getInlineDecoration } from './annotations'; +import type { BlameFontOptions } from './gutterBlameAnnotationProvider'; const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ after: { margin: '0 0 0 3em', textDecoration: 'none', }, - rangeBehavior: DecorationRangeBehavior.ClosedOpen, + rangeBehavior: DecorationRangeBehavior.OpenOpen, }); -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) export class LineAnnotationController implements Disposable { private _cancellation: CancellationTokenSource | undefined; @@ -38,7 +38,7 @@ export class LineAnnotationController implements Disposable { once(container.onReady)(this.onReady, this), configuration.onDidChange(this.onConfigurationChanged, this), container.fileAnnotations.onDidToggleAnnotations(this.onFileAnnotationsToggled, this), - container.richRemoteProviders.onAfterDidChangeConnectionState( + container.integrations.onDidChangeConnectionState( debounce(() => void this.refresh(window.activeTextEditor), 250), ), ); @@ -152,12 +152,12 @@ export class LineAnnotationController implements Disposable { private getPullRequestsForLines( repoPath: string, - lines: Map, + lines: Map, ): Map> { const prs = new Map>(); if (lines.size === 0) return prs; - const remotePromise = this.container.git.getBestRemoteWithRichProvider(repoPath); + const remotePromise = this.container.git.getBestRemoteWithIntegration(repoPath); for (const [, state] of lines) { if (state.commit.isUncommitted) continue; @@ -204,7 +204,7 @@ export class LineAnnotationController implements Disposable { return; } - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); + const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document); if (!trackedDocument.isBlameable && this.suspended) { if (scope != null) { scope.exitDetails = ` ${GlyphChars.Dot} Skipped because the ${ @@ -238,8 +238,10 @@ export class LineAnnotationController implements Disposable { .join()}`; } + let uncommittedOnly = true; + const commitPromises = new Map>(); - const lines = new Map(); + const lines = new Map(); for (const selection of selections) { const state = this.container.lineTracker.getState(selection.active); if (state?.commit == null) { @@ -251,6 +253,9 @@ export class LineAnnotationController implements Disposable { commitPromises.set(state.commit.ref, state.commit.ensureFullDetails()); } lines.set(selection.active, state); + if (!state.commit.isUncommitted) { + uncommittedOnly = false; + } } const repoPath = trackedDocument.uri.repoPath; @@ -268,6 +273,7 @@ export class LineAnnotationController implements Disposable { } const getPullRequests = + !uncommittedOnly && repoPath != null && cfg.pullRequests.enabled && CommitFormatter.has( @@ -294,6 +300,13 @@ export class LineAnnotationController implements Disposable { prs: Map> | undefined, timeout?: number, ) { + const fontOptions: BlameFontOptions = { + family: cfg.fontFamily, + size: cfg.fontSize, + style: cfg.fontStyle, + weight: cfg.fontWeight, + }; + const decorations = []; for (const [l, state] of lines) { @@ -313,6 +326,7 @@ export class LineAnnotationController implements Disposable { pullRequest: pr?.value, pullRequestPendingMessage: `PR ${GlyphChars.Ellipsis}`, }, + fontOptions, cfg.scrollable, ) as DecorationOptions; decoration.range = editor.document.validateRange(new Range(l, maxSmallIntegerV8, l, maxSmallIntegerV8)); diff --git a/src/api/actionRunners.ts b/src/api/actionRunners.ts index dd7b3e3e6411a..1990ec59dea5d 100644 --- a/src/api/actionRunners.ts +++ b/src/api/actionRunners.ts @@ -6,12 +6,11 @@ import type { Container } from '../container'; import { registerCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; +import { getScopedCounter } from '../system/counter'; import { sortCompare } from '../system/string'; import { getQuickPickIgnoreFocusOut } from '../system/utils'; import type { Action, ActionContext, ActionRunner } from './gitlens'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) - type Actions = ActionContext['type']; const actions: Actions[] = ['createPullRequest', 'openPullRequest', 'hover.commands']; @@ -56,16 +55,7 @@ class NoActionRunnersQuickPickItem implements QuickPickItem { } } -let runnerId = 0; -function nextRunnerId() { - if (runnerId === maxSmallIntegerV8) { - runnerId = 1; - } else { - runnerId++; - } - - return runnerId; -} +const runnerIdGenerator = getScopedCounter(); class RegisteredActionRunner implements ActionRunner, Disposable { readonly id: number; @@ -75,7 +65,7 @@ class RegisteredActionRunner implements private readonly runner: ActionRunner, private readonly unregister: () => void, ) { - this.id = nextRunnerId(); + this.id = runnerIdGenerator.next(); } dispose() { @@ -198,13 +188,13 @@ export class ActionRunners implements Disposable { const runnersMap = this._actionRunners; const registeredRunner = new RegisteredActionRunner(type, runner, function (this: RegisteredActionRunner) { - if (runners!.length === 1) { + if (runners.length === 1) { runnersMap.delete(action); onChanged(action); } else { - const index = runners!.indexOf(this); + const index = runners.indexOf(this); if (index !== -1) { - runners!.splice(index, 1); + runners.splice(index, 1); } } }); diff --git a/src/api/api.ts b/src/api/api.ts index 01632b9e3cd33..7b64287d985b5 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -43,7 +43,7 @@ export function preview() { descriptor.value = function (this: any, ...args: any[]) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return - if (Container.instance.prereleaseOrDebugging) return fn!.apply(this, args); + if (Container.instance.prereleaseOrDebugging) return fn.apply(this, args); console.error('GitLens preview APIs are only available in the pre-release edition'); return emptyDisposable; diff --git a/src/avatars.ts b/src/avatars.ts index fe17de42d20e6..fcf8a4a877d89 100644 --- a/src/avatars.ts +++ b/src/avatars.ts @@ -11,7 +11,7 @@ import { filterMap } from './system/iterable'; import { base64, equalsIgnoreCase } from './system/string'; import type { ContactPresenceStatus } from './vsls/vsls'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) let avatarCache: Map | undefined; const avatarQueue = new Map>(); @@ -125,7 +125,13 @@ function getAvatarUriCore( const avatar = createOrUpdateAvatar(key, email, size, hash, options?.defaultStyle); if (avatar.uri != null) return avatar.uri; - if (!options?.cached && repoPathOrCommit != null && getContext('gitlens:hasConnectedRemotes')) { + if ( + !options?.cached && + repoPathOrCommit != null && + getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes( + typeof repoPathOrCommit === 'string' ? repoPathOrCommit : repoPathOrCommit.repoPath, + ) + ) { let query = avatarQueue.get(key); if (query == null && hasAvatarExpired(avatar)) { query = getAvatarUriFromRemoteProvider(avatar, key, email, repoPathOrCommit, { size: size }).then( @@ -220,8 +226,14 @@ async function getAvatarUriFromRemoteProvider( // account = await remote?.provider.getAccountForEmail(email, { avatarSize: size }); // } else { if (typeof repoPathOrCommit !== 'string') { - const remote = await Container.instance.git.getBestRemoteWithRichProvider(repoPathOrCommit.repoPath); - account = await remote?.provider.getAccountForCommit(repoPathOrCommit.ref, { avatarSize: size }); + const remote = await Container.instance.git.getBestRemoteWithIntegration(repoPathOrCommit.repoPath); + if (remote?.hasIntegration()) { + account = await ( + await remote.getIntegration() + )?.getAccountForCommit(remote.provider.repoDesc, repoPathOrCommit.ref, { + avatarSize: size, + }); + } } if (account?.avatarUrl == null) { diff --git a/src/cache.ts b/src/cache.ts index 7e77eb7e1ad7b..29ec62d3e40e7 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,22 +1,24 @@ // import type { EnrichedAutolink } from './annotations/autolinks'; import type { Disposable } from './api/gitlens'; import type { Container } from './container'; +import type { Account } from './git/models/author'; import type { DefaultBranch } from './git/models/defaultBranch'; import type { IssueOrPullRequest } from './git/models/issue'; import type { PullRequest } from './git/models/pullRequest'; -import type { GitRemote } from './git/models/remote'; import type { RepositoryMetadata } from './git/models/repositoryMetadata'; -import type { RemoteProvider } from './git/remotes/remoteProvider'; -import type { RichRemoteProvider } from './git/remotes/richRemoteProvider'; +import type { HostingIntegration, IntegrationBase, ResourceDescriptor } from './plus/integrations/integration'; import { isPromise } from './system/promise'; type Caches = { defaultBranch: { key: `repo:${string}`; value: DefaultBranch }; // enrichedAutolinksBySha: { key: `sha:${string}:${string}`; value: Map }; issuesOrPrsById: { key: `id:${string}:${string}`; value: IssueOrPullRequest }; + issuesOrPrsByIdAndRepo: { key: `id:${string}:${string}:${string}`; value: IssueOrPullRequest }; prByBranch: { key: `branch:${string}:${string}`; value: PullRequest }; + prsById: { key: `id:${string}:${string}`; value: PullRequest }; prsBySha: { key: `sha:${string}:${string}`; value: PullRequest }; repoMetadata: { key: `repo:${string}`; value: RepositoryMetadata }; + currentAccount: { key: `id:${string}`; value: Account }; }; type Cache = keyof Caches; @@ -28,11 +30,13 @@ type Cacheable = () => { value: CacheResult; expiresAt?: number }; type Cached = | { value: T | undefined; + cachedAt: number; expiresAt?: number; etag?: string; } | { value: Promise; + cachedAt: number; expiresAt?: never; // Don't set an expiration on promises as they will resolve to a value with the desired expiration etag?: string; }; @@ -56,12 +60,24 @@ export class CacheProvider implements Disposable { key: CacheKey, etag: string | undefined, cacheable: Cacheable>, + options?: { expiryOverride?: boolean | number }, ): CacheResult> { const item = this._cache.get(`${cache}:${key}`); + // Allow the caller to override the expiry + let expiry; + if (item != null) { + if (typeof options?.expiryOverride === 'number' && options.expiryOverride > 0) { + expiry = item.cachedAt + options.expiryOverride; + } else { + expiry = item.expiresAt; + } + } + if ( item == null || - (item.expiresAt != null && item.expiresAt > 0 && item.expiresAt < Date.now()) || + options?.expiryOverride === true || + (expiry != null && expiry > 0 && expiry < Date.now()) || (item.etag != null && item.etag !== etag) ) { const { value, expiresAt } = cacheable(); @@ -71,60 +87,115 @@ export class CacheProvider implements Disposable { return item.value as CacheResult>; } - getIssueOrPullRequest( - id: string, - remoteOrProvider: RichRemoteProvider | GitRemote, - cacheable: Cacheable, - ): CacheResult { - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); - return this.get('issuesOrPrsById', `id:${id}:${key}`, etag, cacheable); + getCurrentAccount( + integration: IntegrationBase, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getIntegrationKeyAndEtag(integration); + return this.get('currentAccount', `id:${key}`, etag, cacheable, options); } // getEnrichedAutolinks( // sha: string, - // remoteOrProvider: RichRemoteProvider | GitRemote, + // remoteOrProvider: Integration, // cacheable: Cacheable>, + // options?: { force?: boolean }, // ): CacheResult> { // const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); - // return this.get('enrichedAutolinksBySha', `sha:${sha}:${key}`, etag, cacheable); + // return this.get('enrichedAutolinksBySha', `sha:${sha}:${key}`, etag, cacheable, options); // } + getIssueOrPullRequest( + id: string, + resource: ResourceDescriptor, + integration: IntegrationBase | undefined, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getResourceKeyAndEtag(resource, integration); + + if (resource == null) { + return this.get('issuesOrPrsById', `id:${id}:${key}`, etag, cacheable, options); + } + return this.get( + 'issuesOrPrsByIdAndRepo', + `id:${id}:${key}:${JSON.stringify(resource)}}`, + etag, + cacheable, + options, + ); + } + + getPullRequest( + id: string, + resource: ResourceDescriptor, + integration: IntegrationBase | undefined, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getResourceKeyAndEtag(resource, integration); + + if (resource == null) { + return this.get('prsById', `id:${id}:${key}`, etag, cacheable, options); + } + return this.get('prsById', `id:${id}:${key}:${JSON.stringify(resource)}}`, etag, cacheable, options); + } + getPullRequestForBranch( branch: string, - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: ResourceDescriptor, + integration: HostingIntegration | undefined, cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, ): CacheResult { - const cache = 'prByBranch'; - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); + const { key, etag } = getResourceKeyAndEtag(repo, integration); // Wrap the cacheable so we can also add the result to the issuesOrPrsById cache - return this.get(cache, `branch:${branch}:${key}`, etag, this.wrapPullRequestCacheable(cacheable, key, etag)); + return this.get( + 'prByBranch', + `branch:${branch}:${key}`, + etag, + this.wrapPullRequestCacheable(cacheable, key, etag), + options, + ); } getPullRequestForSha( sha: string, - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: ResourceDescriptor, + integration: HostingIntegration | undefined, cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, ): CacheResult { - const cache = 'prsBySha'; - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); + const { key, etag } = getResourceKeyAndEtag(repo, integration); // Wrap the cacheable so we can also add the result to the issuesOrPrsById cache - return this.get(cache, `sha:${sha}:${key}`, etag, this.wrapPullRequestCacheable(cacheable, key, etag)); + return this.get( + 'prsBySha', + `sha:${sha}:${key}`, + etag, + this.wrapPullRequestCacheable(cacheable, key, etag), + options, + ); } getRepositoryDefaultBranch( - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: ResourceDescriptor, + integration: HostingIntegration | undefined, cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, ): CacheResult { - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); - return this.get('defaultBranch', `repo:${key}`, etag, cacheable); + const { key, etag } = getResourceKeyAndEtag(repo, integration); + return this.get('defaultBranch', `repo:${key}`, etag, cacheable, options); } getRepositoryMetadata( - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: ResourceDescriptor, + integration: HostingIntegration | undefined, cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, ): CacheResult { - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); - return this.get('repoMetadata', `repo:${key}`, etag, cacheable); + const { key, etag } = getResourceKeyAndEtag(repo, integration); + return this.get('repoMetadata', `repo:${key}`, etag, cacheable, options); } private set( @@ -145,9 +216,14 @@ export class CacheProvider implements Disposable { }, ); - item = { value: value, etag: etag }; + item = { value: value, etag: etag, cachedAt: Date.now() }; } else { - item = { value: value, etag: etag, expiresAt: expiresAt ?? getExpiresAt(cache, value) }; + item = { + value: value, + etag: etag, + cachedAt: Date.now(), + expiresAt: expiresAt ?? getExpiresAt(cache, value), + }; } this._cache.set(`${cache}:${key}`, item); @@ -181,8 +257,10 @@ function getExpiresAt(cache: T, value: CacheValue | undefine switch (cache) { case 'defaultBranch': case 'repoMetadata': + case 'currentAccount': return 0; // Never expires - case 'issuesOrPrsById': { + case 'issuesOrPrsById': + case 'issuesOrPrsByIdAndRepo': { if (value == null) return 0; // Never expires // Open issues expire after 1 hour, but closed issues expire after 12 hours unless recently updated and then expire in 1 hour @@ -190,19 +268,20 @@ function getExpiresAt(cache: T, value: CacheValue | undefine const issueOrPr = value as CacheValue<'issuesOrPrsById'>; if (!issueOrPr.closed) return defaultExpiresAt; - const updatedAgo = now - (issueOrPr.closedDate ?? issueOrPr.date).getTime(); + const updatedAgo = now - (issueOrPr.closedDate ?? issueOrPr.updatedDate).getTime(); return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000; } case 'prByBranch': + case 'prsById': case 'prsBySha': { if (value == null) return cache === 'prByBranch' ? defaultExpiresAt : 0 /* Never expires */; // Open prs expire after 1 hour, but closed/merge prs expire after 12 hours unless recently updated and then expire in 1 hour - const pr = value as CacheValue<'prsBySha'>; + const pr = value as CacheValue<'prByBranch' | 'prsById' | 'prsBySha'>; if (pr.state === 'opened') return defaultExpiresAt; - const updatedAgo = now - (pr.closedDate ?? pr.mergedDate ?? pr.date).getTime(); + const updatedAgo = now - (pr.closedDate ?? pr.mergedDate ?? pr.updatedDate).getTime(); return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000; } // case 'enrichedAutolinksBySha': @@ -211,11 +290,10 @@ function getExpiresAt(cache: T, value: CacheValue | undefine } } -function getRemoteKeyAndEtag(remoteOrProvider: RemoteProvider | GitRemote) { - return { - key: remoteOrProvider.remoteKey, - etag: remoteOrProvider.hasRichIntegration() - ? `${remoteOrProvider.remoteKey}:${remoteOrProvider.maybeConnected ?? false}` - : remoteOrProvider.remoteKey, - }; +function getResourceKeyAndEtag(resource: ResourceDescriptor, integration?: HostingIntegration | IntegrationBase) { + return { key: resource.key, etag: `${resource.key}:${integration?.maybeConnected ?? false}` }; +} + +function getIntegrationKeyAndEtag(integration: IntegrationBase) { + return { key: integration.id, etag: `${integration.id}:${integration.maybeConnected ?? false}` }; } diff --git a/src/codelens/codeLensController.ts b/src/codelens/codeLensController.ts index 1208362fa0277..cf9b765b052c4 100644 --- a/src/codelens/codeLensController.ts +++ b/src/codelens/codeLensController.ts @@ -5,16 +5,13 @@ import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; import { once } from '../system/event'; import { Logger } from '../system/logger'; -import type { - DocumentBlameStateChangeEvent, - DocumentDirtyIdleTriggerEvent, - GitDocumentState, -} from '../trackers/gitDocumentTracker'; +import type { DocumentBlameStateChangeEvent, DocumentDirtyIdleTriggerEvent } from '../trackers/documentTracker'; +import type { GitCodeLensProvider } from './codeLensProvider'; export class GitCodeLensController implements Disposable { private _canToggle: boolean = false; private _disposable: Disposable | undefined; - private _provider: import('./codeLensProvider').GitCodeLensProvider | undefined; + private _provider: GitCodeLensProvider | undefined; private _providerDisposable: Disposable | undefined; constructor(private readonly container: Container) { @@ -52,22 +49,19 @@ export class GitCodeLensController implements Disposable { } } - private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { + private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { // Only reset if we have saved, since the CodeLens won't naturally be re-rendered if (this._provider == null || !e.blameable) return; Logger.log('Blame state changed; resetting CodeLens provider'); - this._provider.reset('saved'); + this._provider.reset(); } - private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { + private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { if (this._provider == null || !e.document.isBlameable) return; - const maxLines = configuration.get('advanced.blame.sizeThresholdAfterEdit'); - if (maxLines > 0 && e.document.lineCount > maxLines) return; - Logger.log('Dirty idle triggered; resetting CodeLens provider'); - this._provider.reset('idle'); + this._provider.reset(); } toggleCodeLens() { @@ -98,8 +92,8 @@ export class GitCodeLensController implements Disposable { this._provider = new GitCodeLensProvider(this.container); this._providerDisposable = Disposable.from( languages.registerCodeLensProvider(GitCodeLensProvider.selector, this._provider), - this.container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), - this.container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), + this.container.documentTracker.onDidChangeBlameState(this.onBlameStateChanged, this), + this.container.documentTracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), ); } } diff --git a/src/codelens/codeLensProvider.ts b/src/codelens/codeLensProvider.ts index 4e7ed32e1b0ca..71faa474076b4 100644 --- a/src/codelens/codeLensProvider.ts +++ b/src/codelens/codeLensProvider.ts @@ -9,15 +9,13 @@ import type { Uri, } from 'vscode'; import { CodeLens, EventEmitter, Location, Position, Range, SymbolInformation, SymbolKind } from 'vscode'; -import type { - DiffWithPreviousCommandArgs, - OpenOnRemoteCommandArgs, - ShowCommitsInViewCommandArgs, - ShowQuickCommitCommandArgs, - ShowQuickCommitFileCommandArgs, - ShowQuickFileHistoryCommandArgs, - ToggleFileChangesAnnotationCommandArgs, -} from '../commands'; +import type { DiffWithPreviousCommandArgs } from '../commands/diffWithPrevious'; +import type { OpenOnRemoteCommandArgs } from '../commands/openOnRemote'; +import type { ShowCommitsInViewCommandArgs } from '../commands/showCommitsInView'; +import type { ShowQuickCommitCommandArgs } from '../commands/showQuickCommit'; +import type { ShowQuickCommitFileCommandArgs } from '../commands/showQuickCommitFile'; +import type { ShowQuickFileHistoryCommandArgs } from '../commands/showQuickFileHistory'; +import type { ToggleFileChangesAnnotationCommandArgs } from '../commands/toggleFileAnnotations'; import type { CodeLensConfig, CodeLensLanguageScope } from '../config'; import { CodeLensCommand } from '../config'; import { Commands, Schemes } from '../constants'; @@ -93,7 +91,7 @@ export class GitCodeLensProvider implements CodeLensProvider { constructor(private readonly container: Container) {} - reset(_reason?: 'idle' | 'saved') { + reset() { this._onDidChangeCodeLenses.fire(); } @@ -101,20 +99,13 @@ export class GitCodeLensProvider implements CodeLensProvider { // Since we can't currently blame edited virtual documents, don't even attempt anything if dirty if (document.isDirty && isVirtualUri(document.uri)) return []; - const trackedDocument = await this.container.tracker.getOrAdd(document); + const trackedDocument = await this.container.documentTracker.getOrAdd(document); if (!trackedDocument.isBlameable) return []; let dirty = false; - if (document.isDirty) { - // Only allow dirty blames if we are idle - if (trackedDocument.isDirtyIdle) { - const maxLines = configuration.get('advanced.blame.sizeThresholdAfterEdit'); - if (maxLines > 0 && document.lineCount > maxLines) { - dirty = true; - } - } else { - dirty = true; - } + // Only allow dirty blames if we are idle + if (document.isDirty && !trackedDocument.isDirtyIdle) { + dirty = true; } const cfg = configuration.get('codeLens', document); @@ -460,7 +451,7 @@ export class GitCodeLensProvider implements CodeLensProvider { resolveCodeLens(lens: CodeLens, token: CancellationToken): CodeLens | Promise { if (lens instanceof GitRecentChangeCodeLens) return this.resolveGitRecentChangeCodeLens(lens, token); if (lens instanceof GitAuthorsCodeLens) return this.resolveGitAuthorsCodeLens(lens, token); - // eslint-disable-next-line prefer-promise-reject-errors + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors return Promise.reject(undefined); } @@ -546,8 +537,11 @@ export class GitCodeLensProvider implements CodeLensProvider { const count = blame.authors.size; const author = first(blame.authors.values())?.name ?? 'Unknown'; + const andOthers = count > 1 + ? ` and ${pluralize('one other', count - 1, { only: true, plural: 'others' })}` + : ''; - let title = `${pluralize('author', count, { zero: '?' })} (${author}${count > 1 ? ' and others' : ''})`; + let title = `${pluralize('author', count, { zero: '?' })} (${author}${andOthers})`; if (configuration.get('debug')) { title += ` [${lens.languageId}: ${SymbolKind[lens.symbol.kind]}(${lens.range.start.character}-${ lens.range.end.character diff --git a/src/commands.ts b/src/commands.ts index 8384f9c267e52..7c7943e495219 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,67 +1,71 @@ -export * from './commands/addAuthors'; -export * from './commands/browseRepoAtRevision'; -export * from './commands/closeUnchangedFiles'; -export * from './commands/compareWith'; -export * from './commands/copyCurrentBranch'; -export * from './commands/copyDeepLink'; -export * from './commands/copyMessageToClipboard'; -export * from './commands/copyShaToClipboard'; -export * from './commands/copyRelativePathToClipboard'; -export * from './commands/createPullRequestOnRemote'; -export * from './commands/openDirectoryCompare'; -export * from './commands/diffLineWithPrevious'; -export * from './commands/diffLineWithWorking'; -export * from './commands/diffWith'; -export * from './commands/diffWithNext'; -export * from './commands/diffWithPrevious'; -export * from './commands/diffWithRevision'; -export * from './commands/diffWithRevisionFrom'; -export * from './commands/diffWithWorking'; -export * from './commands/externalDiff'; -export * from './commands/generateCommitMessage'; -export * from './commands/ghpr/openOrCreateWorktree'; -export * from './commands/gitCommands'; -export * from './commands/inviteToLiveShare'; -export * from './commands/logging'; -export * from './commands/openAssociatedPullRequestOnRemote'; -export * from './commands/openBranchesOnRemote'; -export * from './commands/openBranchOnRemote'; -export * from './commands/openCurrentBranchOnRemote'; -export * from './commands/openChangedFiles'; -export * from './commands/openCommitOnRemote'; -export * from './commands/openComparisonOnRemote'; -export * from './commands/openFileFromRemote'; -export * from './commands/openFileOnRemote'; -export * from './commands/openFileAtRevision'; -export * from './commands/openFileAtRevisionFrom'; -export * from './commands/openOnRemote'; -export * from './commands/openIssueOnRemote'; -export * from './commands/openPullRequestOnRemote'; -export * from './commands/openRepoOnRemote'; -export * from './commands/openRevisionFile'; -export * from './commands/openWorkingFile'; -export * from './commands/rebaseEditor'; -export * from './commands/refreshHover'; -export * from './commands/remoteProviders'; -export * from './commands/repositories'; -export * from './commands/resets'; -export * from './commands/resetViewsLayout'; -export * from './commands/searchCommits'; -export * from './commands/showCommitsInView'; -export * from './commands/showLastQuickPick'; -export * from './commands/openOnlyChangedFiles'; -export * from './commands/showQuickBranchHistory'; -export * from './commands/showQuickCommit'; -export * from './commands/showQuickCommitFile'; -export * from './commands/showQuickFileHistory'; -export * from './commands/showQuickRepoStatus'; -export * from './commands/showQuickStashList'; -export * from './commands/showView'; -export * from './commands/stashApply'; -export * from './commands/stashSave'; -export * from './commands/switchAIModel'; -export * from './commands/switchMode'; -export * from './commands/toggleCodeLens'; -export * from './commands/toggleFileAnnotations'; -export * from './commands/toggleLineBlame'; -export * from './commands/walkthroughs'; +import './commands/addAuthors'; +import './commands/browseRepoAtRevision'; +import './commands/closeUnchangedFiles'; +import './commands/cloudIntegrations'; +import './commands/compareWith'; +import './commands/copyCurrentBranch'; +import './commands/copyDeepLink'; +import './commands/copyMessageToClipboard'; +import './commands/copyShaToClipboard'; +import './commands/copyRelativePathToClipboard'; +import './commands/createPullRequestOnRemote'; +import './commands/openDirectoryCompare'; +import './commands/diffFolderWithRevision'; +import './commands/diffFolderWithRevisionFrom'; +import './commands/diffLineWithPrevious'; +import './commands/diffLineWithWorking'; +import './commands/diffWith'; +import './commands/diffWithNext'; +import './commands/diffWithPrevious'; +import './commands/diffWithRevision'; +import './commands/diffWithRevisionFrom'; +import './commands/diffWithWorking'; +import './commands/externalDiff'; +import './commands/generateCommitMessage'; +import './commands/ghpr/openOrCreateWorktree'; +import './commands/gitCommands'; +import './commands/inviteToLiveShare'; +import './commands/inspect'; +import './commands/logging'; +import './commands/openAssociatedPullRequestOnRemote'; +import './commands/openBranchesOnRemote'; +import './commands/openBranchOnRemote'; +import './commands/openCurrentBranchOnRemote'; +import './commands/openChangedFiles'; +import './commands/openCommitOnRemote'; +import './commands/openComparisonOnRemote'; +import './commands/openFileFromRemote'; +import './commands/openFileOnRemote'; +import './commands/openFileAtRevision'; +import './commands/openFileAtRevisionFrom'; +import './commands/openOnRemote'; +import './commands/openPullRequestOnRemote'; +import './commands/openRepoOnRemote'; +import './commands/openRevisionFile'; +import './commands/openWorkingFile'; +import './commands/patches'; +import './commands/rebaseEditor'; +import './commands/refreshHover'; +import './commands/remoteProviders'; +import './commands/repositories'; +import './commands/resets'; +import './commands/resetViewsLayout'; +import './commands/searchCommits'; +import './commands/showCommitsInView'; +import './commands/showLastQuickPick'; +import './commands/openOnlyChangedFiles'; +import './commands/showQuickBranchHistory'; +import './commands/showQuickCommit'; +import './commands/showQuickCommitFile'; +import './commands/showQuickFileHistory'; +import './commands/showQuickRepoStatus'; +import './commands/showQuickStashList'; +import './commands/showView'; +import './commands/stashApply'; +import './commands/stashSave'; +import './commands/switchAIModel'; +import './commands/switchMode'; +import './commands/toggleCodeLens'; +import './commands/toggleFileAnnotations'; +import './commands/toggleLineBlame'; +import './commands/walkthroughs'; diff --git a/src/commands/base.ts b/src/commands/base.ts index 6d29bda2cda98..8a2dbbf8ad1aa 100644 --- a/src/commands/base.ts +++ b/src/commands/base.ts @@ -14,16 +14,20 @@ import type { GitBranch } from '../git/models/branch'; import { isBranch } from '../git/models/branch'; import type { GitCommit, GitStashCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; -import { GitContributor } from '../git/models/contributor'; +import type { GitContributor } from '../git/models/contributor'; +import { isContributor } from '../git/models/contributor'; import type { GitFile } from '../git/models/file'; import type { GitReference } from '../git/models/reference'; -import { GitRemote } from '../git/models/remote'; +import type { GitRemote } from '../git/models/remote'; +import { isRemote } from '../git/models/remote'; import { Repository } from '../git/models/repository'; import type { GitTag } from '../git/models/tag'; import { isTag } from '../git/models/tag'; +import { CloudWorkspace, LocalWorkspace } from '../plus/workspaces/models'; import { registerCommand } from '../system/command'; import { sequentialize } from '../system/function'; -import { ViewNode, ViewRefFileNode, ViewRefNode } from '../views/nodes/viewNode'; +import { ViewNode } from '../views/nodes/abstract/viewNode'; +import { ViewRefFileNode, ViewRefNode } from '../views/nodes/abstract/viewRefNode'; export function getCommandUri(uri?: Uri, editor?: TextEditor): Uri | undefined { // Always use the editor.uri (if we have one), so we are correct for a split diff @@ -124,7 +128,7 @@ export function isCommandContextViewNodeHasContributor( ): context is CommandViewNodeContext & { node: ViewNode & { contributor: GitContributor } } { if (context.type !== 'viewItem') return false; - return GitContributor.is((context.node as ViewNode & { contributor: GitContributor }).contributor); + return isContributor((context.node as ViewNode & { contributor: GitContributor }).contributor); } export function isCommandContextViewNodeHasFile( @@ -185,7 +189,7 @@ export function isCommandContextViewNodeHasRemote( ): context is CommandViewNodeContext & { node: ViewNode & { remote: GitRemote } } { if (context.type !== 'viewItem') return false; - return GitRemote.is((context.node as ViewNode & { remote: GitRemote }).remote); + return isRemote((context.node as ViewNode & { remote: GitRemote }).remote); } export function isCommandContextViewNodeHasRepository( @@ -212,6 +216,14 @@ export function isCommandContextViewNodeHasTag( return isTag((context.node as ViewNode & { tag: GitTag }).tag); } +export function isCommandContextViewNodeHasWorkspace( + context: CommandContext, +): context is CommandViewNodeContext & { node: ViewNode & { workspace: CloudWorkspace | LocalWorkspace } } { + if (context.type !== 'viewItem') return false; + const workspace = (context.node as ViewNode & { workspace?: CloudWorkspace | LocalWorkspace }).workspace; + return workspace instanceof CloudWorkspace || workspace instanceof LocalWorkspace; +} + export type CommandContext = | CommandEditorLineContext | CommandGitTimelineItemContext @@ -225,7 +237,7 @@ export type CommandContext = | CommandViewNodeContext | CommandViewNodesContext; -function isScm(scm: any): scm is SourceControl { +export function isScm(scm: any): scm is SourceControl { if (scm == null) return false; return ( @@ -275,7 +287,8 @@ export abstract class Command implements Disposable { command: Commands | `${Commands.ActionPrefix}${ActionContext['type']}`, args: T, ): string { - return `command:${command}?${encodeURIComponent(JSON.stringify(args))}`; + // Since we are using the command in a markdown link, we need to escape ()'s so they don't get interpreted as markdown + return `command:${command}?${encodeURIComponent(JSON.stringify(args)).replace(/([()])/g, '\\$1')}`; } protected readonly contextParsingOptions: CommandContextParsingOptions = { expectsEditor: false }; diff --git a/src/commands/cloudIntegrations.ts b/src/commands/cloudIntegrations.ts new file mode 100644 index 0000000000000..767866ed4c753 --- /dev/null +++ b/src/commands/cloudIntegrations.ts @@ -0,0 +1,24 @@ +import type { Source } from '../constants'; +import { Commands } from '../constants'; +import type { Container } from '../container'; +import type { SupportedCloudIntegrationIds } from '../plus/integrations/authentication/models'; +import { command } from '../system/command'; +import { Command } from './base'; + +export interface ManageCloudIntegrationsCommandArgs extends Source { + integrationId?: SupportedCloudIntegrationIds; +} + +@command() +export class ManageCloudIntegrationsCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.PlusManageCloudIntegrations); + } + + async execute(args?: ManageCloudIntegrationsCommandArgs) { + await this.container.integrations.manageCloudIntegrations( + args?.integrationId ? { integrationId: args.integrationId } : undefined, + args?.source ? { source: args.source, detail: args?.detail } : undefined, + ); + } +} diff --git a/src/commands/copyDeepLink.ts b/src/commands/copyDeepLink.ts index fbf548caa9832..b8ed4b45c91a2 100644 --- a/src/commands/copyDeepLink.ts +++ b/src/commands/copyDeepLink.ts @@ -5,21 +5,26 @@ import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { getBranchNameAndRemote } from '../git/models/branch'; import type { GitReference } from '../git/models/reference'; +import { createReference } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../quickpicks/referencePicker'; import { showRemotePicker } from '../quickpicks/remotePicker'; import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; import { command } from '../system/command'; import { Logger } from '../system/logger'; +import { normalizePath } from '../system/path'; import { DeepLinkType, deepLinkTypeToString, refTypeToDeepLinkType } from '../uris/deepLinks/deepLink'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri, + isCommandContextEditorLine, isCommandContextViewNodeHasBranch, isCommandContextViewNodeHasCommit, isCommandContextViewNodeHasComparison, isCommandContextViewNodeHasRemote, isCommandContextViewNodeHasTag, + isCommandContextViewNodeHasWorkspace, } from './base'; export interface CopyDeepLinkCommandArgs { @@ -28,6 +33,7 @@ export interface CopyDeepLinkCommandArgs { compareWithRef?: StoredNamedRef; remote?: string; prePickRemote?: boolean; + workspaceId?: string; } @command() @@ -39,6 +45,7 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { Commands.CopyDeepLinkToRepo, Commands.CopyDeepLinkToTag, Commands.CopyDeepLinkToComparison, + Commands.CopyDeepLinkToWorkspace, ]); } @@ -47,7 +54,14 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { if (isCommandContextViewNodeHasCommit(context)) { args = { refOrRepoPath: context.node.commit }; } else if (isCommandContextViewNodeHasBranch(context)) { - args = { refOrRepoPath: context.node.branch }; + if (context.command === Commands.CopyDeepLinkToRepo) { + args = { + refOrRepoPath: context.node.branch.repoPath, + remote: context.node.branch.getRemoteName(), + }; + } else { + args = { refOrRepoPath: context.node.branch }; + } } else if (isCommandContextViewNodeHasTag(context)) { args = { refOrRepoPath: context.node.tag }; } else if (isCommandContextViewNodeHasRemote(context)) { @@ -58,6 +72,8 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { compareRef: context.node.compareRef, compareWithRef: context.node.compareWithRef, }; + } else if (isCommandContextViewNodeHasWorkspace(context)) { + args = { workspaceId: context.node.workspace.id }; } } @@ -67,6 +83,16 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { async execute(editor?: TextEditor, uri?: Uri, args?: CopyDeepLinkCommandArgs) { args = { ...args }; + if (args.workspaceId != null) { + try { + await this.container.deepLinks.copyDeepLinkUrl(args.workspaceId); + } catch (ex) { + Logger.error(ex, 'CopyDeepLinkCommand'); + void showGenericErrorMessage('Unable to copy link'); + } + return; + } + let type; let repoPath; if (args?.refOrRepoPath == null) { @@ -120,7 +146,8 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { }, ); if (pick == null) return; - chosenRemote = pick.item; + + chosenRemote = pick; } if (chosenRemote == null) return; @@ -141,3 +168,157 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { } } } + +export interface CopyFileDeepLinkCommandArgs { + ref?: GitReference; + filePath?: string; + lines?: number[]; + repoPath?: string; + remote?: string; + prePickRemote?: boolean; + chooseRef?: boolean; +} + +@command() +export class CopyFileDeepLinkCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super([Commands.CopyDeepLinkToFile, Commands.CopyDeepLinkToFileAtRevision, Commands.CopyDeepLinkToLines]); + } + + protected override preExecute(context: CommandContext, args?: CopyFileDeepLinkCommandArgs) { + if (args == null) { + args = {}; + } + + if (args.ref == null && context.command === Commands.CopyDeepLinkToFileAtRevision) { + args.chooseRef = true; + } + + if (args.lines == null && context.command === Commands.CopyDeepLinkToLines) { + let lines: number[] | undefined; + if (isCommandContextEditorLine(context) && context.line != null) { + lines = [context.line + 1]; + } else if (context.editor?.selection != null && !context.editor.selection.isEmpty) { + if (context.editor.selection.isSingleLine) { + lines = [context.editor.selection.start.line + 1]; + } else { + lines = [context.editor.selection.start.line + 1, context.editor.selection.end.line + 1]; + } + } + + args.lines = lines; + } + + return this.execute(context.editor, context.uri, args); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: CopyFileDeepLinkCommandArgs) { + args = { ...args }; + + const type = DeepLinkType.File; + let repoPath = args?.repoPath; + let filePath = args?.filePath; + let ref = args?.ref; + if (repoPath == null || filePath == null || ref == null) { + uri = getCommandUri(uri, editor); + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + if (gitUri?.path == null || gitUri?.repoPath == null) return; + + if (repoPath == null) { + repoPath = gitUri.repoPath; + } + + if (filePath == null) { + filePath = gitUri?.fsPath; + } + + if (args?.chooseRef !== true && ref == null && repoPath != null && gitUri?.sha != null) { + ref = createReference(gitUri.sha, repoPath, { refType: 'revision' }); + } + + if (repoPath == null || filePath == null) return; + repoPath = normalizePath(repoPath); + filePath = normalizePath(filePath); + + if (!filePath.startsWith(repoPath)) { + Logger.error( + `CopyFileDeepLinkCommand: File path ${filePath} is not contained in repo path ${repoPath}`, + ); + + void showGenericErrorMessage('Unable to copy file link'); + } + + filePath = filePath.substring(repoPath.length + 1); + if (filePath.startsWith('/')) { + filePath = filePath.substring(1); + } + } + + if (!repoPath || !filePath) return; + + if (args?.chooseRef) { + const pick = await showReferencePicker( + repoPath, + `Copy Link to ${filePath} at Reference`, + 'Choose a reference (branch, tag, etc) to copy the file link for', + { + allowRevisions: true, + include: ReferencesQuickPickIncludes.All, + }, + ); + + if (pick == null) { + return; + } else if (pick.ref === '') { + ref = undefined; + } else { + ref = pick; + } + } + + if (!args.remote) { + if (args.ref?.refType === 'branch') { + // If the branch is remote, or has an upstream, pre-select the remote + if (args.ref.remote || args.ref.upstream?.name != null) { + const [branchName, remoteName] = getBranchNameAndRemote(args.ref); + if (branchName != null && remoteName != null) { + args.remote = remoteName; + args.prePickRemote = true; + } + } + } + } + + try { + let chosenRemote; + const remotes = await this.container.git.getRemotes(repoPath, { sort: true }); + const defaultRemote = remotes.find(r => r.default); + if (args.remote && !args.prePickRemote) { + chosenRemote = remotes.find(r => r.name === args?.remote); + } else if (defaultRemote != null) { + chosenRemote = defaultRemote; + } else { + const pick = await showRemotePicker( + `Copy Link to ${deepLinkTypeToString(type)}`, + `Choose which remote to copy the link for`, + remotes, + { + autoPick: true, + picked: args.remote, + setDefault: true, + }, + ); + if (pick == null) return; + + chosenRemote = pick; + } + + if (chosenRemote == null) return; + + await this.container.deepLinks.copyFileDeepLinkUrl(repoPath, filePath, chosenRemote.url, args.lines, ref); + } catch (ex) { + Logger.error(ex, 'CopyFileDeepLinkCommand'); + void showGenericErrorMessage('Unable to copy file link'); + } + } +} diff --git a/src/commands/createPullRequestOnRemote.ts b/src/commands/createPullRequestOnRemote.ts index eb4341b709d30..a7bc786593f91 100644 --- a/src/commands/createPullRequestOnRemote.ts +++ b/src/commands/createPullRequestOnRemote.ts @@ -35,6 +35,7 @@ export class CreatePullRequestOnRemoteCommand extends Command { const providerId = compareRemote.provider.id; const remotes = (await repo.getRemotes({ filter: r => r.provider?.id === providerId, + sort: true, })) as GitRemote[]; const resource: RemoteResource = { diff --git a/src/commands/diffFolderWithRevision.ts b/src/commands/diffFolderWithRevision.ts new file mode 100644 index 0000000000000..7f5d1754b75c0 --- /dev/null +++ b/src/commands/diffFolderWithRevision.ts @@ -0,0 +1,77 @@ +import type { TextDocumentShowOptions, TextEditor } from 'vscode'; +import { FileType, Uri, workspace } from 'vscode'; +import { Commands, GlyphChars } from '../constants'; +import type { Container } from '../container'; +import { openFolderCompare } from '../git/actions/commit'; +import { GitUri } from '../git/gitUri'; +import { shortenRevision } from '../git/models/reference'; +import { showGenericErrorMessage } from '../messages'; +import { showCommitPicker } from '../quickpicks/commitPicker'; +import { CommandQuickPickItem } from '../quickpicks/items/common'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { command } from '../system/command'; +import { Logger } from '../system/logger'; +import { pad } from '../system/string'; +import { ActiveEditorCommand, getCommandUri } from './base'; + +export interface DiffFolderWithRevisionCommandArgs { + uri?: Uri; + ref1?: string; + ref2?: string; + showOptions?: TextDocumentShowOptions; +} + +@command() +export class DiffFolderWithRevisionCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.DiffFolderWithRevision); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: DiffFolderWithRevisionCommandArgs): Promise { + args = { ...args }; + uri = args?.uri ?? getCommandUri(uri, editor); + if (uri == null) return; + + try { + const stat = await workspace.fs.stat(uri); + if (stat.type !== FileType.Directory) { + uri = Uri.joinPath(uri, '..'); + } + } catch {} + + const gitUri = await GitUri.fromUri(uri); + + try { + const repoPath = (await getBestRepositoryOrShowPicker(uri, editor, `Open Folder Changes with Revision`)) + ?.path; + if (!repoPath) return; + + const log = this.container.git + .getLogForFile(gitUri.repoPath, gitUri.fsPath) + .then( + log => + log ?? + (gitUri.sha + ? this.container.git.getLogForFile(gitUri.repoPath, gitUri.fsPath, { ref: gitUri.sha }) + : undefined), + ); + + const relativePath = this.container.git.getRelativePath(uri, repoPath); + const title = `Open Folder Changes with Revision${pad(GlyphChars.Dot, 2, 2)}${relativePath}${ + gitUri.sha ? ` at ${shortenRevision(gitUri.sha)}` : '' + }`; + const pick = await showCommitPicker(log, title, 'Choose a commit to compare with', { + picked: gitUri.sha, + showOtherReferences: [ + CommandQuickPickItem.fromCommand('Choose a Branch or Tag...', Commands.DiffFolderWithRevisionFrom), + ], + }); + if (pick == null) return; + + void openFolderCompare(uri, { repoPath: repoPath, lhs: pick.ref, rhs: gitUri.sha ?? '' }); + } catch (ex) { + Logger.error(ex, 'DiffFolderWithRevisionCommand'); + void showGenericErrorMessage('Unable to open comparison'); + } + } +} diff --git a/src/commands/diffFolderWithRevisionFrom.ts b/src/commands/diffFolderWithRevisionFrom.ts new file mode 100644 index 0000000000000..a2469b664facc --- /dev/null +++ b/src/commands/diffFolderWithRevisionFrom.ts @@ -0,0 +1,102 @@ +import type { TextEditor } from 'vscode'; +import { FileType, Uri, workspace } from 'vscode'; +import { Commands, GlyphChars } from '../constants'; +import type { Container } from '../container'; +import { openFolderCompare } from '../git/actions/commit'; +import { GitUri } from '../git/gitUri'; +import { shortenRevision } from '../git/models/reference'; +import { showGenericErrorMessage } from '../messages'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../quickpicks/referencePicker'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { command } from '../system/command'; +import { Logger } from '../system/logger'; +import { pad } from '../system/string'; +import { ActiveEditorCommand, getCommandUri } from './base'; + +export interface DiffFolderWithRevisionFromCommandArgs { + uri?: Uri; + lhs?: string; + rhs?: string; +} + +@command() +export class DiffFolderWithRevisionFromCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.DiffFolderWithRevisionFrom); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: DiffFolderWithRevisionFromCommandArgs): Promise { + const defaultRHS = args == null; + args = { ...args }; + uri = args?.uri ?? getCommandUri(uri, editor); + if (uri == null) return; + + try { + const stat = await workspace.fs.stat(uri); + if (stat.type !== FileType.Directory) { + uri = Uri.joinPath(uri, '..'); + } + } catch {} + + try { + const repoPath = ( + await getBestRepositoryOrShowPicker(uri, editor, 'Open Folder Changes with Branch or Tag') + )?.path; + if (!repoPath) return; + + const relativePath = this.container.git.getRelativePath(uri, repoPath); + if (args.rhs == null) { + // Default to the current sha or the working tree, if args are missing + if (defaultRHS) { + const gitUri = await GitUri.fromUri(uri); + args.rhs = gitUri.sha ?? ''; + } else { + const pick = await showReferencePicker( + repoPath, + `Open Folder Changes with Branch or Tag${pad(GlyphChars.Dot, 2, 2)}${relativePath}`, + 'Choose a reference (branch, tag, etc) to compare', + { + allowRevisions: true, + include: ReferencesQuickPickIncludes.All, + sort: { branches: { current: true }, tags: {} }, + }, + ); + if (pick?.ref == null) return; + + args.rhs = pick.ref; + } + } + + if (!args.lhs) { + const pick = await showReferencePicker( + repoPath, + `Open Folder Changes with Branch or Tag${pad(GlyphChars.Dot, 2, 2)}${relativePath}${ + args.rhs ? ` at ${shortenRevision(args.rhs)}` : '' + }`, + 'Choose a reference (branch, tag, etc) to compare with', + { + allowRevisions: true, + include: + args.rhs === '' + ? ReferencesQuickPickIncludes.All & ~ReferencesQuickPickIncludes.WorkingTree + : ReferencesQuickPickIncludes.All, + }, + ); + if (pick?.ref == null) return; + + args.lhs = pick.ref; + + // If we are trying to compare to the working tree, swap the lhs and rhs + if (args.rhs !== '' && args.lhs === '') { + args.lhs = args.rhs; + args.rhs = ''; + } + } + + void openFolderCompare(uri, { repoPath: repoPath, lhs: args.lhs, rhs: args.rhs }); + } catch (ex) { + Logger.error(ex, 'DiffFolderWithRevisionFromCommand'); + void showGenericErrorMessage('Unable to open comparison'); + } + } +} diff --git a/src/commands/diffWith.ts b/src/commands/diffWith.ts index 2001933c8ff09..694eb14cf4fb7 100644 --- a/src/commands/diffWith.ts +++ b/src/commands/diffWith.ts @@ -7,9 +7,10 @@ import { isCommit } from '../git/models/commit'; import { deletedOrMissing } from '../git/models/constants'; import { isShaLike, isUncommitted, shortenRevision } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; -import { command, executeCoreCommand } from '../system/command'; +import { command } from '../system/command'; import { Logger } from '../system/logger'; import { basename } from '../system/path'; +import { openDiffEditor } from '../system/utils'; import { Command } from './base'; export interface DiffWithCommandArgsRevision { @@ -57,6 +58,7 @@ export class DiffWithCommand extends Command { args = { repoPath: commit.repoPath, lhs: { + // Don't need to worry about verifying the previous sha, as the DiffWith command will sha: commit.unresolvedPreviousSha, uri: commit.file.originalUri ?? commit.file.uri, }, @@ -179,13 +181,12 @@ export class DiffWithCommand extends Command { args.showOptions.selection = new Range(args.line, 0, args.line, 0); } - void (await executeCoreCommand( - 'vscode.diff', + await openDiffEditor( lhs ?? this.container.git.getRevisionUri(deletedOrMissing, args.lhs.uri.fsPath, args.repoPath), rhs ?? this.container.git.getRevisionUri(deletedOrMissing, args.rhs.uri.fsPath, args.repoPath), title, args.showOptions, - )); + ); } catch (ex) { Logger.error(ex, 'DiffWithCommand', 'getVersionedFile'); void showGenericErrorMessage('Unable to open compare'); diff --git a/src/commands/diffWithRevision.ts b/src/commands/diffWithRevision.ts index 80c51a99c1c7b..2f213b83a78fc 100644 --- a/src/commands/diffWithRevision.ts +++ b/src/commands/diffWithRevision.ts @@ -6,8 +6,11 @@ import { shortenRevision } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; import { showCommitPicker } from '../quickpicks/commitPicker'; import { CommandQuickPickItem } from '../quickpicks/items/common'; +import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive } from '../quickpicks/items/directive'; import { command, executeCommand } from '../system/command'; import { Logger } from '../system/logger'; +import { splitPath } from '../system/path'; import { pad } from '../system/string'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; @@ -47,18 +50,68 @@ export class DiffWithRevisionCommand extends ActiveEditorCommand { ); const title = `Open Changes with Revision${pad(GlyphChars.Dot, 2, 2)}`; - const pick = await showCommitPicker( - log, - `${title}${gitUri.getFormattedFileName({ - suffix: gitUri.sha ? `:${shortenRevision(gitUri.sha)}` : undefined, - truncateTo: quickPickTitleMaxChars - title.length, - })}`, - 'Choose a commit to compare with', - { - picked: gitUri.sha, + const titleWithContext = `${title}${gitUri.getFormattedFileName({ + suffix: gitUri.sha ? `:${shortenRevision(gitUri.sha)}` : undefined, + truncateTo: quickPickTitleMaxChars - title.length, + })}`; + const pick = await showCommitPicker(log, titleWithContext, 'Choose a commit to compare with', { + empty: !gitUri.sha + ? { + getState: async () => { + const items: (CommandQuickPickItem | DirectiveQuickPickItem)[] = []; + + const status = await this.container.git.getStatusForRepo(gitUri.repoPath); + if (status != null) { + for (const f of status.files) { + if (f.workingTreeStatus === '?' || f.workingTreeStatus === '!') { + continue; + } + + const [label, description] = splitPath(f.path, undefined, true); + + items.push( + new CommandQuickPickItem<[Uri]>( + { + label: label, + description: description, + }, + undefined, + Commands.DiffWithRevision, + [this.container.git.getAbsoluteUri(f.path, gitUri.repoPath)], + ), + ); + } + } + + let newPlaceholder; + let newTitle; + + if (items.length) { + newPlaceholder = `${gitUri.getFormattedFileName()} is likely untracked, choose a different file?`; + newTitle = `${titleWithContext} (Untracked?)`; + } else { + newPlaceholder = 'No commits found'; + } + + items.push( + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: items.length ? 'Cancel' : 'OK', + }), + ); + + return { + items: items, + placeholder: newPlaceholder, + title: newTitle, + }; + }, + } + : undefined, + picked: gitUri.sha, + keyboard: { keys: ['right', 'alt+right', 'ctrl+right'], onDidPressKey: async (key, item) => { - void (await executeCommand(Commands.DiffWith, { + await executeCommand(Commands.DiffWith, { repoPath: gitUri.repoPath, lhs: { sha: item.item.ref, @@ -68,20 +121,24 @@ export class DiffWithRevisionCommand extends ActiveEditorCommand { sha: '', uri: gitUri, }, - line: args!.line, - showOptions: args!.showOptions, - })); + line: args.line, + showOptions: args.showOptions, + }); }, - showOtherReferences: [ - CommandQuickPickItem.fromCommand('Choose a Branch or Tag...', Commands.DiffWithRevisionFrom), - CommandQuickPickItem.fromCommand( - 'Choose a Stash...', - Commands.DiffWithRevisionFrom, - { stash: true }, - ), - ], }, - ); + showOtherReferences: [ + CommandQuickPickItem.fromCommand<[Uri]>( + 'Choose a Branch or Tag...', + Commands.DiffWithRevisionFrom, + [uri], + ), + CommandQuickPickItem.fromCommand<[Uri, DiffWithRevisionFromCommandArgs]>( + 'Choose a Stash...', + Commands.DiffWithRevisionFrom, + [uri, { stash: true }], + ), + ], + }); if (pick == null) return; void (await executeCommand(Commands.DiffWith, { diff --git a/src/commands/diffWithRevisionFrom.ts b/src/commands/diffWithRevisionFrom.ts index c4bf16516daa3..a71e3cc468ab7 100644 --- a/src/commands/diffWithRevisionFrom.ts +++ b/src/commands/diffWithRevisionFrom.ts @@ -30,7 +30,7 @@ export class DiffWithRevisionFromCommand extends ActiveEditorCommand { const gitUri = await GitUri.fromUri(uri); if (!gitUri.repoPath) { - void showNoRepositoryWarningMessage('Unable to open file compare'); + void showNoRepositoryWarningMessage('Unable to open file comparison'); return; } @@ -65,10 +65,9 @@ export class DiffWithRevisionFromCommand extends ActiveEditorCommand { const pick = await showReferencePicker( gitUri.repoPath, `${title}${gitUri.getFormattedFileName({ truncateTo: quickPickTitleMaxChars - title.length })}`, - 'Choose a branch or tag to compare with', + 'Choose a reference (branch, tag, etc) to compare with', { - allowEnteringRefs: true, - // checkmarks: false, + allowRevisions: true, }, ); if (pick == null) return; diff --git a/src/commands/diffWithWorking.ts b/src/commands/diffWithWorking.ts index a0cb6fcb8930b..d7e0ff26f8df5 100644 --- a/src/commands/diffWithWorking.ts +++ b/src/commands/diffWithWorking.ts @@ -4,9 +4,12 @@ import { Commands } from '../constants'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { deletedOrMissing, uncommittedStaged } from '../git/models/constants'; +import { createReference } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; +import { showRevisionFilesPicker } from '../quickpicks/revisionFilesPicker'; import { command, executeCommand } from '../system/command'; import { Logger } from '../system/logger'; +import { findOrOpenEditor } from '../system/utils'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; @@ -15,6 +18,7 @@ export interface DiffWithWorkingCommandArgs { uri?: Uri; line?: number; showOptions?: TextDocumentShowOptions; + lhsTitle?: string; } @command() @@ -94,11 +98,23 @@ export class DiffWithWorkingCommand extends ActiveEditorCommand { uri = gitUri.toFileUri(); - const workingUri = await this.container.git.getWorkingUri(gitUri.repoPath!, uri); + let workingUri = await this.container.git.getWorkingUri(gitUri.repoPath!, uri); if (workingUri == null) { - void window.showWarningMessage('Unable to open compare. File has been deleted from the working tree'); + const pickedUri = await showRevisionFilesPicker(this.container, createReference('HEAD', gitUri.repoPath!), { + ignoreFocusOut: true, + initialPath: gitUri.relativePath, + title: `Open File \u2022 Unable to open '${gitUri.relativePath}'`, + placeholder: 'Choose another working file to open', + keyboard: { + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (key, uri) => { + await findOrOpenEditor(uri, { ...args.showOptions, preserveFocus: true, preview: true }); + }, + }, + }); + if (pickedUri == null) return; - return; + workingUri = pickedUri; } void (await executeCommand(Commands.DiffWith, { @@ -106,6 +122,7 @@ export class DiffWithWorkingCommand extends ActiveEditorCommand { lhs: { sha: gitUri.sha, uri: uri, + title: args?.lhsTitle, }, rhs: { sha: '', diff --git a/src/commands/generateCommitMessage.ts b/src/commands/generateCommitMessage.ts index 3682027a18ba6..ca3e29113444f 100644 --- a/src/commands/generateCommitMessage.ts +++ b/src/commands/generateCommitMessage.ts @@ -39,19 +39,21 @@ export class GenerateCommitMessageCommand extends ActiveEditorCommand { try { const currentMessage = scmRepo.inputBox.value; - const message = await this.container.ai.generateCommitMessage(repository, { + const message = await ( + await this.container.ai + )?.generateCommitMessage(repository, { context: currentMessage, progress: { location: ProgressLocation.Notification, title: 'Generating commit message...' }, }); if (message == null) return; void executeCoreCommand('workbench.view.scm'); - scmRepo.inputBox.value = `${currentMessage ? `${currentMessage}\n\n` : ''}${message}`; + scmRepo.inputBox.value = currentMessage ? `${currentMessage}\n\n${message}` : message; } catch (ex) { Logger.error(ex, 'GenerateCommitMessageCommand'); - if (ex instanceof Error && ex.message.startsWith('No staged changes')) { - void window.showInformationMessage('No staged changes to generate a commit message from.'); + if (ex instanceof Error && ex.message.startsWith('No changes')) { + void window.showInformationMessage('No changes to generate a commit message from.'); return; } diff --git a/src/commands/ghpr/openOrCreateWorktree.ts b/src/commands/ghpr/openOrCreateWorktree.ts index 05c2bb96ed4ef..7cf6f41ebbe3f 100644 --- a/src/commands/ghpr/openOrCreateWorktree.ts +++ b/src/commands/ghpr/openOrCreateWorktree.ts @@ -2,7 +2,6 @@ import type { Uri } from 'vscode'; import { window } from 'vscode'; import { Commands } from '../../constants'; import type { Container } from '../../container'; -import { add as addRemote } from '../../git/actions/remote'; import { create as createWorktree, open as openWorktree } from '../../git/actions/worktree'; import { getLocalBranchByUpstream } from '../../git/models/branch'; import type { GitBranchReference } from '../../git/models/reference'; @@ -78,7 +77,7 @@ export class OpenOrCreateWorktreeCommand extends Command { return; } - repo = await repo.getMainRepository(); + repo = await repo.getCommonRepository(); if (repo == null) { void window.showWarningMessage(`Unable to find main repository(${localUri.toString()}) for PR #${number}`); return; @@ -87,36 +86,27 @@ export class OpenOrCreateWorktreeCommand extends Command { const remoteUrl = remoteUri.toString(); const [, remoteDomain, remotePath] = parseGitRemoteUrl(remoteUrl); - let remote: GitRemote | undefined; - [remote] = await repo.getRemotes({ filter: r => r.matches(remoteDomain, remotePath) }); + const remotes = await repo.getRemotes({ filter: r => r.matches(remoteDomain, remotePath) }); + const remote = remotes[0] as GitRemote | undefined; + + let addRemote: { name: string; url: string } | undefined; + let remoteName; if (remote != null) { + remoteName = remote.name; // Ensure we have the latest from the remote await this.container.git.fetch(repo.path, { remote: remote.name }); } else { - const result = await window.showInformationMessage( - `Unable to find a remote for '${remoteUrl}'. Would you like to add a new remote?`, - { modal: true }, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result?.title !== 'Yes') return; - - await addRemote(repo, remoteOwner, remoteUrl, { - confirm: false, - fetch: true, - reveal: false, - }); - [remote] = await repo.getRemotes({ filter: r => r.url === remoteUrl }); - if (remote == null) return; + remoteName = remoteOwner; + addRemote = { name: remoteOwner, url: remoteUrl }; } - const remoteBranchName = `${remote.name}/${ref}`; + const remoteBranchName = `${remoteName}/${ref}`; const localBranchName = `pr/${rootUri.toString() === remoteUri.toString() ? ref : remoteBranchName}`; const qualifiedRemoteBranchName = `remotes/${remoteBranchName}`; const worktree = await getWorktreeForBranch(repo, localBranchName, remoteBranchName); if (worktree != null) { - void openWorktree(worktree); + void openWorktree(worktree, { openOnly: true }); return; } @@ -139,10 +129,10 @@ export class OpenOrCreateWorktreeCommand extends Command { await waitUntilNextTick(); try { - await createWorktree(repo, undefined, branchRef, { createBranch: createBranch }); - - // Ensure that the worktree was created - const worktree = await this.container.git.getWorktree(repo.path, w => w.branch === localBranchName); + const worktree = await createWorktree(repo, undefined, branchRef, { + addRemote: addRemote, + createBranch: createBranch, + }); if (worktree == null) return; // Save the PR number in the branch config diff --git a/src/commands/git/branch.ts b/src/commands/git/branch.ts index 6e21781eeb16a..3eb0f429013ab 100644 --- a/src/commands/git/branch.ts +++ b/src/commands/git/branch.ts @@ -8,6 +8,7 @@ import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; import { pluralize } from '../../system/string'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; +import { getSteps } from '../gitCommands.utils'; import type { AsyncStepResultGenerator, PartialStepState, @@ -18,19 +19,21 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canPickStepContinue, createConfirmStep, createPickStep, endSteps, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { + appendReposToTitle, inputBranchNameStep, pickBranchesStep, pickBranchOrTagStep, pickBranchStep, pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -39,7 +42,7 @@ interface Context { title: string; } -type CreateFlags = '--switch'; +type CreateFlags = '--switch' | '--worktree'; interface CreateState { subcommand: 'create'; @@ -47,6 +50,8 @@ interface CreateState { reference: GitReference; name: string; flags: CreateFlags[]; + + suggestNameOnly?: boolean; } type DeleteFlags = '--force' | '--remotes'; @@ -58,6 +63,8 @@ interface DeleteState { flags: DeleteFlags[]; } +type PruneState = Replace; + type RenameFlags = '-m'; interface RenameState { @@ -68,7 +75,7 @@ interface RenameState { flags: RenameFlags[]; } -type State = CreateState | DeleteState | RenameState; +type State = CreateState | DeleteState | PruneState | RenameState; type BranchStepState = SomeNonNullable, 'subcommand'>; type CreateStepState = BranchStepState>; @@ -87,6 +94,14 @@ function assertStateStepDelete(state: PartialStepState): asserts state is throw new Error('Missing repository'); } +type PruneStepState = BranchStepState>; +function assertStateStepPrune(state: PartialStepState): asserts state is PruneStepState { + if (state.repo instanceof Repository && state.subcommand === 'prune') return; + + debugger; + throw new Error('Missing repository'); +} + type RenameStepState = BranchStepState>; function assertStateStepRename(state: PartialStepState): asserts state is RenameStepState { if (state.repo instanceof Repository && state.subcommand === 'rename') return; @@ -96,7 +111,7 @@ function assertStateStepRename(state: PartialStepState): asserts state is } function assertStateStepDeleteBranches( - state: DeleteStepState, + state: DeleteStepState | PruneStepState, ): asserts state is ExcludeSome { if (Array.isArray(state.references)) return; @@ -107,6 +122,7 @@ function assertStateStepDeleteBranches( const subcommandToTitleMap = new Map([ ['create', 'Create'], ['delete', 'Delete'], + ['prune', 'Prune'], ['rename', 'Rename'], ]); function getTitle(title: string, subcommand: State['subcommand'] | undefined) { @@ -119,12 +135,12 @@ export interface BranchGitCommandArgs { state?: Partial; } -export class BranchGitCommand extends QuickCommand { +export class BranchGitCommand extends QuickCommand { private subcommand: State['subcommand'] | undefined; constructor(container: Container, args?: BranchGitCommandArgs) { super(container, 'branch', 'branch', 'Branch', { - description: 'create, rename, or delete branches', + description: 'create, prune, rename, or delete branches', }); let counter = 0; @@ -137,12 +153,13 @@ export class BranchGitCommand extends QuickCommand { counter++; } - if (args.state.name != null) { + if (!args.state.suggestNameOnly && args.state.name != null) { counter++; } break; case 'delete': + case 'prune': if ( args.state.references != null && (!Array.isArray(args.state.references) || args.state.references.length !== 0) @@ -180,7 +197,9 @@ export class BranchGitCommand extends QuickCommand { } override get canSkipConfirm(): boolean { - return this.subcommand === 'delete' || this.subcommand === 'rename' ? false : super.canSkipConfirm; + return this.subcommand === 'delete' || this.subcommand === 'prune' || this.subcommand === 'rename' + ? false + : super.canSkipConfirm; } override get skipConfirmKey() { @@ -212,7 +231,10 @@ export class BranchGitCommand extends QuickCommand { this.subcommand = state.subcommand; - context.title = getTitle(state.subcommand === 'delete' ? 'Branches' : this.title, state.subcommand); + context.title = getTitle( + state.subcommand === 'delete' || state.subcommand === 'prune' ? 'Branches' : this.title, + state.subcommand, + ); if (state.counter < 2 || state.repo == null || typeof state.repo === 'string') { skippedStepTwo = false; @@ -242,6 +264,10 @@ export class BranchGitCommand extends QuickCommand { assertStateStepDelete(state); yield* this.deleteCommandSteps(state, context); break; + case 'prune': + assertStateStepPrune(state); + yield* this.deleteCommandSteps(state, context); + break; case 'rename': assertStateStepRename(state); yield* this.renameCommandSteps(state, context); @@ -279,6 +305,12 @@ export class BranchGitCommand extends QuickCommand { picked: state.subcommand === 'delete', item: 'delete', }, + { + label: 'prune', + description: 'deletes local branches with missing upstreams', + picked: state.subcommand === 'prune', + item: 'prune', + }, { label: 'rename', description: 'renames the specified branch', @@ -314,7 +346,6 @@ export class BranchGitCommand extends QuickCommand { if (state.counter < 4 || state.name == null) { const result = yield* inputBranchNameStep(state, context, { - placeholder: 'Please provide a name for the new branch', titleContext: ` from ${getReferenceLabel(state.reference, { capitalize: true, icon: false, @@ -334,6 +365,25 @@ export class BranchGitCommand extends QuickCommand { state.flags = result; } + if (state.flags.includes('--worktree')) { + const worktreeResult = yield* getSteps( + this.container, + { + command: 'worktree', + state: { + subcommand: 'create', + reference: state.reference, + createBranch: state.name, + }, + }, + this.pickedVia, + ); + if (worktreeResult === StepResultBreak) continue; + + endSteps(state); + return; + } + endSteps(state); if (state.flags.includes('--switch')) { await state.repo.switch(state.reference.ref, { createBranch: state.name }); @@ -352,12 +402,18 @@ export class BranchGitCommand extends QuickCommand { detail: `Will create a new branch named ${state.name} from ${getReferenceLabel(state.reference)}`, }), createFlagsQuickPickItem(state.flags, ['--switch'], { - label: `${context.title} and Switch`, - description: '--switch', + label: `Create & Switch to Branch`, detail: `Will create and switch to a new branch named ${state.name} from ${getReferenceLabel( state.reference, )}`, }), + createFlagsQuickPickItem(state.flags, ['--worktree'], { + label: `${context.title} in New Worktree`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for a new branch named ${state.name} from ${getReferenceLabel( + state.reference, + )}`, + }), ], context, ); @@ -365,7 +421,8 @@ export class BranchGitCommand extends QuickCommand { return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } - private async *deleteCommandSteps(state: DeleteStepState, context: Context): AsyncStepResultGenerator { + private *deleteCommandSteps(state: DeleteStepState | PruneStepState, context: Context): StepResultGenerator { + const prune = state.subcommand === 'prune'; if (state.flags == null) { state.flags = []; } @@ -383,9 +440,14 @@ export class BranchGitCommand extends QuickCommand { context.title = getTitle('Branches', state.subcommand); const result = yield* pickBranchesStep(state, context, { - filter: b => !b.current, + filter: prune ? b => !b.current && Boolean(b.upstream?.missing) : b => !b.current, picked: state.references?.map(r => r.ref), - placeholder: 'Choose branches to delete', + placeholder: prune + ? 'Choose branches with missing upstreams to delete' + : 'Choose branches to delete', + emptyPlaceholder: prune + ? `No branches with missing upstreams in ${state.repo.formattedName}` + : undefined, sort: { current: false, missingUpstream: true }, }); // Always break on the first step (so we will go back) @@ -396,7 +458,7 @@ export class BranchGitCommand extends QuickCommand { context.title = getTitle( pluralize('Branch', state.references.length, { only: true, plural: 'Branches' }), - state.subcommand, + state.subcommand === 'prune' ? 'delete' : state.subcommand, ); assertStateStepDeleteBranches(state); @@ -414,7 +476,9 @@ export class BranchGitCommand extends QuickCommand { } private *deleteCommandConfirmStep( - state: DeleteStepState>, + state: + | DeleteStepState> + | PruneStepState>, context: Context, ): StepResultGenerator { const confirmations: FlagsQuickPickItem[] = [ @@ -432,7 +496,7 @@ export class BranchGitCommand extends QuickCommand { }), ); - if (state.references.some(b => b.upstream != null)) { + if (state.subcommand !== 'prune' && state.references.some(b => b.upstream != null)) { confirmations.push( createFlagsQuickPickItem(state.flags, ['--remotes'], { label: `${context.title} & Remote${ @@ -483,9 +547,6 @@ export class BranchGitCommand extends QuickCommand { if (state.counter < 4 || state.name == null) { const result = yield* inputBranchNameStep(state, context, { - placeholder: `Please provide a new name for ${getReferenceLabel(state.reference, { - icon: false, - })}`, titleContext: ` ${getReferenceLabel(state.reference, false)}`, value: state.name ?? state.reference.name, }); diff --git a/src/commands/git/cherry-pick.ts b/src/commands/git/cherry-pick.ts index c3f1116581e7e..710539edd7365 100644 --- a/src/commands/git/cherry-pick.ts +++ b/src/commands/git/cherry-pick.ts @@ -16,17 +16,8 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - createConfirmStep, - endSteps, - pickBranchOrTagStep, - pickCommitsStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, createConfirmStep, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { appendReposToTitle, pickBranchOrTagStep, pickCommitsStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -64,11 +55,18 @@ export class CherryPickGitCommand extends QuickCommand { counter++; } - if ( - args?.state?.references != null && - (!Array.isArray(args.state.references) || args.state.references.length !== 0) - ) { - counter++; + if (args?.state?.references != null) { + if (Array.isArray(args.state.references)) { + if (args.state.references.length > 0) { + if (isRevisionReference(args.state.references[0])) { + counter += 2; + } else { + counter++; + } + } + } else { + counter++; + } } this.initialState = { @@ -172,12 +170,27 @@ export class CherryPickGitCommand extends QuickCommand { } } + if (context.selectedBranchOrTag == null && state.references?.length) { + const branches = await this.container.git.getCommitBranches( + state.repo.path, + state.references.map(r => r.ref), + undefined, + { mode: 'contains' }, + ); + if (branches.length) { + const branch = await state.repo.getBranch(branches[0]); + if (branch != null) { + context.selectedBranchOrTag = branch; + } + } + } + if (state.counter < 3 && context.selectedBranchOrTag != null) { const ref = createRevisionRange(context.destination.ref, context.selectedBranchOrTag.ref); let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } diff --git a/src/commands/git/coauthors.ts b/src/commands/git/coauthors.ts index cc8b1430e61f0..e065c7b41a382 100644 --- a/src/commands/git/coauthors.ts +++ b/src/commands/git/coauthors.ts @@ -5,7 +5,8 @@ import { executeCoreCommand } from '../../system/command'; import { normalizePath } from '../../system/path'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { PartialStepState, StepGenerator, StepState } from '../quickCommand'; -import { endSteps, pickContributorsStep, pickRepositoryStep, QuickCommand, StepResultBreak } from '../quickCommand'; +import { endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { pickContributorsStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; diff --git a/src/commands/git/fetch.ts b/src/commands/git/fetch.ts index f2d0b2512ac59..b13e43473f2b0 100644 --- a/src/commands/git/fetch.ts +++ b/src/commands/git/fetch.ts @@ -17,15 +17,8 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - createConfirmStep, - endSteps, - pickRepositoriesStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, createConfirmStep, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { appendReposToTitle, pickRepositoriesStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -162,9 +155,7 @@ export class FetchGitCommand extends QuickCommand { ); } else { const reposToFetch = - state.repos.length === 1 - ? `$(repo) ${state.repos[0].formattedName}` - : `${state.repos.length} repositories`; + state.repos.length === 1 ? `$(repo) ${state.repos[0].formattedName}` : `${state.repos.length} repos`; step = createConfirmStep( appendReposToTitle(`Confirm ${this.title}`, state, context, lastFetchedOn), diff --git a/src/commands/git/log.ts b/src/commands/git/log.ts index a822b5221354b..fc6cb3d409bd5 100644 --- a/src/commands/git/log.ts +++ b/src/commands/git/log.ts @@ -11,14 +11,8 @@ import { pad } from '../../system/string'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import { getSteps } from '../gitCommands.utils'; import type { PartialStepState, StepGenerator, StepResult } from '../quickCommand'; -import { - endSteps, - pickBranchOrTagStep, - pickCommitStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { pickBranchOrTagStep, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; diff --git a/src/commands/git/merge.ts b/src/commands/git/merge.ts index 176306c0386ef..538c9a391e621 100644 --- a/src/commands/git/merge.ts +++ b/src/commands/git/merge.ts @@ -19,17 +19,9 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickBranchOrTagStep, - pickCommitStep, - PickCommitToggleQuickInputButton, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { PickCommitToggleQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickBranchOrTagStep, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -178,7 +170,7 @@ export class MergeGitCommand extends QuickCommand { let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } @@ -220,14 +212,20 @@ export class MergeGitCommand extends QuickCommand { const count = aheadBehind != null ? aheadBehind.ahead + aheadBehind.behind : 0; if (count === 0) { const step: QuickPickStep = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(context.title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, + label: 'OK', detail: `${getReferenceLabel(context.destination, { capitalize: true, - })} is up to date with ${getReferenceLabel(state.reference)}`, + })} is already up to date with ${getReferenceLabel(state.reference)}`, }), + { + placeholder: `Nothing to merge; ${getReferenceLabel(context.destination, { + label: false, + icon: false, + })} is already up to date`, + }, ); const selection: StepSelection = yield step; canPickStepContinue(step, state, selection); diff --git a/src/commands/git/pull.ts b/src/commands/git/pull.ts index 100d906244a63..7e57de3b5c17d 100644 --- a/src/commands/git/pull.ts +++ b/src/commands/git/pull.ts @@ -19,15 +19,9 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - FetchQuickInputButton, - pickRepositoriesStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { FetchQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickRepositoriesStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -153,12 +147,12 @@ export class PullGitCommand extends QuickCommand { step = this.createConfirmStep(appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will pull ${state.repos.length} repositories`, + detail: `Will pull ${state.repos.length} repos`, }), createFlagsQuickPickItem(state.flags, ['--rebase'], { label: `${this.title} with Rebase`, description: '--rebase', - detail: `Will pull ${state.repos.length} repositories by rebasing`, + detail: `Will pull ${state.repos.length} repos by rebasing`, }), ]); } else if (isBranchReference(state.reference)) { diff --git a/src/commands/git/push.ts b/src/commands/git/push.ts index 96df8d58c9463..34409bb576b72 100644 --- a/src/commands/git/push.ts +++ b/src/commands/git/push.ts @@ -1,6 +1,6 @@ -import type { CoreGitConfiguration } from '../../constants'; import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; +import { Features } from '../../features'; import { getRemoteNameFromBranchName } from '../../git/models/branch'; import type { GitBranchReference, GitReference } from '../../git/models/reference'; import { getReferenceLabel, isBranchReference } from '../../git/models/reference'; @@ -21,16 +21,9 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - FetchQuickInputButton, - pickRepositoriesStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { FetchQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickRepositoriesStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -75,8 +68,6 @@ export class PushGitCommand extends QuickCommand { execute(state: State) { const index = state.flags.indexOf('--set-upstream'); if (index !== -1) { - if (!isBranchReference(state.reference)) return Promise.resolve(); - return this.container.git.pushAll(state.repos, { force: false, publish: { remote: state.flags[index + 1] }, @@ -163,8 +154,11 @@ export class PushGitCommand extends QuickCommand { } private async *confirmStep(state: PushStepState, context: Context): AsyncStepResultGenerator { - const useForceWithLease = - configuration.getAny('git.useForcePushWithLease') ?? false; + const useForceWithLease = configuration.getCore('git.useForcePushWithLease') ?? true; + const useForceIfIncludes = + useForceWithLease && + (configuration.getCore('git.useForcePushIfIncludes') ?? true) && + (await this.container.git.supports(state.repos[0].uri, Features.ForceIfIncludes)); let step: QuickPickStep>; @@ -172,14 +166,18 @@ export class PushGitCommand extends QuickCommand { step = this.createConfirmStep(appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will push ${state.repos.length} repositories`, + detail: `Will push ${state.repos.length} repos`, }), createFlagsQuickPickItem(state.flags, ['--force'], { - label: `Force ${this.title}${useForceWithLease ? ' (with lease)' : ''}`, - description: `--force${useForceWithLease ? '-with-lease' : ''}`, - detail: `Will force push${useForceWithLease ? ' (with lease)' : ''} ${ - state.repos.length - } repositories`, + label: `Force ${this.title}${ + useForceIfIncludes ? ' (with lease and if includes)' : useForceWithLease ? ' (with lease)' : '' + }`, + description: `--force${ + useForceWithLease ? `-with-lease${useForceIfIncludes ? ' --force-if-includes' : ''}` : '' + }`, + detail: `Will force push${ + useForceIfIncludes ? ' (with lease and if includes)' : useForceWithLease ? ' (with lease)' : '' + } ${state.repos.length} repos`, }), ]); } else { @@ -190,12 +188,13 @@ export class PushGitCommand extends QuickCommand { if (isBranchReference(state.reference)) { if (state.reference.remote) { step = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(context.title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, - detail: 'Cannot push remote branch', + label: 'OK', + detail: 'Cannot push a remote branch', }), + { placeholder: 'Cannot push a remote branch' }, ); } else { const branch = await repo.getBranch(state.reference.name); @@ -223,13 +222,13 @@ export class PushGitCommand extends QuickCommand { ); } else { step = this.createConfirmStep( - appendReposToTitle('Confirm Publish', state, context), + appendReposToTitle('Publish', state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: 'Cancel Publish', - detail: 'Cannot publish; No remotes found', + label: 'OK', + detail: 'No remotes found', }), - { placeholder: 'Confirm Publish' }, + { placeholder: 'Cannot publish; No remotes found' }, ); } } else if (branch != null && branch?.state.behind > 0) { @@ -237,11 +236,27 @@ export class PushGitCommand extends QuickCommand { appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, ['--force'], { - label: `Force ${this.title}${useForceWithLease ? ' (with lease)' : ''}`, - description: `--force${useForceWithLease ? '-with-lease' : ''}`, - detail: `Will force push${useForceWithLease ? ' (with lease)' : ''} ${ - branch?.state.ahead ? ` ${pluralize('commit', branch.state.ahead)}` : '' - }${branch.getRemoteName() ? ` to ${branch.getRemoteName()}` : ''}${ + label: `Force ${this.title}${ + useForceIfIncludes + ? ' (with lease and if includes)' + : useForceWithLease + ? ' (with lease)' + : '' + }`, + description: `--force${ + useForceWithLease + ? `-with-lease${useForceIfIncludes ? ' --force-if-includes' : ''}` + : '' + }`, + detail: `Will force push${ + useForceIfIncludes + ? ' (with lease and if includes)' + : useForceWithLease + ? ' (with lease)' + : '' + } ${branch?.state.ahead ? ` ${pluralize('commit', branch.state.ahead)}` : ''}${ + branch.getRemoteName() ? ` to ${branch.getRemoteName()}` : '' + }${ branch != null && branch.state.behind > 0 ? `, overwriting ${pluralize('commit', branch.state.behind)}${ branch?.getRemoteName() ? ` on ${branch.getRemoteName()}` : '' @@ -268,12 +283,13 @@ export class PushGitCommand extends QuickCommand { ]); } else { step = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(context.title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, + label: 'OK', detail: 'No commits found to push', }), + { placeholder: 'Nothing to push; No commits found to push' }, ); } } @@ -289,8 +305,17 @@ export class PushGitCommand extends QuickCommand { }; if (status?.state.ahead === 0) { - if (state.reference == null && status.upstream == null) { - state.reference = branch; + if (!isBranchReference(state.reference) && status.upstream == null) { + let pushDetails; + + if (state.reference != null) { + pushDetails = ` up to and including ${getReferenceLabel(state.reference, { + label: false, + })}`; + } else { + state.reference = branch; + pushDetails = ''; + } for (const remote of await repo.getRemotes()) { items.push( @@ -299,7 +324,9 @@ export class PushGitCommand extends QuickCommand { ['--set-upstream', remote.name, status.branch], { label: `Publish ${branch.name} to ${remote.name}`, - detail: `Will publish ${getReferenceLabel(branch)} to ${remote.name}`, + detail: `Will publish ${getReferenceLabel(branch)}${pushDetails} to ${ + remote.name + }`, }, ), ); @@ -315,24 +342,27 @@ export class PushGitCommand extends QuickCommand { ); } else if (status.upstream == null) { step = this.createConfirmStep( - appendReposToTitle('Confirm Publish', state, context), + appendReposToTitle('Publish', state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: 'Cancel Publish', - detail: 'Cannot publish; No remotes found', + label: 'OK', + detail: 'No remotes found', }), - { placeholder: 'Confirm Publish' }, + { placeholder: 'Cannot publish; No remotes found' }, ); } else { step = this.createConfirmStep( - appendReposToTitle('Confirm Push', state, context), + appendReposToTitle(context.title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, - detail: `Cannot push; No commits ahead of ${getRemoteNameFromBranchName( - status.upstream, - )}`, + label: 'OK', + detail: `No commits ahead of ${getRemoteNameFromBranchName(status.upstream?.name)}`, }), + { + placeholder: `Nothing to push; No commits ahead of ${getRemoteNameFromBranchName( + status.upstream?.name, + )}`, + }, ); } } else { @@ -351,10 +381,10 @@ export class PushGitCommand extends QuickCommand { label: false, })}` : '' - }${status?.upstream ? ` to ${getRemoteNameFromBranchName(status.upstream)}` : ''}`; + }${status?.upstream ? ` to ${getRemoteNameFromBranchName(status.upstream?.name)}` : ''}`; } else { pushDetails = `${status?.state.ahead ? ` ${pluralize('commit', status.state.ahead)}` : ''}${ - status?.upstream ? ` to ${getRemoteNameFromBranchName(status.upstream)}` : '' + status?.upstream ? ` to ${getRemoteNameFromBranchName(status.upstream?.name)}` : '' }`; } @@ -370,13 +400,29 @@ export class PushGitCommand extends QuickCommand { }), ]), createFlagsQuickPickItem(state.flags, ['--force'], { - label: `Force ${this.title}${useForceWithLease ? ' (with lease)' : ''}`, - description: `--force${useForceWithLease ? '-with-lease' : ''}`, - detail: `Will force push${useForceWithLease ? ' (with lease)' : ''} ${pushDetails}${ + label: `Force ${this.title}${ + useForceIfIncludes + ? ' (with lease and if includes)' + : useForceWithLease + ? ' (with lease)' + : '' + }`, + description: `--force${ + useForceWithLease + ? `-with-lease${useForceIfIncludes ? ' --force-if-includes' : ''}` + : '' + }`, + detail: `Will force push${ + useForceIfIncludes + ? ' (with lease and if includes)' + : useForceWithLease + ? ' (with lease)' + : '' + } ${pushDetails}${ status != null && status.state.behind > 0 ? `, overwriting ${pluralize('commit', status.state.behind)}${ status?.upstream - ? ` on ${getRemoteNameFromBranchName(status.upstream)}` + ? ` on ${getRemoteNameFromBranchName(status.upstream?.name)}` : '' }` : '' @@ -387,7 +433,7 @@ export class PushGitCommand extends QuickCommand { ? createDirectiveQuickPickItem(Directive.Cancel, true, { label: `Cancel ${this.title}`, detail: `Cannot push; ${getReferenceLabel(branch)} is behind${ - status?.upstream ? ` ${getRemoteNameFromBranchName(status.upstream)}` : '' + status?.upstream ? ` ${getRemoteNameFromBranchName(status.upstream?.name)}` : '' } by ${pluralize('commit', status.state.behind)}`, }) : undefined, diff --git a/src/commands/git/rebase.ts b/src/commands/git/rebase.ts index 5592d86ab9c8a..9ac333f1b7953 100644 --- a/src/commands/git/rebase.ts +++ b/src/commands/git/rebase.ts @@ -20,17 +20,9 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickBranchOrTagStep, - pickCommitStep, - PickCommitToggleQuickInputButton, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { PickCommitToggleQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickBranchOrTagStep, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -186,7 +178,7 @@ export class RebaseGitCommand extends QuickCommand { let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } @@ -231,14 +223,20 @@ export class RebaseGitCommand extends QuickCommand { const count = aheadBehind != null ? aheadBehind.ahead + aheadBehind.behind : 0; if (count === 0) { const step: QuickPickStep = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(context.title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, + label: 'OK', detail: `${getReferenceLabel(context.destination, { capitalize: true, - })} is up to date with ${getReferenceLabel(state.reference)}`, + })} is already up to date with ${getReferenceLabel(state.reference)}`, }), + { + placeholder: `Nothing to rebase; ${getReferenceLabel(context.destination, { + label: false, + icon: false, + })} is already up to date`, + }, ); const selection: StepSelection = yield step; canPickStepContinue(step, state, selection); diff --git a/src/commands/git/remote.ts b/src/commands/git/remote.ts index ed9ca4619d630..2c4821096b915 100644 --- a/src/commands/git/remote.ts +++ b/src/commands/git/remote.ts @@ -19,18 +19,20 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canPickStepContinue, createConfirmStep, createPickStep, endSteps, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { + appendReposToTitle, inputRemoteNameStep, inputRemoteUrlStep, pickRemoteStep, pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -368,8 +370,6 @@ export class RemoteGitCommand extends QuickCommand { } if (state.counter < 3 || state.remote == null) { - context.title = getTitle('Remotes', state.subcommand); - const result = yield* pickRemoteStep(state, context, { picked: state.remote?.name, placeholder: 'Choose remote to remove', diff --git a/src/commands/git/reset.ts b/src/commands/git/reset.ts index f98aeac4e5483..4b72b477b993d 100644 --- a/src/commands/git/reset.ts +++ b/src/commands/git/reset.ts @@ -16,15 +16,8 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickCommitStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { appendReposToTitle, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -130,7 +123,7 @@ export class ResetGitCommand extends QuickCommand { let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } diff --git a/src/commands/git/revert.ts b/src/commands/git/revert.ts index 6756d84e28568..e1300d79cb3ed 100644 --- a/src/commands/git/revert.ts +++ b/src/commands/git/revert.ts @@ -16,15 +16,8 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickCommitsStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { appendReposToTitle, pickCommitsStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -134,7 +127,7 @@ export class RevertGitCommand extends QuickCommand { let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } diff --git a/src/commands/git/search.ts b/src/commands/git/search.ts index 60690fbc6edd3..961018be23341 100644 --- a/src/commands/git/search.ts +++ b/src/commands/git/search.ts @@ -1,21 +1,27 @@ +import type { QuickInputButton, QuickPick } from 'vscode'; +import { ThemeIcon, window } from 'vscode'; import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; import { showDetailsView } from '../../git/actions/commit'; import type { GitCommit } from '../../git/models/commit'; import type { GitLog } from '../../git/models/log'; import type { Repository } from '../../git/models/repository'; -import type { SearchOperators, SearchQuery } from '../../git/search'; +import type { NormalizedSearchOperators, SearchOperators, SearchQuery } from '../../git/search'; import { getSearchQueryComparisonKey, parseSearchQuery, searchOperators } from '../../git/search'; +import { showContributorsPicker } from '../../quickpicks/contributorsPicker'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { ActionQuickPickItem } from '../../quickpicks/items/common'; +import { isDirectiveQuickPickItem } from '../../quickpicks/items/directive'; import { configuration } from '../../system/configuration'; import { getContext } from '../../system/context'; +import { join, map } from '../../system/iterable'; import { pluralize } from '../../system/string'; import { SearchResultsNode } from '../../views/nodes/searchResultsNode'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import { getSteps } from '../gitCommands.utils'; import type { PartialStepState, + QuickPickStep, StepGenerator, StepResult, StepResultGenerator, @@ -23,21 +29,38 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canPickStepContinue, createPickStep, endSteps, + freezeStep, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { MatchAllToggleQuickInputButton, MatchCaseToggleQuickInputButton, MatchRegexToggleQuickInputButton, - pickCommitStep, - pickRepositoryStep, - QuickCommand, ShowResultsInSideBarQuickInputButton, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.buttons'; +import { appendReposToTitle, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; + +const UseAuthorPickerQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('person-add'), + tooltip: 'Pick Authors', +}; + +const UseFilePickerQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('new-file'), + tooltip: 'Pick Files', +}; + +const UseFolderPickerQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('new-folder'), + tooltip: 'Pick Folder', +}; interface Context { + container: Container; repos: Repository[]; associatedView: ViewsWithRepositoryFolders; commit: GitCommit | undefined; @@ -111,10 +134,11 @@ export class SearchGitCommand extends QuickCommand { protected async *steps(state: PartialStepState): StepGenerator { const context: Context = { + container: this.container, repos: this.container.git.openRepositories, associatedView: this.container.searchAndCompareView, commit: undefined, - hasVirtualFolders: getContext('gitlens:hasVirtualFolders', false), + hasVirtualFolders: getContext('gitlens:hasVirtualFolders', false), resultsKey: undefined, resultsPromise: undefined, title: this.title, @@ -287,20 +311,24 @@ export class SearchGitCommand extends QuickCommand { } private *pickSearchOperatorStep(state: SearchStepState, context: Context): StepResultGenerator { - const items: QuickPickItemOfT[] = [ + const items: QuickPickItemOfT[] = [ { label: searchOperatorToTitleMap.get('')!, description: `pattern or message: pattern or =: pattern ${GlyphChars.Dash} use quotes to search for phrases`, + alwaysShow: true, item: 'message:' as const, }, { label: searchOperatorToTitleMap.get('author:')!, description: 'author: pattern or @: pattern', + buttons: [UseAuthorPickerQuickInputButton], + alwaysShow: true, item: 'author:' as const, }, { label: searchOperatorToTitleMap.get('commit:')!, description: 'commit: sha or #: sha', + alwaysShow: true, item: 'commit:' as const, }, context.hasVirtualFolders @@ -308,6 +336,8 @@ export class SearchGitCommand extends QuickCommand { : { label: searchOperatorToTitleMap.get('file:')!, description: 'file: glob or ?: glob', + buttons: [UseFilePickerQuickInputButton, UseFolderPickerQuickInputButton], + alwaysShow: true, item: 'file:' as const, }, context.hasVirtualFolders @@ -315,6 +345,7 @@ export class SearchGitCommand extends QuickCommand { : { label: searchOperatorToTitleMap.get('change:')!, description: 'change: pattern or ~: pattern', + alwaysShow: true, item: 'change:' as const, }, ].filter((i?: T): i is T => i != null); @@ -323,28 +354,22 @@ export class SearchGitCommand extends QuickCommand { const matchAllButton = new MatchAllToggleQuickInputButton(state.matchAll); const matchRegexButton = new MatchRegexToggleQuickInputButton(state.matchRegex); - const step = createPickStep>({ + const step = createPickStep>({ title: appendReposToTitle(context.title, state, context), placeholder: 'e.g. "Updates dependencies" author:eamodio', + ignoreFocusOut: true, matchOnDescription: true, matchOnDetail: true, additionalButtons: [matchCaseButton, matchAllButton, matchRegexButton], items: items, value: state.query, selectValueWhenShown: false, - onDidAccept: (quickpick): boolean => { - const pick = quickpick.selectedItems[0]; - if (!searchOperators.has(pick.item)) return true; - - const value = quickpick.value.trim(); - if (value.length === 0 || searchOperators.has(value)) { - quickpick.value = pick.item; - } else { - quickpick.value = `${value} ${pick.item}`; - } - - void step.onDidChangeValue!(quickpick); + onDidAccept: async quickpick => { + const item = quickpick.selectedItems[0]; + if (isDirectiveQuickPickItem(item)) return false; + if (!searchOperators.has(item.item)) return true; + await updateSearchQuery(item, {}, quickpick, step, state, context); return false; }, onDidClickButton: (quickpick, button) => { @@ -359,6 +384,17 @@ export class SearchGitCommand extends QuickCommand { matchRegexButton.on = state.matchRegex; } }, + onDidClickItemButton: async function (quickpick, button, item) { + if (button === UseAuthorPickerQuickInputButton) { + await updateSearchQuery(item, { author: true }, quickpick, step, state, context); + } else if (button === UseFilePickerQuickInputButton) { + await updateSearchQuery(item, { file: { type: 'file' } }, quickpick, step, state, context); + } else if (button === UseFolderPickerQuickInputButton) { + await updateSearchQuery(item, { file: { type: 'folder' } }, quickpick, step, state, context); + } + + return false; + }, onDidChangeValue: (quickpick): boolean => { const value = quickpick.value.trim(); // Simulate an extra step if we have a value @@ -390,9 +426,13 @@ export class SearchGitCommand extends QuickCommand { { label: 'Search for', description: quickpick.value, - item: quickpick.value as SearchOperators, + item: quickpick.value as NormalizedSearchOperators, + picked: true, }, + ...items, ]; + + quickpick.activeItems = [quickpick.items[0]]; } return true; @@ -410,3 +450,101 @@ export class SearchGitCommand extends QuickCommand { return selection[0].item.trim(); } } + +async function updateSearchQuery( + item: QuickPickItemOfT, + usePickers: { author?: boolean; file?: { type: 'file' | 'folder' } }, + quickpick: QuickPick, + step: QuickPickStep, + state: SearchStepState, + context: Context, +) { + const ops = parseSearchQuery({ + query: quickpick.value, + matchCase: state.matchCase, + matchAll: state.matchAll, + }); + + let append = false; + + if (usePickers?.author && item.item === 'author:') { + using frozen = freezeStep(step, quickpick); + + const authors = ops.get('author:'); + + const contributors = await showContributorsPicker( + context.container, + state.repo, + 'Search by Author', + 'Choose contributors to include commits from', + { + appendReposToTitle: true, + clearButton: true, + ignoreFocusOut: true, + multiselect: true, + picked: c => + authors != null && + ((c.email != null && authors.has(c.email)) || + (c.name != null && authors.has(c.name)) || + (c.username != null && authors.has(c.username))), + }, + ); + + frozen[Symbol.dispose](); + + if (contributors != null) { + const authors = contributors + .map(c => c.email ?? c.name ?? c.username) + .filter((c?: T): c is T => c != null); + if (authors.length) { + ops.set('author:', new Set(authors)); + } else { + ops.delete('author:'); + } + } else { + append = true; + } + } else if (usePickers?.file && item.item === 'file:') { + using frozen = freezeStep(step, quickpick); + + let files = ops.get('file:'); + + const uris = await window.showOpenDialog({ + canSelectFiles: usePickers.file.type === 'file', + canSelectFolders: usePickers.file.type === 'folder', + canSelectMany: usePickers.file.type === 'file', + title: 'Search by File', + openLabel: 'Add to Search', + defaultUri: state.repo.folder?.uri, + }); + + frozen[Symbol.dispose](); + + if (uris?.length) { + if (files == null) { + files = new Set(); + ops.set('file:', files); + } + + for (const uri of uris) { + files.add(context.container.git.getRelativePath(uri, state.repo.uri)); + } + } else { + append = true; + } + + if (files == null || files.size === 0) { + ops.delete('file:'); + } + } else { + const values = ops.get(item.item); + append = !values?.has(''); + } + + quickpick.value = `${join( + map(ops.entries(), ([op, values]) => `${op}${join(values, ` ${op}`)}`), + ' ', + )}${append ? ` ${item.item}` : ''}`; + + void step.onDidChangeValue!(quickpick); +} diff --git a/src/commands/git/show.ts b/src/commands/git/show.ts index 4f9d24484c731..d6a389bb231e7 100644 --- a/src/commands/git/show.ts +++ b/src/commands/git/show.ts @@ -8,16 +8,14 @@ import { CommandQuickPickItem } from '../../quickpicks/items/common'; import { GitCommandQuickPickItem } from '../../quickpicks/items/gitCommands'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { PartialStepState, StepGenerator } from '../quickCommand'; +import { endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; import { - endSteps, pickCommitStep, pickRepositoryStep, - QuickCommand, showCommitOrStashFilesStep, showCommitOrStashFileStep, showCommitOrStashStep, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; diff --git a/src/commands/git/stash.ts b/src/commands/git/stash.ts index 07b5a4c1d7752..0f4ef4de58b65 100644 --- a/src/commands/git/stash.ts +++ b/src/commands/git/stash.ts @@ -21,7 +21,6 @@ import { getSteps } from '../gitCommands.utils'; import type { AsyncStepResultGenerator, PartialStepState, - QuickPickStep, StepGenerator, StepResult, StepResultGenerator, @@ -29,20 +28,17 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canInputStepContinue, canPickStepContinue, canStepContinue, createInputStep, createPickStep, endSteps, - pickRepositoryStep, - pickStashStep, QuickCommand, - RevealInSideBarQuickInputButton, - ShowDetailsViewQuickInputButton, StepResultBreak, } from '../quickCommand'; +import { RevealInSideBarQuickInputButton, ShowDetailsViewQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickRepositoryStep, pickStashesStep, pickStashStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -60,7 +56,7 @@ interface ApplyState { interface DropState { subcommand: 'drop'; repo: string | Repository; - reference: GitStashReference; + references: GitStashReference[]; } interface ListState { @@ -75,7 +71,7 @@ interface PopState { reference: GitStashReference; } -export type PushFlags = '--include-untracked' | '--keep-index' | '--staged'; +export type PushFlags = '--include-untracked' | '--keep-index' | '--staged' | '--snapshot'; interface PushState { subcommand: 'push'; @@ -111,6 +107,9 @@ const subcommandToTitleMap = new Map([ ['rename', 'Rename'], ]); function getTitle(title: string, subcommand: State['subcommand'] | undefined) { + if (subcommand === 'drop') { + title = 'Stashes'; + } return subcommand == null ? title : `${subcommandToTitleMap.get(subcommand)} ${title}`; } @@ -134,12 +133,16 @@ export class StashGitCommand extends QuickCommand { switch (args.state.subcommand) { case 'apply': - case 'drop': case 'pop': if (args.state.reference != null) { counter++; } break; + case 'drop': + if (args.state.references != null) { + counter++; + } + break; case 'push': if (args.state.message != null) { counter++; @@ -186,9 +189,9 @@ export class StashGitCommand extends QuickCommand { repos: this.container.git.openRepositories, associatedView: this.container.stashesView, readonly: - getContext('gitlens:readonly', false) || - getContext('gitlens:untrusted', false) || - getContext('gitlens:hasVirtualFolders', false), + getContext('gitlens:readonly', false) || + getContext('gitlens:untrusted', false) || + getContext('gitlens:hasVirtualFolders', false), title: this.title, }; @@ -278,7 +281,7 @@ export class StashGitCommand extends QuickCommand { }, { label: 'drop', - description: 'deletes the specified stash', + description: 'deletes the specified stashes', picked: state.subcommand === 'drop', item: 'drop', }, @@ -422,32 +425,36 @@ export class StashGitCommand extends QuickCommand { private async *dropCommandSteps(state: DropStepState, context: Context): StepGenerator { while (this.canStepsContinue(state)) { - if (state.counter < 3 || state.reference == null) { - const result: StepResult = yield* pickStashStep(state, context, { + if (state.counter < 3 || !state.references?.length) { + const result: StepResult = yield* pickStashesStep(state, context, { stash: await this.container.git.getStash(state.repo.path), placeholder: (context, stash) => - stash == null ? `No stashes found in ${state.repo.formattedName}` : 'Choose a stash to delete', - picked: state.reference?.ref, + stash == null ? `No stashes found in ${state.repo.formattedName}` : 'Choose stashes to delete', + picked: state.references?.map(r => r.ref), }); // Always break on the first step (so we will go back) if (result === StepResultBreak) break; - state.reference = result; + state.references = result; } const result = yield* this.dropCommandConfirmStep(state, context); if (result === StepResultBreak) continue; endSteps(state); - try { - // drop can only take a stash index, e.g. `stash@{1}` - await state.repo.stashDelete(`stash@{${state.reference.number}}`, state.reference.ref); - } catch (ex) { - Logger.error(ex, context.title); - void showGenericErrorMessage('Unable to delete stash'); + state.references.sort((a, b) => parseInt(b.number, 10) - parseInt(a.number, 10)); + for (const ref of state.references) { + try { + // drop can only take a stash index, e.g. `stash@{1}` + await state.repo.stashDelete(`stash@{${ref.number}}`, ref.ref); + } catch (ex) { + Logger.error(ex, context.title); - return; + void showGenericErrorMessage( + `Unable to delete stash@{${ref.number}}${ref.message ? `: ${ref.message}` : ''}`, + ); + } } } } @@ -458,27 +465,11 @@ export class StashGitCommand extends QuickCommand { [ { label: context.title, - detail: `Will delete ${getReferenceLabel(state.reference)}`, + detail: `Will delete ${getReferenceLabel(state.references)}`, }, ], undefined, - { - placeholder: `Confirm ${context.title}`, - additionalButtons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], - onDidClickButton: (quickpick, button) => { - if (button === ShowDetailsViewQuickInputButton) { - void showDetailsView(state.reference, { - pin: false, - preserveFocus: true, - }); - } else if (button === RevealInSideBarQuickInputButton) { - void reveal(state.reference, { - select: true, - expand: true, - }); - } - }, - }, + { placeholder: `Confirm ${context.title}` }, ); const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? undefined : StepResultBreak; @@ -546,39 +537,55 @@ export class StashGitCommand extends QuickCommand { } try { - await state.repo.stashSave(state.message, state.uris, { - includeUntracked: state.flags.includes('--include-untracked'), - keepIndex: state.flags.includes('--keep-index'), - onlyStaged: state.flags.includes('--staged'), - }); + if (state.flags.includes('--snapshot')) { + await state.repo.stashSaveSnapshot(state.message); + } else { + await state.repo.stashSave(state.message, state.uris, { + includeUntracked: state.flags.includes('--include-untracked'), + keepIndex: state.flags.includes('--keep-index'), + onlyStaged: state.flags.includes('--staged'), + }); + } endSteps(state); } catch (ex) { Logger.error(ex, context.title); - if ( - ex instanceof StashPushError && - ex.reason === StashPushErrorReason.ConflictingStagedAndUnstagedLines && - state.flags.includes('--staged') - ) { - const confirm = { title: 'Yes' }; - const cancel = { title: 'No', isCloseAffordance: true }; - const result = await window.showErrorMessage( - ex.message, - { - modal: true, - }, - confirm, - cancel, - ); - - if (result === confirm) { - state.uris = state.onlyStagedUris; - state.flags.splice(state.flags.indexOf('--staged'), 1); - continue; + if (ex instanceof StashPushError) { + if (ex.reason === StashPushErrorReason.NothingToSave) { + if (!state.flags.includes('--include-untracked')) { + void window.showWarningMessage( + 'No changes to stash. Choose the "Push & Include Untracked" option, if you have untracked files.', + ); + continue; + } + + void window.showInformationMessage('No changes to stash.'); + return; + } + if ( + ex.reason === StashPushErrorReason.ConflictingStagedAndUnstagedLines && + state.flags.includes('--staged') + ) { + const confirm = { title: 'Stash Everything' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showErrorMessage( + `Changes were stashed, but the working tree cannot be updated because at least one file has staged and unstaged changes on the same line(s)\n\nDo you want to try again by stashing both your staged and unstaged changes?`, + { modal: true }, + confirm, + cancel, + ); + + if (result === confirm) { + if (state.uris == null) { + state.uris = state.onlyStagedUris; + } + state.flags.splice(state.flags.indexOf('--staged'), 1); + continue; + } + + return; } - - return; } const msg: string = ex?.message ?? ex?.toString() ?? ''; @@ -626,43 +633,78 @@ export class StashGitCommand extends QuickCommand { } private *pushCommandConfirmStep(state: PushStepState, context: Context): StepResultGenerator { - const step: QuickPickStep> = this.createConfirmStep( + const stagedOnly = state.flags.includes('--staged'); + + const baseFlags: PushFlags[] = []; + if (stagedOnly) { + baseFlags.push('--staged'); + } + + type StepType = FlagsQuickPickItem; + + const confirmations: StepType[] = []; + if (state.uris?.length) { + if (state.flags.includes('--include-untracked')) { + baseFlags.push('--include-untracked'); + } + + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags], { + label: context.title, + detail: `Will stash changes from ${ + state.uris.length === 1 + ? formatPath(state.uris[0], { fileOnly: true }) + : `${state.uris.length} files` + }`, + }), + ); + // If we are including untracked file, then avoid allowing --keep-index since Git will error out for some reason + if (!state.flags.includes('--include-untracked')) { + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags, '--keep-index'], { + label: `${context.title} & Keep Staged`, + detail: `Will stash changes from ${ + state.uris.length === 1 + ? formatPath(state.uris[0], { fileOnly: true }) + : `${state.uris.length} files` + }, but will keep staged files intact`, + }), + ); + } + } else { + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags], { + label: context.title, + detail: `Will stash ${stagedOnly ? 'staged' : 'uncommitted'} changes`, + }), + createFlagsQuickPickItem(state.flags, [...baseFlags, '--snapshot'], { + label: `${context.title} Snapshot`, + detail: 'Will stash uncommitted changes without changing the working tree', + }), + ); + if (!stagedOnly) { + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags, '--include-untracked'], { + label: `${context.title} & Include Untracked`, + description: '--include-untracked', + detail: 'Will stash uncommitted changes, including untracked files', + }), + ); + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags, '--keep-index'], { + label: `${context.title} & Keep Staged`, + description: '--keep-index', + detail: `Will stash ${ + stagedOnly ? 'staged' : 'uncommitted' + } changes, but will keep staged files intact`, + }), + ); + } + } + + const step = this.createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), - state.uris == null || state.uris.length === 0 - ? [ - createFlagsQuickPickItem(state.flags, [], { - label: context.title, - detail: 'Will stash uncommitted changes', - }), - createFlagsQuickPickItem(state.flags, ['--include-untracked'], { - label: `${context.title} & Include Untracked`, - description: '--include-untracked', - detail: 'Will stash uncommitted changes, including untracked files', - }), - createFlagsQuickPickItem(state.flags, ['--keep-index'], { - label: `${context.title} & Keep Staged`, - description: '--keep-index', - detail: 'Will stash uncommitted changes, but will keep staged files intact', - }), - ] - : [ - createFlagsQuickPickItem(state.flags, [], { - label: context.title, - detail: `Will stash changes from ${ - state.uris.length === 1 - ? formatPath(state.uris[0], { fileOnly: true }) - : `${state.uris.length} files` - }`, - }), - createFlagsQuickPickItem(state.flags, ['--keep-index'], { - label: `${context.title} & Keep Staged`, - detail: `Will stash changes from ${ - state.uris.length === 1 - ? formatPath(state.uris[0], { fileOnly: true }) - : `${state.uris.length} files` - }, but will keep staged files intact`, - }), - ], + confirmations, undefined, { placeholder: `Confirm ${context.title}` }, ); diff --git a/src/commands/git/status.ts b/src/commands/git/status.ts index 4735e43c9f064..8715b0c582d61 100644 --- a/src/commands/git/status.ts +++ b/src/commands/git/status.ts @@ -8,7 +8,8 @@ import { GitCommandQuickPickItem } from '../../quickpicks/items/gitCommands'; import { pad } from '../../system/string'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { PartialStepState, StepGenerator, StepState } from '../quickCommand'; -import { endSteps, pickRepositoryStep, QuickCommand, showRepositoryStatusStep, StepResultBreak } from '../quickCommand'; +import { endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { pickRepositoryStep, showRepositoryStatusStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -89,8 +90,7 @@ export class StatusGitCommand extends QuickCommand { refType: 'branch', name: context.status.branch, remote: false, - upstream: - context.status.upstream != null ? { name: context.status.upstream, missing: false } : undefined, + upstream: context.status.upstream, }), { icon: false }, )}`; diff --git a/src/commands/git/switch.ts b/src/commands/git/switch.ts index 4cb844526e034..8e2e126c52897 100644 --- a/src/commands/git/switch.ts +++ b/src/commands/git/switch.ts @@ -1,35 +1,34 @@ import { ProgressLocation, window } from 'vscode'; import type { Container } from '../../container'; import type { GitReference } from '../../git/models/reference'; -import { getNameWithoutRemote, getReferenceLabel, isBranchReference } from '../../git/models/reference'; +import { + getNameWithoutRemote, + getReferenceLabel, + getReferenceTypeLabel, + isBranchReference, +} from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; +import { createQuickPickSeparator } from '../../quickpicks/items/common'; import { isStringArray } from '../../system/array'; +import { executeCommand } from '../../system/command'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; -import type { - PartialStepState, - QuickPickStep, - StepGenerator, - StepResultGenerator, - StepSelection, - StepState, -} from '../quickCommand'; +import { getSteps } from '../gitCommands.utils'; +import type { PartialStepState, StepGenerator, StepResultGenerator, StepSelection, StepState } from '../quickCommand'; +import { canPickStepContinue, endSteps, isCrossCommandReference, QuickCommand, StepResultBreak } from '../quickCommand'; import { appendReposToTitle, - canPickStepContinue, - endSteps, inputBranchNameStep, pickBranchOrTagStepMultiRepo, pickRepositoriesStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; associatedView: ViewsWithRepositoryFolders; + canSwitchToLocalBranch: GitReference | undefined; + promptToCreateBranch: boolean; showTags: boolean; - switchToLocalFrom: GitReference | undefined; title: string; } @@ -38,9 +37,17 @@ interface State { reference: GitReference; createBranch?: string; fastForwardTo?: GitReference; + skipWorktreeConfirmations?: boolean; } -type ConfirmationChoice = 'switch' | 'switch+fast-forward'; +type ConfirmationChoice = + | 'switch' + | 'switchViaWorktree' + | 'switchToLocalBranch' + | 'switchToLocalBranchAndFastForward' + | 'switchToLocalBranchViaWorktree' + | 'switchToNewBranch' + | 'switchToNewBranchViaWorktree'; type SwitchStepState = ExcludeSome, 'repos', string | string[] | Repository>; @@ -52,8 +59,8 @@ export interface SwitchGitCommandArgs { export class SwitchGitCommand extends QuickCommand { constructor(container: Container, args?: SwitchGitCommandArgs) { - super(container, 'switch', 'switch', 'Switch Branch', { - description: 'aka checkout, switches the current branch to a specified branch', + super(container, 'switch', 'switch', 'Switch to...', { + description: 'aka checkout, switches to a specified branch', }); let counter = 0; @@ -72,12 +79,17 @@ export class SwitchGitCommand extends QuickCommand { }; } + private _canConfirmOverride: boolean | undefined; + override get canConfirm(): boolean { + return this._canConfirmOverride ?? true; + } + async execute(state: SwitchStepState) { await window.withProgress( { location: ProgressLocation.Notification, title: `Switching ${ - state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` + state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repos` } to ${state.reference.name}`, }, () => @@ -105,8 +117,9 @@ export class SwitchGitCommand extends QuickCommand { const context: Context = { repos: this.container.git.openRepositories, associatedView: this.container.commitsView, + canSwitchToLocalBranch: undefined, + promptToCreateBranch: false, showTags: false, - switchToLocalFrom: undefined, title: this.title, }; @@ -116,7 +129,7 @@ export class SwitchGitCommand extends QuickCommand { let skippedStepOne = false; - while (this.canStepsContinue(state)) { + outer: while (this.canStepsContinue(state)) { context.title = this.title; if (state.counter < 1 || state.repos == null || state.repos.length === 0 || isStringArray(state.repos)) { @@ -144,6 +157,7 @@ export class SwitchGitCommand extends QuickCommand { if (state.counter < 2 || state.reference == null) { const result = yield* pickBranchOrTagStepMultiRepo(state as SwitchStepState, context, { placeholder: context => `Choose a branch${context.showTags ? ' or tag' : ''} to switch to`, + allowCreate: state.repos.length === 1, }); if (result === StepResultBreak) { // If we skipped the previous step, make sure we back up past it @@ -154,44 +168,167 @@ export class SwitchGitCommand extends QuickCommand { continue; } + if (typeof result == 'string') { + yield* getSteps( + this.container, + { + command: 'branch', + state: { + subcommand: 'create', + repo: state.repos[0], + name: result, + suggestNameOnly: true, + flags: ['--switch'], + }, + }, + this.pickedVia, + ); + + endSteps(state); + return; + } + + if (isCrossCommandReference(result)) { + void executeCommand(result.command, result.args); + endSteps(state); + return; + } + state.reference = result; } - if (isBranchReference(state.reference) && state.reference.remote) { - context.title = `Create Branch and ${this.title}`; + context.canSwitchToLocalBranch = undefined; + + if (isBranchReference(state.reference) && !state.reference.remote) { + state.createBranch = undefined; + + const worktree = await this.container.git.getWorktree( + state.reference.repoPath, + w => w.branch?.name === state.reference!.name, + ); + if (worktree != null && !worktree.main) { + if (state.fastForwardTo != null) { + state.repos[0].merge('--ff-only', state.fastForwardTo.ref); + } + + const worktreeResult = yield* getSteps( + this.container, + { + command: 'worktree', + state: { + subcommand: 'open', + worktree: worktree, + openOnly: true, + overrides: { + disallowBack: true, + confirmation: state.skipWorktreeConfirmations + ? undefined + : { + title: `Confirm Switch to Worktree \u2022 ${getReferenceLabel( + state.reference, + { + icon: false, + label: false, + }, + )}`, + placeholder: `${getReferenceLabel(state.reference, { + capitalize: true, + icon: false, + })} is linked to a worktree`, + }, + }, + repo: state.repos[0], + skipWorktreeConfirmations: state.skipWorktreeConfirmations, + }, + }, + this.pickedVia, + ); + if (worktreeResult === StepResultBreak && !state.skipWorktreeConfirmations) continue; + endSteps(state); + return; + } + } else if (isBranchReference(state.reference) && state.reference.remote) { + // See if there is a local branch that tracks the remote branch const { values: branches } = await this.container.git.getBranches(state.reference.repoPath, { filter: b => b.upstream?.name === state.reference!.name, sort: { orderBy: 'date:desc' }, }); - if (branches.length === 0) { - const result = yield* inputBranchNameStep(state as SwitchStepState, context, { - placeholder: 'Please provide a name for the new branch', - titleContext: ` based on ${getReferenceLabel(state.reference, { - icon: false, - })}`, - value: state.createBranch ?? getNameWithoutRemote(state.reference), - }); - if (result === StepResultBreak) continue; + if (branches.length) { + context.canSwitchToLocalBranch = branches[0]; - state.createBranch = result; - } else { - context.title = `${this.title} to Local Branch`; - context.switchToLocalFrom = state.reference; - state.reference = branches[0]; state.createBranch = undefined; + context.promptToCreateBranch = false; + if (state.skipWorktreeConfirmations) { + state.reference = context.canSwitchToLocalBranch; + continue outer; + } + } else { + context.promptToCreateBranch = true; } - } else { - state.createBranch = undefined; } - if (this.confirm(state.confirm || context.switchToLocalFrom != null)) { + if (this.confirm(context.promptToCreateBranch || context.canSwitchToLocalBranch ? true : state.confirm)) { const result = yield* this.confirmStep(state as SwitchStepState, context); if (result === StepResultBreak) continue; - if (result === 'switch+fast-forward') { - state.fastForwardTo = context.switchToLocalFrom; + switch (result) { + case 'switchToLocalBranch': + state.reference = context.canSwitchToLocalBranch!; + continue outer; + + case 'switchToLocalBranchAndFastForward': + state.fastForwardTo = state.reference; + state.reference = context.canSwitchToLocalBranch!; + continue outer; + + case 'switchToNewBranch': { + context.title = `Switch to New Branch`; + this._canConfirmOverride = false; + + const result = yield* inputBranchNameStep(state as SwitchStepState, context, { + titleContext: ` from ${getReferenceLabel(state.reference, { + capitalize: true, + icon: false, + label: state.reference.refType !== 'branch', + })}`, + value: state.createBranch ?? getNameWithoutRemote(state.reference), + }); + + this._canConfirmOverride = undefined; + + if (result === StepResultBreak) continue outer; + + state.createBranch = result; + break; + } + case 'switchViaWorktree': + case 'switchToLocalBranchViaWorktree': + case 'switchToNewBranchViaWorktree': { + const worktreeResult = yield* getSteps( + this.container, + { + command: 'worktree', + state: { + subcommand: 'create', + reference: + result === 'switchToLocalBranchViaWorktree' + ? context.canSwitchToLocalBranch + : state.reference, + createBranch: + result === 'switchToNewBranchViaWorktree' ? state.createBranch : undefined, + repo: state.repos[0], + skipWorktreeConfirmations: state.skipWorktreeConfirmations, + }, + }, + this.pickedVia, + ); + if (worktreeResult === StepResultBreak && !state.skipWorktreeConfirmations) continue outer; + + endSteps(state); + return; + } } } @@ -203,49 +340,136 @@ export class SwitchGitCommand extends QuickCommand { } private *confirmStep(state: SwitchStepState, context: Context): StepResultGenerator { - let additionalConfirmations: QuickPickItemOfT[]; - if (context.switchToLocalFrom != null && state.repos.length === 1) { - additionalConfirmations = [ - { - label: `${context.title} and Fast-Forward`, + const isLocalBranch = isBranchReference(state.reference) && !state.reference.remote; + + type StepType = QuickPickItemOfT; + if (state.skipWorktreeConfirmations && state.repos.length === 1) { + if (isLocalBranch) { + return 'switchViaWorktree'; + } else if (!state.createBranch && context.canSwitchToLocalBranch != null) { + return 'switchToLocalBranchViaWorktree'; + } + + return 'switchToNewBranchViaWorktree'; + } + + const confirmations: StepType[] = []; + if (!state.createBranch) { + if (context.canSwitchToLocalBranch != null) { + confirmations.push(createQuickPickSeparator('Local')); + confirmations.push({ + label: `Switch to Local Branch`, + description: '', + detail: `Will switch to local ${getReferenceLabel( + context.canSwitchToLocalBranch, + )} for ${getReferenceLabel(state.reference)}`, + item: 'switchToLocalBranch', + }); + + if (state.repos.length === 1) { + confirmations.push({ + label: `Switch to Local Branch & Fast-Forward`, + description: '', + detail: `Will switch to and fast-forward local ${getReferenceLabel( + context.canSwitchToLocalBranch, + )}`, + item: 'switchToLocalBranchAndFastForward', + }); + } + } else if (isLocalBranch) { + confirmations.push({ + label: 'Switch to Branch', description: '', - detail: `Will switch to and fast-forward local ${getReferenceLabel(state.reference)} in $(repo) ${ - state.repos[0].formattedName + detail: `Will switch to ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' }`, - item: 'switch+fast-forward', - }, - ]; - } else { - additionalConfirmations = []; + item: 'switch', + }); + } + } + + if (!isLocalBranch || state.createBranch || context.promptToCreateBranch) { + if (confirmations.length) { + confirmations.push(createQuickPickSeparator('Remote')); + } + confirmations.push({ + label: `Switch to New Local Branch`, + description: '', + detail: `Will create and switch to a new local branch${ + state.createBranch ? ` named ${state.createBranch}` : '' + } from ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' + }`, + item: 'switchToNewBranch', + }); } - const step: QuickPickStep> = this.createConfirmStep< - QuickPickItemOfT - >( - appendReposToTitle(`Confirm ${context.title}`, state, context), - [ - { - label: context.title, - description: state.createBranch ? '-b' : '', - detail: `Will ${ - state.createBranch - ? `create and switch to a new branch named ${state.createBranch} from ${getReferenceLabel( - state.reference, - )}` - : `switch to ${context.switchToLocalFrom != null ? 'local ' : ''}${getReferenceLabel( - state.reference, - )}` - } in ${ - state.repos.length === 1 - ? `$(repo) ${state.repos[0].formattedName}` - : `${state.repos.length} repositories` + if (state.repos.length === 1) { + if (confirmations.length) { + confirmations.push(createQuickPickSeparator('Worktree')); + } + if (isLocalBranch) { + confirmations.push({ + label: `Create Worktree for Branch...`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for ${getReferenceLabel(state.reference)}`, + item: 'switchViaWorktree', + }); + } else if (!state.createBranch && context.canSwitchToLocalBranch != null) { + confirmations.push({ + label: `Create Worktree for Local Branch...`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for local ${getReferenceLabel(context.canSwitchToLocalBranch)}`, + item: 'switchToLocalBranchViaWorktree', + }); + } else { + confirmations.push({ + label: `Create Worktree for New Local Branch...`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for a new local branch${ + state.createBranch ? ` named ${state.createBranch}` : '' + } from ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' + }`, + item: 'switchToNewBranchViaWorktree', + }); + } + } + + if (!isLocalBranch) { + if (confirmations.length) { + confirmations.push(createQuickPickSeparator()); + } + if (!isBranchReference(state.reference)) { + confirmations.push({ + label: `Checkout to ${getReferenceTypeLabel(state.reference)}`, + description: '(detached)', + detail: `Will checkout to ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' }`, item: 'switch', - }, - ...additionalConfirmations, - ], + }); + } else if (!state.createBranch) { + confirmations.push({ + label: `Checkout to Remote Branch`, + description: '(detached)', + detail: `Will checkout to ${getReferenceLabel(state.reference)}`, + item: 'switch', + }); + } + } + + const step = this.createConfirmStep( + appendReposToTitle( + `Confirm Switch to ${getReferenceLabel(state.reference, { icon: false, capitalize: true })}`, + state, + context, + ), + confirmations, undefined, - { placeholder: `Confirm ${context.title}` }, + { + placeholder: `Confirm ${context.title}`, + }, ); const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; diff --git a/src/commands/git/tag.ts b/src/commands/git/tag.ts index 10d2a2fc657dd..bc8ef75e08e61 100644 --- a/src/commands/git/tag.ts +++ b/src/commands/git/tag.ts @@ -18,7 +18,6 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canInputStepContinue, canPickStepContinue, canStepContinue, @@ -26,13 +25,16 @@ import { createInputStep, createPickStep, endSteps, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { + appendReposToTitle, inputTagNameStep, pickBranchOrTagStep, pickRepositoryStep, pickTagsStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -347,7 +349,7 @@ export class TagGitCommand extends QuickCommand { return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } - private async *deleteCommandSteps(state: DeleteStepState, context: Context): StepGenerator { + private *deleteCommandSteps(state: DeleteStepState, context: Context): StepGenerator { while (this.canStepsContinue(state)) { if (state.references != null && !Array.isArray(state.references)) { state.references = [state.references]; diff --git a/src/commands/git/worktree.ts b/src/commands/git/worktree.ts index be138f8f12be5..ed8e9d08ff19a 100644 --- a/src/commands/git/worktree.ts +++ b/src/commands/git/worktree.ts @@ -1,19 +1,30 @@ import type { MessageItem } from 'vscode'; import { QuickInputButtons, Uri, window, workspace } from 'vscode'; import type { Config } from '../../config'; +import { proBadge, proBadgeSuperscript } from '../../constants'; import type { Container } from '../../container'; +import { CancellationError } from '../../errors'; import { PlusFeatures } from '../../features'; -import { convertOpenFlagsToLocation, reveal, revealInFileExplorer } from '../../git/actions/worktree'; +import { convertLocationToOpenFlags, convertOpenFlagsToLocation, reveal } from '../../git/actions/worktree'; import { + ApplyPatchCommitError, + ApplyPatchCommitErrorReason, WorktreeCreateError, WorktreeCreateErrorReason, WorktreeDeleteError, WorktreeDeleteErrorReason, } from '../../git/errors'; +import { uncommitted, uncommittedStaged } from '../../git/models/constants'; import type { GitReference } from '../../git/models/reference'; -import { getNameWithoutRemote, getReferenceLabel, isRevisionReference } from '../../git/models/reference'; +import { + getNameWithoutRemote, + getReferenceLabel, + isBranchReference, + isRevisionReference, + isSha, +} from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; -import { GitWorktree } from '../../git/models/worktree'; +import type { GitWorktree } from '../../git/models/worktree'; import { showGenericErrorMessage } from '../../messages'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { createQuickPickSeparator } from '../../quickpicks/items/common'; @@ -21,9 +32,10 @@ import { Directive } from '../../quickpicks/items/directive'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; import { configuration } from '../../system/configuration'; -import { basename, isDescendent } from '../../system/path'; +import { basename, isDescendant } from '../../system/path'; +import type { Deferred } from '../../system/promise'; import { pluralize, truncateLeft } from '../../system/string'; -import { openWorkspace } from '../../system/utils'; +import { getWorkspaceFriendlyPath, openWorkspace, revealInFileExplorer } from '../../system/utils'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { AsyncStepResultGenerator, @@ -36,34 +48,37 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, - canInputStepContinue, canPickStepContinue, canStepContinue, createConfirmStep, createCustomStep, createPickStep, endSteps, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { + appendReposToTitle, ensureAccessStep, inputBranchNameStep, pickBranchOrTagStep, pickRepositoryStep, pickWorktreesStep, pickWorktreeStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; associatedView: ViewsWithRepositoryFolders; defaultUri?: Uri; - pickedUri?: Uri; + pickedRootFolder?: Uri; + pickedSpecificFolder?: Uri; showTags: boolean; title: string; worktrees?: GitWorktree[]; } +type CreateConfirmationChoice = Uri | 'changeRoot' | 'chooseFolder'; type CreateFlags = '--force' | '-b' | '--detach' | '--direct'; interface CreateState { @@ -71,10 +86,18 @@ interface CreateState { repo: string | Repository; uri: Uri; reference?: GitReference; + addRemote?: { name: string; url: string }; createBranch?: string; flags: CreateFlags[]; + result?: Deferred; reveal?: boolean; + + overrides?: { + title?: string; + }; + + skipWorktreeConfirmations?: boolean; } type DeleteFlags = '--force'; @@ -84,6 +107,10 @@ interface DeleteState { repo: string | Repository; uris: Uri[]; flags: DeleteFlags[]; + + overrides?: { + title?: string; + }; } type OpenFlags = '--add-to-workspace' | '--new-window' | '--reveal-explorer'; @@ -91,15 +118,44 @@ type OpenFlags = '--add-to-workspace' | '--new-window' | '--reveal-explorer'; interface OpenState { subcommand: 'open'; repo: string | Repository; - uri: Uri; + worktree: GitWorktree; flags: OpenFlags[]; + + openOnly?: boolean; + overrides?: { + disallowBack?: boolean; + title?: string; + + confirmation?: { + title?: string; + placeholder?: string; + }; + }; + + skipWorktreeConfirmations?: boolean; +} + +interface CopyChangesState { + subcommand: 'copy-changes'; + repo: string | Repository; + worktree: GitWorktree; + changes: + | { baseSha?: string; contents?: string; type: 'index' | 'working-tree' } + | { baseSha: string; contents: string; type?: 'index' | 'working-tree' }; + + overrides?: { + title?: string; + }; } -type State = CreateState | DeleteState | OpenState; +type State = CreateState | DeleteState | OpenState | CopyChangesState; type WorktreeStepState = SomeNonNullable, 'subcommand'>; type CreateStepState = WorktreeStepState>; type DeleteStepState = WorktreeStepState>; type OpenStepState = WorktreeStepState>; +type CopyChangesStepState = WorktreeStepState< + ExcludeSome +>; function assertStateStepRepository( state: PartialStepState, @@ -110,13 +166,15 @@ function assertStateStepRepository( throw new Error('Missing repository'); } -const subcommandToTitleMap = new Map([ - ['create', 'Create'], - ['delete', 'Delete'], - ['open', 'Open'], +const subcommandToTitleMap = new Map([ + [undefined, `Worktrees ${proBadgeSuperscript}`], + ['create', `Create Worktree`], + ['delete', `Delete Worktrees`], + ['open', `Open Worktree`], + ['copy-changes', 'Copy Changes to'], ]); -function getTitle(title: string, subcommand: State['subcommand'] | undefined) { - return subcommand == null ? title : `${subcommandToTitleMap.get(subcommand)} ${title}`; +function getTitle(subcommand: State['subcommand'] | undefined, suffix?: string) { + return `${subcommandToTitleMap.get(subcommand)}${suffix ?? ''}`; } export interface WorktreeGitCommandArgs { @@ -127,11 +185,10 @@ export interface WorktreeGitCommandArgs { export class WorktreeGitCommand extends QuickCommand { private subcommand: State['subcommand'] | undefined; - private canSkipConfirmOverride: boolean | undefined; constructor(container: Container, args?: WorktreeGitCommandArgs) { - super(container, 'worktree', 'worktree', 'Worktree', { - description: 'open, create, or delete worktrees', + super(container, 'worktree', 'worktree', `Worktrees ${proBadgeSuperscript}`, { + description: `${proBadge}\u00a0\u00a0open, create, or delete worktrees`, }); let counter = 0; @@ -156,7 +213,13 @@ export class WorktreeGitCommand extends QuickCommand { break; case 'open': - if (args.state.uri != null) { + if (args.state.worktree != null) { + counter++; + } + + break; + case 'copy-changes': + if (args.state.worktree != null) { counter++; } @@ -179,8 +242,9 @@ export class WorktreeGitCommand extends QuickCommand { return this.subcommand != null; } + private _canSkipConfirmOverride: boolean | undefined; override get canSkipConfirm(): boolean { - return this.canSkipConfirmOverride ?? false; + return this._canSkipConfirmOverride ?? this.subcommand === 'open'; } override get skipConfirmKey() { @@ -198,7 +262,7 @@ export class WorktreeGitCommand extends QuickCommand { let skippedStepTwo = false; while (this.canStepsContinue(state)) { - context.title = this.title; + context.title = state.overrides?.title ?? this.title; if (state.counter < 1 || state.subcommand == null) { this.subcommand = undefined; @@ -211,6 +275,7 @@ export class WorktreeGitCommand extends QuickCommand { } this.subcommand = state.subcommand; + context.title = state.overrides?.title ?? getTitle(state.subcommand); if (state.counter < 2 || state.repo == null || typeof state.repo === 'string') { skippedStepTwo = false; @@ -229,14 +294,14 @@ export class WorktreeGitCommand extends QuickCommand { } } - // Ensure we use the "main" repository if we are in a worktree already - state.repo = (await state.repo.getMainRepository()) ?? state.repo; + if (state.subcommand !== 'copy-changes') { + // Ensure we use the "main" repository if we are in a worktree already + state.repo = (await state.repo.getCommonRepository()) ?? state.repo; + } assertStateStepRepository(state); const result = yield* ensureAccessStep(state, context, PlusFeatures.Worktrees); - if (result === StepResultBreak) break; - - context.title = getTitle(state.subcommand === 'delete' ? 'Worktrees' : this.title, state.subcommand); + if (result === StepResultBreak) continue; switch (state.subcommand) { case 'create': { @@ -257,6 +322,10 @@ export class WorktreeGitCommand extends QuickCommand { yield* this.openCommandSteps(state as OpenStepState, context); break; } + case 'copy-changes': { + yield* this.copyChangesCommandSteps(state as CopyChangesStepState, context); + break; + } default: endSteps(state); break; @@ -310,11 +379,12 @@ export class WorktreeGitCommand extends QuickCommand { state.flags = []; } - context.pickedUri = undefined; + context.pickedRootFolder = undefined; + context.pickedSpecificFolder = undefined; // Don't allow skipping the confirm step state.confirm = true; - this.canSkipConfirmOverride = undefined; + this._canSkipConfirmOverride = undefined; while (this.canStepsContinue(state)) { if (state.counter < 3 || state.reference == null) { @@ -331,40 +401,55 @@ export class WorktreeGitCommand extends QuickCommand { state.reference = result; } - if (state.counter < 4 || state.uri == null) { - if ( - state.reference != null && - !configuration.get('worktrees.promptForLocation', state.repo.folder) && - context.defaultUri != null - ) { - state.uri = context.defaultUri; - } else { - const result = yield* this.createCommandChoosePathStep(state, context, { - titleContext: ` for ${getReferenceLabel(state.reference, { - capitalize: true, - icon: false, - label: state.reference.refType !== 'branch', - })}`, - defaultUri: context.defaultUri, - }); - if (result === StepResultBreak) continue; - - state.uri = result; - // Keep track of the actual uri they picked, because we will modify it in later steps - context.pickedUri = state.uri; - } + if (state.uri == null) { + state.uri = context.defaultUri!; } if (this.confirm(state.confirm)) { const result = yield* this.createCommandConfirmStep(state, context); if (result === StepResultBreak) continue; + if (typeof result[0] === 'string') { + switch (result[0]) { + case 'changeRoot': { + const result = yield* this.createCommandChoosePathStep(state, context, { + title: `Choose a Different Root Folder for this Worktree`, + label: 'Choose Root Folder', + pickedUri: context.pickedRootFolder, + defaultUri: context.pickedRootFolder ?? context.defaultUri, + }); + if (result === StepResultBreak) continue; + + state.uri = result; + // Keep track of the actual uri they picked, because we will modify it in later steps + context.pickedRootFolder = state.uri; + context.pickedSpecificFolder = undefined; + continue; + } + case 'chooseFolder': { + const result = yield* this.createCommandChoosePathStep(state, context, { + title: `Choose a Specific Folder for this Worktree`, + label: 'Choose Worktree Folder', + pickedUri: context.pickedRootFolder, + defaultUri: context.pickedSpecificFolder ?? context.defaultUri, + }); + if (result === StepResultBreak) continue; + + state.uri = result; + // Keep track of the actual uri they picked, because we will modify it in later steps + context.pickedRootFolder = undefined; + context.pickedSpecificFolder = state.uri; + continue; + } + } + } + [state.uri, state.flags] = result; } // Reset any confirmation overrides state.confirm = true; - this.canSkipConfirmOverride = undefined; + this._canSkipConfirmOverride = undefined; const isRemoteBranch = state.reference?.refType === 'branch' && state.reference?.remote; if (isRemoteBranch && !state.flags.includes('-b')) { @@ -394,8 +479,7 @@ export class WorktreeGitCommand extends QuickCommand { if (state.createBranch == null) { const result = yield* inputBranchNameStep(state, context, { - placeholder: 'Please provide a name for the new branch', - titleContext: ` from ${getReferenceLabel(state.reference, { + titleContext: ` and New Branch from ${getReferenceLabel(state.reference, { capitalize: true, icon: false, label: state.reference.refType !== 'branch', @@ -421,19 +505,17 @@ export class WorktreeGitCommand extends QuickCommand { let worktree: GitWorktree | undefined; try { + if (state.addRemote != null) { + await state.repo.addRemote(state.addRemote.name, state.addRemote.url, { fetch: true }); + } + worktree = await state.repo.createWorktree(uri, { commitish: state.reference?.name, createBranch: state.flags.includes('-b') ? state.createBranch : undefined, detach: state.flags.includes('--detach'), force: state.flags.includes('--force'), }); - - if (state.reveal !== false) { - void reveal(undefined, { - select: true, - focus: true, - }); - } + state.result?.fulfill(worktree); } catch (ex) { if ( WorktreeCreateError.is(ex, WorktreeCreateErrorReason.AlreadyCheckedOut) && @@ -455,27 +537,36 @@ export class WorktreeGitCommand extends QuickCommand { if (result === createBranch) { state.flags.push('-b'); - this.canSkipConfirmOverride = true; + this._canSkipConfirmOverride = true; state.confirm = false; continue; } if (result === force) { state.flags.push('--force'); - this.canSkipConfirmOverride = true; + this._canSkipConfirmOverride = true; state.confirm = false; continue; } } else if (WorktreeCreateError.is(ex, WorktreeCreateErrorReason.AlreadyExists)) { - void window.showErrorMessage( - `Unable to create a new worktree in '${GitWorktree.getFriendlyPath( - uri, - )}' because the folder already exists and is not empty.`, - 'OK', - ); + const confirm: MessageItem = { title: 'OK' }; + const openFolder: MessageItem = { title: 'Open Folder' }; + void window + .showErrorMessage( + `Unable to create a new worktree in '${getWorkspaceFriendlyPath( + uri, + )}' because the folder already exists and is not empty.`, + confirm, + openFolder, + ) + .then(result => { + if (result === openFolder) { + void revealInFileExplorer(uri); + } + }); } else { void showGenericErrorMessage( - `Unable to create a new worktree in '${GitWorktree.getFriendlyPath(uri)}.`, + `Unable to create a new worktree in '${getWorkspaceFriendlyPath(uri)}.`, ); } } @@ -483,68 +574,67 @@ export class WorktreeGitCommand extends QuickCommand { endSteps(state); if (worktree == null) break; + if (state.reveal !== false) { + setTimeout(() => { + if (this.container.worktreesView.visible) { + void reveal(worktree, { select: true, focus: false }); + } + }, 100); + } + type OpenAction = Config['worktrees']['openAfterCreate']; const action: OpenAction = configuration.get('worktrees.openAfterCreate'); - if (action === 'never') break; + if (action !== 'never') { + let flags: OpenFlags[]; + switch (action) { + case 'always': + flags = convertLocationToOpenFlags('currentWindow'); + break; + case 'alwaysNewWindow': + flags = convertLocationToOpenFlags('newWindow'); + break; + case 'onlyWhenEmpty': + flags = convertLocationToOpenFlags( + workspace.workspaceFolders?.length ? 'newWindow' : 'currentWindow', + ); + break; + default: + flags = []; + break; + } - if (action === 'prompt') { yield* this.openCommandSteps( { subcommand: 'open', repo: state.repo, - uri: worktree.uri, + worktree: worktree, + flags: flags, counter: 3, - confirm: true, - } as OpenStepState, + confirm: action === 'prompt', + openOnly: true, + overrides: { disallowBack: true }, + skipWorktreeConfirmations: state.skipWorktreeConfirmations, + } satisfies OpenStepState, context, ); - - break; } - - queueMicrotask(() => { - switch (action) { - case 'always': - openWorkspace(worktree!.uri, { location: 'currentWindow' }); - break; - case 'alwaysNewWindow': - openWorkspace(worktree!.uri, { location: 'newWindow' }); - break; - case 'onlyWhenEmpty': - openWorkspace(worktree!.uri, { - location: workspace.workspaceFolders?.length ? 'currentWindow' : 'newWindow', - }); - break; - } - }); } } - private async *createCommandChoosePathStep( + private *createCommandChoosePathStep( state: CreateStepState, context: Context, - options: { titleContext: string; defaultUri?: Uri }, - ): AsyncStepResultGenerator { + options: { title: string; label: string; pickedUri: Uri | undefined; defaultUri?: Uri }, + ): StepResultGenerator { const step = createCustomStep({ show: async (_step: CustomStep) => { - const hasDefault = options?.defaultUri != null; - const result = await window.showInformationMessage( - `Choose a location in which to create the worktree${options.titleContext}.`, - { modal: true }, - { title: 'Choose Location' }, - ...(hasDefault ? [{ title: 'Use Default Location' }] : []), - ); - - if (result == null) return Directive.Back; - if (result.title === 'Use Default Location') return options.defaultUri!; - const uris = await window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, - defaultUri: context.pickedUri ?? state.uri ?? context.defaultUri, - openLabel: 'Select Worktree Location', - title: `${appendReposToTitle(`Choose a Worktree Location${options.titleContext}`, state, context)}`, + defaultUri: options.pickedUri ?? state.uri ?? context.defaultUri, + openLabel: options.label, + title: options.title, }); if (uris == null || uris.length === 0) return Directive.Back; @@ -554,10 +644,7 @@ export class WorktreeGitCommand extends QuickCommand { }); const value: StepSelection = yield step; - - if (!canStepContinue(step, state, value) || !(await canInputStepContinue(step, state, value))) { - return StepResultBreak; - } + if (!canStepContinue(step, state, value)) return StepResultBreak; return value; } @@ -565,7 +652,7 @@ export class WorktreeGitCommand extends QuickCommand { private *createCommandConfirmStep( state: CreateStepState, context: Context, - ): StepResultGenerator<[Uri, CreateFlags[]]> { + ): StepResultGenerator<[CreateConfirmationChoice, CreateFlags[]]> { /** * Here are the rules for creating the recommended path for the new worktree: * @@ -574,17 +661,21 @@ export class WorktreeGitCommand extends QuickCommand { * If the user picks a folder inside the repo, it will be `/../.worktrees/` */ - const pickedUri = context.pickedUri ?? state.uri; - const pickedFriendlyPath = truncateLeft(GitWorktree.getFriendlyPath(pickedUri), 60); + let createDirectlyInFolder = false; + if (context.pickedSpecificFolder != null) { + createDirectlyInFolder = true; + } + + const pickedUri = context.pickedSpecificFolder ?? context.pickedRootFolder ?? state.uri; + const pickedFriendlyPath = truncateLeft(getWorkspaceFriendlyPath(pickedUri), 60); - let canCreateDirectlyInPicked = true; let recommendedRootUri; const repoUri = state.repo.uri; const trailer = `${basename(repoUri.path)}.worktrees`; if (repoUri.toString() !== pickedUri.toString()) { - if (isDescendent(pickedUri, repoUri)) { + if (isDescendant(pickedUri, repoUri)) { recommendedRootUri = Uri.joinPath(repoUri, '..', trailer); } else if (basename(pickedUri.path) === trailer) { recommendedRootUri = pickedUri; @@ -594,84 +685,140 @@ export class WorktreeGitCommand extends QuickCommand { } else { recommendedRootUri = Uri.joinPath(repoUri, '..', trailer); // Don't allow creating directly into the main worktree folder - canCreateDirectlyInPicked = false; + createDirectlyInFolder = false; } - const branchName = - state.createBranch ?? (state.reference != null ? getNameWithoutRemote(state.reference) : undefined); - - const recommendedUri = branchName - ? Uri.joinPath(recommendedRootUri, ...branchName.replace(/\\/g, '/').split('/')) - : recommendedRootUri; - const recommendedFriendlyPath = truncateLeft(GitWorktree.getFriendlyPath(recommendedUri), 65); - - const recommendedNewBranchFriendlyPath = truncateLeft( - GitWorktree.getFriendlyPath(Uri.joinPath(recommendedRootUri, '')), - 60, + const branchName = state.reference != null ? getNameWithoutRemote(state.reference) : undefined; + + const recommendedFriendlyPath = `/${truncateLeft( + `${trailer}/${branchName?.replace(/\\/g, '/') ?? ''}`, + 65, + )}`; + const recommendedNewBranchFriendlyPath = `/${trailer}/${state.createBranch || ''}`; + + const isBranch = isBranchReference(state.reference); + const isRemoteBranch = isBranchReference(state.reference) && state.reference?.remote; + + type StepType = FlagsQuickPickItem; + const defaultOption = createFlagsQuickPickItem( + state.flags, + [], + { + label: isRemoteBranch + ? 'Create Worktree for New Local Branch' + : isBranch + ? 'Create Worktree for Branch' + : context.title, + description: '', + detail: `Will create worktree in $(folder) ${recommendedFriendlyPath}`, + }, + recommendedRootUri, ); - const isRemoteBranch = state.reference?.refType === 'branch' && state.reference?.remote; + const confirmations: StepType[] = []; + if (!createDirectlyInFolder) { + if (!state.createBranch) { + if (state.skipWorktreeConfirmations) { + return [defaultOption.context, defaultOption.item]; + } + confirmations.push(defaultOption); + } - const step: QuickPickStep> = createConfirmStep( - appendReposToTitle( - `Confirm ${context.title} \u2022 ${getReferenceLabel(state.reference, { - icon: false, - label: false, - })}`, - state, - context, - ), - [ + confirmations.push( createFlagsQuickPickItem( state.flags, - isRemoteBranch ? ['-b'] : [], + ['-b'], { - label: isRemoteBranch ? 'Create Local Branch and Worktree' : context.title, - description: ' in subfolder', - detail: `Will create worktree in $(folder) ${recommendedFriendlyPath}`, + label: isRemoteBranch + ? 'Create Worktree for New Local Branch Named...' + : 'Create Worktree for New Branch Named...', + description: '', + detail: `Will create worktree in $(folder) ${recommendedNewBranchFriendlyPath}`, }, recommendedRootUri, ), + ); + } else { + if (!state.createBranch) { + confirmations.push( + createFlagsQuickPickItem( + state.flags, + ['--direct'], + { + label: isRemoteBranch + ? 'Create Worktree for Local Branch' + : isBranch + ? 'Create Worktree for Branch' + : context.title, + description: '', + detail: `Will create worktree directly in $(folder) ${truncateLeft( + pickedFriendlyPath, + 60, + )}`, + }, + pickedUri, + ), + ); + } + + confirmations.push( createFlagsQuickPickItem( state.flags, - ['-b'], + ['-b', '--direct'], { label: isRemoteBranch - ? 'Create New Local Branch and Worktree' - : 'Create New Branch and Worktree', - description: ' in subfolder', - detail: `Will create worktree in $(folder) ${recommendedNewBranchFriendlyPath}`, + ? 'Create Worktree for New Local Branch' + : 'Create Worktree for New Branch', + description: '', + detail: `Will create worktree directly in $(folder) ${truncateLeft(pickedFriendlyPath, 60)}`, }, - recommendedRootUri, + pickedUri, + ), + ); + } + + if (!createDirectlyInFolder) { + confirmations.push( + createQuickPickSeparator(), + createFlagsQuickPickItem( + [], + [], + { + label: 'Change Root Folder...', + description: `$(folder) ${truncateLeft(pickedFriendlyPath, 65)}`, + picked: false, + }, + 'changeRoot', ), - ...(canCreateDirectlyInPicked - ? [ - createQuickPickSeparator(), - createFlagsQuickPickItem( - state.flags, - ['--direct'], - { - label: isRemoteBranch ? 'Create Local Branch and Worktree' : context.title, - description: ' directly in folder', - detail: `Will create worktree directly in $(folder) ${pickedFriendlyPath}`, - }, - pickedUri, - ), - createFlagsQuickPickItem( - state.flags, - ['-b', '--direct'], - { - label: isRemoteBranch - ? 'Create New Local Branch and Worktree' - : 'Create New Branch and Worktree', - description: ' directly in folder', - detail: `Will create worktree directly in $(folder) ${pickedFriendlyPath}`, - }, - pickedUri, - ), - ] - : []), - ] as FlagsQuickPickItem[], + ); + } + + confirmations.push( + createFlagsQuickPickItem( + [], + [], + { + label: 'Choose a Specific Folder...', + description: '', + picked: false, + }, + 'chooseFolder', + ), + ); + + const step = createConfirmStep( + appendReposToTitle( + `Confirm ${context.title} \u2022 ${ + state.createBranch || + getReferenceLabel(state.reference, { + icon: false, + label: false, + }) + }`, + state, + context, + ), + confirmations, context, ); const selection: StepSelection = yield step; @@ -689,10 +836,10 @@ export class WorktreeGitCommand extends QuickCommand { while (this.canStepsContinue(state)) { if (state.counter < 3 || state.uris == null || state.uris.length === 0) { - context.title = getTitle('Worktrees', state.subcommand); + context.title = getTitle(state.subcommand); const result = yield* pickWorktreesStep(state, context, { - filter: wt => wt.main || !wt.opened, // Can't delete the main or opened worktree + filter: wt => !wt.main || !wt.opened, // Can't delete the main or opened worktree includeStatus: true, picked: state.uris?.map(uri => uri.toString()), placeholder: 'Choose worktrees to delete', @@ -703,7 +850,7 @@ export class WorktreeGitCommand extends QuickCommand { state.uris = result.map(w => w.uri); } - context.title = getTitle(pluralize('Worktree', state.uris.length, { only: true }), state.subcommand); + context.title = getTitle(state.subcommand); const result = yield* this.deleteCommandConfirmStep(state, context); if (result === StepResultBreak) continue; @@ -714,6 +861,7 @@ export class WorktreeGitCommand extends QuickCommand { for (const uri of state.uris) { let retry = false; + let skipHasChangesPrompt = false; do { retry = false; const force = state.flags.includes('--force'); @@ -726,7 +874,7 @@ export class WorktreeGitCommand extends QuickCommand { status = await worktree?.getStatus(); } catch {} - if (status?.hasChanges ?? false) { + if ((status?.hasChanges ?? false) && !skipHasChangesPrompt) { const confirm: MessageItem = { title: 'Force Delete' }; const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; const result = await window.showWarningMessage( @@ -742,6 +890,8 @@ export class WorktreeGitCommand extends QuickCommand { await state.repo.deleteWorktree(uri, { force: force }); } catch (ex) { + skipHasChangesPrompt = false; + if (WorktreeDeleteError.is(ex)) { if (ex.reason === WorktreeDeleteErrorReason.MainWorkingTree) { void window.showErrorMessage('Unable to delete the main worktree'); @@ -760,6 +910,7 @@ export class WorktreeGitCommand extends QuickCommand { if (result === confirm) { state.flags.push('--force'); retry = true; + skipHasChangesPrompt = ex.reason === WorktreeDeleteErrorReason.HasChanges; } } } else { @@ -779,16 +930,14 @@ export class WorktreeGitCommand extends QuickCommand { label: context.title, detail: `Will delete ${pluralize('worktree', state.uris.length, { only: state.uris.length === 1, - })}${state.uris.length === 1 ? ` in $(folder) ${GitWorktree.getFriendlyPath(state.uris[0])}` : ''}`, + })}${state.uris.length === 1 ? ` in $(folder) ${getWorkspaceFriendlyPath(state.uris[0])}` : ''}`, }), createFlagsQuickPickItem(state.flags, ['--force'], { label: `Force ${context.title}`, description: 'including ANY UNCOMMITTED changes', detail: `Will forcibly delete ${pluralize('worktree', state.uris.length, { only: state.uris.length === 1, - })} ${ - state.uris.length === 1 ? ` in $(folder) ${GitWorktree.getFriendlyPath(state.uris[0])}` : '' - }`, + })} ${state.uris.length === 1 ? ` in $(folder) ${getWorkspaceFriendlyPath(state.uris[0])}` : ''}`, }), ], context, @@ -799,80 +948,266 @@ export class WorktreeGitCommand extends QuickCommand { } private async *openCommandSteps(state: OpenStepState, context: Context): StepGenerator { - context.worktrees = await state.repo.getWorktrees(); - if (state.flags == null) { state.flags = []; } + // Allow skipping the confirm step + this._canSkipConfirmOverride = true; + while (this.canStepsContinue(state)) { - if (state.counter < 3 || state.uri == null) { - context.title = getTitle('Worktree', state.subcommand); + if (state.counter < 3 || state.worktree == null) { + context.title = getTitle(state.subcommand); + context.worktrees ??= await state.repo.getWorktrees(); const result = yield* pickWorktreeStep(state, context, { + excludeOpened: true, includeStatus: true, - picked: state.uri?.toString(), + picked: state.worktree?.uri?.toString(), placeholder: 'Choose worktree to open', }); // Always break on the first step (so we will go back) if (result === StepResultBreak) break; - state.uri = result.uri; + state.worktree = result; } - context.title = getTitle('Worktree', state.subcommand); + context.title = getTitle(state.subcommand, ` \u2022 ${state.worktree.name}`); - const result = yield* this.openCommandConfirmStep(state, context); - if (result === StepResultBreak) continue; + if (this.confirm(state.confirm)) { + const result = yield* this.openCommandConfirmStep(state, context); + if (result === StepResultBreak) continue; - state.flags = result; + state.flags = result; + } endSteps(state); - const worktree = context.worktrees.find(wt => wt.uri.toString() === state.uri.toString()); - if (worktree == null) break; - if (state.flags.includes('--reveal-explorer')) { - void revealInFileExplorer(worktree); + void revealInFileExplorer(state.worktree.uri); } else { - openWorkspace(worktree.uri, { location: convertOpenFlagsToLocation(state.flags) }); + let name; + + const repo = (await state.repo.getCommonRepository()) ?? state.repo; + if (repo.name !== state.worktree.name) { + name = `${repo.name}: ${state.worktree.name}`; + } else { + name = state.worktree.name; + } + + openWorkspace(state.worktree.uri, { location: convertOpenFlagsToLocation(state.flags), name: name }); } } } private *openCommandConfirmStep(state: OpenStepState, context: Context): StepResultGenerator { - const step: QuickPickStep> = createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), - [ - createFlagsQuickPickItem(state.flags, [], { - label: context.title, - detail: `Will open in the current window, the worktree in $(folder) ${GitWorktree.getFriendlyPath( - state.uri, - )}`, - }), - createFlagsQuickPickItem(state.flags, ['--new-window'], { - label: `${context.title} in a New Window`, - detail: `Will open in a new window, the worktree in $(folder) ${GitWorktree.getFriendlyPath( - state.uri, - )}`, - }), - createFlagsQuickPickItem(state.flags, ['--add-to-workspace'], { - label: `Add Worktree to Workspace`, - detail: `Will add into the current workspace, the worktree in $(folder) ${GitWorktree.getFriendlyPath( - state.uri, - )}`, - }), + type StepType = FlagsQuickPickItem; + + const newWindowItem = createFlagsQuickPickItem(state.flags, ['--new-window'], { + label: `Open Worktree in a New Window`, + detail: 'Will open the worktree in a new window', + }); + + if (state.skipWorktreeConfirmations) { + return newWindowItem.item; + } + + const confirmations: StepType[] = [ + createFlagsQuickPickItem(state.flags, [], { + label: 'Open Worktree', + detail: 'Will open the worktree in the current window', + }), + newWindowItem, + createFlagsQuickPickItem(state.flags, ['--add-to-workspace'], { + label: `Add Worktree to Workspace`, + detail: 'Will add the worktree into the current workspace', + }), + ]; + + if (!state.openOnly) { + confirmations.push( + createQuickPickSeparator(), createFlagsQuickPickItem(state.flags, ['--reveal-explorer'], { label: `Reveal in File Explorer`, - detail: `Will open in the File Explorer, the worktree in $(folder) ${GitWorktree.getFriendlyPath( - state.uri, - )}`, + description: `$(folder) ${truncateLeft(getWorkspaceFriendlyPath(state.worktree.uri), 40)}`, + detail: 'Will open the worktree in the File Explorer', }), - ], + ); + } + + const step = createConfirmStep( + appendReposToTitle(state.overrides?.confirmation?.title ?? `Confirm ${context.title}`, state, context), + confirmations, context, + undefined, + { + disallowBack: state.overrides?.disallowBack, + placeholder: state.overrides?.confirmation?.placeholder ?? 'Confirm Open Worktree', + }, ); const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } + + private async *copyChangesCommandSteps(state: CopyChangesStepState, context: Context): StepGenerator { + while (this.canStepsContinue(state)) { + context.title = state?.overrides?.title ?? getTitle(state.subcommand); + + if (state.counter < 3 || state.worktree == null) { + context.worktrees ??= await state.repo.getWorktrees(); + + let placeholder; + switch (state.changes.type) { + case 'index': + placeholder = 'Choose a worktree to copy your staged changes to'; + break; + case 'working-tree': + placeholder = 'Choose a worktree to copy your working changes to'; + break; + default: + placeholder = 'Choose a worktree to copy changes to'; + break; + } + + const result = yield* pickWorktreeStep(state, context, { + excludeOpened: true, + includeStatus: true, + picked: state.worktree?.uri?.toString(), + placeholder: placeholder, + }); + // Always break on the first step (so we will go back) + if (result === StepResultBreak) break; + + state.worktree = result; + } + + if (!state.changes.contents || !state.changes.baseSha) { + const diff = await this.container.git.getDiff( + state.repo.uri, + state.changes.type === 'index' ? uncommittedStaged : uncommitted, + 'HEAD', + ); + if (!diff?.contents) { + void window.showErrorMessage(`No changes to copy`); + + endSteps(state); + break; + } + + state.changes.contents = diff.contents; + state.changes.baseSha = diff.from; + } + + if (!isSha(state.changes.baseSha)) { + const commit = await this.container.git.getCommit(state.repo.uri, state.changes.baseSha); + if (commit != null) { + state.changes.baseSha = commit.sha; + } + } + + if (this.confirm(state.confirm)) { + const result = yield* this.copyChangesCommandConfirmStep(state, context); + if (result === StepResultBreak) continue; + } + + endSteps(state); + + try { + const commit = await this.container.git.createUnreachableCommitForPatch( + state.worktree.uri, + state.changes.contents, + state.changes.baseSha, + 'Copied Changes', + ); + if (commit == null) return; + + await this.container.git.applyUnreachableCommitForPatch(state.worktree.uri, commit.sha, { + stash: false, + }); + void window.showInformationMessage(`Changes copied successfully`); + } catch (ex) { + if (ex instanceof CancellationError) return; + + if (ex instanceof ApplyPatchCommitError) { + if (ex.reason === ApplyPatchCommitErrorReason.AppliedWithConflicts) { + void window.showWarningMessage('Changes copied with conflicts'); + } else if (ex.reason === ApplyPatchCommitErrorReason.ApplyAbortedWouldOverwrite) { + void window.showErrorMessage( + 'Unable to copy changes as some local changes would be overwritten', + ); + return; + } else { + void window.showErrorMessage(`Unable to copy changes: ${ex.message}`); + return; + } + } else { + void window.showErrorMessage(`Unable to copy changes: ${ex.message}`); + return; + } + } + + yield* this.openCommandSteps( + { + subcommand: 'open', + repo: state.repo, + worktree: state.worktree, + flags: [], + counter: 3, + confirm: true, + openOnly: true, + overrides: { disallowBack: true }, + } satisfies OpenStepState, + context, + ); + } + } + + private async *copyChangesCommandConfirmStep( + state: CopyChangesStepState, + context: Context, + ): AsyncStepResultGenerator { + const files = await this.container.git.getDiffFiles(state.repo.uri, state.changes.contents!); + const count = files?.files.length ?? 0; + + const confirmations = []; + switch (state.changes.type) { + case 'index': + confirmations.push({ + label: 'Copy Staged Changes to Worktree', + detail: `Will copy the staged changes${ + count > 0 ? ` (${pluralize('file', count)})` : '' + } to worktree '${state.worktree.name}'`, + }); + break; + case 'working-tree': + confirmations.push({ + label: 'Copy Working Changes to Worktree', + detail: `Will copy the working changes${ + count > 0 ? ` (${pluralize('file', count)})` : '' + } to worktree '${state.worktree.name}'`, + }); + break; + + default: + confirmations.push( + createFlagsQuickPickItem([], [], { + label: 'Copy Changes to Worktree', + detail: `Will copy the changes${ + count > 0 ? ` (${pluralize('file', count)})` : '' + } to worktree '${state.worktree.name}'`, + }), + ); + break; + } + + const step = createConfirmStep( + `Confirm ${context.title} \u2022 ${state.worktree.name}`, + confirmations, + context, + ); + + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? undefined : StepResultBreak; + } } diff --git a/src/commands/gitCommands.ts b/src/commands/gitCommands.ts index 99f43cf271bbd..6820f74fa8326 100644 --- a/src/commands/gitCommands.ts +++ b/src/commands/gitCommands.ts @@ -2,6 +2,7 @@ import type { Disposable, InputBox, QuickInputButton, QuickPick, QuickPickItem } import { InputBoxValidationSeverity, QuickInputButtons, window } from 'vscode'; import { Commands } from '../constants'; import { Container } from '../container'; +import type { FocusCommandArgs } from '../plus/focus/focus'; import { Directive, isDirective, isDirectiveQuickPickItem } from '../quickpicks/items/directive'; import { command } from '../system/command'; import { configuration } from '../system/configuration'; @@ -62,7 +63,8 @@ export type GitCommandsCommandArgs = | StatusGitCommandArgs | SwitchGitCommandArgs | TagGitCommandArgs - | WorktreeGitCommandArgs; + | WorktreeGitCommandArgs + | FocusCommandArgs; export type GitCommandsCommandArgsWithCompletion = GitCommandsCommandArgs & { completion?: Deferred }; @@ -74,15 +76,40 @@ export class GitCommandsCommand extends Command { super([ Commands.GitCommands, Commands.GitCommandsBranch, + Commands.GitCommandsBranchCreate, + Commands.GitCommandsBranchDelete, + Commands.GitCommandsBranchPrune, + Commands.GitCommandsBranchRename, + Commands.GitCommandsCheckout, Commands.GitCommandsCherryPick, + Commands.GitCommandsHistory, Commands.GitCommandsMerge, Commands.GitCommandsRebase, + Commands.GitCommandsRemote, + Commands.GitCommandsRemoteAdd, + Commands.GitCommandsRemotePrune, + Commands.GitCommandsRemoteRemove, Commands.GitCommandsReset, Commands.GitCommandsRevert, + Commands.GitCommandsShow, + Commands.GitCommandsStash, + Commands.GitCommandsStashDrop, + Commands.GitCommandsStashList, + Commands.GitCommandsStashPop, + Commands.GitCommandsStashPush, + Commands.GitCommandsStashRename, + Commands.GitCommandsStatus, Commands.GitCommandsSwitch, Commands.GitCommandsTag, + Commands.GitCommandsTagCreate, + Commands.GitCommandsTagDelete, Commands.GitCommandsWorktree, + Commands.GitCommandsWorktreeCreate, + Commands.GitCommandsWorktreeDelete, Commands.GitCommandsWorktreeOpen, + + Commands.CopyWorkingChangesToWorktree, + Commands.ShowLaunchpad, ]); } @@ -91,33 +118,107 @@ export class GitCommandsCommand extends Command { case Commands.GitCommandsBranch: args = { command: 'branch' }; break; + case Commands.GitCommandsBranchCreate: + args = { command: 'branch', state: { subcommand: 'create' } }; + break; + case Commands.GitCommandsBranchDelete: + args = { command: 'branch', state: { subcommand: 'delete' } }; + break; + case Commands.GitCommandsBranchPrune: + args = { command: 'branch', state: { subcommand: 'prune' } }; + break; + case Commands.GitCommandsBranchRename: + args = { command: 'branch', state: { subcommand: 'rename' } }; + break; case Commands.GitCommandsCherryPick: args = { command: 'cherry-pick' }; break; + case Commands.GitCommandsHistory: + args = { command: 'log' }; + break; case Commands.GitCommandsMerge: args = { command: 'merge' }; break; case Commands.GitCommandsRebase: args = { command: 'rebase' }; break; + case Commands.GitCommandsRemote: + args = { command: 'remote' }; + break; + case Commands.GitCommandsRemoteAdd: + args = { command: 'remote', state: { subcommand: 'add' } }; + break; + case Commands.GitCommandsRemotePrune: + args = { command: 'remote', state: { subcommand: 'prune' } }; + break; + case Commands.GitCommandsRemoteRemove: + args = { command: 'remote', state: { subcommand: 'remove' } }; + break; case Commands.GitCommandsReset: args = { command: 'reset' }; break; case Commands.GitCommandsRevert: args = { command: 'revert' }; break; + case Commands.GitCommandsShow: + args = { command: 'show' }; + break; + case Commands.GitCommandsStash: + args = { command: 'stash' }; + break; + case Commands.GitCommandsStashDrop: + args = { command: 'stash', state: { subcommand: 'drop' } }; + break; + case Commands.GitCommandsStashList: + args = { command: 'stash', state: { subcommand: 'list' } }; + break; + case Commands.GitCommandsStashPop: + args = { command: 'stash', state: { subcommand: 'pop' } }; + break; + case Commands.GitCommandsStashPush: + args = { command: 'stash', state: { subcommand: 'push' } }; + break; + case Commands.GitCommandsStashRename: + args = { command: 'stash', state: { subcommand: 'rename' } }; + break; + case Commands.GitCommandsStatus: + args = { command: 'status' }; + break; case Commands.GitCommandsSwitch: + case Commands.GitCommandsCheckout: args = { command: 'switch' }; break; case Commands.GitCommandsTag: args = { command: 'tag' }; break; + case Commands.GitCommandsTagCreate: + args = { command: 'tag', state: { subcommand: 'create' } }; + break; + case Commands.GitCommandsTagDelete: + args = { command: 'tag', state: { subcommand: 'delete' } }; + break; case Commands.GitCommandsWorktree: args = { command: 'worktree' }; break; + case Commands.GitCommandsWorktreeCreate: + args = { command: 'worktree', state: { subcommand: 'create' } }; + break; + case Commands.GitCommandsWorktreeDelete: + args = { command: 'worktree', state: { subcommand: 'delete' } }; + break; case Commands.GitCommandsWorktreeOpen: args = { command: 'worktree', state: { subcommand: 'open' } }; break; + + case Commands.CopyWorkingChangesToWorktree: + args = { + command: 'worktree', + state: { subcommand: 'copy-changes', changes: { type: 'working-tree' } }, + }; + break; + case Commands.ShowLaunchpad: + args = { command: 'focus', ...args }; + break; } return this.execute(args); @@ -240,7 +341,9 @@ export class GitCommandsCommand extends Command { return buttons; } - buttons.push(QuickInputButtons.Back); + if (step.disallowBack !== true) { + buttons.push(QuickInputButtons.Back); + } if (step.additionalButtons != null) { buttons.push(...step.additionalButtons); @@ -249,22 +352,26 @@ export class GitCommandsCommand extends Command { if (command?.canConfirm) { if (command.canSkipConfirm) { - const willConfirmToggle = new WillConfirmToggleQuickInputButton(command.confirm(), async () => { - if (command?.skipConfirmKey == null) return; + const willConfirmToggle = new WillConfirmToggleQuickInputButton( + command.confirm(), + step?.isConfirmationStep ?? false, + async () => { + if (command?.skipConfirmKey == null) return; - const skipConfirmations = configuration.get('gitCommands.skipConfirmations') ?? []; + const skipConfirmations = configuration.get('gitCommands.skipConfirmations') ?? []; - const index = skipConfirmations.indexOf(command.skipConfirmKey); - if (index !== -1) { - skipConfirmations.splice(index, 1); - } else { - skipConfirmations.push(command.skipConfirmKey); - } + const index = skipConfirmations.indexOf(command.skipConfirmKey); + if (index !== -1) { + skipConfirmations.splice(index, 1); + } else { + skipConfirmations.push(command.skipConfirmKey); + } - await configuration.updateEffective('gitCommands.skipConfirmations', skipConfirmations); - }); + await configuration.updateEffective('gitCommands.skipConfirmations', skipConfirmations); + }, + ); buttons.push(willConfirmToggle); - } else { + } else if (!step?.isConfirmationStep) { buttons.push(WillConfirmForcedQuickInputButton); } } @@ -275,6 +382,11 @@ export class GitCommandsCommand extends Command { private async getCommandStep(command: QuickCommand, commandsStep: PickCommandStep) { commandsStep.setCommand(command, 'command'); + // Ensure we've finished discovering repositories before continuing + if (this.container.git.isDiscoveringRepositories != null) { + await this.container.git.isDiscoveringRepositories; + } + const next = await command.next(); if (next.done) return undefined; @@ -309,6 +421,7 @@ export class GitCommandsCommand extends Command { case Directive.Back: return (await commandsStep?.command?.previous()) ?? commandsStep; case Directive.Noop: + case Directive.Reload: return commandsStep.command?.retry(); case Directive.Cancel: default: @@ -339,8 +452,10 @@ export class GitCommandsCommand extends Command { try { // eslint-disable-next-line no-async-promise-executor - return await new Promise(async resolve => { + return await new Promise(async resolve => { const goBack = async () => { + if (step.disallowBack === true) return; + input.value = ''; if (commandsStep.command != null) { input.busy = true; @@ -359,6 +474,9 @@ export class GitCommandsCommand extends Command { const scope = this.container.keyboard.createScope(mapping); void scope.start(); + if (step.value != null) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } disposables.push( scope, @@ -397,12 +515,16 @@ export class GitCommandsCommand extends Command { } }), input.onDidChangeValue(async e => { + if (!input.ignoreFocusOut) { + input.ignoreFocusOut = true; + } + if (scope != null) { // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly if (e.length !== 0) { - await scope.pause(['left', 'right']); + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); } else { - await scope.resume(); + void scope.resume(); } } @@ -431,11 +553,6 @@ export class GitCommandsCommand extends Command { } } - // If we are starting over clear the previously active command - if (commandsStep.command != null && step === commandsStep) { - commandsStep.setCommand(undefined, 'menu'); - } - input.show(); // Manually trigger `onDidChangeValue`, because the InputBox fails to call it if the value is set before it is shown @@ -455,19 +572,19 @@ export class GitCommandsCommand extends Command { } private async showPickStep(step: QuickPickStep, commandsStep: PickCommandStep) { - const originalIgnoreFocusOut = !configuration.get('gitCommands.closeOnFocusOut') + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = !configuration.get('gitCommands.closeOnFocusOut') ? true : step.ignoreFocusOut ?? false; - const originalStepIgnoreFocusOut = step.ignoreFocusOut; - - const quickpick = window.createQuickPick(); - quickpick.ignoreFocusOut = originalIgnoreFocusOut; const disposables: Disposable[] = []; try { - return await new Promise(resolve => { + // eslint-disable-next-line no-async-promise-executor + return await new Promise(async resolve => { async function goBack() { + if (step.disallowBack === true) return; + quickpick.value = ''; if (commandsStep.command != null) { quickpick.busy = true; @@ -507,22 +624,37 @@ export class GitCommandsCommand extends Command { const mapping: KeyMapping = { left: { onDidPressKey: goBack }, }; - if (step.onDidPressKey != null && step.keys != null && step.keys.length !== 0) { + if (step.onDidPressKey != null && step.keys?.length) { for (const key of step.keys) { mapping[key] = { - onDidPressKey: key => step.onDidPressKey!(quickpick, key), + onDidPressKey: key => { + if (!quickpick.activeItems.length) return; + + const item = quickpick.activeItems[0]; + if (isDirectiveQuickPickItem(item)) return; + + return step.onDidPressKey!(quickpick, key, item); + }, }; } } const scope = this.container.keyboard.createScope(mapping); void scope.start(); + if (step.value != null) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } + let firstActiveChange = true; let overrideItems = false; disposables.push( scope, - quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidHide(() => { + if (step.frozen) return; + + resolve(undefined); + }), quickpick.onDidTriggerItemButton(async e => { if ((await step.onDidClickItemButton?.(quickpick, e.button, e.item)) === true) { resolve(await this.nextStep(commandsStep.command!, [e.item], quickpick)); @@ -542,7 +674,7 @@ export class GitCommandsCommand extends Command { } if (e instanceof ToggleQuickInputButton && e.onDidClick != null) { - let activeCommand; + let activeCommand: QuickCommand | undefined; if (commandsStep.command == null && quickpick.activeItems.length !== 0) { const active = quickpick.activeItems[0]; if (isQuickCommand(active)) { @@ -553,7 +685,7 @@ export class GitCommandsCommand extends Command { const result = e.onDidClick(quickpick); quickpick.buttons = this.getButtons( - activeCommand != null ? activeCommand.value : step, + activeCommand?.value && !isCustomStep(activeCommand.value) ? activeCommand.value : step, activeCommand ?? commandsStep.command, ); @@ -564,7 +696,9 @@ export class GitCommandsCommand extends Command { if (isPromise(result)) { quickpick.buttons = this.getButtons( - activeCommand != null ? activeCommand.value : step, + activeCommand?.value && !isCustomStep(activeCommand.value) + ? activeCommand.value + : step, activeCommand ?? commandsStep.command, ); } @@ -581,12 +715,18 @@ export class GitCommandsCommand extends Command { } }), quickpick.onDidChangeValue(async e => { + // If something was typed, keep the quick pick open on focus loss + if (!quickpick.ignoreFocusOut) { + quickpick.ignoreFocusOut = true; + step.ignoreFocusOut = true; + } + if (scope != null) { // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly if (e.length !== 0) { - await scope.pause(['left', 'right']); + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); } else { - await scope.resume(); + void scope.resume(); } } @@ -595,17 +735,6 @@ export class GitCommandsCommand extends Command { if (cancel) return; } - // If something was typed, keep the quick pick open on focus loss - if (e.length !== 0 && !quickpick.ignoreFocusOut) { - quickpick.ignoreFocusOut = true; - step.ignoreFocusOut = true; - } - // If something typed was cleared, and we changed the behavior, then allow the quick pick close on focus loss - else if (e.length === 0 && quickpick.ignoreFocusOut && !originalIgnoreFocusOut) { - quickpick.ignoreFocusOut = originalIgnoreFocusOut; - step.ignoreFocusOut = originalStepIgnoreFocusOut; - } - if (!overrideItems) { if (quickpick.canSelectMany && e === ' ') { quickpick.value = ''; @@ -628,7 +757,7 @@ export class GitCommandsCommand extends Command { commandsStep.setCommand(command, this.startedWith); } else { const cmd = quickpick.value.trim().toLowerCase(); - const item = step.items.find( + const item = (await step.items).find( i => i.label.replace(sanitizeLabel, '').toLowerCase() === cmd, ); if (item == null) return; @@ -650,17 +779,30 @@ export class GitCommandsCommand extends Command { ) { if (step.onValidateValue == null) return; - overrideItems = await step.onValidateValue(quickpick, e.trim(), step.items); + overrideItems = await step.onValidateValue(quickpick, e.trim(), await step.items); } else { overrideItems = false; } // If we are no longer overriding the items, put them back (only if we need to) - if (!overrideItems && quickpick.items.length !== step.items.length) { - quickpick.items = step.items; + if (!overrideItems) { + step.items = await step.items; + if (quickpick.items.length !== step.items.length) { + quickpick.items = step.items; + } } }), quickpick.onDidChangeActive(() => { + // If something changed (after the first time which happens on open), keep the quick pick open on focus loss + if (!firstActiveChange && !quickpick.ignoreFocusOut) { + quickpick.ignoreFocusOut = true; + step.ignoreFocusOut = true; + } + + if (firstActiveChange) { + firstActiveChange = false; + } + if (commandsStep.command != null || quickpick.activeItems.length === 0) return; const command = quickpick.activeItems[0]; @@ -671,16 +813,13 @@ export class GitCommandsCommand extends Command { quickpick.onDidChangeSelection(e => { if (!quickpick.canSelectMany) return; - // If something was selected, keep the quick pick open on focus loss - if (e.length !== 0 && !quickpick.ignoreFocusOut) { + // If something is selected, keep the quick pick open on focus loss + if (!quickpick.ignoreFocusOut) { quickpick.ignoreFocusOut = true; step.ignoreFocusOut = true; } - // If the selection was cleared, and we changed the behavior, then allow the quick pick close on focus loss - else if (e?.length === 0 && quickpick.ignoreFocusOut && !originalIgnoreFocusOut) { - quickpick.ignoreFocusOut = originalIgnoreFocusOut; - step.ignoreFocusOut = originalStepIgnoreFocusOut; - } + + step.onDidChangeSelection?.(quickpick, e); }), quickpick.onDidAccept(async () => { let items = quickpick.selectedItems; @@ -713,6 +852,8 @@ export class GitCommandsCommand extends Command { if (items.length === 1) { const [item] = items; if (isDirectiveQuickPickItem(item)) { + await item.onDidSelect?.(); + switch (item.directive) { case Directive.Cancel: resolve(undefined); @@ -726,23 +867,68 @@ export class GitCommandsCommand extends Command { void loadMore(); return; - case Directive.StartPreviewTrial: - void Container.instance.subscription.startPreviewTrial(); - resolve(undefined); + case Directive.Noop: return; - case Directive.RequiresVerification: - void Container.instance.subscription.resendVerification(); - resolve(undefined); + case Directive.Reload: + resolve(await commandsStep.command?.retry()); return; - case Directive.ExtendTrial: - void Container.instance.subscription.loginOrSignUp(); - resolve(undefined); + case Directive.SignIn: { + const result = await Container.instance.subscription.loginOrSignUp(false, { + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); + resolve(result ? await commandsStep.command?.retry() : undefined); + return; + } + + case Directive.StartPreview: + await Container.instance.subscription.startPreviewTrial({ + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); + resolve(await commandsStep.command?.retry()); + return; + + case Directive.RequiresVerification: { + const result = await Container.instance.subscription.resendVerification({ + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); + resolve(result ? await commandsStep.command?.retry() : undefined); return; + } + + case Directive.StartProTrial: { + const result = await Container.instance.subscription.loginOrSignUp(true, { + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); + resolve(result ? await commandsStep.command?.retry() : undefined); + return; + } case Directive.RequiresPaidSubscription: - void Container.instance.subscription.purchase(); + void Container.instance.subscription.upgrade({ + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); resolve(undefined); return; } @@ -773,16 +959,40 @@ export class GitCommandsCommand extends Command { ); quickpick.title = step.title; - quickpick.placeholder = step.placeholder; quickpick.matchOnDescription = Boolean(step.matchOnDescription); quickpick.matchOnDetail = Boolean(step.matchOnDetail); - quickpick.canSelectMany = Boolean(step.multiselect); - quickpick.items = step.items; + const selectValueWhenShown = step.selectValueWhenShown ?? true; + + let items; + let shown = false; + if (isPromise(step.items)) { + quickpick.placeholder = 'Loading...'; + + quickpick.busy = true; + + // If we set the value before showing the quickpick, VS Code will select the entire value + if (step.value != null && selectValueWhenShown) { + quickpick.value = step.value; + } + + quickpick.show(); + + shown = true; + items = await step.items; + } else { + items = step.items; + } + + quickpick.canSelectMany = + Boolean(step.multiselect) && items.filter(i => !isDirectiveQuickPickItem(i)).length > 1; + quickpick.placeholder = + typeof step.placeholder === 'function' ? step.placeholder(items.length) : step.placeholder; + quickpick.items = items; + quickpick.busy = false; if (quickpick.canSelectMany) { quickpick.selectedItems = step.selectedItems ?? quickpick.items.filter(i => i.picked); - quickpick.activeItems = quickpick.selectedItems; } else { quickpick.activeItems = step.selectedItems ?? quickpick.items.filter(i => i.picked); } @@ -795,12 +1005,14 @@ export class GitCommandsCommand extends Command { // Needs to be after we reset the command quickpick.buttons = this.getButtons(step, commandsStep.command); - const selectValueWhenShown = step.selectValueWhenShown ?? true; - if (step.value != null && selectValueWhenShown) { - quickpick.value = step.value; - } + if (!shown) { + // If we set the value before showing the quickpick, VS Code will select the entire value + if (step.value != null && selectValueWhenShown) { + quickpick.value = step.value; + } - quickpick.show(); + quickpick.show(); + } if (step.value != null && !selectValueWhenShown) { quickpick.value = step.value; diff --git a/src/commands/gitCommands.utils.ts b/src/commands/gitCommands.utils.ts index 66a9b3d86731c..3db8c587fc317 100644 --- a/src/commands/gitCommands.utils.ts +++ b/src/commands/gitCommands.utils.ts @@ -1,5 +1,6 @@ import type { RecentUsage } from '../constants'; import type { Container } from '../container'; +import { FocusCommand } from '../plus/focus/focus'; import { configuration } from '../system/configuration'; import { getContext } from '../system/context'; import { BranchGitCommand } from './git/branch'; @@ -43,7 +44,8 @@ export function getSteps( return command.executeSteps(); } -export class PickCommandStep implements QuickPickStep { +export class PickCommandStep implements QuickPickStep { + readonly type = 'pick'; readonly buttons = []; private readonly hiddenItems: QuickCommand[]; ignoreFocusOut = false; @@ -56,11 +58,9 @@ export class PickCommandStep implements QuickPickStep { private readonly container: Container, args?: GitCommandsCommandArgs, ) { - const hasVirtualFolders = getContext('gitlens:hasVirtualFolders', false); + const hasVirtualFolders = getContext('gitlens:hasVirtualFolders', false); const readonly = - hasVirtualFolders || - getContext('gitlens:readonly', false) || - getContext('gitlens:untrusted', false); + hasVirtualFolders || getContext('gitlens:readonly', false) || getContext('gitlens:untrusted', false); this.items = [ readonly ? undefined : new BranchGitCommand(container, args?.command === 'branch' ? args : undefined), @@ -108,6 +108,9 @@ export class PickCommandStep implements QuickPickStep { } this.hiddenItems = []; + if (args?.command === 'focus') { + this.hiddenItems.push(new FocusCommand(container, args)); + } } private _command: QuickCommand | undefined; diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts new file mode 100644 index 0000000000000..e9a7acc9356f0 --- /dev/null +++ b/src/commands/inspect.ts @@ -0,0 +1,87 @@ +import type { TextEditor, Uri } from 'vscode'; +import { Commands } from '../constants'; +import type { Container } from '../container'; +import { showDetailsView } from '../git/actions/commit'; +import { GitUri } from '../git/gitUri'; +import type { GitRevisionReference } from '../git/models/reference'; +import { createReference, getReferenceFromRevision } from '../git/models/reference'; +import { + showFileNotUnderSourceControlWarningMessage, + showGenericErrorMessage, + showLineUncommittedWarningMessage, +} from '../messages'; +import { command } from '../system/command'; +import { Logger } from '../system/logger'; +import type { CommandContext } from './base'; +import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasCommit } from './base'; + +export interface InspectCommandArgs { + ref?: GitRevisionReference; +} + +@command() +export class InspectCommand extends ActiveEditorCommand { + static getMarkdownCommandArgs(sha: string, repoPath: string): string; + static getMarkdownCommandArgs(args: InspectCommandArgs): string; + static getMarkdownCommandArgs(argsOrSha: InspectCommandArgs | string, repoPath?: string): string { + const args = + typeof argsOrSha === 'string' + ? { ref: createReference(argsOrSha, repoPath!, { refType: 'revision' }), repoPath: repoPath } + : argsOrSha; + return super.getMarkdownCommandArgsCore(Commands.ShowCommitInView, args); + } + + constructor(private readonly container: Container) { + super([Commands.ShowCommitInView, Commands.ShowInDetailsView, Commands.ShowLineCommitInView]); + } + + protected override preExecute(context: CommandContext, args?: InspectCommandArgs) { + if (context.type === 'viewItem') { + args = { ...args }; + if (isCommandContextViewNodeHasCommit(context)) { + args.ref = getReferenceFromRevision(context.node.commit); + } + } + + return this.execute(context.editor, context.uri, args); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: InspectCommandArgs) { + args = { ...args }; + + if (args.ref == null) { + uri = getCommandUri(uri, editor); + if (uri == null) return undefined; + + const gitUri = await GitUri.fromUri(uri); + + const blameLine = editor?.selection.active.line; + if (blameLine == null) return; + + try { + const blame = await this.container.git.getBlameForLine(gitUri, blameLine); + if (blame == null) { + void showFileNotUnderSourceControlWarningMessage('Unable to inspect commit details'); + + return; + } + + // Because the previous sha of an uncommitted file isn't trust worthy we just have to kick out + if (blame.commit.isUncommitted) { + void showLineUncommittedWarningMessage('Unable to inspect commit details'); + + return; + } + + args.ref = blame.commit; + } catch (ex) { + Logger.error(ex, 'InspectCommand', `getBlameForLine(${blameLine})`); + void showGenericErrorMessage('Unable to inspect commit details'); + + return; + } + } + + return showDetailsView(args.ref); + } +} diff --git a/src/commands/logging.ts b/src/commands/logging.ts index 74fe6f38cd4c6..d3450810f3f01 100644 --- a/src/commands/logging.ts +++ b/src/commands/logging.ts @@ -22,6 +22,6 @@ export class DisableDebugLoggingCommand extends Command { } async execute() { - await configuration.updateEffective('outputLevel', 'errors'); + await configuration.updateEffective('outputLevel', 'error'); } } diff --git a/src/commands/openAssociatedPullRequestOnRemote.ts b/src/commands/openAssociatedPullRequestOnRemote.ts index 07cde8fc8013b..8a6c4f20fd4c2 100644 --- a/src/commands/openAssociatedPullRequestOnRemote.ts +++ b/src/commands/openAssociatedPullRequestOnRemote.ts @@ -36,12 +36,12 @@ export class OpenAssociatedPullRequestOnRemoteCommand extends ActiveEditorComman } else { try { const repo = await getRepositoryOrShowPicker('Open Associated Pull Request', undefined, undefined, { - filter: async r => (await this.container.git.getBestRemoteWithRichProvider(r.uri)) != null, + filter: async r => (await this.container.git.getBestRemoteWithIntegration(r.uri)) != null, }); if (repo == null) return; const branch = await repo?.getBranch(); - const pr = await branch?.getAssociatedPullRequest(); + const pr = await branch?.getAssociatedPullRequest({ expiryOverride: true }); args = pr != null diff --git a/src/commands/openCommitOnRemote.ts b/src/commands/openCommitOnRemote.ts index ebdb62054d70a..554d40b954856 100644 --- a/src/commands/openCommitOnRemote.ts +++ b/src/commands/openCommitOnRemote.ts @@ -3,8 +3,13 @@ import { Commands } from '../constants'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { deletedOrMissing } from '../git/models/constants'; +import { isUncommitted } from '../git/models/reference'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { showFileNotUnderSourceControlWarningMessage, showGenericErrorMessage } from '../messages'; +import { + showCommitNotFoundWarningMessage, + showFileNotUnderSourceControlWarningMessage, + showGenericErrorMessage, +} from '../messages'; import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; import { command, executeCommand } from '../system/command'; import { Logger } from '../system/logger'; @@ -89,7 +94,11 @@ export class OpenCommitOnRemoteCommand extends ActiveEditorCommand { const blame = await this.container.git.getBlameForLine(gitUri, blameLine, editor?.document); if (blame == null) { - void showFileNotUnderSourceControlWarningMessage('Unable to open commit on remote provider'); + void showFileNotUnderSourceControlWarningMessage( + args?.clipboard + ? 'Unable to copy the commit SHA' + : 'Unable to open the commit on the remote provider', + ); return; } @@ -100,6 +109,16 @@ export class OpenCommitOnRemoteCommand extends ActiveEditorCommand { : blame.commit.sha; } + if (args.sha == null || args.sha === deletedOrMissing || isUncommitted(args.sha)) { + void showCommitNotFoundWarningMessage( + args?.clipboard + ? 'Unable to copy the commit SHA' + : 'Unable to open the commit on the remote provider', + ); + + return; + } + void (await executeCommand(Commands.OpenOnRemote, { resource: { type: RemoteResourceType.Commit, diff --git a/src/commands/openComparisonOnRemote.ts b/src/commands/openComparisonOnRemote.ts index 00c4ca45f1b97..90a9dd74f4cbb 100644 --- a/src/commands/openComparisonOnRemote.ts +++ b/src/commands/openComparisonOnRemote.ts @@ -4,7 +4,6 @@ import { RemoteResourceType } from '../git/models/remoteResource'; import { showGenericErrorMessage } from '../messages'; import { command, executeCommand } from '../system/command'; import { Logger } from '../system/logger'; -import { ResultsCommitsNode } from '../views/nodes/resultsCommitsNode'; import type { CommandContext } from './base'; import { Command } from './base'; import type { OpenOnRemoteCommandArgs } from './openOnRemote'; @@ -25,13 +24,20 @@ export class OpenComparisonOnRemoteCommand extends Command { protected override preExecute(context: CommandContext, args?: OpenComparisonOnRemoteCommandArgs) { if (context.type === 'viewItem') { - if (context.node instanceof ResultsCommitsNode) { + if (context.node.is('results-commits')) { args = { ...args, repoPath: context.node.repoPath, ref1: context.node.ref1, ref2: context.node.ref2, }; + } else if (context.node.is('compare-results')) { + args = { + ...args, + repoPath: context.node.repoPath, + ref1: context.node.ahead.ref1, + ref2: context.node.ahead.ref2, + }; } } diff --git a/src/commands/openDirectoryCompare.ts b/src/commands/openDirectoryCompare.ts index 2b4662ed5ef61..1af1fcd72c5a2 100644 --- a/src/commands/openDirectoryCompare.ts +++ b/src/commands/openDirectoryCompare.ts @@ -69,8 +69,7 @@ export class OpenDirectoryCompareCommand extends ActiveEditorCommand { 'Directory Compare Working Tree with', 'Choose a branch or tag to compare with', { - allowEnteringRefs: true, - // checkmarks: false, + allowRevisions: true, }, ); if (pick == null) return; diff --git a/src/commands/openFileAtRevision.ts b/src/commands/openFileAtRevision.ts index fc645e61cf8e0..7660801fb9a82 100644 --- a/src/commands/openFileAtRevision.ts +++ b/src/commands/openFileAtRevision.ts @@ -9,8 +9,11 @@ import { shortenRevision } from '../git/models/reference'; import { showCommitHasNoPreviousCommitWarningMessage, showGenericErrorMessage } from '../messages'; import { showCommitPicker } from '../quickpicks/commitPicker'; import { CommandQuickPickItem } from '../quickpicks/items/common'; +import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive } from '../quickpicks/items/directive'; import { command } from '../system/command'; import { Logger } from '../system/logger'; +import { splitPath } from '../system/path'; import { pad } from '../system/string'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri } from './base'; @@ -124,33 +127,89 @@ export class OpenFileAtRevisionCommand extends ActiveEditorCommand { 2, 2, )}`; + const titleWithContext = `${title}${gitUri.getFormattedFileName({ + suffix: gitUri.sha ? `:${shortenRevision(gitUri.sha)}` : undefined, + truncateTo: quickPickTitleMaxChars - title.length, + })}`; const pick = await showCommitPicker( log, - `${title}${gitUri.getFormattedFileName({ - suffix: gitUri.sha ? `:${shortenRevision(gitUri.sha)}` : undefined, - truncateTo: quickPickTitleMaxChars - title.length, - })}`, + titleWithContext, `Choose a commit to ${args.annotationType === 'blame' ? 'blame' : 'open'} the file revision from`, { + empty: !gitUri.sha + ? { + getState: async () => { + const items: (CommandQuickPickItem | DirectiveQuickPickItem)[] = []; + + const status = await this.container.git.getStatusForRepo(gitUri.repoPath); + if (status != null) { + for (const f of status.files) { + if (f.workingTreeStatus === '?' || f.workingTreeStatus === '!') { + continue; + } + + const [label, description] = splitPath(f.path, undefined, true); + + items.push( + new CommandQuickPickItem<[Uri]>( + { + label: label, + description: description, + }, + undefined, + Commands.OpenFileAtRevision, + [this.container.git.getAbsoluteUri(f.path, gitUri.repoPath)], + ), + ); + } + } + + let newPlaceholder; + let newTitle; + + if (items.length) { + newPlaceholder = `${gitUri.getFormattedFileName()} is likely untracked, choose a different file?`; + newTitle = `${titleWithContext} (Untracked?)`; + } else { + newPlaceholder = 'No commits found'; + } + + items.push( + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: items.length ? 'Cancel' : 'OK', + }), + ); + + return { + items: items, + placeholder: newPlaceholder, + title: newTitle, + }; + }, + } + : undefined, picked: gitUri.sha, - keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (key, item) => { - await openFileAtRevision(item.item.file!, item.item, { - annotationType: args!.annotationType, - line: args!.line, - preserveFocus: true, - preview: false, - }); + keyboard: { + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (key, item) => { + await openFileAtRevision(item.item.file!, item.item, { + annotationType: args.annotationType, + line: args.line, + preserveFocus: true, + preview: true, + }); + }, }, showOtherReferences: [ - CommandQuickPickItem.fromCommand( + CommandQuickPickItem.fromCommand<[Uri]>( 'Choose a Branch or Tag...', Commands.OpenFileAtRevisionFrom, + [uri], ), - CommandQuickPickItem.fromCommand( + CommandQuickPickItem.fromCommand<[Uri, OpenFileAtRevisionFromCommandArgs]>( 'Choose a Stash...', Commands.OpenFileAtRevisionFrom, - { stash: true }, + [uri, { stash: true }], ), ], }, diff --git a/src/commands/openFileAtRevisionFrom.ts b/src/commands/openFileAtRevisionFrom.ts index 563ac4b638e6c..f8404988ba425 100644 --- a/src/commands/openFileAtRevisionFrom.ts +++ b/src/commands/openFileAtRevisionFrom.ts @@ -64,21 +64,20 @@ export class OpenFileAtRevisionFromCommand extends ActiveEditorCommand { `${title}${gitUri.getFormattedFileName({ truncateTo: quickPickTitleMaxChars - title.length })}`, 'Choose a branch or tag to open the file revision from', { - allowEnteringRefs: true, - keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (key, quickpick) => { - const [item] = quickpick.activeItems; - if (item != null) { + allowRevisions: true, + keyboard: { + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (key, item) => { await openFileAtRevision( this.container.git.getRevisionUri(item.ref, gitUri.fsPath, gitUri.repoPath!), { - annotationType: args!.annotationType, - line: args!.line, + annotationType: args.annotationType, + line: args.line, preserveFocus: true, - preview: false, + preview: true, }, ); - } + }, }, }, ); diff --git a/src/commands/openFileFromRemote.ts b/src/commands/openFileFromRemote.ts index b5d62386442c6..eb17a9cde5365 100644 --- a/src/commands/openFileFromRemote.ts +++ b/src/commands/openFileFromRemote.ts @@ -54,7 +54,7 @@ export class OpenFileFromRemoteCommand extends Command { } try { - await openEditor(local.uri, { selection: selection, rethrow: true }); + await openEditor(local.uri, { selection: selection, throwOnError: true }); } catch { const uris = await window.showOpenDialog({ title: 'Open local file', diff --git a/src/commands/openFileOnRemote.ts b/src/commands/openFileOnRemote.ts index 008b980a15978..9c92e2de57dc9 100644 --- a/src/commands/openFileOnRemote.ts +++ b/src/commands/openFileOnRemote.ts @@ -120,7 +120,7 @@ export class OpenFileOnRemoteCommand extends ActiveEditorCommand { args = { range: true, ...args }; try { - let remotes = await this.container.git.getRemotesWithProviders(gitUri.repoPath); + let remotes = await this.container.git.getRemotesWithProviders(gitUri.repoPath, { sort: true }); let range: Range | undefined; if (args.range) { @@ -165,9 +165,8 @@ export class OpenFileOnRemoteCommand extends ActiveEditorCommand { : `Open File on Remote From${pad(GlyphChars.Dot, 2, 2)}${gitUri.relativePath}`, `Choose a branch or tag to ${args.clipboard ? 'copy' : 'open'} the file revision from`, { - allowEnteringRefs: true, + allowRevisions: true, autoPick: true, - // checkmarks: false, filter: { branches: b => b.remote || b.upstream != null }, picked: args.branchOrTag, sort: { diff --git a/src/commands/openIssueOnRemote.ts b/src/commands/openIssueOnRemote.ts deleted file mode 100644 index cd4660b817b8f..0000000000000 --- a/src/commands/openIssueOnRemote.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { env, Uri } from 'vscode'; -import { Commands } from '../constants'; -import type { Container } from '../container'; -import { command } from '../system/command'; -import { AutolinkedItemNode } from '../views/nodes/autolinkedItemNode'; -import type { CommandContext } from './base'; -import { Command } from './base'; - -export interface OpenIssueOnRemoteCommandArgs { - clipboard?: boolean; - issue: { url: string }; -} - -@command() -export class OpenIssueOnRemoteCommand extends Command { - constructor(private readonly container: Container) { - super([ - Commands.OpenIssueOnRemote, - Commands.CopyRemoteIssueUrl, - Commands.OpenAutolinkUrl, - Commands.CopyAutolinkUrl, - ]); - } - - protected override preExecute(context: CommandContext, args: OpenIssueOnRemoteCommandArgs) { - if (context.type === 'viewItem' && context.node instanceof AutolinkedItemNode) { - args = { - ...args, - issue: { url: context.node.item.url }, - clipboard: - context.command === Commands.CopyRemoteIssueUrl || context.command === Commands.CopyAutolinkUrl, - }; - } - - return this.execute(args); - } - - async execute(args: OpenIssueOnRemoteCommandArgs) { - if (args.clipboard) { - await env.clipboard.writeText(args.issue.url); - } else { - void env.openExternal(Uri.parse(args.issue.url)); - } - } -} diff --git a/src/commands/openOnRemote.ts b/src/commands/openOnRemote.ts index 21d39adc3f3a5..81b403eaeb39f 100644 --- a/src/commands/openOnRemote.ts +++ b/src/commands/openOnRemote.ts @@ -1,12 +1,14 @@ import { Commands, GlyphChars } from '../constants'; import type { Container } from '../container'; import { createRevisionRange, shortenRevision } from '../git/models/reference'; -import { GitRemote } from '../git/models/remote'; +import type { GitRemote } from '../git/models/remote'; +import { getHighlanderProviders } from '../git/models/remote'; import type { RemoteResource } from '../git/models/remoteResource'; import { RemoteResourceType } from '../git/models/remoteResource'; import type { RemoteProvider } from '../git/remotes/remoteProvider'; import { showGenericErrorMessage } from '../messages'; import { showRemoteProviderPicker } from '../quickpicks/remoteProviderPicker'; +import { ensure } from '../system/array'; import { command } from '../system/command'; import { Logger } from '../system/logger'; import { pad, splitSingle } from '../system/string'; @@ -14,14 +16,14 @@ import { Command } from './base'; export type OpenOnRemoteCommandArgs = | { - resource: RemoteResource; + resource: RemoteResource | RemoteResource[]; repoPath: string; remote?: string; clipboard?: boolean; } | { - resource: RemoteResource; + resource: RemoteResource | RemoteResource[]; remotes: GitRemote[]; remote?: string; @@ -38,7 +40,9 @@ export class OpenOnRemoteCommand extends Command { if (args?.resource == null) return; let remotes = - 'remotes' in args ? args.remotes : await this.container.git.getRemotesWithProviders(args.repoPath); + 'remotes' in args + ? args.remotes + : await this.container.git.getRemotesWithProviders(args.repoPath, { sort: true }); if (args.remote != null) { const filtered = remotes.filter(r => r.name === args.remote); @@ -48,35 +52,47 @@ export class OpenOnRemoteCommand extends Command { } } - try { - if (args.resource.type === RemoteResourceType.Branch) { - // Check to see if the remote is in the branch - const [remoteName, branchName] = splitSingle(args.resource.branch, '/'); - if (branchName != null) { - const remote = remotes.find(r => r.name === remoteName); - if (remote != null) { - args.resource.branch = branchName; - remotes = [remote]; + async function processResource(this: OpenOnRemoteCommand, resource: RemoteResource) { + try { + if (resource.type === RemoteResourceType.Branch) { + // Check to see if the remote is in the branch + const [remoteName, branchName] = splitSingle(resource.branch, '/'); + if (branchName != null) { + const remote = remotes.find(r => r.name === remoteName); + if (remote != null) { + resource.branch = branchName; + remotes = [remote]; + } } - } - } else if (args.resource.type === RemoteResourceType.Revision) { - const { commit, fileName } = args.resource; - if (commit != null) { - const file = await commit.findFile(fileName); - if (file?.status === 'D') { - // Resolve to the previous commit to that file - args.resource.sha = await this.container.git.resolveReference( - commit.repoPath, - `${commit.sha}^`, - fileName, - ); - } else { - args.resource.sha = commit.sha; + } else if (resource.type === RemoteResourceType.Revision) { + const { commit, fileName } = resource; + if (commit != null) { + const file = await commit.findFile(fileName); + if (file?.status === 'D') { + // Resolve to the previous commit to that file + resource.sha = await this.container.git.resolveReference( + commit.repoPath, + `${commit.sha}^`, + fileName, + ); + } else { + resource.sha = commit.sha; + } } } + } catch (ex) { + debugger; + Logger.error(ex, 'OpenOnRemoteCommand.processResource'); + } + } + + try { + const resources = ensure(args.resource)!; + for (const resource of resources) { + await processResource.call(this, resource); } - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); const provider = providers?.length ? providers[0].name : 'Remote'; const options: Parameters[4] = { @@ -86,15 +102,23 @@ export class OpenOnRemoteCommand extends Command { }; let title; - let placeHolder = `Choose which remote to ${args.clipboard ? 'copy the link for' : 'open on'}`; + let placeholder = `Choose which remote to ${ + args.clipboard ? `copy the link${resources.length > 1 ? 's' : ''} for` : 'open on' + }`; function getTitlePrefix(type: string): string { - return args?.clipboard ? `Copy Link to ${type} for ${provider}` : `Open ${type} on ${provider}`; + return args?.clipboard + ? `Copy ${provider} ${type} Link${resources.length > 1 ? 's' : ''}` + : `Open ${type} on ${provider}`; } - switch (args.resource.type) { + const [resource] = resources; + switch (resource.type) { case RemoteResourceType.Branch: - title = `${getTitlePrefix('Branch')}${pad(GlyphChars.Dot, 2, 2)}${args.resource.branch}`; + title = getTitlePrefix('Branch'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${resource.branch}`; + } break; case RemoteResourceType.Branches: @@ -102,40 +126,57 @@ export class OpenOnRemoteCommand extends Command { break; case RemoteResourceType.Commit: - title = `${getTitlePrefix('Commit')}${pad(GlyphChars.Dot, 2, 2)}${shortenRevision( - args.resource.sha, - )}`; + title = getTitlePrefix('Commit'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${shortenRevision(resource.sha)}`; + } break; case RemoteResourceType.Comparison: - title = `${getTitlePrefix('Comparison')}${pad(GlyphChars.Dot, 2, 2)}${createRevisionRange( - args.resource.base, - args.resource.compare, - args.resource.notation ?? '...', - )}`; + title = getTitlePrefix('Comparisons'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${createRevisionRange( + resource.base, + resource.compare, + resource.notation ?? '...', + )}`; + } break; case RemoteResourceType.CreatePullRequest: options.autoPick = true; options.setDefault = false; - title = `${ - args.clipboard - ? `Copy Create Pull Request Link for ${provider}` - : `Create Pull Request on ${provider}` - }${pad(GlyphChars.Dot, 2, 2)}${ - args.resource.base?.branch - ? createRevisionRange(args.resource.base.branch, args.resource.compare.branch, '...') - : args.resource.compare.branch - }`; - - placeHolder = `Choose which remote to ${ - args.clipboard ? 'copy the create pull request link for' : 'create the pull request on' - }`; + if (resources.length > 1) { + title = args.clipboard + ? `Copy ${provider} Create Pull Request Links` + : `Create Pull Requests on ${provider}`; + + placeholder = `Choose which remote to ${ + args.clipboard ? 'copy the create pull request links for' : 'create the pull requests on' + }`; + } else { + title = `${ + args.clipboard + ? `Copy ${provider} Create Pull Request Link` + : `Create Pull Request on ${provider}` + }${pad(GlyphChars.Dot, 2, 2)}${ + resource.base?.branch + ? createRevisionRange(resource.base.branch, resource.compare.branch, '...') + : resource.compare.branch + }`; + + placeholder = `Choose which remote to ${ + args.clipboard ? 'copy the create pull request link for' : 'create the pull request on' + }`; + } break; case RemoteResourceType.File: - title = `${getTitlePrefix('File')}${pad(GlyphChars.Dot, 2, 2)}${args.resource.fileName}`; + title = getTitlePrefix('File'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${resource.fileName}`; + } break; case RemoteResourceType.Repo: @@ -143,19 +184,27 @@ export class OpenOnRemoteCommand extends Command { break; case RemoteResourceType.Revision: { - title = `${getTitlePrefix('File')}${pad(GlyphChars.Dot, 2, 2)}${shortenRevision( - args.resource.sha, - )}${pad(GlyphChars.Dot, 1, 1)}${args.resource.fileName}`; + title = getTitlePrefix('File'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${shortenRevision(resource.sha)}${pad( + GlyphChars.Dot, + 1, + 1, + )}${resource.fileName}`; + } break; } // case RemoteResourceType.Tag: { - // title = `${getTitlePrefix('Tag')}${pad(GlyphChars.Dot, 2, 2)}${args.resource.tag}`; + // title = getTitlePrefix('Tag'); + // if (resources.length === 1) { + // title += `${pad(GlyphChars.Dot, 2, 2)}${args.resource.tag}`; + // } // break; // } } - const pick = await showRemoteProviderPicker(title, placeHolder, args.resource, remotes, options); + const pick = await showRemoteProviderPicker(title, placeholder, resources, remotes, options); await pick?.execute(); } catch (ex) { Logger.error(ex, 'OpenOnRemoteCommand'); diff --git a/src/commands/openPullRequestOnRemote.ts b/src/commands/openPullRequestOnRemote.ts index 497c9b8d407c5..68d59a9fd51d2 100644 --- a/src/commands/openPullRequestOnRemote.ts +++ b/src/commands/openPullRequestOnRemote.ts @@ -1,8 +1,9 @@ -import { env, Uri, window } from 'vscode'; +import { env, window } from 'vscode'; import { Commands } from '../constants'; import type { Container } from '../container'; import { shortenRevision } from '../git/models/reference'; import { command } from '../system/command'; +import { openUrl } from '../system/utils'; import { PullRequestNode } from '../views/nodes/pullRequestNode'; import type { CommandContext } from './base'; import { Command } from './base'; @@ -36,10 +37,13 @@ export class OpenPullRequestOnRemoteCommand extends Command { if (args?.pr == null) { if (args?.repoPath == null || args?.ref == null) return; - const remote = await this.container.git.getBestRemoteWithRichProvider(args.repoPath); - if (!remote?.hasRichIntegration()) return; + const remote = await this.container.git.getBestRemoteWithIntegration(args.repoPath); + if (remote == null) return; - const pr = await remote.provider.getPullRequestForCommit(args.ref); + const provider = await this.container.integrations.getByRemote(remote); + if (provider == null) return; + + const pr = await provider.getPullRequestForCommit(remote.provider.repoDesc, args.ref); if (pr == null) { void window.showInformationMessage(`No pull request associated with '${shortenRevision(args.ref)}'`); return; @@ -52,7 +56,7 @@ export class OpenPullRequestOnRemoteCommand extends Command { if (args.clipboard) { await env.clipboard.writeText(args.pr.url); } else { - void env.openExternal(Uri.parse(args.pr.url)); + void openUrl(args.pr.url); } } } diff --git a/src/commands/patches.ts b/src/commands/patches.ts new file mode 100644 index 0000000000000..ceee8e695a924 --- /dev/null +++ b/src/commands/patches.ts @@ -0,0 +1,411 @@ +import { EntityIdentifierUtils } from '@gitkraken/provider-apis'; +import type { TextEditor } from 'vscode'; +import { env, Uri, window, workspace } from 'vscode'; +import type { ScmResource } from '../@types/vscode.git.resources'; +import { ScmResourceGroupType } from '../@types/vscode.git.resources.enums'; +import { Commands } from '../constants'; +import type { Container } from '../container'; +import { CancellationError } from '../errors'; +import { ApplyPatchCommitError, ApplyPatchCommitErrorReason } from '../git/errors'; +import { uncommitted, uncommittedStaged } from '../git/models/constants'; +import type { GitDiff } from '../git/models/diff'; +import { isSha, shortenRevision } from '../git/models/reference'; +import type { Repository } from '../git/models/repository'; +import type { Draft, LocalDraft } from '../gk/models/drafts'; +import { showPatchesView } from '../plus/drafts/actions'; +import type { ProviderAuth } from '../plus/drafts/draftsService'; +import type { IntegrationId } from '../plus/integrations/providers/models'; +import { getProviderIdFromEntityIdentifier } from '../plus/integrations/providers/utils'; +import type { Change, CreateDraft } from '../plus/webviews/patchDetails/protocol'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { command } from '../system/command'; +import { map } from '../system/iterable'; +import { Logger } from '../system/logger'; +import type { CommandContext } from './base'; +import { + ActiveEditorCommand, + Command, + isCommandContextViewNodeHasCommit, + isCommandContextViewNodeHasComparison, + isCommandContextViewNodeHasFileCommit, +} from './base'; + +export interface CreatePatchCommandArgs { + to?: string; + from?: string; + repoPath?: string; + uris?: Uri[]; +} + +abstract class CreatePatchCommandBase extends Command { + constructor( + protected readonly container: Container, + command: Commands | Commands[], + ) { + super(command); + } + + protected override async preExecute(context: CommandContext, args?: CreatePatchCommandArgs) { + if (args == null) { + if (context.type === 'scm-states') { + const resourcesByGroup = new Map(); + const uris = new Set(); + + let repo; + for (const resource of context.scmResourceStates as ScmResource[]) { + repo ??= await this.container.git.getOrOpenRepository(resource.resourceUri); + + uris.add(resource.resourceUri.toString()); + + let groupResources = resourcesByGroup.get(resource.resourceGroupType!); + if (groupResources == null) { + groupResources = []; + resourcesByGroup.set(resource.resourceGroupType!, groupResources); + } else { + groupResources.push(resource); + } + } + + args = { + repoPath: repo?.path, + to: + resourcesByGroup.size == 1 && resourcesByGroup.has(ScmResourceGroupType.Index) + ? uncommittedStaged + : uncommitted, + from: 'HEAD', + uris: [...map(uris, u => Uri.parse(u))], + }; + } else if (context.type === 'scm-groups') { + const group = context.scmResourceGroups[0]; + if (!group?.resourceStates?.length) return; + + const repo = await this.container.git.getOrOpenRepository(group.resourceStates[0].resourceUri); + + args = { + repoPath: repo?.path, + to: group.id === 'index' ? uncommittedStaged : uncommitted, + from: 'HEAD', + }; + } else if (context.type === 'viewItem') { + if (isCommandContextViewNodeHasCommit(context)) { + args = { + repoPath: context.node.commit.repoPath, + to: context.node.commit.ref, + from: `${context.node.commit.ref}^`, + }; + if (isCommandContextViewNodeHasFileCommit(context)) { + args.uris = [context.node.uri]; + } + } else if (isCommandContextViewNodeHasComparison(context)) { + args = { + repoPath: context.node.uri.fsPath, + to: context.node.compareRef.ref, + from: context.node.compareWithRef.ref, + }; + } + } + } + + return this.execute(args); + } + + protected async getDiff(title: string, args?: CreatePatchCommandArgs): Promise { + let repo; + if (args?.repoPath != null) { + repo = this.container.git.getRepository(args.repoPath); + } + repo ??= await getRepositoryOrShowPicker(title); + if (repo == null) return; + + return this.container.git.getDiff( + repo.uri, + args?.to ?? uncommitted, + args?.from ?? 'HEAD', + args?.uris?.length ? { uris: args.uris } : undefined, + ); + } + + abstract override execute(args?: CreatePatchCommandArgs): Promise; +} + +@command() +export class CreatePatchCommand extends CreatePatchCommandBase { + constructor(container: Container) { + super(container, Commands.CreatePatch); + } + + async execute(args?: CreatePatchCommandArgs) { + const diff = await this.getDiff('Create Patch', args); + if (diff == null) return; + + const d = await workspace.openTextDocument({ content: diff.contents, language: 'diff' }); + await window.showTextDocument(d); + + // const uri = await window.showSaveDialog({ + // filters: { Patches: ['patch'] }, + // saveLabel: 'Create Patch', + // }); + // if (uri == null) return; + + // await workspace.fs.writeFile(uri, new TextEncoder().encode(patch.contents)); + } +} + +@command() +export class CopyPatchToClipboardCommand extends CreatePatchCommandBase { + constructor(container: Container) { + super(container, Commands.CopyPatchToClipboard); + } + + async execute(args?: CreatePatchCommandArgs) { + const diff = await this.getDiff('Copy as Patch', args); + if (diff == null) return; + + await env.clipboard.writeText(diff.contents); + void window.showInformationMessage( + "Copied patch \u2014 use 'Apply Copied Patch' in another window to apply it", + ); + } +} + +@command() +export class ApplyPatchFromClipboardCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.ApplyPatchFromClipboard); + } + + async execute() { + const patch = await env.clipboard.readText(); + let repo = this.container.git.highlander; + + // Make sure it looks like a valid patch + const valid = patch.length ? await this.container.git.validatePatch(repo?.uri ?? Uri.file(''), patch) : false; + if (!valid) { + void window.showWarningMessage('No valid patch found in the clipboard'); + return; + } + + repo ??= await getRepositoryOrShowPicker('Apply Copied Patch'); + if (repo == null) return; + + try { + const commit = await this.container.git.createUnreachableCommitForPatch( + repo.uri, + patch, + 'HEAD', + 'Pasted Patch', + ); + if (commit == null) return; + + await this.container.git.applyUnreachableCommitForPatch(repo.uri, commit.sha, { stash: false }); + void window.showInformationMessage(`Patch applied successfully`); + } catch (ex) { + if (ex instanceof CancellationError) return; + + if (ex instanceof ApplyPatchCommitError) { + if (ex.reason === ApplyPatchCommitErrorReason.AppliedWithConflicts) { + void window.showWarningMessage('Patch applied with conflicts'); + } else { + void window.showErrorMessage(ex.message); + } + } else { + void window.showErrorMessage(`Unable to apply patch: ${ex.message}`); + } + } + } +} + +@command() +export class CreateCloudPatchCommand extends CreatePatchCommandBase { + constructor(container: Container) { + super(container, [Commands.CreateCloudPatch, Commands.ShareAsCloudPatch]); + } + + async execute(args?: CreatePatchCommandArgs) { + if (args?.repoPath == null) { + return showPatchesView({ mode: 'create' }); + } + + const repo = this.container.git.getRepository(args.repoPath); + if (repo == null) { + return showPatchesView({ mode: 'create' }); + } + + const create = await createDraft(this.container, repo, args); + if (create == null) { + return showPatchesView({ mode: 'create', create: { repositories: [repo] } }); + } + return showPatchesView({ mode: 'create', create: create }); + } +} + +@command() +export class OpenPatchCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.OpenPatch); + } + + async execute(editor?: TextEditor) { + let document; + if (editor?.document?.languageId === 'diff') { + document = editor.document; + } else { + const uris = await window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { Patches: ['diff', 'patch'] }, + openLabel: 'Open Patch', + title: 'Open Patch File', + }); + const uri = uris?.[0]; + if (uri == null) return; + + document = await workspace.openTextDocument(uri); + await window.showTextDocument(document); + } + + const patch: LocalDraft = { + draftType: 'local', + patch: { + type: 'local', + uri: document.uri, + contents: document.getText(), + }, + }; + + void showPatchesView({ mode: 'view', draft: patch }); + } +} + +export interface OpenCloudPatchCommandArgs { + type: 'patch' | 'code_suggestion'; + id: string; + patchId?: string; + draft?: Draft; + prEntityId?: string; +} + +@command() +export class OpenCloudPatchCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.OpenCloudPatch); + } + + async execute(args?: OpenCloudPatchCommandArgs) { + const type = args?.type === 'code_suggestion' ? 'Code Suggestion' : 'Cloud Patch'; + if (args?.id == null && args?.draft == null) { + void window.showErrorMessage(`Cannot open ${type}; no patch or patch id provided`); + return; + } + + let providerAuth: ProviderAuth | undefined; + if (args?.prEntityId != null && args?.type === 'code_suggestion') { + let providerId: IntegrationId | undefined; + let providerDomain: string | undefined; + try { + const identifier = EntityIdentifierUtils.decode(args.prEntityId); + providerId = getProviderIdFromEntityIdentifier(identifier); + providerDomain = identifier.domain ?? undefined; + } catch { + void window.showErrorMessage(`Cannot open ${type}; invalid provider details.`); + return; + } + + if (providerId == null) { + void window.showErrorMessage(`Cannot open ${type}; unsupported provider.`); + return; + } + + const integration = await this.container.integrations.get(providerId, providerDomain); + if (integration == null) { + void window.showErrorMessage(`Cannot open ${type}; provider not found.`); + return; + } + + const session = await integration.getSession(); + if (session == null) { + void window.showErrorMessage(`Cannot open ${type}; provider not connected.`); + return; + } + + providerAuth = { provider: integration.id, token: session.accessToken }; + } + + try { + const draft = + args?.draft ?? (await this.container.drafts.getDraft(args?.id, { providerAuth: providerAuth })); + void showPatchesView({ mode: 'view', draft: draft }); + } catch (ex) { + Logger.error(ex, 'OpenCloudPatchCommand'); + void window.showErrorMessage(`Unable to open ${type} '${args.id}'`); + } + } +} + +async function createDraft( + container: Container, + repository: Repository, + args: CreatePatchCommandArgs, +): Promise { + if (args.to == null) return undefined; + + const to = args.to ?? 'HEAD'; + + const change: Change = { + type: 'revision', + repository: { + name: repository.name, + path: repository.path, + uri: repository.uri.toString(), + }, + files: undefined!, + revision: { to: to, from: args.from ?? `${to}^` }, + }; + + const create: CreateDraft = { changes: [change] }; + + const commit = await container.git.getCommit(repository.uri, to); + if (commit == null) return undefined; + + const message = commit.message!.trim(); + const index = message.indexOf('\n'); + if (index < 0) { + create.title = message; + } else { + create.title = message.substring(0, index); + create.description = message.substring(index + 1).trim(); + } + + if (args.from == null) { + if (commit.files == null) return; + + change.files = [...commit.files]; + } else { + const diff = await container.git.getDiff(repository.uri, to, args.from); + if (diff == null) return; + + const result = await container.git.getDiffFiles(repository.uri, diff.contents); + if (result?.files == null) return; + + change.files = result.files; + + create.title = `Comparing ${shortenRevision(args.to)} with ${shortenRevision(args.from)}`; + + if (!isSha(args.to)) { + const commit = await container.git.getCommit(repository.uri, args.to); + if (commit != null) { + change.revision.to = commit.sha; + } + } + + if (!isSha(args.from)) { + const commit = await container.git.getCommit(repository.uri, args.from); + if (commit != null) { + change.revision.from = commit.sha; + } + } + } + + return create; +} diff --git a/src/commands/quickCommand.buttons.ts b/src/commands/quickCommand.buttons.ts index 4211ceb9190a8..3e0dbb9a8da65 100644 --- a/src/commands/quickCommand.buttons.ts +++ b/src/commands/quickCommand.buttons.ts @@ -57,8 +57,18 @@ export class SelectableQuickInputButton extends ToggleQuickInputButton { } } +export const ClearQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('clear-all'), + tooltip: 'Clear', +}; + +export const FeedbackQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('feedback'), + tooltip: 'Give Us Feedback', +}; + export const FetchQuickInputButton: QuickInputButton = { - iconPath: new ThemeIcon('sync'), + iconPath: new ThemeIcon('gitlens-repo-fetch'), tooltip: 'Fetch', }; @@ -107,6 +117,55 @@ export const PickCommitToggleQuickInputButton = class extends ToggleQuickInputBu } }; +export const MergeQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('merge'), + tooltip: 'Merge...', +}; + +export const OpenOnGitHubQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('globe'), + tooltip: 'Open on GitHub', +}; + +export const OpenOnWebQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('globe'), + tooltip: 'Open on gitkraken.dev', +}; + +export const OpenInEditorQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('link-external'), + tooltip: 'Open in Editor', +}; + +export const LaunchpadSettingsQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('gear'), + tooltip: 'Launchpad Settings', +}; + +export const PinQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('pinned'), + tooltip: 'Pin', +}; + +export const UnpinQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('pin'), + tooltip: 'Unpin', +}; + +export const SnoozeQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('bell-slash'), + tooltip: 'Snooze', +}; + +export const RefreshQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('refresh'), + tooltip: 'Refresh', +}; + +export const UnsnoozeQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('bell'), + tooltip: 'Unsnooze', +}; export const OpenInNewWindowQuickInputButton: QuickInputButton = { iconPath: new ThemeIcon('empty-window'), tooltip: 'Open in New Window', @@ -124,7 +183,7 @@ export const SetRemoteAsDefaultQuickInputButton: QuickInputButton = { export const ShowDetailsViewQuickInputButton: QuickInputButton = { iconPath: new ThemeIcon('eye'), - tooltip: 'Open Details', + tooltip: 'Inspect Details', }; export const OpenChangesViewQuickInputButton: QuickInputButton = { @@ -137,6 +196,11 @@ export const ShowResultsInSideBarQuickInputButton: QuickInputButton = { tooltip: 'Show Results in Side Bar', }; +export const OpenWorktreeInNewWindowQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('empty-window'), + tooltip: 'Open Worktree in New Window', +}; + export const ShowTagsToggleQuickInputButton = class extends SelectableQuickInputButton { constructor(on = false) { super('Show Tags', { off: new ThemeIcon('tag'), on: 'icon-tag-selected' }, on); @@ -144,27 +208,25 @@ export const ShowTagsToggleQuickInputButton = class extends SelectableQuickInput }; export const WillConfirmForcedQuickInputButton: QuickInputButton = { - iconPath: new ThemeIcon('check'), - tooltip: 'Will always confirm', + iconPath: new ThemeIcon('gitlens-confirm-checked'), + tooltip: 'You will be presented with a required confirmation step before the action is performed', }; export const WillConfirmToggleQuickInputButton = class extends ToggleQuickInputButton { - constructor(on = false, onDidClick?: (quickInput: QuickInput) => void) { + constructor(on = false, isConfirmationStep: boolean, onDidClick?: (quickInput: QuickInput) => void) { super( () => ({ on: { - tooltip: 'Will confirm', - icon: { - dark: Uri.file(Container.instance.context.asAbsolutePath('images/dark/icon-check.svg')), - light: Uri.file(Container.instance.context.asAbsolutePath('images/light/icon-check.svg')), - }, + tooltip: isConfirmationStep + ? 'For future actions, you will be presented with confirmation step before the action is performed\nClick to toggle' + : 'You will be presented with confirmation step before the action is performed\nClick to toggle', + icon: new ThemeIcon('gitlens-confirm-checked'), }, off: { - tooltip: 'Skips confirm', - icon: { - dark: Uri.file(Container.instance.context.asAbsolutePath('images/dark/icon-no-check.svg')), - light: Uri.file(Container.instance.context.asAbsolutePath('images/light/icon-no-check.svg')), - }, + tooltip: isConfirmationStep + ? "For future actions, you won't be presented with confirmation step before the action is performed\nClick to toggle" + : "You won't be presented with confirmation step before the action is performed\nClick to toggle", + icon: new ThemeIcon('gitlens-confirm-unchecked'), }, }), on, diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index eda59355b6aa8..d9f7353ca8a4a 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -1,4 +1,5 @@ -import type { QuickInputButton, QuickPick } from 'vscode'; +import type { QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; +import { ThemeIcon } from 'vscode'; import { Commands, GlyphChars, quickPickTitleMaxChars } from '../constants'; import { Container } from '../container'; import type { PlusFeatures } from '../features'; @@ -15,7 +16,8 @@ import type { BranchSortOptions, GitBranch } from '../git/models/branch'; import { sortBranches } from '../git/models/branch'; import type { GitCommit, GitStashCommit } from '../git/models/commit'; import { isCommit, isStash } from '../git/models/commit'; -import type { GitContributor } from '../git/models/contributor'; +import type { ContributorQuickPickItem, GitContributor } from '../git/models/contributor'; +import { createContributorQuickPickItem, sortContributors } from '../git/models/contributor'; import type { GitLog } from '../git/models/log'; import type { GitBranchReference, GitReference, GitRevisionReference, GitTagReference } from '../git/models/reference'; import { @@ -28,15 +30,19 @@ import { isStashReference, isTagReference, } from '../git/models/reference'; -import { GitRemote } from '../git/models/remote'; +import type { GitRemote } from '../git/models/remote'; +import { getHighlanderProviderName } from '../git/models/remote'; import { RemoteResourceType } from '../git/models/remoteResource'; import { Repository } from '../git/models/repository'; import type { GitStash } from '../git/models/stash'; import type { GitStatus } from '../git/models/status'; import type { GitTag, TagSortOptions } from '../git/models/tag'; import { sortTags } from '../git/models/tag'; -import type { GitWorktree } from '../git/models/worktree'; +import type { GitWorktree, WorktreeQuickPickItem } from '../git/models/worktree'; +import { createWorktreeQuickPickItem, sortWorktrees } from '../git/models/worktree'; import { remoteUrlRegex } from '../git/parsers/remoteParser'; +import type { FocusCommandArgs } from '../plus/focus/focus'; +import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/account/subscription'; import { CommitApplyFileChangesCommandQuickPickItem, CommitBrowseRepositoryFromHereCommandQuickPickItem, @@ -64,28 +70,25 @@ import { OpenChangedFilesCommandQuickPickItem, OpenOnlyChangedFilesCommandQuickPickItem, } from '../quickpicks/items/commits'; -import type { QuickPickSeparator } from '../quickpicks/items/common'; +import type { QuickPickItemOfT, QuickPickSeparator } from '../quickpicks/items/common'; import { CommandQuickPickItem, createQuickPickSeparator } from '../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; -import { createDirectiveQuickPickItem, Directive } from '../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive, isDirectiveQuickPickItem } from '../quickpicks/items/directive'; import type { BranchQuickPickItem, CommitQuickPickItem, - ContributorQuickPickItem, RemoteQuickPickItem, RepositoryQuickPickItem, TagQuickPickItem, - WorktreeQuickPickItem, } from '../quickpicks/items/gitCommands'; import { createBranchQuickPickItem, createCommitQuickPickItem, - createContributorQuickPickItem, createRefQuickPickItem, createRemoteQuickPickItem, createRepositoryQuickPickItem, + createStashQuickPickItem, createTagQuickPickItem, - createWorktreeQuickPickItem, GitCommandQuickPickItem, } from '../quickpicks/items/gitCommands'; import type { ReferencesQuickPickItem } from '../quickpicks/referencePicker'; @@ -93,17 +96,19 @@ import { CopyRemoteResourceCommandQuickPickItem, OpenRemoteResourceCommandQuickPickItem, } from '../quickpicks/remoteProviderPicker'; -import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../subscription'; -import { filterMap, intersection, isStringArray } from '../system/array'; +import { filterMap, filterMapAsync, intersection, isStringArray } from '../system/array'; import { configuration } from '../system/configuration'; import { formatPath } from '../system/formatPath'; +import { debounce } from '../system/function'; import { first, map } from '../system/iterable'; +import { Logger } from '../system/logger'; import { getSettledValue } from '../system/promise'; import { pad, pluralize, truncate } from '../system/string'; import { openWorkspace } from '../system/utils'; import type { ViewsWithRepositoryFolders } from '../views/viewBase'; import type { AsyncStepResultGenerator, + CrossCommandReference, PartialStepState, QuickPickStep, StepResultGenerator, @@ -114,9 +119,14 @@ import { canInputStepContinue, canPickStepContinue, canStepContinue, + createCrossCommandReference, createInputStep, createPickStep, endSteps, + isCrossCommandReference, + StepResultBreak, +} from './quickCommand'; +import { LoadMoreQuickInputButton, OpenChangesViewQuickInputButton, OpenInNewWindowQuickInputButton, @@ -124,8 +134,7 @@ import { RevealInSideBarQuickInputButton, ShowDetailsViewQuickInputButton, ShowTagsToggleQuickInputButton, - StepResultBreak, -} from './quickCommand'; +} from './quickCommand.buttons'; export function appendReposToTitle< State extends { repo: Repository } | { repos: Repository[] }, @@ -215,44 +224,49 @@ export async function getWorktrees( repoOrWorktrees: Repository | GitWorktree[], { buttons, + excludeOpened, filter, includeStatus, picked, }: { buttons?: QuickInputButton[]; + excludeOpened?: boolean; filter?: (t: GitWorktree) => boolean; includeStatus?: boolean; picked?: string | string[]; }, ): Promise { const worktrees = repoOrWorktrees instanceof Repository ? await repoOrWorktrees.getWorktrees() : repoOrWorktrees; - return Promise.all([ - ...worktrees - .filter(w => filter == null || filter(w)) - .map(async w => { - let missing = false; - let status; - if (includeStatus) { - try { - status = await w.getStatus(); - } catch { - missing = true; - } - } - return createWorktreeQuickPickItem( - w, - picked != null && - (typeof picked === 'string' ? w.uri.toString() === picked : picked.includes(w.uri.toString())), - missing, - { - buttons: buttons, - path: true, - status: status, - }, - ); - }), - ]); + const items = await filterMapAsync(worktrees, async w => { + if ((excludeOpened && w.opened) || filter?.(w) === false) return undefined; + + let missing = false; + let status; + if (includeStatus) { + try { + status = await w.getStatus(); + } catch (ex) { + Logger.error(ex, `Worktree status failed: ${w.uri.toString(true)}`); + missing = true; + } + } + + return createWorktreeQuickPickItem( + w, + picked != null && + (typeof picked === 'string' ? w.uri.toString() === picked : picked.includes(w.uri.toString())), + missing, + { + buttons: buttons, + includeStatus: includeStatus, + path: true, + status: status, + }, + ); + }); + + return sortWorktrees(items); } export async function getBranchesAndOrTags( @@ -503,11 +517,11 @@ export function getValidateGitReferenceFn( const commit = await Container.instance.git.getCommit(repos.path, value); quickpick.items = [ - createCommitQuickPickItem(commit!, true, { + await createCommitQuickPickItem(commit!, true, { alwaysShow: true, buttons: options?.buttons, compact: true, - icon: true, + icon: 'avatar', }), ]; return true; @@ -520,13 +534,13 @@ export async function* inputBranchNameStep< >( state: State, context: Context, - options: { placeholder: string; titleContext?: string; value?: string }, + options: { placeholder?: string; prompt?: string; titleContext?: string; value?: string }, ): AsyncStepResultGenerator { const step = createInputStep({ title: appendReposToTitle(`${context.title}${options.titleContext ?? ''}`, state, context), - placeholder: options.placeholder, + placeholder: options.placeholder ?? 'Branch name', value: options.value, - prompt: 'Enter branch name', + prompt: options.prompt ?? 'Please provide a new branch name', validate: async (value: string | undefined): Promise<[boolean, string | undefined]> => { if (value == null) return [false, undefined]; @@ -691,7 +705,7 @@ export async function* inputTagNameStep< return value; } -export async function* pickBranchStep< +export function* pickBranchStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; showTags?: boolean; title: string }, >( @@ -708,42 +722,42 @@ export async function* pickBranchStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const branches = await getBranches(state.repo, { +): StepResultGenerator { + const items = getBranches(state.repo, { buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, - }); + }).then(branches => + branches.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : branches, + ); const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: branches.length === 0 ? `No branches found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No branches found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - branches.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : branches, + items: items, onDidClickItemButton: (quickpick, button, { item }) => { if (button === RevealInSideBarQuickInputButton) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await BranchActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await BranchActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickBranchesStep< +export function* pickBranchesStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; showTags?: boolean; title: string }, >( @@ -753,53 +767,56 @@ export async function* pickBranchesStep< filter, picked, placeholder, + emptyPlaceholder, sort, titleContext, }: { filter?: (b: GitBranch) => boolean; picked?: string | string[]; placeholder: string; + emptyPlaceholder?: string; sort?: BranchSortOptions; titleContext?: string; }, -): AsyncStepResultGenerator { - const branches = await getBranches(state.repo, { +): StepResultGenerator { + const items = getBranches(state.repo, { buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, sort: sort, - }); + }).then(branches => + !branches.length + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : branches, + ); const step = createPickStep({ - multiselect: branches.length !== 0, + multiselect: true, title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: branches.length === 0 ? `No branches found in ${state.repo.formattedName}` : placeholder, + placeholder: count => + !count ? emptyPlaceholder ?? `No branches found in ${state.repo.formattedName}` : placeholder, matchOnDetail: true, - items: - branches.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : branches, + items: items, onDidClickItemButton: (quickpick, button, { item }) => { if (button === RevealInSideBarQuickInputButton) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await BranchActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await BranchActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* pickBranchOrTagStep< +export function* pickBranchOrTagStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; pickCommitForItem?: boolean; showTags?: boolean; title: string }, >( @@ -822,7 +839,7 @@ export async function* pickBranchOrTagStep< additionalButtons?: QuickInputButton[]; ranges?: boolean; }, -): AsyncStepResultGenerator { +): StepResultGenerator { context.showTags = true; const showTagsButton = new ShowTagsToggleQuickInputButton(context.showTags); @@ -838,24 +855,25 @@ export async function* pickBranchOrTagStep< sort: true, }); }; - const branchesAndOrTags = await getBranchesAndOrTagsFn(); + const items = getBranchesAndOrTagsFn().then(branchesAndOrTags => + branchesAndOrTags.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : branchesAndOrTags, + ); const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: - branchesAndOrTags.length === 0 + placeholder: count => + !count ? `No branches${context.showTags ? ' or tags' : ''} found in ${state.repo.formattedName}` - : `${typeof placeholder === 'string' ? placeholder : placeholder(context)}${GlyphChars.Space.repeat( - 3, - )}(or enter a reference using #)`, + : `${ + typeof placeholder === 'string' ? placeholder : placeholder(context) + } (or enter a revision using #)`, matchOnDescription: true, matchOnDetail: true, value: value, selectValueWhenShown: true, - items: - branchesAndOrTags.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : branchesAndOrTags, + items: items, additionalButtons: [...(additionalButtons ?? []), showTagsButton], onDidClickItemButton: (quickpick, button, { item }) => { if (button === PickCommitQuickInputButton) { @@ -888,7 +906,7 @@ export async function* pickBranchOrTagStep< ? `${state.repo.formattedName} has no branches${context.showTags ? ' or tags' : ''}` : `${ typeof placeholder === 'string' ? placeholder : placeholder(context) - }${GlyphChars.Space.repeat(3)}(or enter a reference using #)`; + } (or enter a revision using #)`; quickpick.items = branchesAndOrTags; } finally { quickpick.busy = false; @@ -896,10 +914,7 @@ export async function* pickBranchOrTagStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; - - const item = quickpick.activeItems[0].item; + onDidPressKey: (_quickpick, _key, { item }) => { if (isBranchReference(item)) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); } else if (isTagReference(item)) { @@ -910,34 +925,53 @@ export async function* pickBranchOrTagStep< }, onValidateValue: getValidateGitReferenceFn(state.repo, { ranges: ranges }), }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickBranchOrTagStepMultiRepo< +export function* pickBranchOrTagStepMultiRepo< State extends StepState & { repos: Repository[]; reference?: GitReference }, - Context extends { repos: Repository[]; showTags?: boolean; title: string }, + Context extends { allowCreate?: boolean; repos: Repository[]; showTags?: boolean; title: string }, >( state: State, context: Context, { + allowCreate, filter, picked, placeholder, titleContext, value, }: { + allowCreate?: boolean; filter?: { branches?: (b: GitBranch) => boolean; tags?: (t: GitTag) => boolean }; picked?: string | string[]; placeholder: string | ((context: Context) => string); titleContext?: string; value?: string; }, -): AsyncStepResultGenerator { +): StepResultGenerator { context.showTags = state.repos.length === 1; const showTagsButton = new ShowTagsToggleQuickInputButton(context.showTags); + const createNewBranchItem: QuickPickItem & { item: string } = { + label: 'Create New Branch...', + iconPath: new ThemeIcon('plus'), + alwaysShow: true, + item: '', + }; + + const choosePullRequestItem: QuickPickItemOfT = { + label: 'Choose a Pull Request...', + iconPath: new ThemeIcon('git-pull-request'), + alwaysShow: true, + item: createCrossCommandReference>(Commands.ShowLaunchpad, { + source: 'git-commands', + }), + }; + const getBranchesAndOrTagsFn = () => { return getBranchesAndOrTags(state.repos, context.showTags ? ['branches', 'tags'] : ['branches'], { buttons: [RevealInSideBarQuickInputButton], @@ -947,30 +981,37 @@ export async function* pickBranchOrTagStepMultiRepo< sort: { branches: { orderBy: 'date:desc' }, tags: { orderBy: 'date:desc' } }, }); }; - const branchesAndOrTags = await getBranchesAndOrTagsFn(); + const items = getBranchesAndOrTagsFn().then(branchesAndOrTags => + branchesAndOrTags.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : allowCreate + ? [createNewBranchItem, choosePullRequestItem, ...branchesAndOrTags] + : [choosePullRequestItem, ...branchesAndOrTags], + ); - const step = createPickStep({ + const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: - branchesAndOrTags.length === 0 + placeholder: count => + !count ? `No ${state.repos.length === 1 ? '' : 'common '}branches${ context.showTags ? ' or tags' : '' - } found in ${ - state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` - }` - : `${typeof placeholder === 'string' ? placeholder : placeholder(context)}${GlyphChars.Space.repeat( - 3, - )}(or enter a reference using #)`, + } found in ${state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repos`}` + : `${ + typeof placeholder === 'string' ? placeholder : placeholder(context) + } (or enter a revision using #)`, matchOnDescription: true, matchOnDetail: true, value: value ?? (isRevisionReference(state.reference) ? state.reference.ref : undefined), selectValueWhenShown: true, - items: - branchesAndOrTags.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : branchesAndOrTags, + items: items, additionalButtons: [showTagsButton], + onDidChangeValue: quickpick => { + createNewBranchItem.item = quickpick.value; + return true; + }, onDidClickItemButton: (quickpick, button, { item }) => { + if (typeof item === 'string' || isCrossCommandReference(item)) return; + if (button === RevealInSideBarQuickInputButton) { if (isBranchReference(item)) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); @@ -997,11 +1038,11 @@ export async function* pickBranchOrTagStepMultiRepo< } found in ${ state.repos.length === 1 ? state.repos[0].formattedName - : `${state.repos.length} repositories` + : `${state.repos.length} repos` }` : `${ typeof placeholder === 'string' ? placeholder : placeholder(context) - }${GlyphChars.Space.repeat(3)}(or enter a reference using #)`; + } (or enter a revision using #)`; quickpick.items = branchesAndOrTags; } finally { quickpick.busy = false; @@ -1009,10 +1050,9 @@ export async function* pickBranchOrTagStepMultiRepo< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; + onDidPressKey: (_quickpick, _key, { item }) => { + if (typeof item === 'string' || isCrossCommandReference(item)) return; - const item = quickpick.activeItems[0].item; if (isBranchReference(item)) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); } else if (isTagReference(item)) { @@ -1057,7 +1097,7 @@ export async function* pickCommitStep< titleContext?: string; }, ): AsyncStepResultGenerator { - function getItems(log: GitLog | undefined) { + async function getItems(log: GitLog | undefined) { if (log == null) { return [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)]; } @@ -1069,23 +1109,33 @@ export async function* pickCommitStep< buttons.splice(0, 0, OpenChangesViewQuickInputButton); } - return [ - ...map(log.commits.values(), commit => - createCommitQuickPickItem( - commit, - picked != null && - (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), - { - buttons: buttons, - compact: true, - icon: true, - }, - ), + const items = []; + + for await (const item of map(log.commits.values(), async commit => + createCommitQuickPickItem( + commit, + picked != null && (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), + { + buttons: buttons, + compact: true, + icon: 'avatar', + }, ), - ...(log?.hasMore ? [createDirectiveQuickPickItem(Directive.LoadMore)] : []), - ]; + )) { + items.push(item); + } + + if (log.hasMore) { + items.push(createDirectiveQuickPickItem(Directive.LoadMore)); + } + + return items; } + const items = getItems(log).then(items => + showInSideBarCommand != null ? [showInSideBarCommand, ...items] : items, + ); + const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), placeholder: typeof placeholder === 'string' ? placeholder : placeholder(context, log), @@ -1094,7 +1144,7 @@ export async function* pickCommitStep< matchOnDetail: true, value: typeof picked === 'string' && log?.count === 0 ? picked : undefined, selectValueWhenShown: true, - items: showInSideBarCommand != null ? [showInSideBarCommand, ...getItems(log)] : getItems(log), + items: items, onDidLoadMore: async quickpick => { quickpick.keepScrollPosition = true; log = await log?.more?.(configuration.get('advanced.maxListItems')); @@ -1145,8 +1195,6 @@ export async function* pickCommitStep< }, keys: ['right', 'alt+right', 'ctrl+right'], onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - const items = quickpick.activeItems.filter( (i): i is CommitQuickPickItem => !CommandQuickPickItem.is(i), ); @@ -1165,6 +1213,7 @@ export async function* pickCommitStep< buttons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], }), }); + const selection: StepSelection = yield step; if (!canPickStepContinue(step, state, selection)) return StepResultBreak; @@ -1198,25 +1247,32 @@ export function* pickCommitsStep< titleContext?: string; }, ): StepResultGenerator { - function getItems(log: GitLog | undefined) { - return log == null - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : [ - ...map(log.commits.values(), commit => - createCommitQuickPickItem( - commit, - picked != null && - (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), - { - buttons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], - compact: true, - icon: true, - }, - ), - ), - // Since this is multi-select, we can't have a "Load more" item - // ...(log?.hasMore ? [DirectiveQuickPickItem.create(Directive.LoadMore)] : []), - ]; + async function getItems(log: GitLog | undefined) { + if (log == null) + return [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)]; + + const items = []; + + for await (const item of map(log.commits.values(), async commit => + createCommitQuickPickItem( + commit, + picked != null && (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), + { + buttons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], + compact: true, + icon: 'avatar', + }, + ), + )) { + items.push(item); + } + + // Since this is multi-select, we can't have a "Load more" item + // if (log.hasMore) { + // items.push(createDirectiveQuickPickItem(Directive.LoadMore)); + // } + + return items; } const step = createPickStep({ @@ -1252,16 +1308,14 @@ export function* pickCommitsStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - + onDidPressKey: async (_quickpick, key, { item }) => { if (key === 'ctrl+right') { - void CommitActions.showDetailsView(quickpick.activeItems[0].item, { + void CommitActions.showDetailsView(item, { pin: false, preserveFocus: true, }); } else { - await CommitActions.reveal(quickpick.activeItems[0].item, { + await CommitActions.reveal(item, { select: true, focus: false, expand: true, @@ -1269,19 +1323,30 @@ export function* pickCommitsStep< } }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* pickContributorsStep< +export function* pickContributorsStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string }, ->( - state: State, - context: Context, - placeholder: string = 'Choose contributors', -): AsyncStepResultGenerator { - const message = (await Container.instance.git.getOrOpenScmRepository(state.repo.path))?.inputBox.value; +>(state: State, context: Context, placeholder: string = 'Choose contributors'): StepResultGenerator { + async function getItems() { + const message = (await Container.instance.git.getOrOpenScmRepository(state.repo.path))?.inputBox.value; + + const items = []; + + for (const c of await Container.instance.git.getContributors(state.repo.path)) { + items.push( + await createContributorQuickPickItem(c, message?.includes(c.getCoauthor()), { + buttons: [RevealInSideBarQuickInputButton], + }), + ); + } + + return sortContributors(items); + } const step = createPickStep({ title: appendReposToTitle(context.title, state, context), @@ -1289,32 +1354,46 @@ export async function* pickContributorsStep< multiselect: true, placeholder: placeholder, matchOnDescription: true, - items: (await Container.instance.git.getContributors(state.repo.path)).map(c => - createContributorQuickPickItem(c, message?.includes(c.getCoauthor()), { - buttons: [RevealInSideBarQuickInputButton], - }), - ), + items: getItems(), onDidClickItemButton: (quickpick, button, { item }) => { if (button === RevealInSideBarQuickInputButton) { void ContributorActions.reveal(item, { select: true, focus: false, expand: true }); } }, - keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; + onDidChangeSelection: debounce((quickpick, e) => { + if (!quickpick.canSelectMany || quickpick.busy) return; + + let update = false; + for (const item of quickpick.items) { + if (isDirectiveQuickPickItem(item)) continue; + + const picked = e.includes(item); + if (item.picked !== picked || item.alwaysShow !== picked) { + item.alwaysShow = item.picked = picked; + update = true; + } + } - void ContributorActions.reveal(quickpick.activeItems[0].item, { + if (update) { + quickpick.items = sortContributors([...quickpick.items]); + quickpick.selectedItems = e; + } + }, 10), + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: (_quickpick, _key, { item }) => { + void ContributorActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* pickRemoteStep< +export function* pickRemoteStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string }, >( @@ -1331,42 +1410,42 @@ export async function* pickRemoteStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const remotes = await getRemotes(state.repo, { +): StepResultGenerator { + const items = getRemotes(state.repo, { buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, - }); + }).then(remotes => + remotes.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : remotes, + ); const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: remotes.length === 0 ? `No remotes found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No remotes found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - remotes.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : remotes, + items: items, onDidClickItemButton: (quickpick, button, { item }) => { if (button === RevealInSideBarQuickInputButton) { void RemoteActions.reveal(item, { select: true, focus: false, expand: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await RemoteActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await RemoteActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickRemotesStep< +export function* pickRemotesStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string }, >( @@ -1383,38 +1462,39 @@ export async function* pickRemotesStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const remotes = await getRemotes(state.repo, { +): StepResultGenerator { + const items = getRemotes(state.repo, { buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, - }); + }).then(remotes => + remotes.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : remotes, + ); const step = createPickStep({ - multiselect: remotes.length !== 0, + multiselect: true, title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: remotes.length === 0 ? `No remotes found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No remotes found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - remotes.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : remotes, + items: items, + onDidClickItemButton: (quickpick, button, { item }) => { if (button === RevealInSideBarQuickInputButton) { void RemoteActions.reveal(item, { select: true, focus: false, expand: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await RemoteActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await RemoteActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } @@ -1435,7 +1515,7 @@ export async function* pickRepositoryStep< items: context.repos.length === 0 ? [createDirectiveQuickPickItem(Directive.Cancel)] - : await Promise.all( + : Promise.all( context.repos.map(r => createRepositoryQuickPickItem(r, r.id === active?.id, { branch: true, @@ -1455,16 +1535,15 @@ export async function* pickRepositoryStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; - - void RepositoryActions.reveal(quickpick.activeItems[0].item.path, context.associatedView, { + onDidPressKey: (_quickpick, _key, { item }) => { + void RepositoryActions.reveal(item.path, context.associatedView, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } @@ -1501,7 +1580,7 @@ export async function* pickRepositoriesStep< items: context.repos.length === 0 ? [createDirectiveQuickPickItem(Directive.Cancel)] - : await Promise.all( + : Promise.all( context.repos.map(repo => createRepositoryQuickPickItem( repo, @@ -1525,16 +1604,15 @@ export async function* pickRepositoriesStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; - - void RepositoryActions.reveal(quickpick.activeItems[0].item.path, context.associatedView, { + onDidPressKey: (_quickpick, _key, { item }) => { + void RepositoryActions.reveal(item.path, context.associatedView, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } @@ -1570,7 +1648,7 @@ export function* pickStashStep< ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] : [ ...map(stash.commits.values(), commit => - createCommitQuickPickItem( + createStashQuickPickItem( commit, picked != null && (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), @@ -1588,17 +1666,75 @@ export function* pickStashStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await StashActions.showDetailsView(quickpick.activeItems[0].item, { pin: false, preserveFocus: true }); + onDidPressKey: async (_quickpick, _key, { item }) => { + await StashActions.showDetailsView(item, { pin: false, preserveFocus: true }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickTagsStep< +export function* pickStashesStep< + State extends PartialStepState & { repo: Repository }, + Context extends { repos: Repository[]; title: string }, +>( + state: State, + context: Context, + { + ignoreFocusOut, + stash, + picked, + placeholder, + titleContext, + }: { + ignoreFocusOut?: boolean; + stash: GitStash | undefined; + picked: string | string[] | undefined; + placeholder: string | ((context: Context, stash: GitStash | undefined) => string); + titleContext?: string; + }, +): StepResultGenerator { + const step = createPickStep>({ + title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), + multiselect: true, + placeholder: typeof placeholder === 'string' ? placeholder : placeholder(context, stash), + ignoreFocusOut: ignoreFocusOut, + matchOnDescription: true, + matchOnDetail: true, + items: + stash == null + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : [ + ...map(stash.commits.values(), commit => + createStashQuickPickItem( + commit, + picked != null && + (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), + { + buttons: [ShowDetailsViewQuickInputButton], + compact: true, + icon: true, + }, + ), + ), + ], + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === ShowDetailsViewQuickInputButton) { + void StashActions.showDetailsView(item, { pin: false, preserveFocus: true }); + } + }, + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (_quickpick, _key, { item }) => { + await StashActions.showDetailsView(item, { pin: false, preserveFocus: true }); + }, + }); + + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; +} + +export function* pickTagsStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; showTags?: boolean; title: string }, >( @@ -1615,22 +1751,23 @@ export async function* pickTagsStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const tags = await getTags(state.repo, { +): StepResultGenerator { + const items = getTags(state.repo, { buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, - }); + }).then(tags => + tags.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : tags, + ); const step = createPickStep({ - multiselect: tags.length !== 0, + multiselect: true, title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: tags.length === 0 ? `No tags found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No tags found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - tags.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : tags, + items: items, onDidClickItemButton: (quickpick, button, { item }) => { if (button === RevealInSideBarQuickInputButton) { void TagActions.reveal(item, { @@ -1641,55 +1778,58 @@ export async function* pickTagsStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await TagActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await TagActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* pickWorktreeStep< +export function* pickWorktreeStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string; worktrees?: GitWorktree[] }, >( state: State, context: Context, { + excludeOpened, filter, includeStatus, picked, placeholder, titleContext, }: { + excludeOpened?: boolean; filter?: (b: GitWorktree) => boolean; includeStatus?: boolean; picked?: string | string[]; placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const worktrees = await getWorktrees(context.worktrees ?? state.repo, { +): StepResultGenerator { + const items = getWorktrees(context.worktrees ?? state.repo, { buttons: [OpenInNewWindowQuickInputButton, RevealInSideBarQuickInputButton], + excludeOpened: excludeOpened, filter: filter, includeStatus: includeStatus, picked: picked, - }); + }).then(worktrees => + worktrees.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : worktrees, + ); const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: worktrees.length === 0 ? `No worktrees found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No worktrees found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - worktrees.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : worktrees, + items: items, onDidClickItemButton: (quickpick, button, { item }) => { switch (button) { case OpenInNewWindowQuickInputButton: @@ -1701,21 +1841,20 @@ export async function* pickWorktreeStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await WorktreeActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await WorktreeActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickWorktreesStep< +export function* pickWorktreesStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string; worktrees?: GitWorktree[] }, >( @@ -1734,23 +1873,24 @@ export async function* pickWorktreesStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const worktrees = await getWorktrees(context.worktrees ?? state.repo, { +): StepResultGenerator { + const items = getWorktrees(context.worktrees ?? state.repo, { buttons: [OpenInNewWindowQuickInputButton, RevealInSideBarQuickInputButton], filter: filter, includeStatus: includeStatus, picked: picked, - }); + }).then(worktrees => + worktrees.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : worktrees, + ); const step = createPickStep({ - multiselect: worktrees.length !== 0, + multiselect: true, title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: worktrees.length === 0 ? `No worktrees found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No worktrees found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - worktrees.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : worktrees, + items: items, onDidClickItemButton: (quickpick, button, { item }) => { switch (button) { case OpenInNewWindowQuickInputButton: @@ -1762,27 +1902,26 @@ export async function* pickWorktreesStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await WorktreeActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await WorktreeActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* showCommitOrStashStep< +export function* showCommitOrStashStep< State extends PartialStepState & { repo: Repository; reference: GitCommit | GitStashCommit }, Context extends { repos: Repository[]; title: string }, >( state: State, context: Context, -): AsyncStepResultGenerator { +): StepResultGenerator { const step: QuickPickStep = createPickStep({ title: appendReposToTitle( @@ -1795,7 +1934,7 @@ export async function* showCommitOrStashStep< ), placeholder: getReferenceLabel(state.reference, { capitalize: true, icon: false }), ignoreFocusOut: true, - items: await getShowCommitOrStashStepItems(state), + items: getShowCommitOrStashStepItems(state), // additionalButtons: [ShowDetailsView, RevealInSideBar], onDidClickItemButton: (quickpick, button, _item) => { switch (button) { @@ -1827,12 +1966,11 @@ export async function* showCommitOrStashStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - await quickpick.activeItems[0].onDidPressKey(key); + onDidPressKey: async (_quickpick, key, item) => { + await item.onDidPressKey(key); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0] : StepResultBreak; } @@ -1858,14 +1996,22 @@ async function getShowCommitOrStashStepItems< reference: state.reference, }, }), - new GitCommandQuickPickItem('Delete Stash...', { + new GitCommandQuickPickItem('Rename Stash...', { command: 'stash', state: { - subcommand: 'drop', + subcommand: 'rename', repo: state.repo, reference: state.reference, }, }), + new GitCommandQuickPickItem('Drop Stash...', { + command: 'stash', + state: { + subcommand: 'drop', + repo: state.repo, + references: [state.reference], + }, + }), createQuickPickSeparator(), new CommitCopyMessageQuickPickItem(state.reference), @@ -1874,7 +2020,7 @@ async function getShowCommitOrStashStepItems< const remotes = await Container.instance.git.getRemotesWithProviders(state.repo.path, { sort: true }); if (remotes?.length) { items.push( - createQuickPickSeparator(GitRemote.getHighlanderProviderName(remotes) ?? 'Remote'), + createQuickPickSeparator(getHighlanderProviderName(remotes) ?? 'Remote'), new OpenRemoteResourceCommandQuickPickItem(remotes, { type: RemoteResourceType.Commit, sha: state.reference.sha, @@ -1891,8 +2037,7 @@ async function getShowCommitOrStashStepItems< const branch = await Container.instance.git.getBranch(state.repo.path); const [branches, published] = await Promise.all([ branch != null - ? Container.instance.git.getCommitBranches(state.repo.path, state.reference.ref, { - branch: branch.name, + ? Container.instance.git.getCommitBranches(state.repo.path, state.reference.ref, branch.name, { commitDate: isCommit(state.reference) ? state.reference.committer.date : undefined, }) : undefined, @@ -2109,24 +2254,23 @@ export function* showCommitOrStashFilesStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - await quickpick.activeItems[0].onDidPressKey(key); + onDidPressKey: async (_quickpick, key, item) => { + await item.onDidPressKey(key); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0] : StepResultBreak; } -export async function* showCommitOrStashFileStep< +export function* showCommitOrStashFileStep< State extends PartialStepState & { repo: Repository; reference: GitCommit | GitStashCommit; fileName: string; }, Context extends { repos: Repository[]; title: string }, ->(state: State, context: Context): AsyncStepResultGenerator { +>(state: State, context: Context): StepResultGenerator { const step: QuickPickStep = createPickStep({ title: appendReposToTitle( getReferenceLabel(state.reference, { @@ -2143,7 +2287,7 @@ export async function* showCommitOrStashFileStep< icon: false, })}`, ignoreFocusOut: true, - items: await getShowCommitOrStashFileStepItems(state), + items: getShowCommitOrStashFileStepItems(state), matchOnDescription: true, // additionalButtons: [ShowDetailsView, RevealInSideBar], onDidClickItemButton: (quickpick, button, _item) => { @@ -2176,12 +2320,11 @@ export async function* showCommitOrStashFileStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - await quickpick.activeItems[0].onDidPressKey(key); + onDidPressKey: async (_quickpick, key, item) => { + await item.onDidPressKey(key); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0] : StepResultBreak; } @@ -2215,7 +2358,7 @@ async function getShowCommitOrStashFileStepItems< const remotes = await Container.instance.git.getRemotesWithProviders(state.repo.path, { sort: true }); if (remotes?.length) { items.push( - createQuickPickSeparator(GitRemote.getHighlanderProviderName(remotes) ?? 'Remote'), + createQuickPickSeparator(getHighlanderProviderName(remotes) ?? 'Remote'), new OpenRemoteResourceCommandQuickPickItem(remotes, { type: RemoteResourceType.Revision, fileName: state.fileName, @@ -2295,16 +2438,15 @@ export function* showRepositoryStatusStep< const working = context.status.getFormattedDiffStatus({ expand: true, separator: ', ' }); const step: QuickPickStep = createPickStep({ title: appendReposToTitle(context.title, state, context), - placeholder: `${upstream ? `${upstream}, ${working}` : working}`, //'Changes to be committed', + placeholder: upstream ? `${upstream}, ${working}` : working, //'Changes to be committed', ignoreFocusOut: true, items: getShowRepositoryStatusStepItems(state, context), keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - await quickpick.activeItems[0].onDidPressKey(key); + onDidPressKey: async (quickpick, key, item) => { + await item.onDidPressKey(key); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0] : StepResultBreak; } @@ -2336,28 +2478,28 @@ function getShowRepositoryStatusStepItems< if (context.status.state.ahead === 0 && context.status.state.behind === 0) { items.push( createDirectiveQuickPickItem(Directive.Noop, true, { - label: `$(git-branch) ${context.status.branch} is up to date with $(git-branch) ${context.status.upstream}`, + label: `$(git-branch) ${context.status.branch} is up to date with $(git-branch) ${context.status.upstream?.name}`, detail: workingTreeStatus, }), ); } else if (context.status.state.ahead !== 0 && context.status.state.behind !== 0) { items.push( createDirectiveQuickPickItem(Directive.Noop, true, { - label: `$(git-branch) ${context.status.branch} has diverged from $(git-branch) ${context.status.upstream}`, + label: `$(git-branch) ${context.status.branch} has diverged from $(git-branch) ${context.status.upstream?.name}`, detail: workingTreeStatus, }), ); } else if (context.status.state.ahead !== 0) { items.push( createDirectiveQuickPickItem(Directive.Noop, true, { - label: `$(git-branch) ${context.status.branch} is ahead of $(git-branch) ${context.status.upstream}`, + label: `$(git-branch) ${context.status.branch} is ahead of $(git-branch) ${context.status.upstream?.name}`, detail: workingTreeStatus, }), ); } else if (context.status.state.behind !== 0) { items.push( createDirectiveQuickPickItem(Directive.Noop, true, { - label: `$(git-branch) ${context.status.branch} is behind $(git-branch) ${context.status.upstream}`, + label: `$(git-branch) ${context.status.branch} is behind $(git-branch) ${context.status.upstream?.name}`, detail: workingTreeStatus, }), ); @@ -2372,7 +2514,7 @@ function getShowRepositoryStatusStepItems< state: { repo: state.repo, reference: createReference( - createRevisionRange(context.status.ref, context.status.upstream), + createRevisionRange(context.status.ref, context.status.upstream?.name), state.repo.path, ), }, @@ -2390,7 +2532,7 @@ function getShowRepositoryStatusStepItems< state: { repo: state.repo, reference: createReference( - createRevisionRange(context.status.upstream, context.status.ref), + createRevisionRange(context.status.upstream?.name, context.status.ref), state.repo.path, ), }, @@ -2422,35 +2564,23 @@ function getShowRepositoryStatusStepItems< } if (computed.staged > 0) { - items.push( - new OpenChangedFilesCommandQuickPickItem(computed.stagedAddsAndChanges, { - label: '$(files) Open Staged Files', - }), - ); + items.push(new OpenChangedFilesCommandQuickPickItem(computed.stagedAddsAndChanges, 'Open Staged Files')); items.push( - new OpenOnlyChangedFilesCommandQuickPickItem(computed.stagedAddsAndChanges, { - label: '$(files) Open Only Staged Files', - }), + new OpenOnlyChangedFilesCommandQuickPickItem(computed.stagedAddsAndChanges, 'Open Only Staged Files'), ); } if (computed.unstaged > 0) { - items.push( - new OpenChangedFilesCommandQuickPickItem(computed.unstagedAddsAndChanges, { - label: '$(files) Open Unstaged Files', - }), - ); + items.push(new OpenChangedFilesCommandQuickPickItem(computed.unstagedAddsAndChanges, 'Open Unstaged Files')); items.push( - new OpenOnlyChangedFilesCommandQuickPickItem(computed.unstagedAddsAndChanges, { - label: '$(files) Open Only Unstaged Files', - }), + new OpenOnlyChangedFilesCommandQuickPickItem(computed.unstagedAddsAndChanges, 'Open Only Unstaged Files'), ); } if (context.status.files.length) { - items.push(new CommandQuickPickItem('$(x) Close Unchanged Files', Commands.CloseUnchangedFiles)); + items.push(new CommandQuickPickItem('Close Unchanged Files', new ThemeIcon('x'), Commands.CloseUnchangedFiles)); } return items; @@ -2466,29 +2596,46 @@ export async function* ensureAccessStep< const directives: DirectiveQuickPickItem[] = []; let placeholder: string; if (access.subscription.current.account?.verified === false) { - directives.push(createDirectiveQuickPickItem(Directive.RequiresVerification, true)); + directives.push( + createDirectiveQuickPickItem(Directive.RequiresVerification, true), + createQuickPickSeparator(), + createDirectiveQuickPickItem(Directive.Cancel), + ); placeholder = 'You must verify your email before you can continue'; } else { if (access.subscription.required == null) return undefined; - placeholder = '✨ Requires a trial or paid plan for use on privately hosted repos'; + placeholder = 'Pro feature — requires a trial or paid plan for use on privately-hosted repos'; if (isSubscriptionPaidPlan(access.subscription.required) && access.subscription.current.account != null) { - placeholder = '✨ Requires a paid plan for use on privately hosted repos'; - directives.push(createDirectiveQuickPickItem(Directive.RequiresPaidSubscription, true)); + placeholder = 'Pro feature — requires a paid plan for use on privately-hosted repos'; + directives.push( + createDirectiveQuickPickItem(Directive.RequiresPaidSubscription, true), + createQuickPickSeparator(), + createDirectiveQuickPickItem(Directive.Cancel), + ); } else if ( access.subscription.current.account == null && !isSubscriptionPreviewTrialExpired(access.subscription.current) ) { - directives.push(createDirectiveQuickPickItem(Directive.StartPreviewTrial, true)); + directives.push( + createDirectiveQuickPickItem(Directive.StartPreview, true), + createQuickPickSeparator(), + createDirectiveQuickPickItem(Directive.Cancel), + ); } else { - directives.push(createDirectiveQuickPickItem(Directive.ExtendTrial)); + directives.push( + createDirectiveQuickPickItem(Directive.StartProTrial, true), + createDirectiveQuickPickItem(Directive.SignIn), + createQuickPickSeparator(), + createDirectiveQuickPickItem(Directive.Cancel), + ); } } const step = createPickStep({ title: appendReposToTitle(context.title, state, context), placeholder: placeholder, - items: [...directives, createDirectiveQuickPickItem(Directive.Cancel)], + items: directives, }); const selection: StepSelection = yield step; diff --git a/src/commands/quickCommand.ts b/src/commands/quickCommand.ts index f2f655eeffe60..b94cbeb7af0cc 100644 --- a/src/commands/quickCommand.ts +++ b/src/commands/quickCommand.ts @@ -1,14 +1,14 @@ import type { InputBox, QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; -import type { Keys } from '../constants'; +import type { Commands, Keys } from '../constants'; import type { Container } from '../container'; +import { createQuickPickSeparator } from '../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; import { createDirectiveQuickPickItem, Directive, isDirective } from '../quickpicks/items/directive'; import { configuration } from '../system/configuration'; -export * from './quickCommand.buttons'; -export * from './quickCommand.steps'; - export interface CustomStep { + type: 'custom'; + ignoreFocusOut?: boolean; show(step: CustomStep): Promise>; @@ -17,67 +17,87 @@ export interface CustomStep { export function isCustomStep( step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResultBreak, ): step is CustomStep { - return typeof step === 'object' && (step as CustomStep).show != null; + return typeof step === 'object' && 'type' in step && step.type === 'custom'; } -export interface QuickInputStep { +export interface QuickInputStep { + type: 'input'; + additionalButtons?: QuickInputButton[]; buttons?: QuickInputButton[]; + disallowBack?: boolean; ignoreFocusOut?: boolean; + isConfirmationStep?: boolean; keys?: StepNavigationKeys[]; placeholder?: string; prompt?: string; title?: string; - value?: string; + value?: T; onDidClickButton?(input: InputBox, button: QuickInputButton): boolean | void | Promise; onDidPressKey?(quickpick: InputBox, key: Keys): void | Promise; - validate?(value: string | undefined): [boolean, string | undefined] | Promise<[boolean, string | undefined]>; + validate?(value: T | undefined): [boolean, T | undefined] | Promise<[boolean, T | undefined]>; } export function isQuickInputStep( - step: QuickPickStep | QuickInputStep | typeof StepResultBreak, + step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResultBreak, ): step is QuickInputStep { - return typeof step === 'object' && (step as QuickPickStep).items == null && (step as CustomStep).show == null; + return typeof step === 'object' && 'type' in step && step.type === 'input'; } export interface QuickPickStep { + type: 'pick'; + additionalButtons?: QuickInputButton[]; allowEmpty?: boolean; buttons?: QuickInputButton[]; + disallowBack?: boolean; ignoreFocusOut?: boolean; - items: (DirectiveQuickPickItem | T)[]; // | DirectiveQuickPickItem[]; + isConfirmationStep?: boolean; + items: (DirectiveQuickPickItem | T)[] | Promise<(DirectiveQuickPickItem | T)[]>; keys?: StepNavigationKeys[]; matchOnDescription?: boolean; matchOnDetail?: boolean; multiselect?: boolean; - placeholder?: string; + placeholder?: string | ((count: number) => string); selectedItems?: QuickPickItem[]; title?: string; value?: string; selectValueWhenShown?: boolean; - onDidAccept?(quickpick: QuickPick): boolean | Promise; - onDidChangeValue?(quickpick: QuickPick): boolean | Promise; - onDidClickButton?(quickpick: QuickPick, button: QuickInputButton): boolean | void | Promise; + frozen?: boolean; + + onDidAccept?(quickpick: QuickPick): boolean | Promise; + onDidChangeValue?(quickpick: QuickPick): boolean | Promise; + onDidChangeSelection?(quickpick: QuickPick, selection: readonly T[]): void; + onDidClickButton?( + quickpick: QuickPick, + button: QuickInputButton, + ): boolean | void | Promise; /** * @returns `true` if the current item should be selected */ onDidClickItemButton?( - quickpick: QuickPick, + quickpick: QuickPick, button: QuickInputButton, item: T, ): boolean | void | Promise; - onDidLoadMore?(quickpick: QuickPick): (DirectiveQuickPickItem | T)[] | Promise<(DirectiveQuickPickItem | T)[]>; - onDidPressKey?(quickpick: QuickPick, key: Keys): void | Promise; - onValidateValue?(quickpick: QuickPick, value: string, items: T[]): boolean | Promise; + onDidLoadMore?( + quickpick: QuickPick, + ): (DirectiveQuickPickItem | T)[] | Promise<(DirectiveQuickPickItem | T)[]>; + onDidPressKey?(quickpick: QuickPick, key: Keys, item: T): void | Promise; + onValidateValue?( + quickpick: QuickPick, + value: string, + items: T[], + ): boolean | Promise; validate?(selection: T[]): boolean; } export function isQuickPickStep( step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResultBreak, ): step is QuickPickStep { - return typeof step === 'object' && (step as QuickPickStep).items != null; + return typeof step === 'object' && 'type' in step && step.type === 'pick'; } export type StepGenerator = @@ -87,10 +107,10 @@ export type StepGenerator = export type StepItemType = T extends CustomStep ? U : T extends QuickPickStep - ? U[] - : T extends QuickInputStep - ? string - : never; + ? U[] + : T extends QuickInputStep + ? string + : never; export type StepNavigationKeys = Exclude; export const StepResultBreak = Symbol('BreakStep'); export type StepResult = typeof StepResultBreak | T; @@ -109,12 +129,12 @@ export type AsyncStepResultGenerator = AsyncGenerator< // | Generator, any | undefined> // | AsyncGenerator, any | undefined>; export type StepSelection = T extends CustomStep - ? U | Directive + ? Exclude | Directive : T extends QuickPickStep - ? U[] | Directive - : T extends QuickInputStep - ? string | Directive - : never; + ? Exclude[] | Directive + : T extends QuickInputStep + ? string | Directive + : never; export type PartialStepState = Partial & { counter: number; confirm?: boolean; startingStep?: number }; export type StepState> = T & { counter: number; confirm?: boolean; startingStep?: number }; @@ -132,13 +152,13 @@ export abstract class QuickCommand implements QuickPickItem { public readonly key: string, public readonly label: string, public readonly title: string, - options: { + options?: { description?: string; detail?: string; - } = {}, + }, ) { - this.description = options.description; - this.detail = options.detail; + this.description = options?.description; + this.detail = options?.detail; } get canConfirm(): boolean { @@ -278,7 +298,7 @@ export async function canInputStepContinue( export function canPickStepContinue( step: T, state: PartialStepState, - selection: StepItemType | Directive, + selection: Directive | StepItemType, ): selection is StepItemType { if (!canStepContinue(step, state, selection)) return false; @@ -290,12 +310,13 @@ export function canPickStepContinue( return false; } -export function canStepContinue( - step: T, +export function canStepContinue( + _step: T, state: PartialStepState, result: Directive | StepItemType, ): result is StepItemType { if (result == null) return false; + if (isDirective(result)) { switch (result) { case Directive.Back: @@ -307,11 +328,6 @@ export function canStepContinue( case Directive.Cancel: endSteps(state); break; - // case Directive.Noop: - // case Directive.RequiresVerification: - // case Directive.RequiresFreeSubscription: - // case Directive.RequiresProSubscription: - // break; } return false; } @@ -324,32 +340,61 @@ export function createConfirmStep> = {}, + options?: Partial>, ): QuickPickStep { return createPickStep({ + isConfirmationStep: true, placeholder: `Confirm ${context.title}`, title: title, ignoreFocusOut: true, - items: [...confirmations, cancel ?? createDirectiveQuickPickItem(Directive.Cancel)], + items: [ + ...confirmations, + createQuickPickSeparator(), + cancel ?? createDirectiveQuickPickItem(Directive.Cancel), + ], selectedItems: [confirmations.find(c => c.picked) ?? confirmations[0]], ...options, }); } -export function createInputStep(step: QuickInputStep): QuickInputStep { +export function createInputStep(step: Optional, 'type'>): QuickInputStep { // Make sure any input steps won't close on focus loss - step.ignoreFocusOut = true; - return step; + return { type: 'input', ...step, ignoreFocusOut: true }; } -export function createPickStep(step: QuickPickStep): QuickPickStep { - return step; +export function createPickStep(step: Optional, 'type'>): QuickPickStep { + return { type: 'pick', ...step }; } -export function createCustomStep(step: CustomStep): CustomStep { - return step; +export function createCustomStep(step: Optional, 'type'>): CustomStep { + return { type: 'custom', ...step }; } export function endSteps(state: PartialStepState) { state.counter = -1; } + +export function freezeStep(step: QuickPickStep, quickpick: QuickPick): Disposable { + quickpick.enabled = false; + step.frozen = true; + return { + [Symbol.dispose]: () => { + step.frozen = false; + quickpick.enabled = true; + quickpick.show(); + }, + }; +} + +export interface CrossCommandReference { + command: Commands; + args?: T; +} + +export function isCrossCommandReference(value: any): value is CrossCommandReference { + return value.command != null; +} + +export function createCrossCommandReference(command: Commands, args: T): CrossCommandReference { + return { command: command, args: args }; +} diff --git a/src/commands/remoteProviders.ts b/src/commands/remoteProviders.ts index edaa1be64d988..338e242c295b6 100644 --- a/src/commands/remoteProviders.ts +++ b/src/commands/remoteProviders.ts @@ -1,9 +1,11 @@ import { Commands } from '../constants'; import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; -import { GitRemote } from '../git/models/remote'; +import type { GitRemote } from '../git/models/remote'; +import { isRemote } from '../git/models/remote'; import type { Repository } from '../git/models/repository'; -import type { RichRemoteProvider } from '../git/remotes/richRemoteProvider'; +import type { RemoteProvider } from '../git/remotes/remoteProvider'; +import { isSupportedCloudIntegrationId } from '../plus/integrations/authentication/models'; import { showRepositoryPicker } from '../quickpicks/repositoryPicker'; import { command } from '../system/command'; import { first } from '../system/iterable'; @@ -21,7 +23,7 @@ export class ConnectRemoteProviderCommand extends Command { static getMarkdownCommandArgs(remote: GitRemote): string; static getMarkdownCommandArgs(argsOrRemote: ConnectRemoteProviderCommandArgs | GitRemote): string { let args: ConnectRemoteProviderCommandArgs | GitCommit; - if (GitRemote.is(argsOrRemote)) { + if (isRemote(argsOrRemote)) { args = { remote: argsOrRemote.name, repoPath: argsOrRemote.repoPath, @@ -46,15 +48,15 @@ export class ConnectRemoteProviderCommand extends Command { } async execute(args?: ConnectRemoteProviderCommandArgs): Promise { - let remote: GitRemote | undefined; + let remote: GitRemote | undefined; let remotes: GitRemote[] | undefined; let repoPath; if (args?.repoPath == null) { - const repos = new Map>(); + const repos = new Map>(); for (const repo of this.container.git.openRepositories) { - const remote = await repo.getRichRemote(); - if (remote?.provider != null && !(await remote.provider.isConnected())) { + const remote = await repo.getBestRemoteWithIntegration({ includeDisconnected: true }); + if (remote?.provider != null) { repos.set(repo, remote); } } @@ -70,25 +72,49 @@ export class ConnectRemoteProviderCommand extends Command { 'Choose which repository to connect to the remote provider', [...repos.keys()], ); - if (pick?.item == null) return undefined; + if (pick == null) return undefined; - repoPath = pick.repoPath; - remote = repos.get(pick.item)!; + repoPath = pick.path; + remote = repos.get(pick)!; } } else if (args?.remote == null) { repoPath = args.repoPath; - remote = await this.container.git.getBestRemoteWithRichProvider(repoPath, { includeDisconnected: true }); + remote = await this.container.git.getBestRemoteWithIntegration(repoPath, { includeDisconnected: true }); if (remote == null) return false; } else { repoPath = args.repoPath; remotes = await this.container.git.getRemotesWithProviders(repoPath); - remote = remotes.find(r => r.name === args.remote) as GitRemote | undefined; - if (!remote?.hasRichIntegration()) return false; + remote = remotes.find(r => r.name === args.remote) as GitRemote | undefined; + if (!remote?.hasIntegration()) return false; + } + + const integration = await this.container.integrations.getByRemote(remote); + if (integration == null) return false; + + // Some integrations does not require managmement of Cloud Integrations (e.g. GitHub that can take a built-in VS Code session), + // therefore we try to connect them right away. + // Only if our attempt fails, we fall to manageCloudIntegrations flow. + let connected = await integration.connect(); + + if (!connected) { + if (isSupportedCloudIntegrationId(integration.id)) { + await this.container.integrations.manageCloudIntegrations( + { integrationId: integration.id, skipIfConnected: true }, + { + source: 'remoteProvider', + detail: { + action: 'connect', + integration: integration.id, + }, + }, + ); + } + + connected = await integration.connect(); } - const connected = await remote.provider.connect(); if ( connected && !(remotes ?? (await this.container.git.getRemotesWithProviders(repoPath))).some(r => r.default) @@ -110,7 +136,7 @@ export class DisconnectRemoteProviderCommand extends Command { static getMarkdownCommandArgs(remote: GitRemote): string; static getMarkdownCommandArgs(argsOrRemote: DisconnectRemoteProviderCommandArgs | GitRemote): string { let args: DisconnectRemoteProviderCommandArgs | GitCommit; - if (GitRemote.is(argsOrRemote)) { + if (isRemote(argsOrRemote)) { args = { remote: argsOrRemote.name, repoPath: argsOrRemote.repoPath, @@ -138,13 +164,13 @@ export class DisconnectRemoteProviderCommand extends Command { } async execute(args?: DisconnectRemoteProviderCommandArgs): Promise { - let remote: GitRemote | undefined; + let remote: GitRemote | undefined; let repoPath; if (args?.repoPath == null) { - const repos = new Map>(); + const repos = new Map>(); for (const repo of this.container.git.openRepositories) { - const remote = await repo.getRichRemote(true); + const remote = await repo.getBestRemoteWithIntegration({ includeDisconnected: false }); if (remote != null) { repos.set(repo, remote); } @@ -161,25 +187,24 @@ export class DisconnectRemoteProviderCommand extends Command { 'Choose which repository to disconnect from the remote provider', [...repos.keys()], ); - if (pick?.item == null) return undefined; + if (pick == null) return undefined; - repoPath = pick.repoPath; - remote = repos.get(pick.item)!; + repoPath = pick.path; + remote = repos.get(pick)!; } } else if (args?.remote == null) { repoPath = args.repoPath; - remote = await this.container.git.getBestRemoteWithRichProvider(repoPath, { includeDisconnected: false }); + remote = await this.container.git.getBestRemoteWithIntegration(repoPath, { includeDisconnected: false }); if (remote == null) return undefined; } else { repoPath = args.repoPath; - remote = (await this.container.git.getRemotesWithProviders(repoPath)).find(r => r.name === args.remote) as - | GitRemote - | undefined; - if (!remote?.hasRichIntegration()) return undefined; + remote = (await this.container.git.getRemotesWithProviders(repoPath)).find(r => r.name === args.remote); + if (!remote?.hasIntegration()) return undefined; } - return remote.provider.disconnect(); + const integration = await this.container.integrations.getByRemote(remote); + return integration?.disconnect(); } } diff --git a/src/commands/resets.ts b/src/commands/resets.ts index 197da78755fed..dafd45714fbe3 100644 --- a/src/commands/resets.ts +++ b/src/commands/resets.ts @@ -1,51 +1,205 @@ -import { ConfigurationTarget } from 'vscode'; +import type { MessageItem } from 'vscode'; +import { ConfigurationTarget, window } from 'vscode'; import { resetAvatarCache } from '../avatars'; import { Commands } from '../constants'; import type { Container } from '../container'; +import type { QuickPickItemOfT } from '../quickpicks/items/common'; +import { createQuickPickSeparator } from '../quickpicks/items/common'; import { command } from '../system/command'; import { configuration } from '../system/configuration'; import { Command } from './base'; +const resetTypes = [ + 'ai', + 'avatars', + 'integrations', + 'plus', + 'repositoryAccess', + 'suppressedWarnings', + 'usageTracking', + 'workspace', +] as const; +type ResetType = 'all' | (typeof resetTypes)[number]; + @command() -export class ResetAIKeyCommand extends Command { +export class ResetCommand extends Command { constructor(private readonly container: Container) { - super(Commands.ResetAIKey); + super(Commands.Reset); } + async execute() { + type ResetQuickPickItem = QuickPickItemOfT; - execute() { - this.container.ai.reset(); - } -} + const items: ResetQuickPickItem[] = [ + { + label: 'AI Keys...', + detail: 'Clears any locally stored AI keys', + item: 'ai', + }, + { + label: 'Avatars...', + detail: 'Clears the stored avatar cache', + item: 'avatars', + }, + { + label: 'Integrations (Authentication)...', + detail: 'Clears any locally stored authentication for integrations', + item: 'integrations', + }, + { + label: 'Repository Access...', + detail: 'Clears the stored repository access cache', + item: 'repositoryAccess', + }, + { + label: 'Suppressed Warnings...', + detail: 'Clears any suppressed warnings, e.g. messages with "Don\'t Show Again" options', + item: 'suppressedWarnings', + }, + { + label: 'Usage Tracking...', + detail: 'Clears any locally tracked usage, typically used for first time experience', + item: 'usageTracking', + }, + { + label: 'Workspace Storage...', + detail: 'Clears stored data associated with the current workspace', + item: 'workspace', + }, + createQuickPickSeparator(), + { + label: 'Everything...', + description: ' — \u00a0be very careful with this!', + detail: 'Clears ALL locally stored data; ALL GitLens state will be LOST', + item: 'all', + }, + ]; -@command() -export class ResetAvatarCacheCommand extends Command { - constructor(private readonly container: Container) { - super(Commands.ResetAvatarCache); - } + if (this.container.debugging) { + items.splice( + 0, + 0, + { + label: 'Subscription Reset', + detail: 'Resets the stored subscription', + item: 'plus', + }, + createQuickPickSeparator(), + ); + } - execute() { - resetAvatarCache('all'); - } -} + // create a quick pick with options to clear all the different resets that GitLens supports + const pick = await window.showQuickPick(items, { + title: 'Reset Stored Data', + placeHolder: 'Choose which data to reset, will be prompted to confirm', + }); -@command() -export class ResetSuppressedWarningsCommand extends Command { - constructor(private readonly container: Container) { - super(Commands.ResetSuppressedWarnings); + if (pick?.item == null) return; + if (pick.item === 'plus' && !this.container.debugging) return; + + const confirm: MessageItem = { title: 'Reset' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + + let confirmationMessage: string | undefined; + switch (pick?.item) { + case 'all': + confirmationMessage = 'Are you sure you want to reset EVERYTHING?'; + confirm.title = 'Reset Everything'; + break; + case 'ai': + confirmationMessage = 'Are you sure you want to reset all of the stored AI keys?'; + confirm.title = 'Reset AI Keys'; + break; + case 'avatars': + confirmationMessage = 'Are you sure you want to reset the avatar cache?'; + confirm.title = 'Reset Avatars'; + break; + case 'integrations': + confirmationMessage = 'Are you sure you want to reset all of the stored integrations?'; + confirm.title = 'Reset Integrations'; + break; + case 'repositoryAccess': + confirmationMessage = 'Are you sure you want to reset the repository access cache?'; + confirm.title = 'Reset Repository Access'; + break; + case 'suppressedWarnings': + confirmationMessage = 'Are you sure you want to reset all of the suppressed warnings?'; + confirm.title = 'Reset Suppressed Warnings'; + break; + case 'usageTracking': + confirmationMessage = 'Are you sure you want to reset all of the usage tracking?'; + confirm.title = 'Reset Usage Tracking'; + break; + case 'workspace': + confirmationMessage = 'Are you sure you want to reset the stored data for the current workspace?'; + confirm.title = 'Reset Workspace Storage'; + break; + } + + if (confirmationMessage != null) { + const result = await window.showWarningMessage( + `This is IRREVERSIBLE!\n${confirmationMessage}`, + { modal: true }, + confirm, + cancel, + ); + if (result !== confirm) return; + } + + await this.reset(pick.item); } - async execute() { - await configuration.update('advanced.messages', undefined, ConfigurationTarget.Global); + private async reset(reset: ResetType) { + switch (reset) { + case 'all': + for (const r of resetTypes) { + await this.reset(r); + } + + await this.container.storage.reset(); + break; + + case 'ai': + await (await this.container.ai)?.reset(true); + break; + + case 'avatars': + resetAvatarCache('all'); + break; + + case 'integrations': + await this.container.integrations.reset(); + break; + + case 'plus': + await this.container.subscription.logout(true, undefined); + break; + + case 'repositoryAccess': + await this.container.git.clearAllRepoVisibilityCaches(); + break; + + case 'suppressedWarnings': + await configuration.update('advanced.messages', undefined, ConfigurationTarget.Global); + break; + + case 'usageTracking': + await this.container.usage.reset(); + break; + + case 'workspace': + await this.container.storage.resetWorkspace(); + break; + } } } @command() -export class ResetTrackedUsageCommand extends Command { +export class ResetAIKeyCommand extends Command { constructor(private readonly container: Container) { - super(Commands.ResetTrackedUsage); + super(Commands.ResetAIKey); } async execute() { - await this.container.usage.reset(); + await (await this.container.ai)?.reset(); } } diff --git a/src/commands/showCommitsInView.ts b/src/commands/showCommitsInView.ts index 562eb621561cf..c23e07cbedf86 100644 --- a/src/commands/showCommitsInView.ts +++ b/src/commands/showCommitsInView.ts @@ -2,16 +2,13 @@ import type { TextEditor, Uri } from 'vscode'; import { Commands } from '../constants'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; -import { showDetailsView } from '../git/actions/commit'; import { GitUri } from '../git/gitUri'; -import { createReference } from '../git/models/reference'; import { createSearchQueryForCommits } from '../git/search'; import { showFileNotUnderSourceControlWarningMessage, showGenericErrorMessage } from '../messages'; import { command } from '../system/command'; import { filterMap } from '../system/iterable'; import { Logger } from '../system/logger'; -import type { CommandContext } from './base'; -import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasCommit } from './base'; +import { ActiveEditorCommand, getCommandUri } from './base'; export interface ShowCommitsInViewCommandArgs { refs?: string[]; @@ -24,29 +21,17 @@ export class ShowCommitsInViewCommand extends ActiveEditorCommand { static getMarkdownCommandArgs(args: ShowCommitsInViewCommandArgs): string; static getMarkdownCommandArgs(argsOrSha: ShowCommitsInViewCommandArgs | string, repoPath?: string): string { const args = typeof argsOrSha === 'string' ? { refs: [argsOrSha], repoPath: repoPath } : argsOrSha; - return super.getMarkdownCommandArgsCore(Commands.ShowCommitInView, args); + return super.getMarkdownCommandArgsCore(Commands.ShowCommitsInView, args); } constructor(private readonly container: Container) { - super([Commands.ShowCommitInView, Commands.ShowInDetailsView, Commands.ShowCommitsInView]); - } - - protected override preExecute(context: CommandContext, args?: ShowCommitsInViewCommandArgs) { - if (context.type === 'viewItem') { - args = { ...args }; - if (isCommandContextViewNodeHasCommit(context)) { - args.refs = [context.node.commit.sha]; - args.repoPath = context.node.commit.repoPath; - } - } - - return this.execute(context.editor, context.uri, args); + super(Commands.ShowCommitsInView); } async execute(editor?: TextEditor, uri?: Uri, args?: ShowCommitsInViewCommandArgs) { args = { ...args }; - if (args.refs === undefined) { + if (args.refs == null) { uri = getCommandUri(uri, editor); if (uri == null) return undefined; @@ -80,9 +65,9 @@ export class ShowCommitsInViewCommand extends ActiveEditorCommand { } } - if (args.refs.length === 1) { - return showDetailsView(createReference(args.refs[0], args.repoPath!, { refType: 'revision' })); - } + // if (args.refs.length === 1) { + // return showDetailsView(createReference(args.refs[0], args.repoPath!, { refType: 'revision' })); + // } return executeGitCommand({ command: 'search', diff --git a/src/commands/showQuickCommitFile.ts b/src/commands/showQuickCommitFile.ts index 5e6f8c77254e5..e5b341cc3b1c6 100644 --- a/src/commands/showQuickCommitFile.ts +++ b/src/commands/showQuickCommitFile.ts @@ -7,17 +7,17 @@ import { GitUri } from '../git/gitUri'; import type { GitCommit, GitStashCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; import type { GitLog } from '../git/models/log'; +import { createReference } from '../git/models/reference'; import { showCommitNotFoundWarningMessage, showFileNotUnderSourceControlWarningMessage, showGenericErrorMessage, showLineUncommittedWarningMessage, } from '../messages'; -import { command, executeCommand } from '../system/command'; +import { command } from '../system/command'; import { Logger } from '../system/logger'; import type { CommandContext } from './base'; import { ActiveEditorCachedCommand, getCommandUri, isCommandContextViewNodeHasCommit } from './base'; -import type { ShowCommitsInViewCommandArgs } from './showCommitsInView'; export interface ShowQuickCommitFileCommandArgs { commit?: GitCommit | GitStashCommit; @@ -25,8 +25,6 @@ export interface ShowQuickCommitFileCommandArgs { fileLog?: GitLog; revisionUri?: string; sha?: string; - - inView?: boolean; } @command() @@ -36,13 +34,7 @@ export class ShowQuickCommitFileCommand extends ActiveEditorCachedCommand { } constructor(private readonly container: Container) { - super([ - Commands.ShowQuickCommitFile, - Commands.ShowQuickCommitRevision, - Commands.ShowQuickCommitRevisionInDiffLeft, - Commands.ShowQuickCommitRevisionInDiffRight, - Commands.ShowLineCommitInView, - ]); + super(Commands.ShowQuickCommitFile); } protected override async preExecute(context: CommandContext, args?: ShowQuickCommitFileCommandArgs) { @@ -50,16 +42,6 @@ export class ShowQuickCommitFileCommand extends ActiveEditorCachedCommand { args = { ...args, line: context.line }; } - if (context.command === Commands.ShowLineCommitInView) { - args = { ...args, inView: true }; - } - - if (context.editor != null && context.command.startsWith(Commands.ShowQuickCommitRevision)) { - const gitUri = await GitUri.fromUri(context.editor.document.uri); - - args = { ...args, sha: gitUri.sha }; - } - if (context.type === 'viewItem') { args = { ...args, sha: context.node.uri.sha }; @@ -151,24 +133,50 @@ export class ShowQuickCommitFileCommand extends ActiveEditorCachedCommand { } } - if (args.inView) { - await executeCommand(Commands.ShowCommitsInView, { - refs: [args.commit.sha], - repoPath: args.commit.repoPath, - }); - } else { - await executeGitCommand({ - command: 'show', - state: { - repo: args.commit.repoPath, - reference: args.commit, - fileName: path, - }, - }); - } + await executeGitCommand({ + command: 'show', + state: { + repo: args.commit.repoPath, + reference: args.commit, + fileName: path, + }, + }); } catch (ex) { Logger.error(ex, 'ShowQuickCommitFileDetailsCommand'); void showGenericErrorMessage('Unable to show commit file details'); } } } + +@command() +export class ShowQuickCommitRevisionCommand extends ActiveEditorCachedCommand { + constructor(private readonly container: Container) { + super([ + Commands.ShowQuickCommitRevision, + Commands.ShowQuickCommitRevisionInDiffLeft, + Commands.ShowQuickCommitRevisionInDiffRight, + ]); + } + + async execute(editor?: TextEditor, uri?: Uri) { + uri = getCommandUri(uri, editor); + if (uri == null) return; + + try { + const gitUri = await GitUri.fromUri(uri); + if (gitUri?.sha == null) return; + + await executeGitCommand({ + command: 'show', + state: { + repo: gitUri.repoPath, + reference: createReference(gitUri.sha, gitUri.repoPath!, { refType: 'revision' }), + fileName: gitUri.fsPath, + }, + }); + } catch (ex) { + Logger.error(ex, 'ShowQuickCommitRevisionCommand'); + void showGenericErrorMessage('Unable to show commit details'); + } + } +} diff --git a/src/commands/showView.ts b/src/commands/showView.ts index 4679979d95b22..be20a6276342e 100644 --- a/src/commands/showView.ts +++ b/src/commands/showView.ts @@ -1,5 +1,6 @@ import { Commands } from '../constants'; import type { Container } from '../container'; +import type { GraphWebviewShowingArgs } from '../plus/webviews/graph/registration'; import { command } from '../system/command'; import type { CommandContext } from './base'; import { Command } from './base'; @@ -12,9 +13,11 @@ export class ShowViewCommand extends Command { Commands.ShowCommitDetailsView, Commands.ShowCommitsView, Commands.ShowContributorsView, + Commands.ShowDraftsView, Commands.ShowFileHistoryView, Commands.ShowGraphView, Commands.ShowHomeView, + Commands.ShowAccountView, Commands.ShowLineHistoryView, Commands.ShowRemotesView, Commands.ShowRepositoriesView, @@ -27,11 +30,11 @@ export class ShowViewCommand extends Command { ]); } - protected override preExecute(context: CommandContext, ...args: any[]) { + protected override preExecute(context: CommandContext, ...args: unknown[]) { return this.execute(context, ...args); } - async execute(context: CommandContext, ...args: any[]) { + async execute(context: CommandContext, ...args: unknown[]) { const command = context.command as Commands; switch (command) { case Commands.ShowBranchesView: @@ -42,20 +45,16 @@ export class ShowViewCommand extends Command { return this.container.commitsView.show(); case Commands.ShowContributorsView: return this.container.contributorsView.show(); + case Commands.ShowDraftsView: + return this.container.draftsView.show(); case Commands.ShowFileHistoryView: return this.container.fileHistoryView.show(); case Commands.ShowHomeView: return this.container.homeView.show(); case Commands.ShowAccountView: return this.container.accountView.show(); - case Commands.ShowGraphView: { - let commandArgs = args; - if (context.type === 'scm' && context.scm?.rootUri != null) { - const repo = this.container.git.getRepository(context.scm.rootUri); - commandArgs = repo != null ? [repo, ...args] : args; - } - return this.container.graphView.show(undefined, ...commandArgs); - } + case Commands.ShowGraphView: + return this.container.graphView.show(undefined, ...(args as GraphWebviewShowingArgs)); case Commands.ShowLineHistoryView: return this.container.lineHistoryView.show(); case Commands.ShowRemotesView: diff --git a/src/commands/stashSave.ts b/src/commands/stashSave.ts index e7d556c108335..9ee13bebfb341 100644 --- a/src/commands/stashSave.ts +++ b/src/commands/stashSave.ts @@ -1,6 +1,6 @@ import type { Uri } from 'vscode'; import type { ScmResource } from '../@types/vscode.git.resources'; -import { ScmResourceGroupType } from '../@types/vscode.git.resources.enums'; +import { ScmResourceGroupType, ScmStatus } from '../@types/vscode.git.resources.enums'; import { Commands } from '../constants'; import type { Container } from '../container'; import { Features } from '../features'; @@ -19,6 +19,7 @@ export interface StashSaveCommandArgs { message?: string; repoPath?: string; uris?: Uri[]; + includeUntracked?: boolean; keepStaged?: boolean; onlyStaged?: boolean; onlyStagedUris?: Uri[]; @@ -41,50 +42,91 @@ export class StashSaveCommand extends Command { } else if (isCommandContextViewNodeHasRepoPath(context)) { args = { ...args }; args.repoPath = context.node.repoPath; + } else if (context.type === 'scm') { + if (context.scm.rootUri != null) { + const repo = this.container.git.getRepository(context.scm.rootUri); + if (repo != null) { + args = { ...args }; + args.repoPath = repo.path; + } + } } else if (context.type === 'scm-states') { args = { ...args }; - args.uris = context.scmResourceStates.map(s => s.resourceUri); - args.repoPath = (await this.container.git.getOrOpenRepository(args.uris[0]))?.path; - - if ( - !context.scmResourceStates.some( - s => (s as ScmResource).resourceGroupType === ScmResourceGroupType.Index, - ) - ) { + + let hasOnlyStaged = undefined; + let hasStaged = false; + let hasUntracked = false; + + const uris: Uri[] = []; + + for (const resource of context.scmResourceStates as ScmResource[]) { + uris.push(resource.resourceUri); + if (resource.type === ScmStatus.UNTRACKED) { + hasUntracked = true; + } + + if (resource.resourceGroupType === ScmResourceGroupType.Index) { + hasStaged = true; + if (hasOnlyStaged == null) { + hasOnlyStaged = true; + } + } else { + hasOnlyStaged = false; + } + } + + const repo = await this.container.git.getOrOpenRepository(uris[0]); + + args.repoPath = repo?.path; + args.onlyStaged = repo != null && hasOnlyStaged ? await repo.supports(Features.StashOnlyStaged) : false; + if (args.keepStaged == null && !hasStaged) { args.keepStaged = true; } + args.includeUntracked = hasUntracked; + + args.uris = uris; } else if (context.type === 'scm-groups') { args = { ...args }; - let isStagedOnly = true; + let hasOnlyStaged = undefined; let hasStaged = false; - const uris = context.scmResourceGroups.reduce((a, b) => { - const isStaged = b.id === 'index'; - if (isStagedOnly && !isStaged) { - isStagedOnly = false; + let hasUntracked = false; + + const uris: Uri[] = []; + const stagedUris: Uri[] = []; + + for (const group of context.scmResourceGroups) { + for (const resource of group.resourceStates as ScmResource[]) { + uris.push(resource.resourceUri); + if (resource.type === ScmStatus.UNTRACKED) { + hasUntracked = true; + } } - if (isStaged) { + + if (group.id === 'index') { hasStaged = true; + if (hasOnlyStaged == null) { + hasOnlyStaged = true; + } + stagedUris.push(...group.resourceStates.map(s => s.resourceUri)); + } else { + hasOnlyStaged = false; } - return a.concat(b.resourceStates.map(s => s.resourceUri)); - }, []); + } const repo = await this.container.git.getOrOpenRepository(uris[0]); - let canUseStagedOnly = false; - if (isStagedOnly && repo != null) { - canUseStagedOnly = await repo.supports(Features.StashOnlyStaged); + + args.repoPath = repo?.path; + args.onlyStaged = repo != null && hasOnlyStaged ? await repo.supports(Features.StashOnlyStaged) : false; + if (args.keepStaged == null && !hasStaged) { + args.keepStaged = true; } + args.includeUntracked = hasUntracked; - if (canUseStagedOnly) { - args.onlyStaged = true; - args.onlyStagedUris = uris; + if (args.onlyStaged) { + args.onlyStagedUris = stagedUris; } else { args.uris = uris; - args.repoPath = repo?.path; - - if (!hasStaged) { - args.keepStaged = true; - } } } @@ -96,6 +138,7 @@ export class StashSaveCommand extends Command { args?.repoPath, args?.uris, args?.message, + args?.includeUntracked, args?.keepStaged, args?.onlyStaged, args?.onlyStagedUris, diff --git a/src/commands/switchAIModel.ts b/src/commands/switchAIModel.ts index ac600050a4e66..e5b8336d60f02 100644 --- a/src/commands/switchAIModel.ts +++ b/src/commands/switchAIModel.ts @@ -1,8 +1,6 @@ import { Commands } from '../constants'; import type { Container } from '../container'; -import { showAIModelPicker } from '../quickpicks/aiModelPicker'; import { command } from '../system/command'; -import { configuration } from '../system/configuration'; import { Command } from './base'; @command() @@ -12,10 +10,6 @@ export class SwitchAIModelCommand extends Command { } async execute() { - const pick = await showAIModelPicker(); - if (pick == null) return; - - await configuration.updateEffective('ai.experimental.provider', pick.provider); - await configuration.updateEffective(`ai.experimental.${pick.provider}.model`, pick.model); + await (await this.container.ai)?.switchModel(); } } diff --git a/src/commands/switchMode.ts b/src/commands/switchMode.ts index e19ba1f84bb26..b1dc705f1ca09 100644 --- a/src/commands/switchMode.ts +++ b/src/commands/switchMode.ts @@ -21,7 +21,7 @@ export class SwitchModeCommand extends Command { const pick = await showModePicker(); if (pick === undefined) return; - setLogScopeExit(scope, ` \u2014 mode=${pick.key ?? ''}`); + setLogScopeExit(scope, ` \u2022 mode=${pick.key ?? ''}`); const active = configuration.get('mode.active'); if (active === pick.key) return; diff --git a/src/commands/walkthroughs.ts b/src/commands/walkthroughs.ts index f5674e42b4f65..1c702dfa3e716 100644 --- a/src/commands/walkthroughs.ts +++ b/src/commands/walkthroughs.ts @@ -1,7 +1,8 @@ +import type { Source, Sources, WalkthroughSteps } from '../constants'; import { Commands } from '../constants'; import type { Container } from '../container'; import { command } from '../system/command'; -import { openWalkthrough } from '../system/utils'; +import { openWalkthrough as openWalkthroughCore } from '../system/utils'; import { Command } from './base'; @command() @@ -10,13 +11,36 @@ export class GetStartedCommand extends Command { super(Commands.GetStarted); } - execute(walkthroughId?: string) { - const extensionId = this.container.context.extension.id; - // If the walkthroughId param is the same as the extension id, then this was run from the extensions view gear menu - if (walkthroughId === extensionId) { - walkthroughId = undefined; - } + execute(extensionIdOrsource?: Sources) { + // If the extensionIdOrsource is the same as the current extension, then it came from the extension content menu in the extension view, so don't pass the source + const source = extensionIdOrsource !== this.container.context.extension.id ? undefined : extensionIdOrsource; + openWalkthrough(this.container, source ? { source: source } : undefined); + } +} + +export interface OpenWalkthroughCommandArgs extends Source { + step?: WalkthroughSteps | undefined; +} + +@command() +export class OpenWalkthroughCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.OpenWalkthrough); + } + + execute(args?: OpenWalkthroughCommandArgs) { + openWalkthrough(this.container, args); + } +} - void openWalkthrough(extensionId, walkthroughId ?? 'gitlens.welcome', undefined, false); +function openWalkthrough(container: Container, args?: OpenWalkthroughCommandArgs) { + if (container.telemetry.enabled) { + container.telemetry.sendEvent( + 'walkthrough', + { step: args?.step }, + args?.source ? { source: args.source, detail: args?.detail } : undefined, + ); } + + void openWalkthroughCore(container.context.extension.id, 'welcome', args?.step, false); } diff --git a/src/config.ts b/src/config.ts index e53907bdc7418..635c5b056e07c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,18 +1,21 @@ -import type { AnthropicModels } from './ai/anthropicProvider'; -import type { OpenAIModels } from './ai/openaiProvider'; +import type { VSCodeAIModels } from './ai/vscodeProvider'; +import type { SupportedAIModels } from './constants'; +import type { ResourceDescriptor } from './plus/integrations/integration'; import type { DateTimeFormat } from './system/date'; import type { LogLevel } from './system/logger.constants'; export interface Config { readonly ai: { readonly experimental: { - readonly provider: 'openai' | 'anthropic'; + readonly generateCommitMessage: { + readonly enabled: boolean; + }; + readonly model: SupportedAIModels | null; readonly openai: { - readonly model?: OpenAIModels; - readonly url?: string | null; + readonly url: string | null; }; - readonly anthropic: { - readonly model?: AnthropicModels; + readonly vscode: { + readonly model: VSCodeAIModels | null; }; }; }; @@ -21,6 +24,10 @@ export interface Config { readonly avatars: boolean; readonly compact: boolean; readonly dateFormat: DateTimeFormat | (string & object) | null; + readonly fontFamily: string; + readonly fontSize: number; + readonly fontStyle: string; + readonly fontWeight: string; readonly format: string; readonly heatmap: { readonly enabled: boolean; @@ -38,16 +45,26 @@ export interface Config { readonly locations: ChangesLocations[]; /*readonly*/ toggleMode: AnnotationsToggleMode; }; + readonly cloudPatches: { + readonly enabled: boolean; + readonly experimental: { + readonly layout: 'editor' | 'view'; + }; + }; readonly codeLens: CodeLensConfig; readonly currentLine: { readonly dateFormat: string | null; /*readonly*/ enabled: boolean; + readonly fontFamily: string; + readonly fontSize: number; + readonly fontStyle: string; + readonly fontWeight: string; readonly format: string; - readonly uncommittedChangesFormat: string | null; readonly pullRequests: { readonly enabled: boolean; }; readonly scrollable: boolean; + readonly uncommittedChangesFormat: string | null; }; readonly debug: boolean; readonly deepLinks: { @@ -63,12 +80,38 @@ export interface Config { readonly detectNestedRepositories: boolean; readonly experimental: { readonly generateCommitMessagePrompt: string; - readonly nativeGit: boolean; + readonly generateCloudPatchMessagePrompt: string; + readonly generateCodeSuggestionMessagePrompt: string; }; readonly fileAnnotations: { + readonly preserveWhileEditing: boolean; readonly command: string | null; + readonly dismissOnEscape: boolean; + }; + readonly launchpad: { + readonly allowMultiple: boolean; + readonly ignoredOrganizations: string[]; + readonly ignoredRepositories: string[]; + readonly staleThreshold: number | null; + readonly indicator: { + readonly enabled: boolean; + readonly openInEditor: boolean; + readonly icon: 'default' | 'group'; + readonly label: false | 'item' | 'counts'; + readonly useColors: boolean; + readonly groups: ('mergeable' | 'blocked' | 'needs-review' | 'follow-up')[]; + readonly polling: { + enabled: boolean; + interval: number; + }; + }; + readonly experimental: { + readonly queryLimit: number; + readonly queryUseInvolvesFilter: boolean; + }; }; readonly gitCommands: { + readonly avatars: boolean; readonly closeOnFocusOut: boolean; readonly search: { readonly matchAll: boolean; @@ -79,6 +122,9 @@ export interface Config { readonly skipConfirmations: string[]; readonly sortBy: GitCommandSorting; }; + readonly gitKraken: { + readonly activeOrganizationId: string | null; + }; readonly graph: GraphConfig; readonly heatmap: { readonly ageThreshold: number; @@ -119,6 +165,7 @@ export interface Config { }; readonly keymap: KeyMap; readonly liveshare: { + readonly enabled: boolean; readonly allowGuestAccess: boolean; }; readonly menus: boolean | MenuConfig; @@ -155,6 +202,7 @@ export interface Config { readonly sortBranchesBy: BranchSorting; readonly sortContributorsBy: ContributorSorting; readonly sortTagsBy: TagSorting; + readonly sortRepositoriesBy: RepositoriesSorting; readonly statusBar: { readonly alignment: 'left' | 'right'; readonly command: StatusBarCommand; @@ -191,6 +239,7 @@ export interface Config { readonly enabled: boolean; }; readonly visualHistory: { + readonly allowMultiple: boolean; readonly queryLimit: number; }; readonly worktrees: { @@ -213,6 +262,7 @@ export interface AutolinkReference { readonly type?: AutolinkType; readonly description?: string; + readonly descriptor?: ResourceDescriptor; } export type BlameHighlightLocations = 'gutter' | 'line' | 'overview'; @@ -239,6 +289,7 @@ export const enum CodeLensCommand { export type CodeLensScopes = 'document' | 'containers' | 'blocks'; export type ContributorSorting = 'count:desc' | 'count:asc' | 'date:desc' | 'date:asc' | 'name:asc' | 'name:desc'; +export type RepositoriesSorting = 'discovered' | 'lastFetched:desc' | 'lastFetched:asc' | 'name:asc' | 'name:desc'; export type CustomRemoteType = | 'AzureDevOps' | 'Bitbucket' @@ -254,12 +305,27 @@ export type DateSource = 'authored' | 'committed'; export type DateStyle = 'absolute' | 'relative'; export type FileAnnotationType = 'blame' | 'changes' | 'heatmap'; export type GitCommandSorting = 'name' | 'usage'; -export type GraphScrollMarkersAdditionalTypes = 'localBranches' | 'remoteBranches' | 'stashes' | 'tags'; -export type GraphMinimapMarkersAdditionalTypes = 'localBranches' | 'remoteBranches' | 'stashes' | 'tags'; +export type GraphScrollMarkersAdditionalTypes = + | 'localBranches' + | 'remoteBranches' + | 'stashes' + | 'tags' + | 'pullRequests'; +export type GraphMinimapMarkersAdditionalTypes = + | 'localBranches' + | 'remoteBranches' + | 'stashes' + | 'tags' + | 'pullRequests'; export type GravatarDefaultStyle = 'wavatar' | 'identicon' | 'monsterid' | 'mp' | 'retro' | 'robohash'; export type HeatmapLocations = 'gutter' | 'line' | 'overview'; export type KeyMap = 'alternate' | 'chorded' | 'none'; -export type OutputLevel = 'silent' | 'errors' | 'verbose' | 'debug'; + +type DeprecatedOutputLevel = + | /** @deprecated use `off` */ 'silent' + | /** @deprecated use `error` */ 'errors' + | /** @deprecated use `info` */ 'verbose'; +export type OutputLevel = LogLevel | DeprecatedOutputLevel; export const enum StatusBarCommand { CopyRemoteCommitUrl = 'gitlens.copyRemoteCommitUrl', @@ -303,6 +369,7 @@ export interface AdvancedConfig { readonly externalDirectoryDiffTool: string | null; readonly fileHistoryFollowsRenames: boolean; readonly fileHistoryShowAllBranches: boolean; + readonly fileHistoryShowMergeCommits: boolean; readonly maxListItems: number; readonly maxSearchItems: number; readonly messages: { [key in SuppressedMessages]: boolean }; @@ -314,33 +381,35 @@ export interface AdvancedConfig { } export interface GraphConfig { + readonly allowMultiple: boolean; readonly avatars: boolean; readonly commitOrdering: 'date' | 'author-date' | 'topo'; readonly dateFormat: DateTimeFormat | string | null; readonly dateStyle: DateStyle | null; readonly defaultItemLimit: number; readonly dimMergeCommits: boolean; + readonly highlightRowsOnRefHover: boolean; + readonly layout: 'editor' | 'panel'; readonly minimap: { readonly enabled: boolean; readonly dataType: 'commits' | 'lines'; readonly additionalTypes: GraphMinimapMarkersAdditionalTypes[]; }; - readonly highlightRowsOnRefHover: boolean; - readonly layout: 'editor' | 'panel'; - readonly scrollRowPadding: number; - readonly showDetailsView: 'open' | 'selection' | false; - readonly showGhostRefsOnRowHover: boolean; - readonly scrollMarkers: { + readonly onlyFollowFirstParent: boolean; + readonly pageItemLimit: number; + readonly pullRequests: { readonly enabled: boolean; - readonly additionalTypes: GraphScrollMarkersAdditionalTypes[]; }; - readonly pullRequests: { + readonly scrollMarkers: { readonly enabled: boolean; + readonly additionalTypes: GraphScrollMarkersAdditionalTypes[]; }; + readonly scrollRowPadding: number; + readonly searchItemLimit: number; + readonly showDetailsView: 'open' | 'selection' | false; + readonly showGhostRefsOnRowHover: boolean; readonly showRemoteNames: boolean; readonly showUpstreamStatus: boolean; - readonly pageItemLimit: number; - readonly searchItemLimit: number; readonly statusBar: { readonly enabled: boolean; }; @@ -418,12 +487,13 @@ export interface MenuConfig { | { readonly graph: boolean; }; - readonly scmRepositoryInline: false | { readonly graph: boolean }; + readonly scmRepositoryInline: false | { readonly graph: boolean; readonly stash: boolean }; readonly scmRepository: | false | { readonly authors: boolean; readonly generateCommitMessage: boolean; + readonly patch: boolean; readonly graph: boolean; }; readonly scmGroupInline: @@ -436,6 +506,7 @@ export interface MenuConfig { | { readonly compare: boolean; readonly openClose: boolean; + readonly patch: boolean; readonly stash: boolean; }; readonly scmItemInline: @@ -514,9 +585,12 @@ export type SuppressedMessages = | 'suppressRebaseSwitchToTextWarning' | 'suppressIntegrationDisconnectedTooManyFailedRequestsWarning' | 'suppressIntegrationRequestFailed500Warning' - | 'suppressIntegrationRequestTimedOutWarning'; + | 'suppressIntegrationRequestTimedOutWarning' + | 'suppressBlameInvalidIgnoreRevsFileWarning' + | 'suppressBlameInvalidIgnoreRevsFileBadRevisionWarning'; export interface ViewsCommonConfig { + readonly collapseWorktreesWhenPossible: boolean; readonly defaultItemLimit: number; readonly formats: { readonly commits: { @@ -532,16 +606,12 @@ export interface ViewsCommonConfig { readonly stashes: { readonly label: string; readonly description: string; + readonly tooltip: string; }; }; + readonly openChangesInMultiDiffEditor: boolean; readonly pageItemLimit: number; readonly showRelativeDateMarkers: boolean; - - readonly experimental: { - readonly multiSelect: { - readonly enabled: boolean | null | undefined; - }; - }; } export const viewsCommonConfigKeys: (keyof ViewsCommonConfig)[] = [ @@ -556,14 +626,18 @@ interface ViewsConfigs { readonly commits: CommitsViewConfig; readonly commitDetails: CommitDetailsViewConfig; readonly contributors: ContributorsViewConfig; + readonly drafts: DraftsViewConfig; readonly fileHistory: FileHistoryViewConfig; readonly lineHistory: LineHistoryViewConfig; + readonly patchDetails: PatchDetailsViewConfig; + readonly pullRequest: PullRequestViewConfig; readonly remotes: RemotesViewConfig; readonly repositories: RepositoriesViewConfig; readonly searchAndCompare: SearchAndCompareViewConfig; readonly stashes: StashesViewConfig; readonly tags: TagsViewConfig; readonly worktrees: WorktreesViewConfig; + readonly workspaces: WorkspacesViewConfig; } export type ViewsConfigKeys = keyof ViewsConfigs; @@ -572,14 +646,18 @@ export const viewsConfigKeys: ViewsConfigKeys[] = [ 'commits', 'commitDetails', 'contributors', + 'drafts', 'fileHistory', 'lineHistory', + 'patchDetails', + 'pullRequest', 'remotes', 'repositories', 'searchAndCompare', 'stashes', 'tags', 'worktrees', + 'workspaces', ]; export type ViewsConfig = ViewsCommonConfig & ViewsConfigs; @@ -624,6 +702,11 @@ export interface CommitDetailsViewConfig { }; } +export interface PatchDetailsViewConfig { + readonly avatars: boolean; + readonly files: ViewsFilesConfig; +} + export interface ContributorsViewConfig { readonly avatars: boolean; readonly files: ViewsFilesConfig; @@ -636,6 +719,14 @@ export interface ContributorsViewConfig { readonly showStatistics: boolean; } +export interface DraftsViewConfig { + readonly avatars: boolean; + readonly branches: undefined; + readonly files: ViewsFilesConfig; + readonly pullRequests: undefined; + readonly reveal: undefined; +} + export interface FileHistoryViewConfig { readonly avatars: boolean; readonly files: ViewsFilesConfig; @@ -649,6 +740,15 @@ export interface LineHistoryViewConfig { readonly avatars: boolean; } +export interface PullRequestViewConfig { + readonly avatars: boolean; + readonly branches: undefined; + readonly files: ViewsFilesConfig; + readonly pullRequests: undefined; + readonly reveal: undefined; + readonly showBranchComparison: undefined; +} + export interface RemotesViewConfig { readonly avatars: boolean; readonly branches: { @@ -726,6 +826,32 @@ export interface WorktreesViewConfig { readonly showBranchComparison: false | Extract; } +export interface WorkspacesViewConfig { + readonly avatars: boolean; + readonly branches: { + readonly layout: ViewBranchesLayout; + readonly showBranchComparison: false | Extract; + }; + readonly compact: boolean; + readonly files: ViewsFilesConfig; + readonly includeWorkingTree: boolean; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForBranches: boolean; + readonly showForCommits: boolean; + }; + readonly showBranchComparison: false | ViewShowBranchComparison; + readonly showBranches: boolean; + readonly showCommits: boolean; + readonly showContributors: boolean; + readonly showIncomingActivity: boolean; + readonly showRemotes: boolean; + readonly showStashes: boolean; + readonly showTags: boolean; + readonly showUpstreamStatus: boolean; + readonly showWorktrees: boolean; +} + export interface ViewsFilesConfig { readonly compact: boolean; readonly icon: 'status' | 'type'; @@ -733,17 +859,56 @@ export interface ViewsFilesConfig { readonly threshold: number; } -export function fromOutputLevel(level: LogLevel | OutputLevel): LogLevel { +export function fromOutputLevel(level: OutputLevel): LogLevel { switch (level) { - case 'silent': + case /** @deprecated use `off` */ 'silent': return 'off'; - case 'errors': + case /** @deprecated use `error` */ 'errors': return 'error'; - case 'verbose': + case /** @deprecated use `info` */ 'verbose': return 'info'; - case 'debug': - return 'debug'; default: return level; } } + +export type CoreConfig = { + readonly editor: { + readonly letterSpacing: number; + }; + readonly files: { + readonly encoding: string; + readonly exclude: Record; + }; + readonly git: { + readonly autoRepositoryDetection: boolean | 'subFolders' | 'openEditors'; + readonly enabled: boolean; + readonly fetchOnPull: boolean; + readonly path: string | string[] | null; + readonly pullTags: boolean; + readonly repositoryScanIgnoredFolders: string[]; + readonly repositoryScanMaxDepth: number; + readonly useForcePushIfIncludes: boolean; + readonly useForcePushWithLease: boolean; + }; + readonly http: { + readonly proxy: string; + readonly proxySupport: 'fallback' | 'off' | 'on' | 'override'; + readonly proxyStrictSSL: boolean; + }; + readonly multiDiffEditor: { + readonly experimental: { + readonly enabled: boolean; + }; + }; + readonly search: { + readonly exclude: Record; + }; + readonly workbench: { + readonly editorAssociations: Record | { viewType: string; filenamePattern: string }[]; + readonly tree: { + readonly renderIndentGuides: 'always' | 'none' | 'onHover'; + readonly indent: number; + }; + }; +}; diff --git a/src/constants.ts b/src/constants.ts index 6bb7a1ea12677..8da1e04bbde78 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,13 +1,26 @@ -import type { ViewShowBranchComparison } from './config'; +import type { AnthropicModels } from './ai/anthropicProvider'; +import type { GeminiModels } from './ai/geminiProvider'; +import type { OpenAIModels } from './ai/openaiProvider'; +import type { VSCodeAIModels } from './ai/vscodeProvider'; +import type { AnnotationStatus } from './annotations/annotationProvider'; +import type { FileAnnotationType, ViewShowBranchComparison } from './config'; import type { Environment } from './container'; import type { StoredSearchQuery } from './git/search'; -import type { Subscription } from './subscription'; +import type { Subscription, SubscriptionPlanId, SubscriptionState } from './plus/gk/account/subscription'; +import type { SupportedCloudIntegrationIds } from './plus/integrations/authentication/models'; +import type { Integration } from './plus/integrations/integration'; +import type { IntegrationId } from './plus/integrations/providers/models'; +import type { TelemetryEventData } from './telemetry/telemetry'; import type { TrackedUsage, TrackedUsageKeys } from './telemetry/usageTracker'; export const extensionPrefix = 'gitlens'; export const quickPickTitleMaxChars = 80; -export const ImageMimetypes: Record = { +export const previewBadge = 'ᴘʀᴇᴠɪᴇᴡ'; +export const proBadge = 'ᴘʀᴏ'; +export const proBadgeSuperscript = 'ᴾᴿᴼ'; + +export const ImageMimetypes: Record = Object.freeze({ '.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg', @@ -17,7 +30,27 @@ export const ImageMimetypes: Record = { '.tif': 'image/tiff', '.tiff': 'image/tiff', '.bmp': 'image/bmp', -}; +}); + +export const urls = Object.freeze({ + codeSuggest: 'https://gitkraken.com/solutions/code-suggest?utm_source=gitlens-extension&utm_medium=in-app-links', + cloudPatches: 'https://gitkraken.com/solutions/cloud-patches?utm_source=gitlens-extension&utm_medium=in-app-links', + graph: 'https://gitkraken.com/solutions/commit-graph?utm_source=gitlens-extension&utm_medium=in-app-links', + launchpad: 'https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links', + platform: 'https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links', + pricing: 'https://gitkraken.com/gitlens/pricing?utm_source=gitlens-extension&utm_medium=in-app-links', + proFeatures: 'https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links', + security: 'https://help.gitkraken.com/gitlens/security?utm_source=gitlens-extension&utm_medium=in-app-links', + workspaces: 'https://gitkraken.com/solutions/workspaces?utm_source=gitlens-extension&utm_medium=in-app-links', + + cli: 'https://gitkraken.com/cli?utm_source=gitlens-extension&utm_medium=in-app-links', + browserExtension: 'https://gitkraken.com/browser-extension?utm_source=gitlens-extension&utm_medium=in-app-links', + desktop: 'https://gitkraken.com/git-client?utm_source=gitlens-extension&utm_medium=in-app-links', + + releaseNotes: 'https://help.gitkraken.com/gitlens/gitlens-release-notes-current/', + releaseAnnouncement: + 'https://www.gitkraken.com/blog/gitkraken-launches-devex-platform-acquires-codesee?utm_source=gitlens-extension&utm_medium=in-app-links', +}); export const enum CharCode { /** @@ -72,6 +105,8 @@ export type Colors = | `${typeof extensionPrefix}.decorations.deletedForegroundColor` | `${typeof extensionPrefix}.decorations.ignoredForegroundColor` | `${typeof extensionPrefix}.decorations.modifiedForegroundColor` + | `${typeof extensionPrefix}.decorations.statusMergingOrRebasingConflictForegroundColor` + | `${typeof extensionPrefix}.decorations.statusMergingOrRebasingForegroundColor` | `${typeof extensionPrefix}.decorations.renamedForegroundColor` | `${typeof extensionPrefix}.decorations.untrackedForegroundColor` | `${typeof extensionPrefix}.decorations.workspaceCurrentForegroundColor` @@ -82,6 +117,12 @@ export type Colors = | `${typeof extensionPrefix}.gutterBackgroundColor` | `${typeof extensionPrefix}.gutterForegroundColor` | `${typeof extensionPrefix}.gutterUncommittedForegroundColor` + | `${typeof extensionPrefix}.launchpadIndicatorMergeableColor` + | `${typeof extensionPrefix}.launchpadIndicatorMergeableHoverColor` + | `${typeof extensionPrefix}.launchpadIndicatorBlockedColor` + | `${typeof extensionPrefix}.launchpadIndicatorBlockedHoverColor` + | `${typeof extensionPrefix}.launchpadIndicatorAttentionColor` + | `${typeof extensionPrefix}.launchpadIndicatorAttentionHoverColor` | `${typeof extensionPrefix}.lineHighlightBackgroundColor` | `${typeof extensionPrefix}.lineHighlightOverviewRulerColor` | `${typeof extensionPrefix}.mergedPullRequestIconColor` @@ -111,19 +152,21 @@ export const enum Commands { BrowseRepoBeforeRevisionInNewWindow = 'gitlens.browseRepoBeforeRevisionInNewWindow', ClearFileAnnotations = 'gitlens.clearFileAnnotations', CloseUnchangedFiles = 'gitlens.closeUnchangedFiles', - CloseWelcomeView = 'gitlens.closeWelcomeView', CompareWith = 'gitlens.compareWith', CompareHeadWith = 'gitlens.compareHeadWith', CompareWorkingWith = 'gitlens.compareWorkingWith', ComputingFileAnnotations = 'gitlens.computingFileAnnotations', ConnectRemoteProvider = 'gitlens.connectRemoteProvider', - CopyAutolinkUrl = 'gitlens.copyAutolinkUrl', CopyCurrentBranch = 'gitlens.copyCurrentBranch', CopyDeepLinkToBranch = 'gitlens.copyDeepLinkToBranch', CopyDeepLinkToCommit = 'gitlens.copyDeepLinkToCommit', CopyDeepLinkToComparison = 'gitlens.copyDeepLinkToComparison', + CopyDeepLinkToFile = 'gitlens.copyDeepLinkToFile', + CopyDeepLinkToFileAtRevision = 'gitlens.copyDeepLinkToFileAtRevision', + CopyDeepLinkToLines = 'gitlens.copyDeepLinkToLines', CopyDeepLinkToRepo = 'gitlens.copyDeepLinkToRepo', CopyDeepLinkToTag = 'gitlens.copyDeepLinkToTag', + CopyDeepLinkToWorkspace = 'gitlens.copyDeepLinkToWorkspace', CopyMessageToClipboard = 'gitlens.copyMessageToClipboard', CopyRemoteBranchesUrl = 'gitlens.copyRemoteBranchesUrl', CopyRemoteBranchUrl = 'gitlens.copyRemoteBranchUrl', @@ -132,14 +175,20 @@ export const enum Commands { CopyRemoteFileUrl = 'gitlens.copyRemoteFileUrlToClipboard', CopyRemoteFileUrlWithoutRange = 'gitlens.copyRemoteFileUrlWithoutRange', CopyRemoteFileUrlFrom = 'gitlens.copyRemoteFileUrlFrom', - CopyRemoteIssueUrl = 'gitlens.copyRemoteIssueUrl', CopyRemotePullRequestUrl = 'gitlens.copyRemotePullRequestUrl', CopyRemoteRepositoryUrl = 'gitlens.copyRemoteRepositoryUrl', CopyShaToClipboard = 'gitlens.copyShaToClipboard', CopyRelativePathToClipboard = 'gitlens.copyRelativePathToClipboard', + ApplyPatchFromClipboard = 'gitlens.applyPatchFromClipboard', + CopyPatchToClipboard = 'gitlens.copyPatchToClipboard', + CopyWorkingChangesToWorktree = 'gitlens.copyWorkingChangesToWorktree', + CreatePatch = 'gitlens.createPatch', + CreateCloudPatch = 'gitlens.createCloudPatch', CreatePullRequestOnRemote = 'gitlens.createPullRequestOnRemote', DiffDirectory = 'gitlens.diffDirectory', DiffDirectoryWithHead = 'gitlens.diffDirectoryWithHead', + DiffFolderWithRevision = 'gitlens.diffFolderWithRevision', + DiffFolderWithRevisionFrom = 'gitlens.diffFolderWithRevisionFrom', DiffWith = 'gitlens.diffWith', DiffWithNext = 'gitlens.diffWithNext', DiffWithNextInDiffLeft = 'gitlens.diffWithNextInDiffLeft', @@ -164,8 +213,8 @@ export const enum Commands { FetchRepositories = 'gitlens.fetchRepositories', GenerateCommitMessage = 'gitlens.generateCommitMessage', GetStarted = 'gitlens.getStarted', + GKSwitchOrganization = 'gitlens.gk.switchOrganization', InviteToLiveShare = 'gitlens.inviteToLiveShare', - OpenAutolinkUrl = 'gitlens.openAutolinkUrl', OpenBlamePriorToChange = 'gitlens.openBlamePriorToChange', OpenBranchesOnRemote = 'gitlens.openBranchesOnRemote', OpenBranchOnRemote = 'gitlens.openBranchOnRemote', @@ -181,7 +230,8 @@ export const enum Commands { OpenFileAtRevisionFrom = 'gitlens.openFileRevisionFrom', OpenFolderHistory = 'gitlens.openFolderHistory', OpenOnRemote = 'gitlens.openOnRemote', - OpenIssueOnRemote = 'gitlens.openIssueOnRemote', + OpenCloudPatch = 'gitlens.openCloudPatch', + OpenPatch = 'gitlens.openPatch', OpenPullRequestOnRemote = 'gitlens.openPullRequestOnRemote', OpenAssociatedPullRequestOnRemote = 'gitlens.openAssociatedPullRequestOnRemote', OpenRepoOnRemote = 'gitlens.openRepoOnRemote', @@ -196,37 +246,60 @@ export const enum Commands { PushRepositories = 'gitlens.pushRepositories', GitCommands = 'gitlens.gitCommands', GitCommandsBranch = 'gitlens.gitCommands.branch', + GitCommandsBranchCreate = 'gitlens.gitCommands.branch.create', + GitCommandsBranchDelete = 'gitlens.gitCommands.branch.delete', + GitCommandsBranchPrune = 'gitlens.gitCommands.branch.prune', + GitCommandsBranchRename = 'gitlens.gitCommands.branch.rename', + GitCommandsCheckout = 'gitlens.gitCommands.checkout', GitCommandsCherryPick = 'gitlens.gitCommands.cherryPick', + GitCommandsHistory = 'gitlens.gitCommands.history', GitCommandsMerge = 'gitlens.gitCommands.merge', GitCommandsRebase = 'gitlens.gitCommands.rebase', + GitCommandsRemote = 'gitlens.gitCommands.remote', + GitCommandsRemoteAdd = 'gitlens.gitCommands.remote.add', + GitCommandsRemotePrune = 'gitlens.gitCommands.remote.prune', + GitCommandsRemoteRemove = 'gitlens.gitCommands.remote.remove', GitCommandsReset = 'gitlens.gitCommands.reset', GitCommandsRevert = 'gitlens.gitCommands.revert', + GitCommandsShow = 'gitlens.gitCommands.show', + GitCommandsStash = 'gitlens.gitCommands.stash', + GitCommandsStashDrop = 'gitlens.gitCommands.stash.drop', + GitCommandsStashList = 'gitlens.gitCommands.stash.list', + GitCommandsStashPop = 'gitlens.gitCommands.stash.pop', + GitCommandsStashPush = 'gitlens.gitCommands.stash.push', + GitCommandsStashRename = 'gitlens.gitCommands.stash.rename', + GitCommandsStatus = 'gitlens.gitCommands.status', GitCommandsSwitch = 'gitlens.gitCommands.switch', GitCommandsTag = 'gitlens.gitCommands.tag', + GitCommandsTagCreate = 'gitlens.gitCommands.tag.create', + GitCommandsTagDelete = 'gitlens.gitCommands.tag.delete', GitCommandsWorktree = 'gitlens.gitCommands.worktree', + GitCommandsWorktreeCreate = 'gitlens.gitCommands.worktree.create', + GitCommandsWorktreeDelete = 'gitlens.gitCommands.worktree.delete', GitCommandsWorktreeOpen = 'gitlens.gitCommands.worktree.open', OpenOrCreateWorktreeForGHPR = 'gitlens.ghpr.views.openOrCreateWorktree', PlusHide = 'gitlens.plus.hide', - PlusLoginOrSignUp = 'gitlens.plus.loginOrSignUp', + PlusLogin = 'gitlens.plus.login', PlusLogout = 'gitlens.plus.logout', PlusManage = 'gitlens.plus.manage', - PlusPurchase = 'gitlens.plus.purchase', + PlusManageCloudIntegrations = 'gitlens.plus.cloudIntegrations.manage', + PlusReactivateProTrial = 'gitlens.plus.reactivateProTrial', PlusResendVerification = 'gitlens.plus.resendVerification', PlusRestore = 'gitlens.plus.restore', PlusShowPlans = 'gitlens.plus.showPlans', + PlusSignUp = 'gitlens.plus.signUp', PlusStartPreviewTrial = 'gitlens.plus.startPreviewTrial', + PlusUpgrade = 'gitlens.plus.upgrade', PlusValidate = 'gitlens.plus.validate', QuickOpenFileHistory = 'gitlens.quickOpenFileHistory', - RefreshFocus = 'gitlens.focus.refresh', + RefreshLaunchpad = 'gitlens.launchpad.refresh', RefreshGraph = 'gitlens.graph.refresh', RefreshHover = 'gitlens.refreshHover', - RefreshTimelinePage = 'gitlens.timeline.refresh', - ResetAvatarCache = 'gitlens.resetAvatarCache', + Reset = 'gitlens.reset', ResetAIKey = 'gitlens.resetAIKey', - ResetSuppressedWarnings = 'gitlens.resetSuppressedWarnings', - ResetTrackedUsage = 'gitlens.resetTrackedUsage', ResetViewsLayout = 'gitlens.resetViewsLayout', RevealCommitInView = 'gitlens.revealCommitInView', + ShareAsCloudPatch = 'gitlens.shareAsCloudPatch', SearchCommits = 'gitlens.showCommitSearch', SearchCommitsInView = 'gitlens.views.searchAndCompare.searchCommits', ShowBranchesView = 'gitlens.showBranchesView', @@ -235,6 +308,7 @@ export const enum Commands { ShowCommitsInView = 'gitlens.showCommitsInView', ShowCommitsView = 'gitlens.showCommitsView', ShowContributorsView = 'gitlens.showContributorsView', + ShowDraftsView = 'gitlens.showDraftsView', ShowFileHistoryView = 'gitlens.showFileHistoryView', ShowFocusPage = 'gitlens.showFocusPage', ShowGraph = 'gitlens.showGraph', @@ -245,10 +319,13 @@ export const enum Commands { ShowInCommitGraph = 'gitlens.showInCommitGraph', ShowInCommitGraphView = 'gitlens.showInCommitGraphView', ShowInDetailsView = 'gitlens.showInDetailsView', + ShowInTimeline = 'gitlens.showInTimeline', ShowLastQuickPick = 'gitlens.showLastQuickPick', + ShowLaunchpad = 'gitlens.showLaunchpad', ShowLineCommitInView = 'gitlens.showLineCommitInView', ShowLineHistoryView = 'gitlens.showLineHistoryView', OpenOnlyChangedFiles = 'gitlens.openOnlyChangedFiles', + ShowPatchDetailsPage = 'gitlens.showPatchDetailsPage', ShowQuickBranchHistory = 'gitlens.showQuickBranchHistory', ShowQuickCommit = 'gitlens.showQuickCommitDetails', ShowQuickCommitFile = 'gitlens.showQuickCommitFileDetails', @@ -263,20 +340,21 @@ export const enum Commands { ShowRepositoriesView = 'gitlens.showRepositoriesView', ShowSearchAndCompareView = 'gitlens.showSearchAndCompareView', ShowSettingsPage = 'gitlens.showSettingsPage', - ShowSettingsPageAndJumpToBranchesView = 'gitlens.showSettingsPage#branches-view', - ShowSettingsPageAndJumpToCommitsView = 'gitlens.showSettingsPage#commits-view', - ShowSettingsPageAndJumpToContributorsView = 'gitlens.showSettingsPage#contributors-view', - ShowSettingsPageAndJumpToFileHistoryView = 'gitlens.showSettingsPage#file-history-view', - ShowSettingsPageAndJumpToLineHistoryView = 'gitlens.showSettingsPage#line-history-view', - ShowSettingsPageAndJumpToRemotesView = 'gitlens.showSettingsPage#remotes-view', - ShowSettingsPageAndJumpToRepositoriesView = 'gitlens.showSettingsPage#repositories-view', - ShowSettingsPageAndJumpToSearchAndCompareView = 'gitlens.showSettingsPage#search-compare-view', - ShowSettingsPageAndJumpToStashesView = 'gitlens.showSettingsPage#stashes-view', - ShowSettingsPageAndJumpToTagsView = 'gitlens.showSettingsPage#tags-view', - ShowSettingsPageAndJumpToWorkTreesView = 'gitlens.showSettingsPage#worktrees-view', - ShowSettingsPageAndJumpToViews = 'gitlens.showSettingsPage#views', - ShowSettingsPageAndJumpToCommitGraph = 'gitlens.showSettingsPage#commit-graph', - ShowSettingsPageAndJumpToAutolinks = 'gitlens.showSettingsPage#autolinks', + ShowSettingsPageAndJumpToFileAnnotations = 'gitlens.showSettingsPage!file-annotations', + ShowSettingsPageAndJumpToBranchesView = 'gitlens.showSettingsPage!branches-view', + ShowSettingsPageAndJumpToCommitsView = 'gitlens.showSettingsPage!commits-view', + ShowSettingsPageAndJumpToContributorsView = 'gitlens.showSettingsPage!contributors-view', + ShowSettingsPageAndJumpToFileHistoryView = 'gitlens.showSettingsPage!file-history-view', + ShowSettingsPageAndJumpToLineHistoryView = 'gitlens.showSettingsPage!line-history-view', + ShowSettingsPageAndJumpToRemotesView = 'gitlens.showSettingsPage!remotes-view', + ShowSettingsPageAndJumpToRepositoriesView = 'gitlens.showSettingsPage!repositories-view', + ShowSettingsPageAndJumpToSearchAndCompareView = 'gitlens.showSettingsPage!search-compare-view', + ShowSettingsPageAndJumpToStashesView = 'gitlens.showSettingsPage!stashes-view', + ShowSettingsPageAndJumpToTagsView = 'gitlens.showSettingsPage!tags-view', + ShowSettingsPageAndJumpToWorkTreesView = 'gitlens.showSettingsPage!worktrees-view', + ShowSettingsPageAndJumpToViews = 'gitlens.showSettingsPage!views', + ShowSettingsPageAndJumpToCommitGraph = 'gitlens.showSettingsPage!commit-graph', + ShowSettingsPageAndJumpToAutolinks = 'gitlens.showSettingsPage!autolinks', ShowStashesView = 'gitlens.showStashesView', ShowTagsView = 'gitlens.showTagsView', ShowTimelinePage = 'gitlens.showTimelinePage', @@ -298,14 +376,18 @@ export const enum Commands { ToggleFileHeatmap = 'gitlens.toggleFileHeatmap', ToggleFileHeatmapInDiffLeft = 'gitlens.toggleFileHeatmapInDiffLeft', ToggleFileHeatmapInDiffRight = 'gitlens.toggleFileHeatmapInDiffRight', + ToggleLaunchpadIndicator = 'gitlens.launchpad.indicator.toggle', ToggleGraph = 'gitlens.toggleGraph', ToggleMaximizedGraph = 'gitlens.toggleMaximizedGraph', ToggleLineBlame = 'gitlens.toggleLineBlame', ToggleReviewMode = 'gitlens.toggleReviewMode', ToggleZenMode = 'gitlens.toggleZenMode', ViewsCopy = 'gitlens.views.copy', + ViewsCopyAsMarkdown = 'gitlens.views.copyAsMarkdown', + ViewsCopyUrl = 'gitlens.views.copyUrl', ViewsOpenDirectoryDiff = 'gitlens.views.openDirectoryDiff', ViewsOpenDirectoryDiffWithWorking = 'gitlens.views.openDirectoryDiffWithWorking', + ViewsOpenUrl = 'gitlens.views.openUrl', Deprecated_DiffHeadWith = 'gitlens.diffHeadWith', Deprecated_DiffWorkingWith = 'gitlens.diffWorkingWith', @@ -331,17 +413,20 @@ export type TreeViewCommands = `gitlens.views.${ | 'copy' | 'refresh' | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` - | `setMyCommitsOnly${'On' | 'Off'}` + | `setCommitsFilter${'Authors' | 'Off'}` | `setShowAvatars${'On' | 'Off'}` | `setShowBranchComparison${'On' | 'Off'}` - | `setShowBranchPullRequest${'On' | 'Off'}`}` + | `setShowBranchPullRequest${'On' | 'Off'}` + | `setShowMergeCommits${'On' | 'Off'}`}` | `contributors.${ | 'copy' | 'refresh' | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` | `setShowAllBranches${'On' | 'Off'}` | `setShowAvatars${'On' | 'Off'}` + | `setShowMergeCommits${'On' | 'Off'}` | `setShowStatistics${'On' | 'Off'}`}` + | `drafts.${'copy' | 'refresh' | 'info' | 'create' | 'delete' | `setShowAvatars${'On' | 'Off'}`}` | `fileHistory.${ | 'copy' | 'refresh' @@ -350,6 +435,7 @@ export type TreeViewCommands = `gitlens.views.${ | `setEditorFollowing${'On' | 'Off'}` | `setRenameFollowing${'On' | 'Off'}` | `setShowAllBranches${'On' | 'Off'}` + | `setShowMergeCommits${'On' | 'Off'}` | `setShowAvatars${'On' | 'Off'}`}` | `lineHistory.${ | 'copy' @@ -357,6 +443,12 @@ export type TreeViewCommands = `gitlens.views.${ | 'changeBase' | `setEditorFollowing${'On' | 'Off'}` | `setShowAvatars${'On' | 'Off'}`}` + | `pullRequest.${ + | 'copy' + | 'refresh' + | 'close' + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setShowAvatars${'On' | 'Off'}`}` | `remotes.${ | 'copy' | 'refresh' @@ -446,8 +538,10 @@ export type TreeViewTypes = | 'branches' | 'commits' | 'contributors' + | 'drafts' | 'fileHistory' | 'lineHistory' + | 'pullRequest' | 'remotes' | 'repositories' | 'searchAndCompare' @@ -455,13 +549,20 @@ export type TreeViewTypes = | 'tags' | 'workspaces' | 'worktrees'; -export type TreeViewIds = `gitlens.views.${TreeViewTypes}`; +export type TreeViewIds = `gitlens.views.${T}`; -export type WebviewTypes = 'graph' | 'settings' | 'timeline' | 'welcome' | 'focus'; +export type WebviewTypes = 'focus' | 'graph' | 'patchDetails' | 'settings' | 'timeline' | 'welcome'; export type WebviewIds = `gitlens.${WebviewTypes}`; -export type WebviewViewTypes = 'account' | 'commitDetails' | 'graph' | 'graphDetails' | 'home' | 'timeline'; -export type WebviewViewIds = `gitlens.views.${WebviewViewTypes}`; +export type WebviewViewTypes = + | 'account' + | 'commitDetails' + | 'graph' + | 'graphDetails' + | 'home' + | 'patchDetails' + | 'timeline'; +export type WebviewViewIds = `gitlens.views.${T}`; export type ViewTypes = TreeViewTypes | WebviewViewTypes; export type ViewIds = TreeViewIds | WebviewViewIds; @@ -506,42 +607,107 @@ export const viewIdsByDefaultContainerId = new Map & + Record<`gitlens:key:${Keys}`, boolean> & + Record<`gitlens:webview:${WebviewTypes | CustomEditorTypes}:visible`, boolean> & + Record<`gitlens:webviewView:${WebviewViewTypes}:visible`, boolean>; export type CoreCommands = | 'cursorMove' @@ -549,6 +715,8 @@ export type CoreCommands = | 'editor.action.showReferences' | 'editor.action.webvieweditor.showFind' | 'editorScroll' + | 'list.collapseAllToFocus' + | 'openInIntegratedTerminal' | 'openInTerminal' | 'revealFileInOS' | 'revealInExplorer' @@ -557,6 +725,7 @@ export type CoreCommands = | 'vscode.open' | 'vscode.openFolder' | 'vscode.openWith' + | 'vscode.changes' | 'vscode.diff' | 'vscode.executeCodeLensProvider' | 'vscode.executeDocumentSymbolProvider' @@ -585,27 +754,6 @@ export type CoreGitCommands = | 'git.pushForce' | 'git.undoCommit'; -export type CoreConfiguration = - | 'editor.letterSpacing' - | 'files.encoding' - | 'files.exclude' - | 'http.proxy' - | 'http.proxySupport' - | 'http.proxyStrictSSL' - | 'search.exclude' - | 'workbench.editorAssociations' - | 'workbench.tree.renderIndentGuides'; - -export type CoreGitConfiguration = - | 'git.autoRepositoryDetection' - | 'git.enabled' - | 'git.fetchOnPull' - | 'git.path' - | 'git.pullTags' - | 'git.repositoryScanIgnoredFolders' - | 'git.repositoryScanMaxDepth' - | 'git.useForcePushWithLease'; - export const enum GlyphChars { AngleBracketLeftHeavy = '\u2770', AngleBracketRightHeavy = '\u2771', @@ -682,28 +830,56 @@ export const enum Schemes { Virtual = 'vscode-vfs', } -export type TelemetryEvents = - | 'account/validation/failed' - | 'activate' - | 'command' - | 'command/core' - | 'remoteProviders/connected' - | 'remoteProviders/disconnected' - | 'providers/changed' - | 'providers/context' - | 'providers/registrationComplete' - | 'repositories/changed' - | 'repositories/visibility' - | 'repository/opened' - | 'repository/visibility' +export type Sources = + | 'account' + | 'code-suggest' + | 'cloud-patches' + | 'commandPalette' + | 'deeplink' + | 'git-commands' + | 'graph' + | 'home' + | 'inspect' + | 'inspect-overview' + | 'integrations' + | 'launchpad' + | 'launchpad-indicator' + | 'notification' + | 'patchDetails' + | 'prompt' + | 'remoteProvider' + | 'settings' + | 'timeline' + | 'trial-indicator' | 'subscription' - | 'subscription/changed' - | 'usage/track'; + | 'walkthrough' + | 'welcome'; -export type AIProviders = 'anthropic' | 'openai'; +export interface Source { + source: Sources; + detail?: string | TelemetryEventData; +} + +export type AIProviders = 'anthropic' | 'gemini' | 'openai' | 'vscode'; +export type AIModels = Provider extends 'openai' + ? OpenAIModels + : Provider extends 'anthropic' + ? AnthropicModels + : Provider extends 'gemini' + ? GeminiModels + : Provider extends 'vscode' + ? VSCodeAIModels + : AnthropicModels | GeminiModels | OpenAIModels; + +export type SupportedAIModels = + | `anthropic:${AIModels<'anthropic'>}` + | `google:${AIModels<'gemini'>}` + | `openai:${AIModels<'openai'>}` + | 'vscode'; export type SecretKeys = - | `gitlens.integration.auth:${string}` + | `gitlens.integration.auth:${IntegrationId}|${string}` + | `gitlens.integration.auth.cloud:${IntegrationId}|${string}` | `gitlens.${AIProviders}.key` | `gitlens.plus.auth:${Environment}`; @@ -757,8 +933,17 @@ export type GlobalStorage = { // Keep the pre-release version separate from the released version preVersion: string; 'views:welcome:visible': boolean; + 'confirm:draft:storage': boolean; + 'home:sections:collapsed': string[]; + 'launchpad:groups:collapsed': StoredFocusGroup[]; + 'launchpad:indicator:hasLoaded': boolean; + 'launchpad:indicator:hasInteracted': string; } & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & { [key in `provider:authentication:skip:${string}`]: boolean; +} & { [key in `gk:${string}:checkin`]: Stored } & { + [key in `gk:${string}:organizations`]: Stored; +} & { [key in `jira:${string}:organizations`]: Stored } & { + [key in `jira:${string}:projects`]: Stored; }; export type DeprecatedWorkspaceStorage = { @@ -785,11 +970,78 @@ export type WorkspaceStorage = { 'views:repositories:autoRefresh': boolean; 'views:searchAndCompare:pinned': StoredSearchAndCompareItems; 'views:commitDetails:autolinksExpanded': boolean; -} & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & { [key in `connected:${string}`]: boolean }; + 'views:commitDetails:pullRequestExpanded': boolean; +} & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & { + [key in `connected:${Integration['key']}`]: boolean; +}; export interface Stored { v: SchemaVersion; data: T; + timestamp?: number; +} + +export interface StoredGKCheckInResponse { + user: StoredGKUser; + licenses: { + paidLicenses: Record; + effectiveLicenses: Record; + }; +} + +export interface StoredGKUser { + id: string; + name: string; + email: string; + status: 'activated' | 'pending'; + createdDate: string; + firstGitLensCheckIn?: string; +} + +export interface StoredGKLicense { + latestStatus: 'active' | 'canceled' | 'cancelled' | 'expired' | 'in_trial' | 'non_renewing' | 'trial'; + latestStartDate: string; + latestEndDate: string; + organizationId: string | undefined; + reactivationCount?: number; +} + +export type StoredGKLicenseType = + | 'gitlens-pro' + | 'gitlens-teams' + | 'gitlens-hosted-enterprise' + | 'gitlens-self-hosted-enterprise' + | 'gitlens-standalone-enterprise' + | 'bundle-pro' + | 'bundle-teams' + | 'bundle-hosted-enterprise' + | 'bundle-self-hosted-enterprise' + | 'bundle-standalone-enterprise' + | 'gitkraken_v1-pro' + | 'gitkraken_v1-teams' + | 'gitkraken_v1-hosted-enterprise' + | 'gitkraken_v1-self-hosted-enterprise' + | 'gitkraken_v1-standalone-enterprise'; + +export interface StoredOrganization { + id: string; + name: string; + role: 'owner' | 'admin' | 'billing' | 'user'; +} + +export interface StoredJiraOrganization { + key: string; + id: string; + name: string; + url: string; + avatarUrl: string; +} + +export interface StoredJiraProject { + key: string; + id: string; + name: string; + resourceId: string; } export interface StoredAvatar { @@ -807,6 +1059,7 @@ export interface StoredRepoVisibilityInfo { export interface StoredBranchComparison { ref: string; + label?: string; notation: '..' | '...' | undefined; type: Exclude | undefined; checkedFiles?: string[]; @@ -817,6 +1070,9 @@ export type StoredBranchComparisons = Record; export type StoredStarred = Record; export type RecentUsage = Record; + +export type WalkthroughSteps = + | 'get-started' + | 'core-features' + | 'pro-features' + | 'pro-trial' + | 'pro-upgrade' + | 'pro-reactivate' + | 'pro-paid' + | 'visualize' + | 'launchpad' + | 'code-collab' + | 'integrations' + | 'more'; + +export type StoredFocusGroup = + | 'current-branch' + | 'pinned' + | 'mergeable' + | 'blocked' + | 'follow-up' + | 'needs-review' + | 'waiting-for-review' + | 'draft' + | 'other' + | 'snoozed'; + +export type TelemetryGlobalContext = { + debugging: boolean; + enabled: boolean; + prerelease: boolean; + install: boolean; + upgrade: boolean; + upgradedFrom: string | undefined; + 'folders.count': number; + 'folders.schemes': string; + 'providers.count': number; + 'providers.ids': string; + 'repositories.count': number; + 'repositories.hasRemotes': boolean; + 'repositories.hasRichRemotes': boolean; + 'repositories.hasConnectedRemotes': boolean; + 'repositories.withRemotes': number; + 'repositories.withHostingIntegrations': number; + 'repositories.withHostingIntegrationsConnected': number; + 'repositories.remoteProviders': string; + 'repositories.schemes': string; + 'repositories.visibility': 'private' | 'public' | 'local' | 'mixed'; + 'workspace.isTrusted': boolean; +} & SubscriptionEventData; + +export type TelemetryEvents = { + /** Sent when account validation fails */ + 'account/validation/failed': { + 'account.id': string; + exception: string; + code: string | undefined; + statusCode: string | undefined; + }; + + /** Sent when GitLens is activated */ + activate: { + 'activation.elapsed': number; + 'activation.mode': string | undefined; + } & Record<`config.${string}`, string | number | boolean | null>; + + /** Sent when a cloud-based hosting provider is connected */ + 'cloudIntegrations/hosting/connected': { + 'hostingProvider.provider': IntegrationId; + 'hostingProvider.key': string; + }; + /** Sent when a cloud-based hosting provider is disconnected */ + 'cloudIntegrations/hosting/disconnected': { + 'hostingProvider.provider': IntegrationId; + 'hostingProvider.key': string; + }; + /** Sent when a cloud-based issue provider is connected */ + 'cloudIntegrations/issue/connected': { + 'issueProvider.provider': IntegrationId; + 'issueProvider.key': string; + }; + /** Sent when a cloud-based issue provider is disconnected */ + 'cloudIntegrations/issue/disconnected': { + 'issueProvider.provider': IntegrationId; + 'issueProvider.key': string; + }; + /** Sent when a user chooses to manage the cloud integrations */ + 'cloudIntegrations/settingsOpened': { + 'integration.id': SupportedCloudIntegrationIds | undefined; + }; + + /** Sent when a code suggestion is archived */ + codeSuggestionArchived: { + provider: string | undefined; + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + repoPrivacy: 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + draftId: string; + /** Named for compatibility with other GK surfaces */ + reason: 'committed' | 'rejected' | 'accepted'; + }; + /** Sent when a code suggestion is created */ + codeSuggestionCreated: { + provider: string | undefined; + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + repoPrivacy: 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + draftId: string; + /** Named for compatibility with other GK surfaces */ + draftPrivacy: 'public' | 'private' | 'invite_only' | 'provider_access'; + /** Named for compatibility with other GK surfaces */ + filesChanged: number; + /** Named for compatibility with other GK surfaces */ + source: 'reviewMode'; + }; + /** Sent when a code suggestion is opened */ + codeSuggestionViewed: { + provider: string | undefined; + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + repoPrivacy: 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + draftId: string; + /** Named for compatibility with other GK surfaces */ + draftPrivacy: 'public' | 'private' | 'invite_only' | 'provider_access'; + /** Named for compatibility with other GK surfaces */ + source?: string; + }; + + /** Sent when a GitLens command is executed */ + command: + | { + command: Commands.GitCommands; + context?: { mode?: string; submode?: string }; + } + | { + command: string; + context?: undefined; + webview?: string; + }; + /** Sent when a VS Code command is executed by a GitLens provided action */ + 'command/core': { command: string }; + + /** Sent when the user takes an action on a launchpad item */ + 'launchpad/title/action': LaunchpadEventData & { + action: 'feedback' | 'open-on-gkdev' | 'refresh' | 'settings'; + }; + + /** Sent when the user takes an action on a launchpad item */ + 'launchpad/action': LaunchpadEventData & { + action: + | 'open' + | 'code-suggest' + | 'merge' + | 'soft-open' + | 'switch' + | 'open-worktree' + | 'switch-and-code-suggest' + | 'show-overview' + | 'open-changes' + | 'open-in-graph' + | 'pin' + | 'unpin' + | 'snooze' + | 'unsnooze' + | 'open-suggestion' + | 'open-suggestion-browser'; + } & Partial>; + /** Sent when the user changes launchpad configuration settings */ + 'launchpad/configurationChanged': { + 'config.launchpad.staleThreshold': number | null; + 'config.launchpad.ignoredOrganizations': number; + 'config.launchpad.ignoredRepositories': number; + 'config.launchpad.indicator.enabled': boolean; + 'config.launchpad.indicator.openInEditor': boolean; + 'config.launchpad.indicator.icon': 'default' | 'group'; + 'config.launchpad.indicator.label': false | 'item' | 'counts'; + 'config.launchpad.indicator.useColors': boolean; + 'config.launchpad.indicator.groups': string; + 'config.launchpad.indicator.polling.enabled': boolean; + 'config.launchpad.indicator.polling.interval': number; + }; + /** Sent when the user expands/collapses a launchpad group */ + 'launchpad/groupToggled': LaunchpadEventData & { + group: LaunchpadGroups; + collapsed: boolean; + }; + /** Sent when the user opens launchpad; use `instance` to correlate a launchpad "session" */ + 'launchpad/open': LaunchpadEventDataBase; + /** Sent when the launchpad is opened; use `instance` to correlate a launchpad "session" */ + 'launchpad/opened': LaunchpadEventData & { + connected: boolean; + }; + /** Sent when the launchpad has "reloaded" (while open, e.g. user refreshed or back button) and is disconnected; use `instance` to correlate a launchpad "session" */ + 'launchpad/steps/connect': LaunchpadEventData & { + connected: boolean; + }; + /** Sent when the launchpad has "reloaded" (while open, e.g. user refreshed or back button) and is connected; use `instance` to correlate a launchpad "session" */ + 'launchpad/steps/main': LaunchpadEventData & { + connected: boolean; + }; + /** Sent when the user opens the details of a launchpad item (e.g. click on an item); use `instance` to correlate a launchpad "session" */ + 'launchpad/steps/details': LaunchpadEventData & { + action: 'select'; + } & Partial>; + /** Sent when the user hides the launchpad indicator */ + 'launchpad/indicator/hidden': void; + /** Sent when the launchpad indicator loads (with data) for the first time ever for this device */ + 'launchpad/indicator/firstLoad': void; + /** Sent when a launchpad operation is taking longer than a set timeout to complete */ + 'launchpad/operation/slow': { + timeout: number; + operation: 'getMyPullRequests' | 'getCodeSuggestions' | 'getEnrichedItems' | 'getCodeSuggestionCounts'; + duration: number; + }; + + /** Sent when a PR review was started in the inspect overview */ + openReviewMode: { + provider: string; + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + /** Provided for compatibility with other GK surfaces */ + repoPrivacy: 'private' | 'public' | 'local' | undefined; + filesChanged: number; + /** Provided for compatibility with other GK surfaces */ + source: Sources; + }; + + /** Sent when the "context" of the workspace changes (e.g. repo added, integration connected, etc) */ + 'providers/context': void; + + /** Sent when we've loaded all the git providers and their repositories */ + 'providers/registrationComplete': { + 'config.git.autoRepositoryDetection': boolean | 'subFolders' | 'openEditors' | undefined; + }; + + /** Sent when a local (Git remote-based) hosting provider is connected */ + 'remoteProviders/connected': { + 'hostingProvider.provider': IntegrationId; + 'hostingProvider.key': string; + + /** @deprecated */ + 'remoteProviders.key': string; + }; + /** Sent when a local (Git remote-based) hosting provider is disconnected */ + 'remoteProviders/disconnected': { + 'hostingProvider.provider': IntegrationId; + 'hostingProvider.key': string; + + /** @deprecated */ + 'remoteProviders.key': string; + }; + + /** Sent when the workspace's repositories change */ + 'repositories/changed': { + 'repositories.added': number; + 'repositories.removed': number; + }; + /** Sent when the workspace's repository visibility is first requested */ + 'repositories/visibility': { + 'repositories.visibility': 'private' | 'public' | 'local' | 'mixed'; + }; + + /** Sent when a repository is opened */ + 'repository/opened': { + 'repository.id': string; + 'repository.scheme': string; + 'repository.closed': boolean; + 'repository.folder.scheme': string | undefined; + 'repository.provider.id': string; + 'repository.remoteProviders': string; + }; + /** Sent when a repository's visibility is first requested */ + 'repository/visibility': { + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + 'repository.id': string | undefined; + 'repository.scheme': string | undefined; + 'repository.closed': boolean | undefined; + 'repository.folder.scheme': string | undefined; + 'repository.provider.id': string | undefined; + }; + + /** Sent when the subscription is loaded */ + subscription: SubscriptionEventData; + 'subscription/action': + | { + action: + | 'sign-up' + | 'sign-in' + | 'sign-out' + | 'manage' + | 'reactivate' + | 'resend-verification' + | 'pricing' + | 'start-preview-trial' + | 'upgrade'; + } + | { + action: 'visibility'; + visible: boolean; + }; + /** Sent when the subscription changes */ + 'subscription/changed': SubscriptionEventData; + + /** Sent when a "tracked feature" is interacted with, today that is only when webview/webviewView/custom editor is shown */ + 'usage/track': { + 'usage.key': TrackedUsageKeys; + 'usage.count': number; + }; + + /** Sent when the walkthrough is opened */ + walkthrough: { + step?: + | 'get-started' + | 'core-features' + | 'pro-features' + | 'pro-trial' + | 'pro-upgrade' + | 'pro-reactivate' + | 'pro-paid' + | 'visualize' + | 'launchpad' + | 'code-collab' + | 'integrations' + | 'more'; + }; +}; + +export type LaunchpadTelemetryContext = LaunchpadEventData; + +type LaunchpadEventDataBase = { + instance: number; + 'initialState.group': string | undefined; + 'initialState.selectTopItem': boolean; +}; + +type LaunchpadEventData = LaunchpadEventDataBase & + ( + | Partial<{ 'items.error': string }> + | Partial< + { + 'items.count': number; + 'items.timings.prs': number; + 'items.timings.codeSuggestionCounts': number; + 'items.timings.enrichedItems': number; + 'groups.count': number; + } & Record<`groups.${LaunchpadGroups}.count`, number> & + Record<`groups.${LaunchpadGroups}.collapsed`, boolean | undefined> + > + ); + +type LaunchpadGroups = + | 'current-branch' + | 'pinned' + | 'mergeable' + | 'blocked' + | 'follow-up' + | 'needs-review' + | 'waiting-for-review' + | 'draft' + | 'other' + | 'snoozed'; + +type SubscriptionEventData = { + 'subscription.state'?: SubscriptionState; + 'subscription.status'?: + | 'verification' + | 'free' + | 'preview' + | 'preview-expired' + | 'trial' + | 'trial-expired' + | 'trial-reactivation-eligible' + | 'paid' + | 'unknown'; +} & Partial< + Record<`account.${string}`, string | number | boolean | undefined> & + Record<`subscription.${string}`, string | number | boolean | undefined> & + Record<`subscription.previewTrial.${string}`, string | number | boolean | undefined> & + Record<`previous.account.${string}`, string | number | boolean | undefined> & + Record<`previous.subscription.${string}`, string | number | boolean | undefined> & + Record<`previous.subscription.previewTrial.${string}`, string | number | boolean | undefined> +>; diff --git a/src/container.ts b/src/container.ts index 58dda4ab72ae2..dbb7ea13ef44f 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,7 +1,7 @@ import type { ConfigurationChangeEvent, Disposable, Event, ExtensionContext } from 'vscode'; -import { EventEmitter, ExtensionMode } from 'vscode'; +import { EventEmitter, ExtensionMode, Uri } from 'vscode'; import { getSupportedGitProviders, getSupportedRepositoryPathMappingProvider } from '@env/providers'; -import { AIProviderService } from './ai/aiProviderService'; +import type { AIProviderService } from './ai/aiProviderService'; import { Autolinks } from './annotations/autolinks'; import { FileAnnotationController } from './annotations/fileAnnotationController'; import { LineAnnotationController } from './annotations/lineAnnotationController'; @@ -9,31 +9,49 @@ import { ActionRunners } from './api/actionRunners'; import { setDefaultGravatarsStyle } from './avatars'; import { CacheProvider } from './cache'; import { GitCodeLensController } from './codelens/codeLensController'; -import type { ToggleFileAnnotationCommandArgs } from './commands'; +import type { ToggleFileAnnotationCommandArgs } from './commands/toggleFileAnnotations'; import type { DateStyle, FileAnnotationType, ModeConfig } from './config'; import { fromOutputLevel } from './config'; import { Commands, extensionPrefix } from './constants'; import { EventBus } from './eventBus'; import { GitFileSystemProvider } from './git/fsProvider'; import { GitProviderService } from './git/gitProviderService'; -import { GitHubAuthenticationProvider } from './git/remotes/github'; -import { GitLabAuthenticationProvider } from './git/remotes/gitlab'; -import { RichRemoteProviderService } from './git/remotes/remoteProviderService'; import { LineHoverController } from './hovers/lineHoverController'; import type { RepositoryPathMappingProvider } from './pathMapping/repositoryPathMappingProvider'; -import { AccountAuthenticationProvider } from './plus/gk/authenticationProvider'; +import { DraftService } from './plus/drafts/draftsService'; +import { EnrichmentService } from './plus/focus/enrichmentService'; +import { FocusIndicator } from './plus/focus/focusIndicator'; +import { FocusProvider } from './plus/focus/focusProvider'; +import { AccountAuthenticationProvider } from './plus/gk/account/authenticationProvider'; +import { OrganizationService } from './plus/gk/account/organizationService'; +import { SubscriptionService } from './plus/gk/account/subscriptionService'; import { ServerConnection } from './plus/gk/serverConnection'; -import { IntegrationAuthenticationService } from './plus/integrationAuthentication'; -import { SubscriptionService } from './plus/subscription/subscriptionService'; +import type { CloudIntegrationService } from './plus/integrations/authentication/cloudIntegrationService'; +import { IntegrationAuthenticationService } from './plus/integrations/authentication/integrationAuthentication'; +import { IntegrationService } from './plus/integrations/integrationService'; +import type { GitHubApi } from './plus/integrations/providers/github/github'; +import type { GitLabApi } from './plus/integrations/providers/gitlab/gitlab'; +import { RepositoryIdentityService } from './plus/repos/repositoryIdentityService'; import { registerAccountWebviewView } from './plus/webviews/account/registration'; -import { registerFocusWebviewPanel } from './plus/webviews/focus/registration'; +import { registerFocusWebviewCommands, registerFocusWebviewPanel } from './plus/webviews/focus/registration'; +import type { GraphWebviewShowingArgs } from './plus/webviews/graph/registration'; import { registerGraphWebviewCommands, registerGraphWebviewPanel, registerGraphWebviewView, } from './plus/webviews/graph/registration'; import { GraphStatusBarController } from './plus/webviews/graph/statusbar'; -import { registerTimelineWebviewPanel, registerTimelineWebviewView } from './plus/webviews/timeline/registration'; +import type { PatchDetailsWebviewShowingArgs } from './plus/webviews/patchDetails/registration'; +import { + registerPatchDetailsWebviewPanel, + registerPatchDetailsWebviewView, +} from './plus/webviews/patchDetails/registration'; +import type { TimelineWebviewShowingArgs } from './plus/webviews/timeline/registration'; +import { + registerTimelineWebviewCommands, + registerTimelineWebviewPanel, + registerTimelineWebviewView, +} from './plus/webviews/timeline/registration'; import { scheduleAddMissingCurrentWorkspaceRepos, WorkspacesService } from './plus/workspaces/workspacesService'; import { StatusBarController } from './statusbar/statusBarController'; import { executeCommand } from './system/command'; @@ -46,15 +64,17 @@ import type { Storage } from './system/storage'; import { TelemetryService } from './telemetry/telemetry'; import { UsageTracker } from './telemetry/usageTracker'; import { GitTerminalLinkProvider } from './terminal/linkProvider'; -import { GitDocumentTracker } from './trackers/gitDocumentTracker'; -import { GitLineTracker } from './trackers/gitLineTracker'; +import { GitDocumentTracker } from './trackers/documentTracker'; +import { LineTracker } from './trackers/lineTracker'; import { DeepLinkService } from './uris/deepLinks/deepLinkService'; import { UriService } from './uris/uriService'; import { BranchesView } from './views/branchesView'; import { CommitsView } from './views/commitsView'; import { ContributorsView } from './views/contributorsView'; +import { DraftsView } from './views/draftsView'; import { FileHistoryView } from './views/fileHistoryView'; import { LineHistoryView } from './views/lineHistoryView'; +import { PullRequestView } from './views/pullRequestView'; import { RemotesView } from './views/remotesView'; import { RepositoriesView } from './views/repositoriesView'; import { SearchAndCompareView } from './views/searchAndCompareView'; @@ -65,6 +85,7 @@ import { ViewFileDecorationProvider } from './views/viewDecorationProvider'; import { WorkspacesView } from './views/workspacesView'; import { WorktreesView } from './views/worktreesView'; import { VslsController } from './vsls/vsls'; +import type { CommitDetailsWebviewShowingArgs } from './webviews/commitDetails/registration'; import { registerCommitDetailsWebviewView, registerGraphDetailsWebviewView, @@ -72,7 +93,7 @@ import { import { registerHomeWebviewView } from './webviews/home/registration'; import { RebaseEditorProvider } from './webviews/rebase/rebaseEditor'; import { registerSettingsWebviewCommands, registerSettingsWebviewPanel } from './webviews/settings/registration'; -import type { WebviewPanelProxy, WebviewViewProxy } from './webviews/webviewsController'; +import type { WebviewViewProxy } from './webviews/webviewsController'; import { WebviewsController } from './webviews/webviewsController'; import { registerWelcomeWebviewPanel } from './webviews/welcome/registration'; @@ -171,6 +192,7 @@ export class Container { private _disposables: Disposable[]; private _terminalLinks: GitTerminalLinkProvider | undefined; private _webviews: WebviewsController; + private _focusIndicator: FocusIndicator | undefined; private constructor( context: ExtensionContext, @@ -196,21 +218,22 @@ export class Container { this._disposables.push( (this._accountAuthentication = new AccountAuthenticationProvider(this, this._connection)), ); + this._disposables.push((this._uri = new UriService(this))); this._disposables.push((this._subscription = new SubscriptionService(this, this._connection, previousVersion))); + this._disposables.push((this._organizations = new OrganizationService(this, this._connection))); this._disposables.push((this._git = new GitProviderService(this))); this._disposables.push(new GitFileSystemProvider(this)); - this._disposables.push((this._uri = new UriService(this))); - this._disposables.push((this._deepLinks = new DeepLinkService(this))); this._disposables.push((this._actionRunners = new ActionRunners(this))); - this._disposables.push((this._tracker = new GitDocumentTracker(this))); - this._disposables.push((this._lineTracker = new GitLineTracker(this))); + this._disposables.push((this._documentTracker = new GitDocumentTracker(this))); + this._disposables.push((this._lineTracker = new LineTracker(this, this._documentTracker))); this._disposables.push((this._keyboard = new Keyboard())); this._disposables.push((this._vsls = new VslsController(this))); this._disposables.push((this._eventBus = new EventBus())); + this._disposables.push((this._focusProvider = new FocusProvider(this))); this._disposables.push((this._fileAnnotationController = new FileAnnotationController(this))); this._disposables.push((this._lineAnnotationController = new LineAnnotationController(this))); @@ -219,27 +242,40 @@ export class Container { this._disposables.push((this._codeLensController = new GitCodeLensController(this))); this._disposables.push((this._webviews = new WebviewsController(this))); - this._disposables.push(registerTimelineWebviewPanel(this._webviews)); - this._disposables.push((this._timelineView = registerTimelineWebviewView(this._webviews))); - this._disposables.push((this._graphPanel = registerGraphWebviewPanel(this._webviews))); - this._disposables.push(registerGraphWebviewCommands(this, this._graphPanel)); + const graphPanels = registerGraphWebviewPanel(this._webviews); + this._disposables.push(graphPanels); + this._disposables.push(registerGraphWebviewCommands(this, graphPanels)); this._disposables.push((this._graphView = registerGraphWebviewView(this._webviews))); this._disposables.push(new GraphStatusBarController(this)); - const settingsWebviewPanel = registerSettingsWebviewPanel(this._webviews); - this._disposables.push(settingsWebviewPanel); - this._disposables.push(registerSettingsWebviewCommands(settingsWebviewPanel)); - this._disposables.push(registerWelcomeWebviewPanel(this._webviews)); + const focusPanels = registerFocusWebviewPanel(this._webviews); + this._disposables.push(focusPanels); + this._disposables.push(registerFocusWebviewCommands(focusPanels)); + + const timelinePanels = registerTimelineWebviewPanel(this._webviews); + this._disposables.push(timelinePanels); + this._disposables.push(registerTimelineWebviewCommands(timelinePanels)); + this._disposables.push((this._timelineView = registerTimelineWebviewView(this._webviews))); + this._disposables.push((this._rebaseEditor = new RebaseEditorProvider(this))); - this._disposables.push(registerFocusWebviewPanel(this._webviews)); + + const settingsPanels = registerSettingsWebviewPanel(this._webviews); + this._disposables.push(settingsPanels); + this._disposables.push(registerSettingsWebviewCommands(settingsPanels)); + + this._disposables.push(registerWelcomeWebviewPanel(this._webviews)); this._disposables.push(new ViewFileDecorationProvider()); this._disposables.push((this._repositoriesView = new RepositoriesView(this))); this._disposables.push((this._commitDetailsView = registerCommitDetailsWebviewView(this._webviews))); + const patchDetailsPanels = registerPatchDetailsWebviewPanel(this._webviews); + this._disposables.push(patchDetailsPanels); + this._disposables.push((this._patchDetailsView = registerPatchDetailsWebviewView(this._webviews))); this._disposables.push((this._graphDetailsView = registerGraphDetailsWebviewView(this._webviews))); this._disposables.push((this._commitsView = new CommitsView(this))); + this._disposables.push((this._pullRequestView = new PullRequestView(this))); this._disposables.push((this._fileHistoryView = new FileHistoryView(this))); this._disposables.push((this._lineHistoryView = new LineHistoryView(this))); this._disposables.push((this._branchesView = new BranchesView(this))); @@ -249,22 +285,39 @@ export class Container { this._disposables.push((this._worktreesView = new WorktreesView(this))); this._disposables.push((this._contributorsView = new ContributorsView(this))); this._disposables.push((this._searchAndCompareView = new SearchAndCompareView(this))); + this._disposables.push((this._draftsView = new DraftsView(this))); this._disposables.push((this._workspacesView = new WorkspacesView(this))); this._disposables.push((this._homeView = registerHomeWebviewView(this._webviews))); this._disposables.push((this._accountView = registerAccountWebviewView(this._webviews))); + if (configuration.get('launchpad.indicator.enabled')) { + this._disposables.push((this._focusIndicator = new FocusIndicator(this, this._focusProvider))); + } + if (configuration.get('terminalLinks.enabled')) { this._disposables.push((this._terminalLinks = new GitTerminalLinkProvider(this))); } this._disposables.push( configuration.onDidChange(e => { - if (!configuration.changed(e, 'terminalLinks.enabled')) return; + if (configuration.changed(e, 'terminalLinks.enabled')) { + this._terminalLinks?.dispose(); + this._terminalLinks = undefined; + if (configuration.get('terminalLinks.enabled')) { + this._disposables.push((this._terminalLinks = new GitTerminalLinkProvider(this))); + } + } + + if (configuration.changed(e, 'launchpad.indicator.enabled')) { + this._focusIndicator?.dispose(); + this._focusIndicator = undefined; - this._terminalLinks?.dispose(); - if (configuration.get('terminalLinks.enabled')) { - this._disposables.push((this._terminalLinks = new GitTerminalLinkProvider(this))); + this.telemetry.sendEvent('launchpad/indicator/hidden'); + + if (configuration.get('launchpad.indicator.enabled')) { + this._disposables.push((this._focusIndicator = new FocusIndicator(this, this._focusProvider))); + } } }), ); @@ -329,15 +382,33 @@ export class Container { return this._accountAuthentication; } + private readonly _accountView: WebviewViewProxy<[]>; + get accountView() { + return this._accountView; + } + private readonly _actionRunners: ActionRunners; get actionRunners() { return this._actionRunners; } - private _ai: AIProviderService | undefined; + private _ai: Promise | undefined; get ai() { if (this._ai == null) { - this._disposables.push((this._ai = new AIProviderService(this))); + async function load(this: Container) { + try { + const ai = new ( + await import(/* webpackChunkName: "ai" */ './ai/aiProviderService') + ).AIProviderService(this); + this._disposables.push(ai); + return ai; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._ai = load.call(this); } return this._ai; } @@ -365,6 +436,50 @@ export class Container { return this._cache; } + private _cloudIntegrations: Promise | undefined; + get cloudIntegrations() { + if (this._cloudIntegrations == null) { + async function load(this: Container) { + try { + const cloudIntegrations = new ( + await import( + /* webpackChunkName: "integrations" */ './plus/integrations/authentication/cloudIntegrationService' + ) + ).CloudIntegrationService(this, this._connection); + return cloudIntegrations; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._cloudIntegrations = load.call(this); + } + + return this._cloudIntegrations; + } + + private _drafts: DraftService | undefined; + get drafts() { + if (this._drafts == null) { + this._disposables.push((this._drafts = new DraftService(this, this._connection))); + } + return this._drafts; + } + + private _repositoryIdentity: RepositoryIdentityService | undefined; + get repositoryIdentity() { + if (this._repositoryIdentity == null) { + this._disposables.push((this._repositoryIdentity = new RepositoryIdentityService(this, this._connection))); + } + return this._repositoryIdentity; + } + + private readonly _draftsView: DraftsView; + get draftsView() { + return this._draftsView; + } + private readonly _codeLensController: GitCodeLensController; get codeLens() { return this._codeLensController; @@ -375,7 +490,7 @@ export class Container { return this._commitsView; } - private readonly _commitDetailsView: WebviewViewProxy; + private readonly _commitDetailsView: WebviewViewProxy; get commitDetailsView() { return this._commitDetailsView; } @@ -395,6 +510,25 @@ export class Container { return this._context.extensionMode === ExtensionMode.Development; } + private readonly _deepLinks: DeepLinkService; + get deepLinks() { + return this._deepLinks; + } + + private readonly _documentTracker: GitDocumentTracker; + get documentTracker() { + return this._documentTracker; + } + + private _enrichments: EnrichmentService | undefined; + get enrichments() { + if (this._enrichments == null) { + this._disposables.push((this._enrichments = new EnrichmentService(this, new ServerConnection(this)))); + } + + return this._enrichments; + } + @memoize() get env(): Environment { if (this.prereleaseOrDebugging) { @@ -421,99 +555,94 @@ export class Container { return this._fileHistoryView; } + private readonly _focusProvider: FocusProvider; + get focus(): FocusProvider { + return this._focusProvider; + } + private readonly _git: GitProviderService; get git() { return this._git; } - private readonly _uri: UriService; - get uri() { - return this._uri; - } - - private readonly _deepLinks: DeepLinkService; - get deepLinks() { - return this._deepLinks; - } - - private _github: Promise | undefined; + private _github: Promise | undefined; get github() { if (this._github == null) { - this._github = this._loadGitHubApi(); + async function load(this: Container) { + try { + const github = new ( + await import( + /* webpackChunkName: "integrations" */ './plus/integrations/providers/github/github' + ) + ).GitHubApi(this); + this._disposables.push(github); + return github; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._github = load.call(this); } return this._github; } - private async _loadGitHubApi() { - try { - const github = new (await import(/* webpackChunkName: "github" */ './plus/github/github')).GitHubApi(this); - this._disposables.push(github); - return github; - } catch (ex) { - Logger.error(ex); - return undefined; - } - } - - private _gitlab: Promise | undefined; + private _gitlab: Promise | undefined; get gitlab() { if (this._gitlab == null) { - this._gitlab = this._loadGitLabApi(); + async function load(this: Container) { + try { + const gitlab = new ( + await import( + /* webpackChunkName: "integrations" */ './plus/integrations/providers/gitlab/gitlab' + ) + ).GitLabApi(this); + this._disposables.push(gitlab); + return gitlab; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._gitlab = load.call(this); } return this._gitlab; } - private async _loadGitLabApi() { - try { - const gitlab = new (await import(/* webpackChunkName: "gitlab" */ './plus/gitlab/gitlab')).GitLabApi(this); - this._disposables.push(gitlab); - return gitlab; - } catch (ex) { - Logger.error(ex); - return undefined; - } - } - - private readonly _graphDetailsView: WebviewViewProxy; + private readonly _graphDetailsView: WebviewViewProxy; get graphDetailsView() { return this._graphDetailsView; } - private readonly _graphPanel: WebviewPanelProxy; - private readonly _graphView: WebviewViewProxy; + private readonly _graphView: WebviewViewProxy; get graphView() { return this._graphView; } - private readonly _homeView: WebviewViewProxy; + private readonly _homeView: WebviewViewProxy<[]>; get homeView() { return this._homeView; } - private readonly _accountView: WebviewViewProxy; - get accountView() { - return this._accountView; - } - @memoize() get id() { return this._context.extension.id; } - private _integrationAuthentication: IntegrationAuthenticationService | undefined; - get integrationAuthentication() { - if (this._integrationAuthentication == null) { + private _integrations: IntegrationService | undefined; + get integrations(): IntegrationService { + if (this._integrations == null) { + const authenticationService = new IntegrationAuthenticationService(this); this._disposables.push( - (this._integrationAuthentication = new IntegrationAuthenticationService(this)), - // Register any integration authentication providers - new GitHubAuthenticationProvider(this), - new GitLabAuthenticationProvider(this), + authenticationService, + (this._integrations = new IntegrationService(this, authenticationService)), ); } - - return this._integrationAuthentication; + return this._integrations; } private readonly _keyboard: Keyboard; @@ -536,11 +665,29 @@ export class Container { return this._lineHoverController; } - private readonly _lineTracker: GitLineTracker; + private readonly _lineTracker: LineTracker; get lineTracker() { return this._lineTracker; } + private _mode: ModeConfig | undefined; + get mode() { + if (this._mode == null) { + this._mode = configuration.get('modes')?.[configuration.get('mode.active')]; + } + return this._mode; + } + + private _organizations: OrganizationService; + get organizations() { + return this._organizations; + } + + private readonly _patchDetailsView: WebviewViewProxy; + get patchDetailsView() { + return this._patchDetailsView; + } + private readonly _prerelease; get prerelease() { return this._prerelease; @@ -551,6 +698,11 @@ export class Container { return this._prerelease || this.debugging; } + private readonly _pullRequestView: PullRequestView; + get pullRequestView() { + return this._pullRequestView; + } + private readonly _rebaseEditor: RebaseEditorProvider; get rebaseEditor() { return this._rebaseEditor; @@ -574,14 +726,6 @@ export class Container { return this._repositoryPathMapping; } - private _richRemoteProviders: RichRemoteProviderService | undefined; - get richRemoteProviders(): RichRemoteProviderService { - if (this._richRemoteProviders == null) { - this._richRemoteProviders = new RichRemoteProviderService(this); - } - return this._richRemoteProviders; - } - private readonly _searchAndCompareView: SearchAndCompareView; get searchAndCompareView() { return this._searchAndCompareView; @@ -617,14 +761,14 @@ export class Container { return this._telemetry; } - private readonly _timelineView: WebviewViewProxy; + private readonly _timelineView: WebviewViewProxy; get timelineView() { return this._timelineView; } - private readonly _tracker: GitDocumentTracker; - get tracker() { - return this._tracker; + private readonly _uri: UriService; + get uri() { + return this._uri; } private readonly _usage: UsageTracker; @@ -668,14 +812,6 @@ export class Container { return this._worktreesView; } - private _mode: ModeConfig | undefined; - get mode() { - if (this._mode == null) { - this._mode = configuration.get('modes')?.[configuration.get('mode.active')]; - } - return this._mode; - } - private ensureModeApplied() { const mode = this.mode; if (mode == null) { @@ -777,6 +913,39 @@ export class Container { }, }); } + + @memoize() + private get baseGkDevUri(): Uri { + if (this.env === 'staging') { + return Uri.parse('https://staging.gitkraken.dev'); + } + + if (this.env === 'dev') { + return Uri.parse('https://dev.gitkraken.dev'); + } + + return Uri.parse('https://gitkraken.dev'); + } + + getGkDevUri(path?: string, query?: string): Uri { + let uri = path != null ? Uri.joinPath(this.baseGkDevUri, path) : this.baseGkDevUri; + if (query != null) { + uri = uri.with({ query: query }); + } + return uri; + } + + getGkDevExchangeUri(token: string, successPath: string, failurePath?: string): Uri { + return Uri.joinPath(this.baseGkDevUri, `api/exchange/${token}`).with({ + query: `success=${encodeURIComponent(successPath)}${ + failurePath ? `&failure=${encodeURIComponent(failurePath)}` : '' + }`, + }); + } + + generateWebGkDevUrl(path?: string): string { + return this.getGkDevUri(path, '?source=gitlens').toString(); + } } export function isContainer(container: any): container is Container { diff --git a/src/emojis.ts b/src/emojis.ts index fa40cc758c31d..9784430d16965 100644 --- a/src/emojis.ts +++ b/src/emojis.ts @@ -1,12 +1,12 @@ import { emojis as compressed } from './emojis.generated'; import { decompressFromBase64LZString } from './system/string'; -const emojiRegex = /:([-+_a-z0-9]+):/g; +const emojiRegex = /(^|\s):([-+_a-z0-9]+):($|\s)/g; let emojis: Record | undefined = undefined; export function emojify(message: string) { if (emojis == null) { emojis = JSON.parse(decompressFromBase64LZString(compressed)); } - return message.replace(emojiRegex, (s, code) => emojis![code] || s); + return message.replace(emojiRegex, (s, $1, code, $3) => (emojis![code] ? `${$1}${emojis![code]}${$3}` : s)); } diff --git a/src/env/browser/fetch.ts b/src/env/browser/fetch.ts index ed131b4464707..1841ef17aac4d 100644 --- a/src/env/browser/fetch.ts +++ b/src/env/browser/fetch.ts @@ -9,10 +9,17 @@ declare global { } declare type _BodyInit = BodyInit; +declare type _HeadersInit = HeadersInit; declare type _RequestInit = RequestInit; declare type _Response = Response; declare type _RequestInfo = RequestInfo; -export type { _BodyInit as BodyInit, _RequestInit as RequestInit, _Response as Response, _RequestInfo as RequestInfo }; +export type { + _BodyInit as BodyInit, + _HeadersInit as HeadersInit, + _RequestInit as RequestInit, + _Response as Response, + _RequestInfo as RequestInfo, +}; export function getProxyAgent(_strictSSL?: boolean): HttpsProxyAgent | undefined { return undefined; diff --git a/src/env/browser/platform.ts b/src/env/browser/platform.ts index 436a8a05e4798..7898e1b1dad20 100644 --- a/src/env/browser/platform.ts +++ b/src/env/browser/platform.ts @@ -13,3 +13,7 @@ export function getPlatform(): string { if (isLinux) return 'web-linux'; return 'web'; } + +export function getTempFile(filename: string): string { + return filename; +} diff --git a/src/env/browser/providers.ts b/src/env/browser/providers.ts index 35eb926071413..7e35e661fe37d 100644 --- a/src/env/browser/providers.ts +++ b/src/env/browser/providers.ts @@ -1,7 +1,7 @@ import { Container } from '../../container'; import { GitCommandOptions } from '../../git/commandOptions'; // Force import of GitHub since dynamic imports are not supported in the WebWorker ExtensionHost -import { GitHubGitProvider } from '../../plus/github/githubGitProvider'; +import { GitHubGitProvider } from '../../plus/integrations/providers/github/githubGitProvider'; import { GitProvider } from '../../git/gitProvider'; import { RepositoryWebPathMappingProvider } from './pathMapping/repositoryWebPathMappingProvider'; import { WorkspacesWebPathMappingProvider } from './pathMapping/workspacesWebPathMappingProvider'; diff --git a/src/env/node/fetch.ts b/src/env/node/fetch.ts index 9354060f43d20..50f79ee60a973 100644 --- a/src/env/node/fetch.ts +++ b/src/env/node/fetch.ts @@ -2,12 +2,11 @@ import * as process from 'process'; import * as url from 'url'; import { HttpsProxyAgent } from 'https-proxy-agent'; import fetch from 'node-fetch'; -import type { CoreConfiguration } from '../../constants'; import { configuration } from '../../system/configuration'; import { Logger } from '../../system/logger'; export { fetch }; -export type { BodyInit, RequestInfo, RequestInit, Response } from 'node-fetch'; +export type { BodyInit, HeadersInit, RequestInfo, RequestInit, Response } from 'node-fetch'; export function getProxyAgent(strictSSL?: boolean): HttpsProxyAgent | undefined { let proxyUrl: string | undefined; @@ -17,21 +16,13 @@ export function getProxyAgent(strictSSL?: boolean): HttpsProxyAgent | undefined proxyUrl = proxy.url ?? undefined; strictSSL = strictSSL ?? proxy.strictSSL; } else { - const proxySupport = configuration.getAny( - 'http.proxySupport', - undefined, - 'override', - ); + const proxySupport = configuration.getCore('http.proxySupport', undefined, 'override'); if (proxySupport === 'off') { strictSSL = strictSSL ?? true; } else { - strictSSL = - strictSSL ?? configuration.getAny('http.proxyStrictSSL', undefined, true); - proxyUrl = - configuration.getAny('http.proxy') || - process.env.HTTPS_PROXY || - process.env.HTTP_PROXY; + strictSSL = strictSSL ?? configuration.getCore('http.proxyStrictSSL', undefined, true); + proxyUrl = configuration.getCore('http.proxy') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; } } diff --git a/src/env/node/git/commitMessageProvider.ts b/src/env/node/git/commitMessageProvider.ts new file mode 100644 index 0000000000000..9df7bdf1801a6 --- /dev/null +++ b/src/env/node/git/commitMessageProvider.ts @@ -0,0 +1,80 @@ +import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode'; +import { ProgressLocation, ThemeIcon, window } from 'vscode'; +import type { + CommitMessageProvider, + API as ScmGitApi, + Repository as ScmGitRepository, +} from '../../../@types/vscode.git'; +import type { Container } from '../../../container'; +import { configuration } from '../../../system/configuration'; +import { log } from '../../../system/decorators/log'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; + +class AICommitMessageProvider implements CommitMessageProvider, Disposable { + icon: ThemeIcon = new ThemeIcon('sparkle'); + title: string = 'Generate Commit Message (Experimental)'; + + private readonly _disposable: Disposable; + private _subscription: Disposable | undefined; + + constructor( + private readonly container: Container, + private readonly scmGit: ScmGitApi, + ) { + this._disposable = configuration.onDidChange(this.onConfigurationChanged, this); + + this.onConfigurationChanged(); + } + + private onConfigurationChanged(e?: ConfigurationChangeEvent) { + if (e == null || configuration.changed(e, 'ai.experimental.generateCommitMessage.enabled')) { + if (configuration.get('ai.experimental.generateCommitMessage.enabled')) { + this._subscription = this.scmGit.registerCommitMessageProvider(this); + } else { + this._subscription?.dispose(); + this._subscription = undefined; + } + } + } + + dispose() { + this._subscription?.dispose(); + this._disposable.dispose(); + } + + @log({ args: false }) + async provideCommitMessage(repository: ScmGitRepository, changes: string[], cancellation: CancellationToken) { + const scope = getLogScope(); + + const currentMessage = repository.inputBox.value; + try { + const message = await ( + await this.container.ai + )?.generateCommitMessage(changes, { + cancellation: cancellation, + context: currentMessage, + progress: { + location: ProgressLocation.Notification, + title: 'Generating commit message...', + }, + }); + return currentMessage ? `${currentMessage}\n\n${message}` : message; + } catch (ex) { + Logger.error(scope, ex); + + if (ex instanceof Error && ex.message.startsWith('No changes')) { + void window.showInformationMessage('No changes to generate a commit message from.'); + return; + } + + return undefined; + } + } +} + +export function registerCommitMessageProvider(container: Container, scmGit: ScmGitApi): Disposable | undefined { + return typeof scmGit.registerCommitMessageProvider === 'function' + ? new AICommitMessageProvider(container, scmGit) + : undefined; +} diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 55216e294e90e..cdffa4842423b 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -10,6 +10,10 @@ import { GlyphChars } from '../../../constants'; import type { GitCommandOptions, GitSpawnOptions } from '../../../git/commandOptions'; import { GitErrorHandling } from '../../../git/commandOptions'; import { + BlameIgnoreRevsFileBadRevisionError, + BlameIgnoreRevsFileError, + CherryPickError, + CherryPickErrorReason, FetchError, FetchErrorReason, PullError, @@ -20,14 +24,15 @@ import { StashPushErrorReason, WorkspaceUntrustedError, } from '../../../git/errors'; +import type { GitDir } from '../../../git/gitProvider'; import type { GitDiffFilter } from '../../../git/models/diff'; import { isUncommitted, isUncommittedStaged, shortenRevision } from '../../../git/models/reference'; import type { GitUser } from '../../../git/models/user'; -import { GitBranchParser } from '../../../git/parsers/branchParser'; -import { GitLogParser } from '../../../git/parsers/logParser'; -import { GitReflogParser } from '../../../git/parsers/reflogParser'; +import { parseGitBranchesDefaultFormat } from '../../../git/parsers/branchParser'; +import { parseGitLogAllFormat, parseGitLogDefaultFormat } from '../../../git/parsers/logParser'; +import { parseGitRefLogDefaultFormat } from '../../../git/parsers/reflogParser'; import { parseGitRemoteUrl } from '../../../git/parsers/remoteParser'; -import { GitTagParser } from '../../../git/parsers/tagParser'; +import { parseGitTagsDefaultFormat } from '../../../git/parsers/tagParser'; import { splitAt } from '../../../system/array'; import { configuration } from '../../../system/configuration'; import { log } from '../../../system/decorators/log'; @@ -46,7 +51,6 @@ import { fsExists, isWindows, run, RunError } from './shell'; const emptyArray = Object.freeze([]) as unknown as any[]; const emptyObj = Object.freeze({}); -const emptyStr = ''; const gitBranchDefaultConfigs = Object.freeze(['-c', 'color.branch=false']); const gitDiffDefaultConfigs = Object.freeze(['-c', 'color.diff=false']); @@ -72,10 +76,13 @@ export const GitErrors = { changesWouldBeOverwritten: /Your local changes to the following files would be overwritten/i, commitChangesFirst: /Please, commit your changes before you can/i, conflict: /^CONFLICT \([^)]+\): \b/m, + invalidObjectName: /invalid object name: (.*)\s/i, + invalidObjectNameList: /could not open object name list: (.*)\s/i, noFastForward: /\(non-fast-forward\)/i, noMergeBase: /no merge base/i, noRemoteRepositorySpecified: /No remote repository specified\./i, notAValidObjectName: /Not a valid object name/i, + notAWorkingTree: /'(.*?)' is not a working tree/i, noUserNameConfigured: /Please tell me who you are\./i, invalidLineCount: /file .+? has only \d+ lines/i, uncommittedChanges: /contains modified or untracked files/i, @@ -140,7 +147,16 @@ function defaultExceptionHandler(ex: Error, cwd: string | undefined, start?: [nu throw ex; } +let _uniqueCounterForStdin = 0; +function getStdinUniqueKey(): number { + if (_uniqueCounterForStdin === Number.MAX_SAFE_INTEGER) { + _uniqueCounterForStdin = 0; + } + return _uniqueCounterForStdin++; +} + type ExitCodeOnlyGitCommandOptions = GitCommandOptions & { exitCodeOnly: true }; +export type PushForceOptions = { withLease: true; ifIncludes?: boolean } | { withLease: false; ifIncludes?: never }; export class Git { /** Map of running git commands -- avoids running duplicate overlaping commands */ @@ -172,7 +188,9 @@ export class Git { const gitCommand = `[${runOpts.cwd}] git ${args.join(' ')}`; - const command = `${correlationKey !== undefined ? `${correlationKey}:` : ''}${gitCommand}`; + const command = `${correlationKey !== undefined ? `${correlationKey}:` : ''}${ + options?.stdin != null ? `${getStdinUniqueKey()}:` : '' + }${gitCommand}`; let waiting; let promise = this.pendingCommands.get(command); @@ -223,32 +241,7 @@ export class Git { } } finally { this.pendingCommands.delete(command); - - const duration = getDurationMilliseconds(start); - const slow = duration > slowCallWarningThreshold; - const status = - slow || waiting - ? ` (${slow ? `slow${waiting ? ', waiting' : ''}` : ''}${waiting ? 'waiting' : ''})` - : ''; - - if (exception != null) { - Logger.error( - '', - `[GIT ] ${gitCommand} ${GlyphChars.Dot} ${(exception.message || String(exception) || '') - .trim() - .replace(/fatal: /g, '') - .replace(/\r?\n|\r/g, ` ${GlyphChars.Dot} `)} [${duration}ms]${status}`, - ); - } else if (slow) { - Logger.warn(`[GIT ] ${gitCommand} [*${duration}ms]${status}`); - } else { - Logger.log(`[GIT ] ${gitCommand} [${duration}ms]${status}`); - } - this.logGitCommand( - `${gitCommand}${exception != null ? ` ${GlyphChars.Dot} FAILED` : ''}${waiting ? ' (waited)' : ''}`, - duration, - exception, - ); + this.logGitCommand(gitCommand, exception, getDurationMilliseconds(start), waiting); } } @@ -275,7 +268,7 @@ export class Git { }, }; - const gitCommand = `[${spawnOpts.cwd as string}] git ${args.join(' ')}`; + const gitCommand = `(spawn) [${spawnOpts.cwd as string}] git ${args.join(' ')}`; // Fixes https://github.com/gitkraken/vscode-gitlens/issues/73 & https://github.com/gitkraken/vscode-gitlens/issues/161 // See https://stackoverflow.com/questions/4144417/how-to-handle-asian-characters-in-file-names-in-git-on-os-x @@ -292,9 +285,9 @@ export class Git { } if (cancellation) { - const controller = new AbortController(); - spawnOpts.signal = controller.signal; - cancellation.onCancellationRequested(() => controller.abort()); + const aborter = new AbortController(); + spawnOpts.signal = aborter.signal; + cancellation.onCancellationRequested(() => aborter.abort()); } const proc = spawn(await this.path(), args, spawnOpts); @@ -304,30 +297,7 @@ export class Git { let exception: Error | undefined; proc.once('error', e => (exception = e)); - proc.once('exit', () => { - const duration = getDurationMilliseconds(start); - const slow = duration > slowCallWarningThreshold; - const status = slow ? ' (slow)' : ''; - - if (exception != null) { - Logger.error( - '', - `[SGIT ] ${gitCommand} ${GlyphChars.Dot} ${(exception.message || String(exception) || '') - .trim() - .replace(/fatal: /g, '') - .replace(/\r?\n|\r/g, ` ${GlyphChars.Dot} `)} [${duration}ms]${status}`, - ); - } else if (slow) { - Logger.warn(`[SGIT ] ${gitCommand} [*${duration}ms]${status}`); - } else { - Logger.log(`[SGIT ] ${gitCommand} [${duration}ms]${status}`); - } - this.logGitCommand( - `${gitCommand}${exception != null ? ` ${GlyphChars.Dot} FAILED` : ''}`, - duration, - exception, - ); - }); + proc.once('exit', () => this.logGitCommand(gitCommand, exception, getDurationMilliseconds(start), false)); return proc; } @@ -388,25 +358,68 @@ export class Git { return this.git({ cwd: repoPath, stdin: patch }, ...params); } + async apply2( + repoPath: string, + options?: { + cancellation?: CancellationToken; + configs?: readonly string[]; + errors?: GitErrorHandling; + env?: Record; + stdin?: string; + }, + ...args: string[] + ) { + return this.git( + { + cwd: repoPath, + cancellation: options?.cancellation, + configs: options?.configs ?? gitLogDefaultConfigs, + env: options?.env, + errors: options?.errors, + stdin: options?.stdin, + }, + 'apply', + ...args, + ...(options?.stdin ? ['-'] : emptyArray), + ); + } + private readonly ignoreRevsFileMap = new Map(); async blame( repoPath: string | undefined, fileName: string, - ref?: string, - options: { args?: string[] | null; ignoreWhitespace?: boolean; startLine?: number; endLine?: number } = {}, + options?: ({ ref: string | undefined; contents?: never } | { contents: string; ref?: never }) & { + args?: string[] | null; + correlationKey?: string; + ignoreWhitespace?: boolean; + startLine?: number; + endLine?: number; + }, ) { const [file, root] = splitPath(fileName, repoPath, true); const params = ['blame', '--root', '--incremental']; - if (options.ignoreWhitespace) { + if (options?.ignoreWhitespace) { params.push('-w'); } - if (options.startLine != null && options.endLine != null) { + if (options?.startLine != null && options.endLine != null) { params.push(`-L ${options.startLine},${options.endLine}`); } - if (options.args != null) { + if (options?.args != null) { + // See if the args contains a value like: `--ignore-revs-file ` or `--ignore-revs-file=` to account for user error + // If so split it up into two args + const argIndex = options.args.findIndex( + arg => arg !== '--ignore-revs-file' && arg.startsWith('--ignore-revs-file'), + ); + if (argIndex !== -1) { + const match = /^--ignore-revs-file\s*=?\s*(.*)$/.exec(options.args[argIndex]); + if (match != null) { + options.args.splice(argIndex, 1, '--ignore-revs-file', match[1]); + } + } + params.push(...options.args); } @@ -451,74 +464,77 @@ export class Git { } let stdin; - if (ref) { - if (isUncommittedStaged(ref)) { + if (options?.contents != null) { + // Pipe the blame contents to stdin + params.push('--contents', '-'); + + stdin = options.contents; + } else if (options?.ref) { + if (isUncommittedStaged(options.ref)) { // Pipe the blame contents to stdin params.push('--contents', '-'); // Get the file contents for the staged version using `:` stdin = await this.show(repoPath, fileName, ':'); } else { - params.push(ref); + params.push(options.ref); } } - return this.git({ cwd: root, stdin: stdin }, ...params, '--', file); - } - - blame__contents( - repoPath: string | undefined, - fileName: string, - contents: string, - options: { - args?: string[] | null; - correlationKey?: string; - ignoreWhitespace?: boolean; - startLine?: number; - endLine?: number; - } = {}, - ) { - const [file, root] = splitPath(fileName, repoPath, true); + try { + const blame = await this.git( + { cwd: root, stdin: stdin, correlationKey: options?.correlationKey }, + ...params, + '--', + file, + ); + return blame; + } catch (ex) { + // Since `-c blame.ignoreRevsFile=` doesn't seem to work (unlike as the docs suggest), try to detect the error and throw a more helpful one + let match = GitErrors.invalidObjectNameList.exec(ex.message); + if (match != null) { + throw new BlameIgnoreRevsFileError(match[1], ex); + } - const params = ['blame', '--root', '--incremental']; + match = GitErrors.invalidObjectName.exec(ex.message); + if (match != null) { + throw new BlameIgnoreRevsFileBadRevisionError(match[1], ex); + } - if (options.ignoreWhitespace) { - params.push('-w'); - } - if (options.startLine != null && options.endLine != null) { - params.push(`-L ${options.startLine},${options.endLine}`); - } - if (options.args != null) { - params.push(...options.args); + throw ex; } + } - // Pipe the blame contents to stdin - params.push('--contents', '-'); - - return this.git( - { cwd: root, stdin: contents, correlationKey: options.correlationKey }, - ...params, - '--', - file, - ); + branch__set_upstream(repoPath: string, branch: string, remote: string, remoteBranch: string) { + return this.git({ cwd: repoPath }, 'branch', '--set-upstream-to', `${remote}/${remoteBranch}`, branch); } - branch__containsOrPointsAt( + branchOrTag__containsOrPointsAt( repoPath: string, - ref: string, - { - mode = 'contains', - name = undefined, - remotes = false, - }: { mode?: 'contains' | 'pointsAt'; name?: string; remotes?: boolean } = {}, + refs: string[], + options?: { + type?: 'branch' | 'tag'; + all?: boolean; + mode?: 'contains' | 'pointsAt'; + name?: string; + remotes?: boolean; + }, ) { - const params = ['branch']; - if (remotes) { + const params: string[] = [options?.type ?? 'branch']; + if (options?.all) { + params.push('-a'); + } else if (options?.remotes) { params.push('-r'); } - params.push(mode === 'pointsAt' ? `--points-at=${ref}` : `--contains=${ref}`, '--format=%(refname:short)'); - if (name != null) { - params.push(name); + + params.push('--format=%(refname:short)'); + + for (const ref of refs) { + params.push(options?.mode === 'pointsAt' ? `--points-at=${ref}` : `--contains=${ref}`); + } + + if (options?.name != null) { + params.push(options.name); } return this.git( @@ -527,6 +543,11 @@ export class Git { ); } + async cat_file__size(repoPath: string, oid: string): Promise { + const data = await this.git({ cwd: repoPath }, 'cat-file', '-s', oid); + return data.length ? parseInt(data.trim(), 10) : 0; + } + check_ignore(repoPath: string, ...files: string[]) { return this.git( { cwd: repoPath, errors: GitErrorHandling.Ignore, stdin: files.join('\0') }, @@ -577,6 +598,31 @@ export class Git { return this.git({ cwd: repoPath }, ...params); } + async cherrypick(repoPath: string, sha: string, options: { noCommit?: boolean; errors?: GitErrorHandling } = {}) { + const params = ['cherry-pick']; + if (options?.noCommit) { + params.push('-n'); + } + params.push(sha); + + try { + await this.git({ cwd: repoPath, errors: options?.errors }, ...params); + } catch (ex) { + const msg: string = ex?.toString() ?? ''; + let reason: CherryPickErrorReason = CherryPickErrorReason.Other; + if ( + GitErrors.changesWouldBeOverwritten.test(msg) || + GitErrors.changesWouldBeOverwritten.test(ex.stderr ?? '') + ) { + reason = CherryPickErrorReason.AbortedWouldOverwrite; + } else if (GitErrors.conflict.test(msg) || GitErrors.conflict.test(ex.stdout ?? '')) { + reason = CherryPickErrorReason.Conflicts; + } + + throw new CherryPickError(reason, ex, sha); + } + } + // TODO: Expand to include options and other params async clone(url: string, parentPath: string): Promise { let count = 0; @@ -680,7 +726,7 @@ export class Git { const [, ref] = match; // If the bad ref is trying to find a parent ref, assume we hit to the last commit, so try again using the root sha - if (ref === ref1 && ref != null && ref.endsWith('^')) { + if (ref === ref1 && ref?.endsWith('^')) { return this.diff(repoPath, fileName, rootSha, ref2, options); } } @@ -765,7 +811,7 @@ export class Git { const [, matchedRef] = match; // If the bad ref is trying to find a parent ref, assume we hit to the last commit, so try again using the root sha - if (matchedRef === ref && matchedRef != null && matchedRef.endsWith('^')) { + if (matchedRef === ref && matchedRef?.endsWith('^')) { return this.diff__contents(repoPath, fileName, rootSha, contents, options); } } @@ -778,17 +824,17 @@ export class Git { repoPath: string, ref1?: string, ref2?: string, - { filters, similarityThreshold }: { filters?: GitDiffFilter[]; similarityThreshold?: number | null } = {}, + options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ) { const params = [ 'diff', '--name-status', - `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, + `-M${options?.similarityThreshold == null ? '' : `${options?.similarityThreshold}%`}`, '--no-ext-diff', '-z', ]; - if (filters != null && filters.length !== 0) { - params.push(`--diff-filter=${filters.join('')}`); + if (options?.filters?.length) { + params.push(`--diff-filter=${options.filters.join('')}`); } if (ref1) { params.push(ref1); @@ -797,7 +843,12 @@ export class Git { params.push(ref2); } - return this.git({ cwd: repoPath, configs: gitDiffDefaultConfigs }, ...params, '--'); + params.push('--'); + if (options?.path) { + params.push(options.path); + } + + return this.git({ cwd: repoPath, configs: gitDiffDefaultConfigs }, ...params); } async diff__shortstat(repoPath: string, ref?: string) { @@ -900,17 +951,32 @@ export class Git { async push( repoPath: string, - options: { branch?: string; force?: boolean; publish?: boolean; remote?: string; upstream?: string }, + options: { + branch?: string; + force?: PushForceOptions; + publish?: boolean; + remote?: string; + upstream?: string; + }, ): Promise { const params = ['push']; - if (options.force) { - params.push('--force'); + if (options.force != null) { + if (options.force.withLease) { + params.push('--force-with-lease'); + if (options.force.ifIncludes) { + if (await this.isAtLeastVersion('2.30.0')) { + params.push('--force-if-includes'); + } + } + } else { + params.push('--force'); + } } if (options.branch && options.remote) { if (options.upstream) { - params.push('-u', options.remote, `${options.upstream}:${options.branch}`); + params.push('-u', options.remote, `${options.branch}:${options.upstream}`); } else if (options.publish) { params.push('--set-upstream', options.remote, options.branch); } else { @@ -930,7 +996,20 @@ export class Git { } else if (GitWarnings.tipBehind.test(msg) || GitWarnings.tipBehind.test(ex.stderr ?? '')) { reason = PushErrorReason.TipBehind; } else if (GitErrors.pushRejected.test(msg) || GitErrors.pushRejected.test(ex.stderr ?? '')) { - reason = PushErrorReason.PushRejected; + if (options?.force?.withLease) { + if (/! \[rejected\].*\(stale info\)/m.test(ex.stderr || '')) { + reason = PushErrorReason.PushRejectedWithLease; + } else if ( + options.force.ifIncludes && + /! \[rejected\].*\(remote ref updated since checkout\)/m.test(ex.stderr || '') + ) { + reason = PushErrorReason.PushRejectedWithLeaseIfIncludes; + } else { + reason = PushErrorReason.PushRejected; + } + } else { + reason = PushErrorReason.PushRejected; + } } else if (GitErrors.permissionDenied.test(msg) || GitErrors.permissionDenied.test(ex.stderr ?? '')) { reason = PushErrorReason.PermissionDenied; } else if (GitErrors.remoteConnection.test(msg) || GitErrors.remoteConnection.test(ex.stderr ?? '')) { @@ -945,7 +1024,7 @@ export class Git { async pull( repoPath: string, - options: { branch?: string; remote?: string; rebase?: boolean; tags?: boolean }, + options: { branch?: string; remote?: string; upstream?: string; rebase?: boolean; tags?: boolean }, ): Promise { const params = ['pull']; @@ -959,7 +1038,7 @@ export class Git { if (options.remote && options.branch) { params.push(options.remote); - params.push(options.branch); + params.push(options.upstream ? `${options.upstream}:${options.branch}` : options.branch); } try { @@ -1003,7 +1082,7 @@ export class Git { } for_each_ref__branch(repoPath: string, options: { all: boolean } = { all: false }) { - const params = ['for-each-ref', `--format=${GitBranchParser.defaultFormat}`, 'refs/heads']; + const params = ['for-each-ref', `--format=${parseGitBranchesDefaultFormat}`, 'refs/heads']; if (options.all) { params.push('refs/remotes'); } @@ -1012,85 +1091,6 @@ export class Git { } log( - repoPath: string, - ref: string | undefined, - { - all, - argsOrFormat, - authors, - limit, - merges, - ordering, - similarityThreshold, - since, - until, - }: { - all?: boolean; - argsOrFormat?: string | string[]; - authors?: GitUser[]; - limit?: number; - merges?: boolean; - ordering?: 'date' | 'author-date' | 'topo' | null; - similarityThreshold?: number | null; - since?: number | string; - until?: number | string; - }, - ) { - if (argsOrFormat == null) { - argsOrFormat = ['--name-status', `--format=${all ? GitLogParser.allFormat : GitLogParser.defaultFormat}`]; - } - - if (typeof argsOrFormat === 'string') { - argsOrFormat = [`--format=${argsOrFormat}`]; - } - - const params = [ - 'log', - ...argsOrFormat, - '--full-history', - `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, - '-m', - ]; - - if (ordering) { - params.push(`--${ordering}-order`); - } - - if (limit) { - params.push(`-n${limit + 1}`); - } - - if (since) { - params.push(`--since="${since}"`); - } - - if (until) { - params.push(`--until="${until}"`); - } - - if (!merges) { - params.push('--first-parent'); - } - - if (authors != null && authors.length !== 0) { - if (!params.includes('--use-mailmap')) { - params.push('--use-mailmap'); - } - params.push(...authors.map(a => `--author=^${a.name} <${a.email}>$`)); - } - - if (all) { - params.push('--all', '--single-worktree'); - } - - if (ref && !isUncommittedStaged(ref)) { - params.push(ref); - } - - return this.git({ cwd: repoPath, configs: gitLogDefaultConfigsWithFiles }, ...params, '--'); - } - - log2( repoPath: string, options?: { cancellation?: CancellationToken; @@ -1205,8 +1205,8 @@ export class Git { // TODO@eamodio remove this in favor of argsOrFormat fileMode = 'full', filters, - firstParent = false, limit, + merges = false, ordering, renames = true, reverse = false, @@ -1220,8 +1220,8 @@ export class Git { // TODO@eamodio remove this in favor of argsOrFormat fileMode?: 'full' | 'simple' | 'none'; filters?: GitDiffFilter[]; - firstParent?: boolean; limit?: number; + merges?: boolean; ordering?: 'date' | 'author-date' | 'topo' | null; renames?: boolean; reverse?: boolean; @@ -1234,7 +1234,7 @@ export class Git { const [file, root] = splitPath(fileName, repoPath, true); if (argsOrFormat == null) { - argsOrFormat = [`--format=${all ? GitLogParser.allFormat : GitLogParser.defaultFormat}`]; + argsOrFormat = [`--format=${all ? parseGitLogAllFormat : parseGitLogDefaultFormat}`]; } if (typeof argsOrFormat === 'string') { @@ -1263,18 +1263,17 @@ export class Git { params.push('--all', '--single-worktree'); } - // Can't allow rename detection (`--follow`) if `all` or a `startLine` is specified - if (renames && (all || startLine != null)) { + if (merges) { + params.push('--first-parent'); + } + + // Can't allow rename detection (`--follow`) if a `startLine` is specified + if (renames && startLine != null) { renames = false; } - params.push(renames ? '--follow' : '-m'); - if (/*renames ||*/ firstParent) { - params.push('--first-parent'); - // In Git >= 2.29.0 `--first-parent` implies `-m`, so lets include it for consistency - if (renames) { - params.push('-m'); - } + if (renames) { + params.push('--follow'); } if (filters != null && filters.length !== 0) { @@ -1353,13 +1352,13 @@ export class Git { async log__find_object( repoPath: string, - objectId: string, + oid: string, ref: string, ordering: 'date' | 'author-date' | 'topo' | null, file?: string, cancellation?: CancellationToken, ) { - const params = ['log', '-n1', '--no-renames', '--format=%H', `--find-object=${objectId}`, ref]; + const params = ['log', '-n1', '--no-renames', '--format=%H', `--find-object=${oid}`, ref]; if (ordering) { params.push(`--${ordering}-order`); @@ -1431,7 +1430,7 @@ export class Git { 'show', '--stdin', '--name-status', - `--format=${GitLogParser.defaultFormat}`, + `--format=${parseGitLogDefaultFormat}`, '--use-mailmap', ); } @@ -1444,7 +1443,7 @@ export class Git { 'log', ...(options?.stdin ? ['--stdin'] : emptyArray), '--name-status', - `--format=${GitLogParser.defaultFormat}`, + `--format=${parseGitLogDefaultFormat}`, '--use-mailmap', ...search, ...(options?.ordering ? [`--${options.ordering}-order`] : emptyArray), @@ -1465,14 +1464,18 @@ export class Git { async ls_files( repoPath: string, fileName: string, - { ref, untracked }: { ref?: string; untracked?: boolean } = {}, + options?: { ref?: string; untracked?: boolean }, ): Promise { const params = ['ls-files']; - if (ref && !isUncommitted(ref)) { - params.push(`--with-tree=${ref}`); + if (options?.ref) { + if (!isUncommitted(options.ref)) { + params.push(`--with-tree=${options.ref}`); + } else if (isUncommittedStaged(options.ref)) { + params.push('--stage'); + } } - if (!ref && untracked) { + if (!options?.ref && options?.untracked) { params.push('-o'); } @@ -1535,7 +1538,7 @@ export class Git { skip?: number; } = {}, ): Promise { - const params = ['log', '--walk-reflogs', `--format=${GitReflogParser.defaultFormat}`, '--date=iso8601']; + const params = ['log', '--walk-reflogs', `--format=${parseGitRefLogDefaultFormat}`, '--date=iso8601']; if (ordering) { params.push(`--${ordering}-order`); @@ -1631,12 +1634,17 @@ export class Git { async rev_list__left_right( repoPath: string, refs: string[], + authors?: GitUser[] | undefined, ): Promise<{ ahead: number; behind: number } | undefined> { + const params = ['rev-list', '--left-right', '--count']; + + if (authors?.length) { + params.push(...authors.map(a => `--author=^${a.name} <${a.email}>$`)); + } + const data = await this.git( { cwd: repoPath, errors: GitErrorHandling.Ignore }, - 'rev-list', - '--left-right', - '--count', + ...params, ...refs, '--', ); @@ -2015,34 +2023,47 @@ export class Git { ); } + async stash__create(repoPath: string): Promise { + const params = ['stash', 'create']; + + const data = await this.git({ cwd: repoPath }, ...params); + return data?.trim() || undefined; + } + + async stash__store(repoPath: string, sha: string, message?: string): Promise { + const params = ['stash', 'store']; + + if (message) { + params.push('-m', message); + } + + params.push(sha); + + await this.git({ cwd: repoPath }, ...params); + } + async stash__push( repoPath: string, message?: string, - { - includeUntracked, - keepIndex, - onlyStaged, - pathspecs, - stdin, - }: { + options?: { includeUntracked?: boolean; keepIndex?: boolean; onlyStaged?: boolean; pathspecs?: string[]; stdin?: boolean; - } = {}, + }, ): Promise { const params = ['stash', 'push']; - if (includeUntracked || (pathspecs != null && pathspecs.length !== 0)) { - params.push('-u'); + if ((options?.includeUntracked || options?.pathspecs?.length) && !options?.onlyStaged) { + params.push('--include-untracked'); } - if (keepIndex) { - params.push('-k'); + if (options?.keepIndex && !options?.includeUntracked) { + params.push('--keep-index'); } - if (onlyStaged) { + if (options?.onlyStaged) { if (await this.isAtLeastVersion('2.35')) { params.push('--staged'); } else { @@ -2054,24 +2075,24 @@ export class Git { params.push('-m', message); } - if (stdin && pathspecs != null && pathspecs.length !== 0) { - void (await this.git( - { cwd: repoPath, stdin: pathspecs.join('\0') }, - ...params, - '--pathspec-from-file=-', - '--pathspec-file-nul', - )); - - return; - } - - params.push('--'); - if (pathspecs != null && pathspecs.length !== 0) { - params.push(...pathspecs); + let stdin; + if (options?.pathspecs?.length) { + if (options.stdin) { + stdin = options.pathspecs.join('\0'); + params.push('--pathspec-from-file=-', '--pathspec-file-nul', '--'); + } else { + params.push('--', ...options.pathspecs); + } + } else { + params.push('--'); } try { - void (await this.git({ cwd: repoPath }, ...params)); + const data = await this.git({ cwd: repoPath, stdin: stdin }, ...params); + if (data.includes('No local changes to save')) { + throw new StashPushError(StashPushErrorReason.NothingToSave); + return; + } } catch (ex) { if ( ex instanceof RunError && @@ -2087,7 +2108,7 @@ export class Git { async status( repoPath: string, porcelainVersion: number = 1, - { similarityThreshold }: { similarityThreshold?: number | null } = {}, + options?: { similarityThreshold?: number }, ): Promise { const params = [ 'status', @@ -2096,7 +2117,9 @@ export class Git { '-u', ]; if (await this.isAtLeastVersion('2.18')) { - params.push(`--find-renames${similarityThreshold == null ? '' : `=${similarityThreshold}%`}`); + params.push( + `--find-renames${options?.similarityThreshold == null ? '' : `=${options.similarityThreshold}%`}`, + ); } return this.git( @@ -2106,33 +2129,12 @@ export class Git { ); } - async status__file( - repoPath: string, - fileName: string, - porcelainVersion: number = 1, - { similarityThreshold }: { similarityThreshold?: number | null } = {}, - ): Promise { - const [file, root] = splitPath(fileName, repoPath, true); - - const params = ['status', porcelainVersion >= 2 ? `--porcelain=v${porcelainVersion}` : '--porcelain']; - if (await this.isAtLeastVersion('2.18')) { - params.push(`--find-renames${similarityThreshold == null ? '' : `=${similarityThreshold}%`}`); - } - - return this.git( - { cwd: root, configs: gitStatusDefaultConfigs, env: { GIT_OPTIONAL_LOCKS: '0' } }, - ...params, - '--', - file, - ); - } - symbolic_ref(repoPath: string, ref: string) { return this.git({ cwd: repoPath }, 'symbolic-ref', '--short', ref); } tag(repoPath: string) { - return this.git({ cwd: repoPath }, 'tag', '-l', `--format=${GitTagParser.defaultFormat}`); + return this.git({ cwd: repoPath }, 'tag', '-l', `--format=${parseGitTagsDefaultFormat}`); } worktree__add( @@ -2177,22 +2179,22 @@ export class Git { } async readDotGitFile( - repoPath: string, - paths: string[], + gitDir: GitDir, + pathParts: string[], options?: { numeric?: false; throw?: boolean; trim?: boolean }, ): Promise; async readDotGitFile( - repoPath: string, - path: string[], + gitDir: GitDir, + pathParts: string[], options?: { numeric: true; throw?: boolean; trim?: boolean }, ): Promise; async readDotGitFile( - repoPath: string, + gitDir: GitDir, pathParts: string[], options?: { numeric?: boolean; throw?: boolean; trim?: boolean }, ): Promise { try { - const bytes = await workspace.fs.readFile(Uri.file(joinPaths(repoPath, '.git', ...pathParts))); + const bytes = await workspace.fs.readFile(Uri.joinPath(gitDir.uri, ...pathParts)); let contents = textDecoder.decode(bytes); contents = options?.trim ?? true ? contents.trim() : contents; @@ -2234,7 +2236,7 @@ export class Git { } Logger.log(scope, `\u2022 '${text}'`); - this.logGitCommand(`[TERM] ${text}`, 0); + this.logCore(`[TERM] ${text}`); const terminal = ensureGitTerminal(); terminal.show(false); @@ -2244,31 +2246,37 @@ export class Git { terminal.sendText(text, options?.execute ?? false); } - private _gitOutput: OutputChannel | undefined; + private logGitCommand(command: string, ex: Error | undefined, duration: number, waiting: boolean): void { + const slow = duration > slowCallWarningThreshold; + const status = slow && waiting ? ' (slow, waiting)' : waiting ? ' (waiting)' : slow ? ' (slow)' : ''; + + if (ex != null) { + Logger.error( + '', + `[GIT ] ${command} ${GlyphChars.Dot} ${(ex.message || String(ex) || '') + .trim() + .replace(/fatal: /g, '') + .replace(/\r?\n|\r/g, ` ${GlyphChars.Dot} `)} [${duration}ms]${status}`, + ); + } else if (slow) { + Logger.warn(`[GIT ] ${command} [*${duration}ms]${status}`); + } else { + Logger.log(`[GIT ] ${command} [${duration}ms]${status}`); + } - private logGitCommand(command: string, duration: number, ex?: Error): void { - if (!Logger.enabled('debug') && !Logger.isDebugging) return; + this.logCore(`[${slow ? '*' : ' '}${duration.toString().padStart(6)}ms] ${command}${status}`, ex); + } - const slow = duration > slowCallWarningThreshold; + private _gitOutput: OutputChannel | undefined; - if (Logger.isDebugging) { - if (ex != null) { - console.error(Logger.timestamp, '[GitLens (Git)]', command ?? emptyStr, ex); - } else if (slow) { - console.warn(Logger.timestamp, '[GitLens (Git)]', command ?? emptyStr); - } else { - console.log(Logger.timestamp, '[GitLens (Git)]', command ?? emptyStr); - } - } + private logCore(message: string, ex?: Error | undefined): void { + if (!Logger.enabled(ex != null ? 'error' : 'debug')) return; - if (this._gitOutput == null) { - this._gitOutput = window.createOutputChannel('GitLens (Git)'); + this._gitOutput ??= window.createOutputChannel('GitLens (Git)'); + this._gitOutput.appendLine(`${Logger.timestamp} ${message}${ex != null ? ` ${GlyphChars.Dot} FAILED` : ''}`); + if (ex != null) { + this._gitOutput.appendLine(`\n${String(ex)}\n`); } - this._gitOutput.appendLine( - `${Logger.timestamp} [${slow ? '*' : ' '}${duration.toString().padStart(6)}ms] ${command}${ - ex != null ? `\n\n${ex.toString()}` : emptyStr - }`, - ); } } diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 4b6224b2d7f84..a99df2b0384f6 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -1,6 +1,6 @@ -import { readdir, realpath } from 'fs'; -import { homedir, hostname, userInfo } from 'os'; -import { resolve as resolvePath } from 'path'; +import { promises as fs, readdir, realpath } from 'fs'; +import { homedir, hostname, tmpdir, userInfo } from 'os'; +import path, { resolve as resolvePath } from 'path'; import { env as process_env } from 'process'; import type { CancellationToken, Event, TextDocument, WorkspaceFolder } from 'vscode'; import { Disposable, env, EventEmitter, extensions, FileType, Range, Uri, window, workspace } from 'vscode'; @@ -8,25 +8,29 @@ import { md5 } from '@env/crypto'; import { fetch, getProxyAgent } from '@env/fetch'; import { hrtime } from '@env/hrtime'; import { isLinux, isWindows } from '@env/platform'; -import type { - API as BuiltInGitApi, - Repository as BuiltInGitRepository, - GitExtension, -} from '../../../@types/vscode.git'; +import type { GitExtension, API as ScmGitApi } from '../../../@types/vscode.git'; import { getCachedAvatarUri } from '../../../avatars'; -import type { CoreConfiguration, CoreGitConfiguration } from '../../../constants'; import { GlyphChars, Schemes } from '../../../constants'; import type { Container } from '../../../container'; import { emojify } from '../../../emojis'; +import { CancellationError } from '../../../errors'; import { Features } from '../../../features'; import { GitErrorHandling } from '../../../git/commandOptions'; import { + ApplyPatchCommitError, + ApplyPatchCommitErrorReason, + BlameIgnoreRevsFileBadRevisionError, + BlameIgnoreRevsFileError, + CherryPickError, + CherryPickErrorReason, FetchError, GitSearchError, PullError, PushError, + PushErrorReason, StashApplyError, StashApplyErrorReason, + StashPushError, WorktreeCreateError, WorktreeCreateErrorReason, WorktreeDeleteError, @@ -39,6 +43,7 @@ import type { GitProviderDescriptor, NextComparisonUrisResult, PagedResult, + PagingOptions, PreviousComparisonUrisResult, PreviousLineComparisonUrisResult, RepositoryCloseEvent, @@ -48,7 +53,7 @@ import type { RevisionUriData, ScmRepository, } from '../../../git/gitProvider'; -import { encodeGitLensRevisionUriAuthority, GitUri } from '../../../git/gitUri'; +import { encodeGitLensRevisionUriAuthority, GitUri, isGitUri } from '../../../git/gitUri'; import type { GitBlame, GitBlameAuthor, GitBlameLine, GitBlameLines } from '../../../git/models/blame'; import type { BranchSortOptions } from '../../../git/models/branch'; import { @@ -64,7 +69,14 @@ import type { GitStashCommit } from '../../../git/models/commit'; import { GitCommit, GitCommitIdentity } from '../../../git/models/commit'; import { deletedOrMissing, uncommitted, uncommittedStaged } from '../../../git/models/constants'; import { GitContributor } from '../../../git/models/contributor'; -import type { GitDiff, GitDiffFile, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat } from '../../../git/models/diff'; +import type { + GitDiff, + GitDiffFile, + GitDiffFiles, + GitDiffFilter, + GitDiffLine, + GitDiffShortStat, +} from '../../../git/models/diff'; import type { GitFile, GitFileStatus } from '../../../git/models/file'; import { GitFileChange } from '../../../git/models/file'; import type { @@ -80,7 +92,7 @@ import type { import type { GitLog } from '../../../git/models/log'; import type { GitMergeStatus } from '../../../git/models/merge'; import type { GitRebaseStatus } from '../../../git/models/rebase'; -import type { GitBranchReference } from '../../../git/models/reference'; +import type { GitBranchReference, GitReference, GitTagReference } from '../../../git/models/reference'; import { createReference, getBranchTrackingWithoutRemote, @@ -94,7 +106,8 @@ import { shortenRevision, } from '../../../git/models/reference'; import type { GitReflog } from '../../../git/models/reflog'; -import { getRemoteIconUri, getVisibilityCacheKey, GitRemote } from '../../../git/models/remote'; +import type { GitRemote } from '../../../git/models/remote'; +import { getRemoteIconUri, getVisibilityCacheKey, sortRemotes } from '../../../git/models/remote'; import { RemoteResourceType } from '../../../git/models/remoteResource'; import type { RepositoryChangeEvent } from '../../../git/models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; @@ -107,9 +120,14 @@ import type { GitTreeEntry } from '../../../git/models/tree'; import type { GitUser } from '../../../git/models/user'; import { isUserMatch } from '../../../git/models/user'; import type { GitWorktree } from '../../../git/models/worktree'; -import { GitBlameParser } from '../../../git/parsers/blameParser'; -import { GitBranchParser } from '../../../git/parsers/branchParser'; -import { parseDiffNameStatusFiles, parseDiffShortStat, parseFileDiff } from '../../../git/parsers/diffParser'; +import { parseGitBlame } from '../../../git/parsers/blameParser'; +import { parseGitBranches } from '../../../git/parsers/branchParser'; +import { + parseGitApplyFiles, + parseGitDiffNameStatusFiles, + parseGitDiffShortStat, + parseGitFileDiff, +} from '../../../git/parsers/diffParser'; import { createLogParserSingle, createLogParserWithFiles, @@ -118,19 +136,25 @@ import { getGraphStatsParser, getRefAndDateParser, getRefParser, - GitLogParser, LogType, + parseGitLog, + parseGitLogAllFormat, + parseGitLogDefaultFormat, + parseGitLogSimple, + parseGitLogSimpleFormat, + parseGitLogSimpleRenamed, } from '../../../git/parsers/logParser'; -import { GitReflogParser } from '../../../git/parsers/reflogParser'; -import { GitRemoteParser } from '../../../git/parsers/remoteParser'; -import { GitStatusParser } from '../../../git/parsers/statusParser'; -import { GitTagParser } from '../../../git/parsers/tagParser'; -import { GitTreeParser } from '../../../git/parsers/treeParser'; -import { GitWorktreeParser } from '../../../git/parsers/worktreeParser'; +import { parseGitRefLog } from '../../../git/parsers/reflogParser'; +import { parseGitRemotes } from '../../../git/parsers/remoteParser'; +import { parseGitStatus } from '../../../git/parsers/statusParser'; +import { parseGitTags } from '../../../git/parsers/tagParser'; +import { parseGitLsFiles, parseGitTree } from '../../../git/parsers/treeParser'; +import { parseGitWorktrees } from '../../../git/parsers/worktreeParser'; import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../git/remotes/remoteProviders'; import type { GitSearch, GitSearchResultData, GitSearchResults, SearchQuery } from '../../../git/search'; import { getGitArgsFromSearchQuery, getSearchQueryComparisonKey } from '../../../git/search'; import { + showBlameInvalidIgnoreRevsFileWarningMessage, showGenericErrorMessage, showGitDisabledErrorMessage, showGitInvalidConfigErrorMessage, @@ -163,6 +187,7 @@ import { joinPaths, maybeUri, normalizePath, + pathEquals, relative, splitPath, } from '../../../system/path'; @@ -172,9 +197,10 @@ import { equalsIgnoreCase, getDurationMilliseconds, interpolate, splitSingle } f import { PathTrie } from '../../../system/trie'; import { compare, fromString } from '../../../system/version'; import { serializeWebviewItemContext } from '../../../system/webview'; -import type { CachedBlame, CachedDiff, CachedLog, TrackedDocument } from '../../../trackers/gitDocumentTracker'; -import { GitDocumentState } from '../../../trackers/gitDocumentTracker'; -import type { Git } from './git'; +import type { CachedBlame, CachedDiff, CachedLog, TrackedGitDocument } from '../../../trackers/trackedDocument'; +import { GitDocumentState } from '../../../trackers/trackedDocument'; +import { registerCommitMessageProvider } from './commitMessageProvider'; +import type { Git, PushForceOptions } from './git'; import { getShaInLogRegex, GitErrors, @@ -199,7 +225,8 @@ const driveLetterRegex = /(?<=^\/?)([a-zA-Z])(?=:\/)/; const userConfigRegex = /^user\.(name|email) (.*)$/gm; const mappedAuthorRegex = /(.+)\s<(.+)>/; const stashSummaryRegex = - /(?:(?:(?WIP) on|On) (?[^/](?!.*\/\.)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[^\000-\037\177 ~^:?*[\\]+[^./]):\s*)?(?

.*)$/s; + // eslint-disable-next-line no-control-regex + /(?:(?:(?WIP) on|On) (?[^/](?!.*\/\.)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[^\x00-\x1F\x7F ~^:?*[\\]+[^./]):\s*)?(?.*)$/s; const reflogCommands = ['merge', 'pull']; @@ -239,14 +266,15 @@ export class LocalGitProvider implements GitProvider, Disposable { } private readonly _branchesCache = new Map>>(); - private readonly _contributorsCache = new Map>(); - private readonly _mergeStatusCache = new Map(); - private readonly _rebaseStatusCache = new Map(); + private readonly _contributorsCache = new Map>>(); + private readonly _mergeStatusCache = new Map>(); + private readonly _rebaseStatusCache = new Map>(); private readonly _remotesCache = new Map>(); private readonly _repoInfoCache = new Map(); private readonly _stashesCache = new Map(); private readonly _tagsCache = new Map>>(); private readonly _trackedPaths = new PathTrie>(); + private readonly _worktreesCache = new Map>(); private _disposables: Disposable[] = []; @@ -259,13 +287,11 @@ export class LocalGitProvider implements GitProvider, Disposable { this._disposables.push( configuration.onDidChange(e => { if (configuration.changed(e, 'remotes')) { - this.resetCaches('remotes'); + this.resetCaches(undefined, 'remotes'); } }, this), this.container.events.on('git:cache:reset', e => - e.data.repoPath - ? this.resetCache(e.data.repoPath, ...(e.data.caches ?? emptyArray)) - : this.resetCaches(...(e.data.caches ?? emptyArray)), + this.resetCaches(e.data.repoPath, ...(e.data.caches ?? emptyArray)), ), ); } @@ -286,12 +312,9 @@ export class LocalGitProvider implements GitProvider, Disposable { if (e.changed(RepositoryChange.Heads, RepositoryChange.Remotes, RepositoryChangeComparisonMode.Any)) { this._branchesCache.delete(repo.path); this._contributorsCache.delete(repo.path); - this._contributorsCache.delete(`stats|${repo.path}`); } if (e.changed(RepositoryChange.Remotes, RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { - const remotes = this._remotesCache.get(repo.path); - void disposeRemotes([remotes]); this._remotesCache.delete(repo.path); } @@ -315,6 +338,10 @@ export class LocalGitProvider implements GitProvider, Disposable { this._tagsCache.delete(repo.path); } + if (e.changed(RepositoryChange.Worktrees, RepositoryChangeComparisonMode.Any)) { + this._worktreesCache.delete(repo.path); + } + this._onDidChangeRepository.fire(e); } @@ -331,7 +358,7 @@ export class LocalGitProvider implements GitProvider, Disposable { private async findGit(): Promise { const scope = getLogScope(); - if (!configuration.getAny('git.enabled', null, true)) { + if (!configuration.getCore('git.enabled', null, true)) { Logger.log(scope, 'Built-in Git is disabled ("git.enabled": false)'); void showGitDisabledErrorMessage(); @@ -344,30 +371,40 @@ export class LocalGitProvider implements GitProvider, Disposable { const scmGit = await scmGitPromise; if (scmGit == null) return; + registerCommitMessageProvider(this.container, scmGit); + // Find env to pass to Git - if (configuration.get('experimental.nativeGit')) { - for (const v of Object.values(scmGit.git)) { - if (v != null && typeof v === 'object' && 'git' in v) { - for (const vv of Object.values(v.git)) { - if (vv != null && typeof vv === 'object' && 'GIT_ASKPASS' in vv) { - Logger.debug(scope, 'Found built-in Git env'); - - this.git.setEnv(vv); - break; - } + for (const v of Object.values(scmGit.git)) { + if (v != null && typeof v === 'object' && 'git' in v) { + for (const vv of Object.values(v.git)) { + if (vv != null && typeof vv === 'object' && 'GIT_ASKPASS' in vv) { + Logger.debug(scope, 'Found built-in Git env'); + + this.git.setEnv(vv); + break; } } } } + const closing = new Set(); + const fireRepositoryClosed = debounce(() => { + if (this.container.deactivating) return; + + for (const uri of closing) { + this._onDidCloseRepository.fire({ uri: uri }); + } + closing.clear(); + }, 1000); + this._disposables.push( // Since we will get "close" events for repos when vscode is shutting down, debounce the event so ensure we aren't shutting down - scmGit.onDidCloseRepository( - debounce(e => { - if (this.container.deactivating) return; - this._onDidCloseRepository.fire({ uri: e.rootUri }); - }, 1000), - ), + scmGit.onDidCloseRepository(e => { + if (this.container.deactivating) return; + + closing.add(e.rootUri); + fireRepositoryClosed(); + }), scmGit.onDidOpenRepository(e => this._onDidOpenRepository.fire({ uri: e.rootUri })), ); @@ -377,9 +414,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } void subscribeToScmOpenCloseRepository.call(this); - const potentialGitPaths = - configuration.getAny('git.path') ?? - this.container.storage.getWorkspace('gitPath'); + const potentialGitPaths = configuration.getCore('git.path') ?? this.container.storage.getWorkspace('gitPath'); const start = hrtime(); @@ -434,10 +469,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (uri.scheme !== Schemes.File) return []; try { - const autoRepositoryDetection = - configuration.getAny( - 'git.autoRepositoryDetection', - ) ?? true; + const autoRepositoryDetection = configuration.getCore('git.autoRepositoryDetection') ?? true; const folder = workspace.getWorkspaceFolder(uri); if (folder == null && !options?.silent) return []; @@ -456,7 +488,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (!options?.silent && (autoRepositoryDetection === true || autoRepositoryDetection === 'subFolders')) { for (const repository of repositories) { - void this.openScmRepository(repository.uri); + void this.getOrOpenScmRepository(repository.uri); } } @@ -490,53 +522,43 @@ export class LocalGitProvider implements GitProvider, Disposable { closed?: boolean, ): Repository[] { if (!closed) { - void this.openScmRepository(uri); + void this.getOrOpenScmRepository(uri); } - // Add a closed (hidden) repository for the canonical version + const opened = [ + new Repository( + this.container, + this.onRepositoryChanged.bind(this), + this.descriptor, + folder ?? workspace.getWorkspaceFolder(uri), + uri, + root, + suspended ?? !window.state.focused, + closed, + ), + ]; + + // Add a closed (hidden) repository for the canonical version if not already opened const canonicalUri = this.toCanonicalMap.get(getBestPath(uri)); - if (canonicalUri != null) { - return [ - new Repository( - this.container, - this.onRepositoryChanged.bind(this), - this.descriptor, - folder, - uri, - root, - suspended ?? !window.state.focused, - closed, - // canonicalUri, - ), + if (canonicalUri != null && this.container.git.getRepository(canonicalUri) == null) { + opened.push( new Repository( this.container, this.onRepositoryChanged.bind(this), this.descriptor, - folder, + folder ?? workspace.getWorkspaceFolder(canonicalUri), canonicalUri, root, suspended ?? !window.state.focused, true, - // uri, ), - ]; + ); } - return [ - new Repository( - this.container, - this.onRepositoryChanged.bind(this), - this.descriptor, - folder, - uri, - root, - suspended ?? !window.state.focused, - closed, - ), - ]; + return opened; } - @debug() + @debug({ singleLine: true }) openRepositoryInitWatcher(): RepositoryInitWatcher { const watcher = workspace.createFileSystemWatcher('**/.git', false, true, true); return { @@ -559,6 +581,10 @@ export class LocalGitProvider implements GitProvider, Disposable { supported = await this.git.isAtLeastVersion('2.35.0'); this._supportedFeatures.set(feature, supported); return supported; + case Features.ForceIfIncludes: + supported = await this.git.isAtLeastVersion('2.30.0'); + this._supportedFeatures.set(feature, supported); + return supported; default: return true; } @@ -620,13 +646,11 @@ export class LocalGitProvider implements GitProvider, Disposable { // Check if the url returns a 200 status code let promise = this._pendingRemoteVisibility.get(url); if (promise == null) { - const cancellation = new AbortController(); - let timeout: ReturnType; - promise = fetch(url, { method: 'HEAD', agent: getProxyAgent(), signal: cancellation.signal }).then(r => { - clearTimeout(timeout); - return r; - }); - timeout = setTimeout(() => cancellation.abort(), 30000); + const aborter = new AbortController(); + const timer = setTimeout(() => aborter.abort(), 30000); + + promise = fetch(url, { method: 'HEAD', agent: getProxyAgent(), signal: aborter.signal }); + void promise.finally(() => clearTimeout(timer)); this._pendingRemoteVisibility.set(url, promise); } @@ -671,7 +695,7 @@ export class LocalGitProvider implements GitProvider, Disposable { depth = depth ?? configuration.get('advanced.repositorySearchDepth', rootUri) ?? - configuration.getAny('git.repositoryScanMaxDepth', rootUri, 1); + configuration.getCore('git.repositoryScanMaxDepth', rootUri, 1); Logger.log(scope, `searching (depth=${depth})...`); @@ -696,12 +720,10 @@ export class LocalGitProvider implements GitProvider, Disposable { if (depth <= 0 || cancellation?.isCancellationRequested) return repositories; // Get any specified excludes -- this is a total hack, but works for some simple cases and something is better than nothing :) - const excludes = new Set( - configuration.getAny('git.repositoryScanIgnoredFolders', rootUri, []), - ); + const excludes = new Set(configuration.getCore('git.repositoryScanIgnoredFolders', rootUri, [])); for (let [key, value] of Object.entries({ - ...configuration.getAny>('files.exclude', rootUri, {}), - ...configuration.getAny>('search.exclude', rootUri, {}), + ...configuration.getCore('files.exclude', rootUri, {}), + ...configuration.getCore('search.exclude', rootUri, {}), })) { if (!value) continue; if (key.includes('*.')) continue; @@ -861,7 +883,21 @@ export class LocalGitProvider implements GitProvider, Disposable { return undefined; } - if (isUncommittedStaged(ref)) return this.getScmGitUri(path, repoPath); + // If the ref is the index, then try to create a Uri using the Git extension, but if we can't find a repo for it, then generate our own Uri + if (isUncommittedStaged(ref)) { + let scmRepo = await this.getScmRepository(repoPath); + if (scmRepo == null) { + // If the repoPath is a canonical path, then we need to remap it to the real path, because the vscode.git extension always uses the real path + const realUri = this.fromCanonicalMap.get(repoPath); + if (realUri != null) { + scmRepo = await this.getScmRepository(realUri.fsPath); + } + } + + if (scmRepo != null) { + return this.getScmGitUri(path, repoPath); + } + } return this.getRevisionUri(repoPath, path, ref); } @@ -903,11 +939,18 @@ export class LocalGitProvider implements GitProvider, Disposable { } getRevisionUri(repoPath: string, path: string, ref: string): Uri { - if (isUncommitted(ref)) { - return isUncommittedStaged(ref) ? this.getScmGitUri(path, repoPath) : this.getAbsoluteUri(path, repoPath); - } + if (isUncommitted(ref) && !isUncommittedStaged(ref)) return this.getAbsoluteUri(path, repoPath); + + let uncPath; path = normalizePath(this.getAbsoluteUri(path, repoPath).fsPath); + if (path.startsWith('//')) { + // save the UNC part of the path so we can re-add it later + const index = path.indexOf('/', 2); + uncPath = path.substring(0, index); + path = path.substring(index); + } + if (path.charCodeAt(0) !== slash) { path = `/${path}`; } @@ -915,13 +958,17 @@ export class LocalGitProvider implements GitProvider, Disposable { const metadata: RevisionUriData = { ref: ref, repoPath: normalizePath(repoPath), + uncPath: uncPath, }; const uri = Uri.from({ scheme: Schemes.GitLens, authority: encodeGitLensRevisionUriAuthority(metadata), path: path, - query: ref ? JSON.stringify({ ref: shortenRevision(ref) }) : undefined, + // Replace `/` with `\u2009\u2215\u2009` so that it doesn't get treated as part of the path of the file + query: ref + ? JSON.stringify({ ref: shortenRevision(ref).replaceAll('/', '\u2009\u2215\u2009') }) + : undefined, }); return uri; } @@ -952,7 +999,7 @@ export class LocalGitProvider implements GitProvider, Disposable { // Now check if that commit had any renames data = await this.git.log__file(repoPath, '.', ref, { - argsOrFormat: GitLogParser.simpleFormat, + argsOrFormat: parseGitLogSimpleFormat, fileMode: 'simple', filters: ['R', 'C', 'D'], limit: 1, @@ -960,7 +1007,7 @@ export class LocalGitProvider implements GitProvider, Disposable { }); if (data == null || data.length === 0) break; - const [foundRef, foundFile, foundStatus] = GitLogParser.parseSimpleRenamed(data, relativePath); + const [foundRef, foundFile, foundStatus] = parseGitLogSimpleRenamed(data, relativePath); if (foundStatus === 'D' && foundFile != null) return undefined; if (foundRef == null || foundFile == null) break; @@ -1035,6 +1082,138 @@ export class LocalGitProvider implements GitProvider, Disposable { } } + @log() + async applyUnreachableCommitForPatch( + repoPath: string, + ref: string, + options?: { + branchName?: string; + createBranchIfNeeded?: boolean; + createWorktreePath?: string; + stash?: boolean | 'prompt'; + }, + ): Promise { + const scope = getLogScope(); + + if (options?.stash) { + // Stash any changes first + const status = await this.getStatusForRepo(repoPath); + if (status?.files?.length) { + if (options.stash === 'prompt') { + const confirm = { title: 'Stash Changes' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + 'You have changes in your working tree.\nDo you want to stash them before applying the patch?', + { modal: true }, + confirm, + cancel, + ); + + if (result !== confirm) throw new CancellationError(); + } + + try { + await this.git.stash__push(repoPath, undefined, { includeUntracked: true }); + } catch (ex) { + Logger.error(ex, scope); + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.StashFailed, + `Unable to apply patch; failed stashing working changes changes${ + ex instanceof StashPushError ? `: ${ex.message}` : '' + }`, + ex, + ); + } + } + } + + let targetPath = repoPath; + const currentBranch = await this.getBranch(repoPath); + const branchExists = + options?.branchName == null || + currentBranch?.name === options.branchName || + (await this.getBranches(repoPath, { filter: b => b.name === options.branchName }))?.values?.length > 0; + const shouldCreate = options?.branchName != null && !branchExists && options.createBranchIfNeeded; + + // TODO: Worktree creation should ideally be handled before calling this, and then + // applyPatchCommit should be pointing to the worktree path. If done here, the newly created + // worktree cannot be opened and we cannot handle issues elegantly. + if (options?.createWorktreePath != null) { + if (options?.branchName === null || options.branchName === currentBranch?.name) { + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.CreateWorktreeFailed, + 'Unable to apply patch; failed creating worktree', + ); + } + + try { + await this.createWorktree(repoPath, options.createWorktreePath, { + commitish: options?.branchName != null && branchExists ? options.branchName : currentBranch?.name, + createBranch: shouldCreate ? options.branchName : undefined, + }); + } catch (ex) { + Logger.error(ex, scope); + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.CreateWorktreeFailed, + `Unable to apply patch; failed creating worktree${ + ex instanceof WorktreeCreateError ? `: ${ex.message}` : '' + }`, + ex, + ); + } + + const worktree = await this.container.git.getWorktree( + repoPath, + w => normalizePath(w.uri.fsPath) === normalizePath(options.createWorktreePath!), + ); + if (worktree == null) { + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.CreateWorktreeFailed, + 'Unable to apply patch; failed creating worktree', + ); + } + + targetPath = worktree.uri.fsPath; + } + + if (options?.branchName != null && currentBranch?.name !== options.branchName) { + const checkoutRef = shouldCreate ? currentBranch?.ref ?? 'HEAD' : options.branchName; + await this.checkout(targetPath, checkoutRef, { + createBranch: shouldCreate ? options.branchName : undefined, + }); + } + + // Apply the patch using a cherry pick without committing + try { + await this.git.cherrypick(targetPath, ref, { noCommit: true, errors: GitErrorHandling.Throw }); + } catch (ex) { + Logger.error(ex, scope); + if (ex instanceof CherryPickError) { + if (ex.reason === CherryPickErrorReason.Conflicts) { + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.AppliedWithConflicts, + `Patch applied with conflicts`, + ex, + ); + } + + if (ex.reason === CherryPickErrorReason.AbortedWouldOverwrite) { + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.ApplyAbortedWouldOverwrite, + `Unable to apply patch as some local changes would be overwritten`, + ex, + ); + } + } + + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.ApplyFailed, + `Unable to apply patch${ex instanceof CherryPickError ? `: ${ex.message}` : ''}`, + ex, + ); + } + } + @log() async checkout( repoPath: string, @@ -1074,75 +1253,138 @@ export class LocalGitProvider implements GitProvider, Disposable { return undefined; } - @log({ singleLine: true }) - private resetCache( + @log({ args: { 1: '', 3: '' } }) + async createUnreachableCommitForPatch( repoPath: string, - ...caches: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[] - ) { - if (caches.length === 0 || caches.includes('branches')) { - this._branchesCache.delete(repoPath); - } + contents: string, + baseRef: string, + message: string, + ): Promise { + const scope = getLogScope(); - if (caches.length === 0 || caches.includes('contributors')) { - this._contributorsCache.delete(repoPath); - } + // Create a temporary index file + const tempDir = await fs.mkdtemp(path.join(tmpdir(), 'gl-')); + const tempIndex = joinPaths(tempDir, 'index'); - if (caches.length === 0 || caches.includes('remotes')) { - const remotes = this._remotesCache.get(repoPath); - void disposeRemotes([remotes]); - this._remotesCache.delete(repoPath); - } + try { + // Tell Git to use our soon to be created index file + const env = { GIT_INDEX_FILE: tempIndex }; - if (caches.length === 0 || caches.includes('stashes')) { - this._stashesCache.delete(repoPath); - } + // Create the temp index file from a base ref/sha - if (caches.length === 0 || caches.includes('status')) { - this._mergeStatusCache.delete(repoPath); - this._rebaseStatusCache.delete(repoPath); - } + // Get the tree of the base + const newIndex = await this.git.git( + { + cwd: repoPath, + env: env, + }, + 'ls-tree', + '-z', + '-r', + '--full-name', + baseRef, + ); - if (caches.length === 0 || caches.includes('tags')) { - this._tagsCache.delete(repoPath); - } + // Write the tree to our temp index + await this.git.git( + { + cwd: repoPath, + env: env, + stdin: newIndex, + }, + 'update-index', + '-z', + '--index-info', + ); + + // Apply the patch to our temp index, without touching the working directory + await this.git.apply2(repoPath, { env: env, stdin: contents }, '--cached'); - if (caches.length === 0) { - this._trackedPaths.delete(repoPath); - this._repoInfoCache.delete(repoPath); + // Create a new tree from our patched index + const tree = ( + await this.git.git( + { + cwd: repoPath, + env: env, + }, + 'write-tree', + ) + )?.trim(); + + // Create new commit from the tree + const sha = ( + await this.git.git( + { + cwd: repoPath, + env: env, + }, + 'commit-tree', + tree, + '-p', + baseRef, + '-m', + message, + ) + )?.trim(); + + return this.getCommit(repoPath, sha); + } catch (ex) { + Logger.error(ex, scope); + debugger; + + throw ex; + } finally { + // Delete the temporary index file + try { + await fs.rm(tempDir, { recursive: true }); + } catch (ex) { + debugger; + } } } @log({ singleLine: true }) - private resetCaches(...caches: GitCaches[]) { - if (caches.length === 0 || caches.includes('branches')) { - this._branchesCache.clear(); + private resetCaches(repoPath: string | undefined, ...caches: GitCaches[]) { + const cachesToClear = []; + + if (!caches.length || caches.includes('branches')) { + cachesToClear.push(this._branchesCache); } - if (caches.length === 0 || caches.includes('contributors')) { - this._contributorsCache.clear(); + if (!caches.length || caches.includes('contributors')) { + cachesToClear.push(this._contributorsCache); } - if (caches.length === 0 || caches.includes('remotes')) { - void disposeRemotes([...this._remotesCache.values()]); - this._remotesCache.clear(); + if (!caches.length || caches.includes('remotes')) { + cachesToClear.push(this._remotesCache); } - if (caches.length === 0 || caches.includes('stashes')) { - this._stashesCache.clear(); + if (!caches.length || caches.includes('stashes')) { + cachesToClear.push(this._stashesCache); } - if (caches.length === 0 || caches.includes('status')) { - this._mergeStatusCache.clear(); - this._rebaseStatusCache.clear(); + if (!caches.length || caches.includes('status')) { + cachesToClear.push(this._mergeStatusCache, this._rebaseStatusCache); } - if (caches.length === 0 || caches.includes('tags')) { - this._tagsCache.clear(); + if (!caches.length || caches.includes('tags')) { + cachesToClear.push(this._tagsCache); } - if (caches.length === 0) { - this._trackedPaths.clear(); - this._repoInfoCache.clear(); + if (!caches.length || caches.includes('worktrees')) { + cachesToClear.push(this._worktreesCache); + } + + if (!caches.length) { + cachesToClear.push(this._trackedPaths, this._repoInfoCache); + } + + for (const cache of cachesToClear) { + if (repoPath != null) { + cache.delete(repoPath); + } else { + cache.clear(); + } } } @@ -1190,11 +1432,9 @@ export class LocalGitProvider implements GitProvider, Disposable { this.container.events.fire('git:cache:reset', { repoPath: repoPath }); } catch (ex) { Logger.error(ex, scope); - if (FetchError.is(ex)) { - void window.showErrorMessage(ex.message); - } else { - throw ex; - } + if (!FetchError.is(ex)) throw ex; + + void window.showErrorMessage(ex.message); } } @@ -1202,38 +1442,97 @@ export class LocalGitProvider implements GitProvider, Disposable { @log() async push( repoPath: string, - options?: { branch?: GitBranchReference; force?: boolean; publish?: { remote: string } }, + options?: { reference?: GitReference; force?: boolean; publish?: { remote: string } }, ): Promise { const scope = getLogScope(); - let branch = options?.branch; - if (!isBranchReference(branch)) { - branch = await this.getBranch(repoPath); - if (branch == null) return undefined; + let branchName: string; + let remoteName: string | undefined; + let upstreamName: string | undefined; + let setUpstream: + | { + branch: string; + remote: string; + remoteBranch: string; + } + | undefined; + + if (isBranchReference(options?.reference)) { + if (options.publish != null) { + branchName = options.reference.name; + remoteName = options.publish.remote; + } else { + [branchName, remoteName] = getBranchNameAndRemote(options.reference); + } + upstreamName = getBranchTrackingWithoutRemote(options.reference); + } else { + const branch = await this.getBranch(repoPath); + if (branch == null) return; + + branchName = + options?.reference != null + ? `${options.reference.ref}:${ + options?.publish != null ? 'refs/heads/' : '' + }${branch.getNameWithoutRemote()}` + : branch.name; + remoteName = branch.getRemoteName() ?? options?.publish?.remote; + upstreamName = options?.reference == null && options?.publish != null ? branch.name : undefined; + + // Git can't setup remote tracking when publishing a new branch to a specific commit, so we'll need to do it after the push + if (options?.publish?.remote != null && options?.reference != null) { + setUpstream = { + branch: branch.getNameWithoutRemote(), + remote: remoteName!, + remoteBranch: branch.getNameWithoutRemote(), + }; + } } - const [branchName, remoteName] = getBranchNameAndRemote(branch); - if (options?.publish == null && remoteName == null && branch.upstream == null) { - return undefined; + if (options?.publish == null && remoteName == null && upstreamName == null) { + debugger; + throw new PushError(PushErrorReason.Other); + } + + let forceOpts: PushForceOptions | undefined; + if (options?.force) { + const withLease = configuration.getCore('git.useForcePushWithLease') ?? true; + if (withLease) { + forceOpts = { + withLease: withLease, + ifIncludes: configuration.getCore('git.useForcePushIfIncludes') ?? true, + }; + } else { + forceOpts = { + withLease: withLease, + }; + } } try { await this.git.push(repoPath, { branch: branchName, - remote: options?.publish ? options.publish.remote : remoteName, - upstream: getBranchTrackingWithoutRemote(branch), - force: options?.force, + remote: remoteName, + upstream: upstreamName, + force: forceOpts, publish: options?.publish != null, }); + // Since Git can't setup remote tracking when publishing a new branch to a specific commit, do it now + if (setUpstream != null) { + await this.git.branch__set_upstream( + repoPath, + setUpstream.branch, + setUpstream.remote, + setUpstream.remoteBranch, + ); + } + this.container.events.fire('git:cache:reset', { repoPath: repoPath }); } catch (ex) { Logger.error(ex, scope); - if (PushError.is(ex)) { - void window.showErrorMessage(ex.message); - } else { - throw ex; - } + if (!PushError.is(ex)) throw ex; + + void window.showErrorMessage(ex.message); } } @@ -1258,6 +1557,7 @@ export class LocalGitProvider implements GitProvider, Disposable { await this.git.pull(repoPath, { branch: branchName, remote: remoteName, + upstream: getBranchTrackingWithoutRemote(branch), rebase: options?.rebase, tags: options?.tags, }); @@ -1265,11 +1565,9 @@ export class LocalGitProvider implements GitProvider, Disposable { this.container.events.fire('git:cache:reset', { repoPath: repoPath }); } catch (ex) { Logger.error(ex, scope); - if (PullError.is(ex)) { - void window.showErrorMessage(ex.message); - } else { - throw ex; - } + if (!PullError.is(ex)) throw ex; + + void window.showErrorMessage(ex.message); } } @@ -1278,7 +1576,7 @@ export class LocalGitProvider implements GitProvider, Disposable { protected readonly unsafePaths = new Set(); @gate() - @debug() + @debug({ exit: true }) async findRepositoryUri(uri: Uri, isDirectory?: boolean): Promise { const scope = getLogScope(); @@ -1319,10 +1617,14 @@ export class LocalGitProvider implements GitProvider, Disposable { ), ); if (networkPath != null) { + // If the repository is at the root of the mapped drive then we + // have to append `\` (ex: D:\) otherwise the path is not valid. + const isDriveRoot = pathEquals(repoUri.fsPath, networkPath); + repoPath = normalizePath( repoUri.fsPath.replace( networkPath, - `${letter.toLowerCase()}:${networkPath.endsWith('\\') ? '\\' : ''}`, + `${letter.toLowerCase()}:${isDriveRoot || networkPath.endsWith('\\') ? '\\' : ''}`, ), ); return Uri.file(repoPath); @@ -1346,7 +1648,7 @@ export class LocalGitProvider implements GitProvider, Disposable { return; } - if (equalsIgnoreCase(uri.fsPath, resolvedPath)) { + if (pathEquals(uri.fsPath, resolvedPath)) { Logger.debug(scope, `No symlink detected; repoPath=${repoPath}`); resolve([repoPath!, undefined]); return; @@ -1387,8 +1689,9 @@ export class LocalGitProvider implements GitProvider, Disposable { getAheadBehindCommitCount( repoPath: string, refs: string[], + options?: { authors?: GitUser[] | undefined }, ): Promise<{ ahead: number; behind: number } | undefined> { - return this.git.rev_list__left_right(repoPath, refs); + return this.git.rev_list__left_right(repoPath, refs, options?.authors); } @gate((u, d) => `${u.toString()}|${d?.isDirty}`) @@ -1403,7 +1706,7 @@ export class LocalGitProvider implements GitProvider, Disposable { key += `:${uri.sha}`; } - const doc = await this.container.tracker.getOrAdd(document ?? uri); + const doc = await this.container.documentTracker.getOrAdd(document ?? uri); if (this.useCaching) { if (doc.state != null) { const cachedBlame = doc.state.getBlame(key); @@ -1415,9 +1718,7 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: '${key}'`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } const promise = this.getBlameCore(uri, doc, key, scope); @@ -1436,11 +1737,11 @@ export class LocalGitProvider implements GitProvider, Disposable { private async getBlameCore( uri: GitUri, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { - const paths = await this.isTrackedPrivate(uri); + const paths = await this.isTrackedWithDetails(uri); if (paths == null) { Logger.log(scope, `Skipping blame; '${uri.fsPath}' is not tracked`); return emptyPromise as Promise; @@ -1449,25 +1750,42 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = paths; try { - const data = await this.git.blame(root, relativePath, uri.sha, { - args: configuration.get('advanced.blame.customArguments'), - ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), - }); - const blame = GitBlameParser.parse(this.container, data, root, await this.getCurrentUser(root)); + const [dataResult, userResult, statResult] = await Promise.allSettled([ + this.git.blame(root, relativePath, { + ref: uri.sha, + args: configuration.get('advanced.blame.customArguments'), + ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), + }), + this.getCurrentUser(root), + workspace.fs.stat(uri), + ]); + + const blame = parseGitBlame( + this.container, + root, + getSettledValue(dataResult), + getSettledValue(userResult), + getSettledValue(statResult)?.mtime, + ); return blame; } catch (ex) { + Logger.error(ex, scope); + // Trap and cache expected blame errors if (document.state != null) { const msg = ex?.toString() ?? ''; - Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); + Logger.debug(scope, `Cache replace (with empty promise): '${key}'; reason=${msg}`); const value: CachedBlame = { item: emptyPromise as Promise, errorMessage: msg, }; document.state.setBlame(key, value); + document.setBlameFailure(ex); - document.setBlameFailure(); + if (ex instanceof BlameIgnoreRevsFileError || ex instanceof BlameIgnoreRevsFileBadRevisionError) { + void showBlameInvalidIgnoreRevsFileWarningMessage(ex); + } return emptyPromise as Promise; } @@ -1482,7 +1800,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const key = `blame:${md5(contents)}`; - const doc = await this.container.tracker.getOrAdd(uri); + const doc = await this.container.documentTracker.getOrAdd(uri); if (this.useCaching) { if (doc.state != null) { const cachedBlame = doc.state.getBlame(key); @@ -1494,9 +1812,7 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: ${key}`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } const promise = this.getBlameContentsCore(uri, contents, doc, key, scope); @@ -1516,11 +1832,11 @@ export class LocalGitProvider implements GitProvider, Disposable { private async getBlameContentsCore( uri: GitUri, contents: string, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { - const paths = await this.isTrackedPrivate(uri); + const paths = await this.isTrackedWithDetails(uri); if (paths == null) { Logger.log(scope, `Skipping blame; '${uri.fsPath}' is not tracked`); return emptyPromise as Promise; @@ -1529,26 +1845,44 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = paths; try { - const data = await this.git.blame__contents(root, relativePath, contents, { - args: configuration.get('advanced.blame.customArguments'), - correlationKey: `:${key}`, - ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), - }); - const blame = GitBlameParser.parse(this.container, data, root, await this.getCurrentUser(root)); + const [dataResult, userResult, statResult] = await Promise.allSettled([ + this.git.blame(root, relativePath, { + contents: contents, + args: configuration.get('advanced.blame.customArguments'), + correlationKey: `:${key}`, + ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), + }), + this.getCurrentUser(root), + workspace.fs.stat(uri), + ]); + + const blame = parseGitBlame( + this.container, + root, + getSettledValue(dataResult), + getSettledValue(userResult), + getSettledValue(statResult)?.mtime, + ); return blame; } catch (ex) { + Logger.error(ex, scope); + // Trap and cache expected blame errors if (document.state != null) { const msg = ex?.toString() ?? ''; - Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); + Logger.debug(scope, `Cache replace (with empty promise): '${key}'; reason=${msg}`); const value: CachedBlame = { item: emptyPromise as Promise, errorMessage: msg, }; document.state.setBlame(key, value); + document.setBlameFailure(ex); + + if (ex instanceof BlameIgnoreRevsFileError || ex instanceof BlameIgnoreRevsFileBadRevisionError) { + void showBlameInvalidIgnoreRevsFileWarningMessage(ex); + } - document.setBlameFailure(); return emptyPromise as Promise; } @@ -1568,6 +1902,8 @@ export class LocalGitProvider implements GitProvider, Disposable { ): Promise { if (document?.isDirty) return this.getBlameForLineContents(uri, editorLine, document.getText(), options); + const scope = getLogScope(); + if (!options?.forceSingleLine && this.useCaching) { const blame = await this.getBlame(uri, document); if (blame == null) return undefined; @@ -1593,13 +1929,25 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = splitPath(uri, uri.repoPath); try { - const data = await this.git.blame(root, relativePath, uri.sha, { - args: configuration.get('advanced.blame.customArguments'), - ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), - startLine: lineToBlame, - endLine: lineToBlame, - }); - const blame = GitBlameParser.parse(this.container, data, root, await this.getCurrentUser(root)); + const [dataResult, userResult, statResult] = await Promise.allSettled([ + this.git.blame(root, relativePath, { + ref: uri.sha, + args: configuration.get('advanced.blame.customArguments'), + ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), + startLine: lineToBlame, + endLine: lineToBlame, + }), + this.getCurrentUser(root), + workspace.fs.stat(uri), + ]); + + const blame = parseGitBlame( + this.container, + root, + getSettledValue(dataResult), + getSettledValue(userResult), + getSettledValue(statResult)?.mtime, + ); if (blame == null) return undefined; return { @@ -1607,7 +1955,12 @@ export class LocalGitProvider implements GitProvider, Disposable { commit: first(blame.commits.values())!, line: blame.lines[editorLine], }; - } catch { + } catch (ex) { + Logger.error(ex, scope); + if (ex instanceof BlameIgnoreRevsFileError || ex instanceof BlameIgnoreRevsFileBadRevisionError) { + void showBlameInvalidIgnoreRevsFileWarningMessage(ex); + } + return undefined; } } @@ -1644,13 +1997,25 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = splitPath(uri, uri.repoPath); try { - const data = await this.git.blame__contents(root, relativePath, contents, { - args: configuration.get('advanced.blame.customArguments'), - ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), - startLine: lineToBlame, - endLine: lineToBlame, - }); - const blame = GitBlameParser.parse(this.container, data, root, await this.getCurrentUser(root)); + const [dataResult, userResult, statResult] = await Promise.allSettled([ + this.git.blame(root, relativePath, { + contents: contents, + args: configuration.get('advanced.blame.customArguments'), + ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), + startLine: lineToBlame, + endLine: lineToBlame, + }), + this.getCurrentUser(root), + workspace.fs.stat(uri), + ]); + + const blame = parseGitBlame( + this.container, + root, + getSettledValue(dataResult), + getSettledValue(userResult), + getSettledValue(statResult)?.mtime, + ); if (blame == null) return undefined; return { @@ -1727,6 +2092,7 @@ export class LocalGitProvider implements GitProvider, Disposable { }; } + @gate() @log() async getBranch(repoPath: string): Promise { let { @@ -1741,16 +2107,17 @@ export class LocalGitProvider implements GitProvider, Disposable { const [name, upstream] = data[0].split('\n'); if (isDetachedHead(name)) { - const [rebaseStatus, committerDate] = await Promise.all([ + const [rebaseStatusResult, committerDateResult] = await Promise.allSettled([ this.getRebaseStatus(repoPath), - this.git.log__recent_committerdate(repoPath, commitOrdering), ]); + const committerDate = getSettledValue(committerDateResult); + branch = new GitBranch( this.container, repoPath, - rebaseStatus?.incoming.name ?? name, + getSettledValue(rebaseStatusResult)?.incoming.name ?? name, false, true, committerDate != null ? new Date(Number(committerDate) * 1000) : undefined, @@ -1759,7 +2126,7 @@ export class LocalGitProvider implements GitProvider, Disposable { undefined, undefined, undefined, - rebaseStatus != null, + rebaseStatusResult != null, ); } @@ -1770,8 +2137,8 @@ export class LocalGitProvider implements GitProvider, Disposable { async getBranches( repoPath: string | undefined, options?: { - cursor?: string; filter?: (b: GitBranch) => boolean; + paging?: PagingOptions; sort?: boolean | BranchSortOptions; }, ): Promise> { @@ -1815,7 +2182,7 @@ export class LocalGitProvider implements GitProvider, Disposable { return current != null ? { values: [current] } : emptyPagedResult; } - return { values: GitBranchParser.parse(this.container, data, repoPath!) }; + return { values: parseGitBranches(this.container, data, repoPath!) }; } catch (ex) { this._branchesCache.delete(repoPath!); @@ -1825,10 +2192,8 @@ export class LocalGitProvider implements GitProvider, Disposable { resultsPromise = load.call(this); - if (this.useCaching) { - if (options?.cursor == null) { - this._branchesCache.set(repoPath, resultsPromise); - } + if (this.useCaching && options?.paging?.cursor == null) { + this._branchesCache.set(repoPath, resultsPromise); } } @@ -1852,7 +2217,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const data = await this.git.diff__shortstat(repoPath, ref); if (!data) return undefined; - return parseDiffShortStat(data); + return parseGitDiffShortStat(data); } @log() @@ -1866,26 +2231,28 @@ export class LocalGitProvider implements GitProvider, Disposable { @log() async getCommitBranches( repoPath: string, - ref: string, - options?: { branch?: string; commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + refs: string[], + branch?: string | undefined, + options?: + | { all?: boolean; commitDate?: Date; mode?: 'contains' | 'pointsAt' } + | { commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, ): Promise { - if (options?.branch) { - const data = await this.git.branch__containsOrPointsAt(repoPath, ref, { + if (branch != null) { + const data = await this.git.branchOrTag__containsOrPointsAt(repoPath, refs, { + type: 'branch', mode: 'contains', - name: options.branch, + name: branch, }); - if (!data) return []; - - return [data?.trim()]; + return data ? [data?.trim()] : []; } - const data = await this.git.branch__containsOrPointsAt(repoPath, ref, options); + const data = await this.git.branchOrTag__containsOrPointsAt(repoPath, refs, { type: 'branch', ...options }); if (!data) return []; return filterMap(data.split('\n'), b => b.trim() || undefined); } - @log() + @log({ exit: true }) getCommitCount(repoPath: string, ref: string): Promise { return this.git.rev_list__count(repoPath, ref); } @@ -1938,6 +2305,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const defaultLimit = options?.limit ?? configuration.get('graph.defaultItemLimit') ?? 5000; const defaultPageLimit = configuration.get('graph.pageItemLimit') ?? 1000; const ordering = configuration.get('graph.commitOrdering', undefined, 'date'); + const onlyFollowFirstParent = configuration.get('graph.onlyFollowFirstParent', undefined, false); const deferStats = options?.include?.stats; // && defaultLimit > 1000; @@ -1946,7 +2314,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const statsParser = getGraphStatsParser(); const [refResult, stashResult, branchesResult, remotesResult, currentUserResult] = await Promise.allSettled([ - this.git.log2(repoPath, undefined, ...refParser.arguments, '-n1', options?.ref ?? 'HEAD'), + this.git.log(repoPath, undefined, ...refParser.arguments, '-n1', options?.ref ?? 'HEAD'), this.getStash(repoPath), this.getBranches(repoPath), this.getRemotes(repoPath), @@ -2002,6 +2370,9 @@ export class LocalGitProvider implements GitProvider, Disposable { do { const args = [...parser.arguments, `--${ordering}-order`, '--all']; + if (onlyFollowFirstParent) { + args.push('--first-parent'); + } if (cursor?.skip) { args.push(`--skip=${cursor.skip}`); } @@ -2018,7 +2389,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } else { args.push(`-n${nextPageLimit + 1}`); - data = await this.git.log2(repoPath, stdin ? { stdin: stdin } : undefined, ...args); + data = await this.git.log(repoPath, stdin ? { stdin: stdin } : undefined, ...args); if (cursor) { if (!getShaInLogRegex(cursor.sha).test(data)) { @@ -2119,12 +2490,12 @@ export class LocalGitProvider implements GitProvider, Disposable { refRemoteHeads = []; refTags = []; contexts = {}; - head = false; if (commit.tips) { groupedRefs.clear(); for (tip of commit.tips.split(', ')) { + head = false; if (tip === 'refs/stash') continue; if (tip.startsWith('tag: ')) { @@ -2154,8 +2525,8 @@ export class LocalGitProvider implements GitProvider, Disposable { continue; } - head = tip.startsWith('HEAD'); - if (head) { + if (tip.startsWith('HEAD')) { + head = true; reachableFromHEAD.add(commit.sha); if (tip !== 'HEAD') { @@ -2197,6 +2568,7 @@ export class LocalGitProvider implements GitProvider, Disposable { avatarUrl: avatarUrl, context: serializeWebviewItemContext(context), current: tip === headRefUpstreamName, + hostingServiceType: remote.provider?.gkProviderId, }; refRemoteHeads.push(refRemoteHead); @@ -2355,7 +2727,7 @@ export class LocalGitProvider implements GitProvider, Disposable { rows.push({ sha: commit.sha, - parents: parents, + parents: onlyFollowFirstParent ? [parents[0]] : parents, author: isCurrentUser ? 'You' : commit.author, email: commit.authorEmail, date: Number(ordering === 'author-date' ? commit.authorDate : commit.committerDate) * 1000, @@ -2405,7 +2777,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } args.push(`--${ordering}-order`, '--all'); - const statsData = await this.git.log2(repoPath, stdin ? { stdin: stdin } : undefined, ...args); + const statsData = await this.git.log(repoPath, stdin ? { stdin: stdin } : undefined, ...args); if (statsData) { const commitStats = statsParser.parse(statsData); for (const stat of commitStats) { @@ -2451,6 +2823,18 @@ export class LocalGitProvider implements GitProvider, Disposable { return getCommitsForGraphCore.call(this, defaultLimit, selectSha); } + @log() + async getCommitTags( + repoPath: string, + ref: string, + options?: { commitDate?: Date; mode?: 'contains' | 'pointsAt' }, + ): Promise { + const data = await this.git.branchOrTag__containsOrPointsAt(repoPath, [ref], { type: 'tag', ...options }); + if (!data) return []; + + return filterMap(data.split('\n'), b => b.trim() || undefined); + } + getConfig(repoPath: string, key: string): Promise { return this.git.config__get(key, repoPath); } @@ -2462,13 +2846,24 @@ export class LocalGitProvider implements GitProvider, Disposable { @log() async getContributors( repoPath: string, - options?: { all?: boolean; ref?: string; stats?: boolean }, + options?: { all?: boolean; merges?: boolean | 'first-parent'; ref?: string; stats?: boolean }, ): Promise { if (repoPath == null) return []; - const key = options?.stats ? `stats|${repoPath}` : repoPath; + let key = options?.ref ?? ''; + if (options?.all) { + key += ':all'; + } + if (options?.merges) { + key += `:merges:${options.merges}`; + } + if (options?.stats) { + key += ':stats'; + } + + const contributorsCache = this.useCaching ? this._contributorsCache.get(repoPath) : undefined; - let contributors = this.useCaching ? this._contributorsCache.get(key) : undefined; + let contributors = contributorsCache?.get(key); if (contributors == null) { async function load(this: LocalGitProvider) { try { @@ -2476,10 +2871,20 @@ export class LocalGitProvider implements GitProvider, Disposable { const currentUser = await this.getCurrentUser(repoPath); const parser = getContributorsParser(options?.stats); - const data = await this.git.log(repoPath, options?.ref, { - all: options?.all, - argsOrFormat: parser.arguments, - }); + const args = [...parser.arguments, '--full-history', '--use-mailmap']; + + const merges = options?.merges ?? true; + if (merges) { + args.push(merges === 'first-parent' ? '--first-parent' : '--no-min-parents'); + } else { + args.push('--no-merges'); + } + + if (options?.all) { + args.push('--all', '--single-worktree'); + } + + const data = await this.git.log(repoPath, { ref: options?.ref }, ...args); const contributors = new Map(); @@ -2509,7 +2914,7 @@ export class LocalGitProvider implements GitProvider, Disposable { return [...contributors.values()]; } catch (ex) { - this._contributorsCache.delete(key); + contributorsCache?.delete(key); return []; } @@ -2518,7 +2923,11 @@ export class LocalGitProvider implements GitProvider, Disposable { contributors = load.call(this); if (this.useCaching) { - this._contributorsCache.set(key, contributors); + if (contributorsCache == null) { + this._contributorsCache.set(repoPath, new Map([[key, contributors]])); + } else { + contributorsCache.set(key, contributors); + } } } @@ -2594,7 +3003,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log() + @log({ exit: true }) async getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise { if (repoPath == null) return undefined; @@ -2623,42 +3032,68 @@ export class LocalGitProvider implements GitProvider, Disposable { @log() async getDiff( repoPath: string, - ref1: string, - ref2?: string, - options?: { context?: number }, + to: string, + from?: string, + options?: { context?: number; uris?: Uri[] }, ): Promise { + const scope = getLogScope(); const params = [`-U${options?.context ?? 3}`]; - if (ref1 === uncommitted) { - // Get only unstaged changes - ref2 = 'HEAD'; - } else if (ref1 === uncommittedStaged) { - // Get up to staged changes + if (to === uncommitted) { + if (from != null) { + params.push(from); + } else { + // Get only unstaged changes + from = 'HEAD'; + } + } else if (to === uncommittedStaged) { params.push('--staged'); - if (ref2 != null) { - params.push(ref2); + if (from != null) { + params.push(from); } else { - ref2 = 'HEAD'; + // Get only staged changes + from = 'HEAD'; } - } else if (ref2 == null) { - if (ref1 === '' || ref1.toUpperCase() === 'HEAD') { - ref2 = 'HEAD'; - params.push(ref2); + } else if (from == null) { + if (to === '' || to.toUpperCase() === 'HEAD') { + from = 'HEAD'; + params.push(from); } else { - ref2 = ref1; - params.push(`${ref1}^`, ref2); + from = `${to}^`; + params.push(from, to); } } else { - params.push(ref1, ref2); + params.push(from, to); } - const data = await this.git.diff2(repoPath, undefined, ...params); - if (!data) return undefined; + if (options?.uris) { + params.push('--', ...options.uris.map(u => u.fsPath)); + } - const diff: GitDiff = { baseSha: ref2, contents: data }; + let data; + try { + data = await this.git.diff2(repoPath, { errors: GitErrorHandling.Throw }, ...params); + } catch (ex) { + debugger; + Logger.error(ex, scope); + return undefined; + } + + const diff: GitDiff = { contents: data, from: from, to: to }; return diff; } + @log({ args: { 1: false } }) + async getDiffFiles(repoPath: string, contents: string): Promise { + const data = await this.git.apply2(repoPath, { stdin: contents }, '--numstat', '--summary', '-z'); + if (!data) return undefined; + + const files = parseGitApplyFiles(data, repoPath); + return { + files: files, + }; + } + @log() async getDiffForFile(uri: GitUri, ref1: string | undefined, ref2?: string): Promise { const scope = getLogScope(); @@ -2671,7 +3106,7 @@ export class LocalGitProvider implements GitProvider, Disposable { key += `:${ref2}`; } - const doc = await this.container.tracker.getOrAdd(uri); + const doc = await this.container.documentTracker.getOrAdd(uri); if (this.useCaching) { if (doc.state != null) { const cachedDiff = doc.state.getDiff(key); @@ -2683,9 +3118,7 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: '${key}'`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } const encoding = await getEncoding(uri); @@ -2718,7 +3151,7 @@ export class LocalGitProvider implements GitProvider, Disposable { ref1: string | undefined, ref2: string | undefined, options: { encoding?: string }, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { @@ -2733,7 +3166,7 @@ export class LocalGitProvider implements GitProvider, Disposable { similarityThreshold: configuration.get('advanced.similarityThreshold'), }); - const diff = parseFileDiff(data); + const diff = parseGitFileDiff(data); return diff; } catch (ex) { // Trap and cache expected diff errors @@ -2760,7 +3193,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const key = `diff:${md5(contents)}`; - const doc = await this.container.tracker.getOrAdd(uri); + const doc = await this.container.documentTracker.getOrAdd(uri); if (this.useCaching) { if (doc.state != null) { const cachedDiff = doc.state.getDiff(key); @@ -2772,9 +3205,7 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: ${key}`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } const encoding = await getEncoding(uri); @@ -2807,7 +3238,7 @@ export class LocalGitProvider implements GitProvider, Disposable { ref: string, contents: string, options: { encoding?: string }, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { @@ -2820,7 +3251,7 @@ export class LocalGitProvider implements GitProvider, Disposable { similarityThreshold: configuration.get('advanced.similarityThreshold'), }); - const diff = parseFileDiff(data); + const diff = parseGitFileDiff(data); return diff; } catch (ex) { // Trap and cache expected diff errors @@ -2847,7 +3278,7 @@ export class LocalGitProvider implements GitProvider, Disposable { editorLine: number, // 0-based, Git is 1-based ref1: string | undefined, ref2?: string, - ): Promise { + ): Promise { try { const diff = await this.getDiffForFile(uri, ref1, ref2); if (diff == null) return undefined; @@ -2856,7 +3287,13 @@ export class LocalGitProvider implements GitProvider, Disposable { const hunk = diff.hunks.find(c => c.current.position.start <= line && c.current.position.end >= line); if (hunk == null) return undefined; - return hunk.lines[line - Math.min(hunk.current.position.start, hunk.previous.position.start)]; + const hunkLine = hunk.lines.get(line); + if (hunkLine == null) return undefined; + + return { + hunk: hunk, + line: hunkLine, + }; } catch (ex) { return undefined; } @@ -2867,16 +3304,16 @@ export class LocalGitProvider implements GitProvider, Disposable { repoPath: string, ref1?: string, ref2?: string, - options?: { filters?: GitDiffFilter[]; similarityThreshold?: number }, + options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ): Promise { try { const data = await this.git.diff__name_status(repoPath, ref1, ref2, { - similarityThreshold: configuration.get('advanced.similarityThreshold'), + similarityThreshold: configuration.get('advanced.similarityThreshold') ?? undefined, ...options, }); if (!data) return undefined; - const files = parseDiffNameStatusFiles(data, repoPath); + const files = parseGitDiffNameStatusFiles(data, repoPath); return files == null || files.length === 0 ? undefined : files; } catch (ex) { return undefined; @@ -2892,13 +3329,22 @@ export class LocalGitProvider implements GitProvider, Disposable { const data = await this.git.show__name_status(root, relativePath, ref); if (!data) return undefined; - const files = parseDiffNameStatusFiles(data, repoPath); + const files = parseGitDiffNameStatusFiles(data, repoPath); if (files == null || files.length === 0) return undefined; return files[0]; } - @debug() + @log({ exit: true }) + async getFirstCommitSha(repoPath: string): Promise { + const data = await this.git.rev_list(repoPath, 'HEAD', { maxParents: 0 }); + return data?.[0]; + } + + @gate() + @debug({ + exit: r => `returned ${r.uri.toString(true)}, commonUri=${r.commonUri?.toString(true)}`, + }) async getGitDir(repoPath: string): Promise { const repo = this._repoInfoCache.get(repoPath); if (repo?.gitDir != null) return repo.gitDir; @@ -2941,7 +3387,7 @@ export class LocalGitProvider implements GitProvider, Disposable { authors?: GitUser[]; cursor?: string; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; status?: null | 'name-status' | 'numstat' | 'stat'; @@ -2955,14 +3401,10 @@ export class LocalGitProvider implements GitProvider, Disposable { try { const limit = options?.limit ?? configuration.get('advanced.maxListItems') ?? 0; - const merges = options?.merges == null ? true : options.merges; - const ordering = options?.ordering ?? configuration.get('advanced.commitOrdering'); const similarityThreshold = configuration.get('advanced.similarityThreshold'); - const args = [ - `--format=${options?.all ? GitLogParser.allFormat : GitLogParser.defaultFormat}`, + `--format=${options?.all ? parseGitLogAllFormat : parseGitLogDefaultFormat}`, `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, - '-m', ]; if (options?.status !== null) { @@ -2971,9 +3413,19 @@ export class LocalGitProvider implements GitProvider, Disposable { if (options?.all) { args.push('--all'); } - if (!merges) { - args.push('--first-parent'); + + const merges = options?.merges ?? true; + if (merges) { + if (limit <= 2) { + // Ensure we return the merge commit files when we are asking for a specific ref + args.push('-m'); + } + args.push(merges === 'first-parent' ? '--first-parent' : '--no-min-parents'); + } else { + args.push('--no-merges'); } + + const ordering = options?.ordering ?? configuration.get('advanced.commitOrdering'); if (ordering) { args.push(`--${ordering}-order`); } @@ -3007,7 +3459,7 @@ export class LocalGitProvider implements GitProvider, Disposable { args.push(`-n${limit + 1}`); } - const data = await this.git.log2( + const data = await this.git.log( repoPath, { configs: gitLogDefaultConfigsWithFiles, ref: options?.ref, stdin: options?.stdin }, ...args, @@ -3050,7 +3502,7 @@ export class LocalGitProvider implements GitProvider, Disposable { // ); // } - const log = GitLogParser.parse( + const log = parseGitLog( this.container, data, LogType.Log, @@ -3070,8 +3522,8 @@ export class LocalGitProvider implements GitProvider, Disposable { if (log.hasMore) { let opts; if (options != null) { - let extraArgs; - ({ extraArgs, ...opts } = options); + let _; + ({ extraArgs: _, ...opts } = options); } log.more = this.getLogMoreFn(log, opts); } @@ -3092,7 +3544,7 @@ export class LocalGitProvider implements GitProvider, Disposable { authors?: GitUser[]; cursor?: string; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -3104,16 +3556,36 @@ export class LocalGitProvider implements GitProvider, Disposable { try { const parser = createLogParserSingle('%H'); + const args = [...parser.arguments, '--full-history']; - const data = await this.git.log(repoPath, options?.ref, { - authors: options?.authors, - argsOrFormat: parser.arguments, - limit: limit, - merges: options?.merges == null ? true : options.merges, - similarityThreshold: configuration.get('advanced.similarityThreshold'), - since: options?.since, - ordering: options?.ordering ?? configuration.get('advanced.commitOrdering'), - }); + const ordering = options?.ordering ?? configuration.get('advanced.commitOrdering'); + if (ordering) { + args.push(`--${ordering}-order`); + } + + if (limit) { + args.push(`-n${limit + 1}`); + } + + if (options?.since) { + args.push(`--since="${options.since}"`); + } + + const merges = options?.merges ?? true; + if (merges) { + args.push(merges === 'first-parent' ? '--first-parent' : '--no-min-parents'); + } else { + args.push('--no-merges'); + } + + if (options?.authors?.length) { + if (!args.includes('--use-mailmap')) { + args.push('--use-mailmap'); + } + args.push(...options.authors.map(a => `--author=^${a.name} <${a.email}>$`)); + } + + const data = await this.git.log(repoPath, { ref: options?.ref }, ...args); const commits = new Set(parser.parse(data)); return commits; @@ -3258,47 +3730,58 @@ export class LocalGitProvider implements GitProvider, Disposable { throw new Error(`File name cannot match the repository path; path=${relativePath}`); } - options = { reverse: false, ...options }; + const opts: typeof options & Parameters[2] = { + reverse: false, + ...options, + }; + + if (opts.renames == null) { + opts.renames = configuration.get('advanced.fileHistoryFollowsRenames'); + } - if (options.renames == null) { - options.renames = configuration.get('advanced.fileHistoryFollowsRenames'); + if (opts.merges == null) { + opts.merges = configuration.get('advanced.fileHistoryShowMergeCommits'); } let key = 'log'; - if (options.ref != null) { - key += `:${options.ref}`; + if (opts.ref != null) { + key += `:${opts.ref}`; } - if (options.all == null) { - options.all = configuration.get('advanced.fileHistoryShowAllBranches'); + if (opts.all == null) { + opts.all = configuration.get('advanced.fileHistoryShowAllBranches'); } - if (options.all) { + if (opts.all) { key += ':all'; } - options.limit = options.limit ?? configuration.get('advanced.maxListItems') ?? 0; - if (options.limit) { - key += `:n${options.limit}`; + opts.limit = opts.limit ?? configuration.get('advanced.maxListItems') ?? 0; + if (opts.limit) { + key += `:n${opts.limit}`; + } + + if (opts.merges) { + key += ':merges'; } - if (options.renames) { + if (opts.renames) { key += ':follow'; } - if (options.reverse) { + if (opts.reverse) { key += ':reverse'; } - if (options.since) { - key += `:since=${options.since}`; + if (opts.since) { + key += `:since=${opts.since}`; } - if (options.skip) { - key += `:skip${options.skip}`; + if (opts.skip) { + key += `:skip${opts.skip}`; } - const doc = await this.container.tracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, options.ref)); - if (!options.force && this.useCaching && options.range == null) { + const doc = await this.container.documentTracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, opts.ref)); + if (!opts.force && this.useCaching && opts.range == null) { if (doc.state != null) { const cachedLog = doc.state.getLog(key); if (cachedLog != null) { @@ -3306,20 +3789,20 @@ export class LocalGitProvider implements GitProvider, Disposable { return cachedLog.item; } - if (options.ref != null || (options.limit != null && options.limit !== 0)) { + if (opts.ref != null || (opts.limit != null && opts.limit !== 0)) { // Since we are looking for partial log, see if we have the log of the whole file const cachedLog = doc.state.getLog( - `log${options.renames ? ':follow' : ''}${options.reverse ? ':reverse' : ''}`, + `log${opts.renames ? ':follow' : ''}${opts.reverse ? ':reverse' : ''}`, ); if (cachedLog != null) { - if (options.ref == null) { + if (opts.ref == null) { Logger.debug(scope, `Cache hit: ~'${key}'`); return cachedLog.item; } Logger.debug(scope, `Cache ?: '${key}'`); let log = await cachedLog.item; - if (log != null && !log.hasMore && log.commits.has(options.ref)) { + if (log != null && !log.hasMore && log.commits.has(opts.ref)) { Logger.debug(scope, `Cache hit: '${key}'`); // Create a copy of the log starting at the requested commit @@ -3330,12 +3813,12 @@ export class LocalGitProvider implements GitProvider, Disposable { log.commits.entries(), ([ref, c]) => { if (skip) { - if (ref !== options?.ref) return undefined; + if (ref !== opts?.ref) return undefined; skip = false; } i++; - if (options?.limit != null && i > options.limit) { + if (opts?.limit != null && i > opts.limit) { return undefined; } @@ -3344,14 +3827,14 @@ export class LocalGitProvider implements GitProvider, Disposable { ), ); - const opts = { ...options }; + const optsCopy = { ...opts }; log = { ...log, - limit: options.limit, + limit: optsCopy.limit, count: commits.size, commits: commits, query: (limit: number | undefined) => - this.getLogForFile(repoPath, pathOrUri, { ...opts, limit: limit }), + this.getLogForFile(repoPath, pathOrUri, { ...optsCopy, limit: limit }), }; return log; @@ -3362,14 +3845,12 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: '${key}'`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } - const promise = this.getLogForFileCore(repoPath, relativePath, options, doc, key, scope); + const promise = this.getLogForFileCore(repoPath, relativePath, opts, doc, key, scope); - if (doc.state != null && options.range == null) { + if (doc.state != null && opts.range == null) { Logger.debug(scope, `Cache add: '${key}'`); const value: CachedLog = { @@ -3392,6 +3873,7 @@ export class LocalGitProvider implements GitProvider, Disposable { all?: boolean; cursor?: string; limit?: number; + merges?: boolean; ordering?: 'date' | 'author-date' | 'topo' | null; range?: Range; ref?: string; @@ -3400,13 +3882,13 @@ export class LocalGitProvider implements GitProvider, Disposable { since?: string; skip?: number; }, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { - const paths = await this.isTrackedPrivate(path, repoPath, ref); + const paths = await this.isTrackedWithDetails(path, repoPath, ref); if (paths == null) { - Logger.log(scope, `Skipping blame; '${path}' is not tracked`); + Logger.log(scope, `Skipping log; '${path}' is not tracked`); return emptyPromise as Promise; } @@ -3417,14 +3899,27 @@ export class LocalGitProvider implements GitProvider, Disposable { range = new Range(range.end, range.start); } - const data = await this.git.log__file(root, relativePath, ref, { + let data = await this.git.log__file(root, relativePath, ref, { ordering: configuration.get('advanced.commitOrdering'), ...options, - firstParent: options.renames, startLine: range == null ? undefined : range.start.line + 1, endLine: range == null ? undefined : range.end.line + 1, }); - const log = GitLogParser.parse( + + // If we didn't find any history from the working tree, check to see if the file was renamed + if (!data && ref == null) { + const status = await this.getStatusForFile(root, relativePath); + if (status?.originalPath != null) { + data = await this.git.log__file(root, status.originalPath, ref, { + ordering: configuration.get('advanced.commitOrdering'), + ...options, + startLine: range == null ? undefined : range.start.line + 1, + endLine: range == null ? undefined : range.end.line + 1, + }); + } + } + + const log = parseGitLog( this.container, data, // If this is the log of a folder, parse it as a normal log rather than a file log @@ -3490,7 +3985,18 @@ export class LocalGitProvider implements GitProvider, Disposable { moreLimit = moreLimit ?? configuration.get('advanced.maxSearchItems') ?? 0; - const ref = last(log.commits.values())?.ref; + const commit = last(log.commits.values()); + let ref; + if (commit != null) { + ref = commit.ref; + // Check to make sure the filename hasn't changed and if it has use the previous + if (commit.file != null) { + const path = commit.file.originalPath ?? commit.file.path; + if (path !== relativePath) { + relativePath = path; + } + } + } const moreLog = await this.getLogForFile(log.repoPath, relativePath, { ...options, limit: moreUntil == null ? moreLimit : 0, @@ -3545,20 +4051,25 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @gate() @log() async getMergeStatus(repoPath: string): Promise { let status = this.useCaching ? this._mergeStatusCache.get(repoPath) : undefined; - if (status === undefined) { - const merge = await this.git.rev_parse__verify(repoPath, 'MERGE_HEAD'); - if (merge != null) { - const [branch, mergeBase, possibleSourceBranches] = await Promise.all([ + if (status == null) { + async function getCore(this: LocalGitProvider): Promise { + const merge = await this.git.rev_parse__verify(repoPath, 'MERGE_HEAD'); + if (merge == null) return undefined; + + const [branchResult, mergeBaseResult, possibleSourceBranchesResult] = await Promise.allSettled([ this.getBranch(repoPath), this.getMergeBase(repoPath, 'MERGE_HEAD', 'HEAD'), - this.getCommitBranches(repoPath, 'MERGE_HEAD', { mode: 'pointsAt' }), + this.getCommitBranches(repoPath, ['MERGE_HEAD'], undefined, { all: true, mode: 'pointsAt' }), ]); - status = { + const branch = getSettledValue(branchResult); + const mergeBase = getSettledValue(mergeBaseResult); + const possibleSourceBranches = getSettledValue(possibleSourceBranchesResult); + + return { type: 'merge', repoPath: repoPath, mergeBase: mergeBase, @@ -3572,66 +4083,110 @@ export class LocalGitProvider implements GitProvider, Disposable { remote: false, }) : undefined, - }; + } satisfies GitMergeStatus; } + status = getCore.call(this); if (this.useCaching) { - this._mergeStatusCache.set(repoPath, status ?? null); + this._mergeStatusCache.set(repoPath, status); } } - return status ?? undefined; + return status; } - @gate() @log() async getRebaseStatus(repoPath: string): Promise { let status = this.useCaching ? this._rebaseStatusCache.get(repoPath) : undefined; - if (status === undefined) { - const rebase = await this.git.rev_parse__verify(repoPath, 'REBASE_HEAD'); - if (rebase != null) { - let [mergeBase, branch, onto, stepsNumber, stepsMessage, stepsTotal] = await Promise.all([ - this.getMergeBase(repoPath, 'REBASE_HEAD', 'HEAD'), - this.git.readDotGitFile(repoPath, ['rebase-merge', 'head-name']), - this.git.readDotGitFile(repoPath, ['rebase-merge', 'onto']), - this.git.readDotGitFile(repoPath, ['rebase-merge', 'msgnum'], { numeric: true }), + if (status == null) { + async function getCore(this: LocalGitProvider): Promise { + const gitDir = await this.getGitDir(repoPath); + const [rebaseMergeHeadResult, rebaseApplyHeadResult] = await Promise.allSettled([ + this.git.readDotGitFile(gitDir, ['rebase-merge', 'head-name']), + this.git.readDotGitFile(gitDir, ['rebase-apply', 'head-name']), + ]); + const rebaseMergeHead = getSettledValue(rebaseMergeHeadResult); + const rebaseApplyHead = getSettledValue(rebaseApplyHeadResult); + + let branch = rebaseApplyHead ?? rebaseMergeHead; + if (branch == null) return undefined; + + const path = rebaseApplyHead != null ? 'rebase-apply' : 'rebase-merge'; + + const [ + rebaseHeadResult, + origHeadResult, + ontoResult, + stepsNumberResult, + stepsTotalResult, + stepsMessageResult, + ] = await Promise.allSettled([ + this.git.rev_parse__verify(repoPath, 'REBASE_HEAD'), + this.git.readDotGitFile(gitDir, [path, 'orig-head']), + this.git.readDotGitFile(gitDir, [path, 'onto']), + this.git.readDotGitFile(gitDir, [path, 'msgnum'], { numeric: true }), + this.git.readDotGitFile(gitDir, [path, 'end'], { numeric: true }), this.git - .readDotGitFile(repoPath, ['rebase-merge', 'message'], { throw: true }) - .catch(() => this.git.readDotGitFile(repoPath, ['rebase-merge', 'message-squashed'])), - this.git.readDotGitFile(repoPath, ['rebase-merge', 'end'], { numeric: true }), + .readDotGitFile(gitDir, [path, 'message'], { throw: true }) + .catch(() => this.git.readDotGitFile(gitDir, [path, 'message-squashed'])), ]); - if (branch == null || onto == null) return undefined; + const origHead = getSettledValue(origHeadResult); + const onto = getSettledValue(ontoResult); + if (origHead == null || onto == null) return undefined; + + let mergeBase; + const rebaseHead = getSettledValue(rebaseHeadResult); + if (rebaseHead != null) { + mergeBase = await this.getMergeBase(repoPath, rebaseHead, 'HEAD'); + } else { + mergeBase = await this.getMergeBase(repoPath, onto, origHead); + } if (branch.startsWith('refs/heads/')) { branch = branch.substr(11).trim(); } - const possibleSourceBranches = await this.getCommitBranches(repoPath, onto, { mode: 'pointsAt' }); + const [branchTipsResult, tagTipsResult] = await Promise.allSettled([ + this.getCommitBranches(repoPath, [onto], undefined, { all: true, mode: 'pointsAt' }), + this.getCommitTags(repoPath, onto, { mode: 'pointsAt' }), + ]); + + const branchTips = getSettledValue(branchTipsResult); + const tagTips = getSettledValue(tagTipsResult); - let possibleSourceBranch: string | undefined; - for (const b of possibleSourceBranches) { - if (b.startsWith('(no branch, rebasing')) continue; + let ontoRef: GitBranchReference | GitTagReference | undefined; + if (branchTips != null) { + for (const ref of branchTips) { + if (ref.startsWith('(no branch, rebasing')) continue; + + ontoRef = createReference(ref, repoPath, { + refType: 'branch', + name: ref, + remote: false, + }); + break; + } + } + if (ontoRef == null && tagTips != null) { + for (const ref of tagTips) { + if (ref.startsWith('(no branch, rebasing')) continue; - possibleSourceBranch = b; - break; + ontoRef = createReference(ref, repoPath, { + refType: 'tag', + name: ref, + }); + break; + } } - status = { + return { type: 'rebase', repoPath: repoPath, mergeBase: mergeBase, - HEAD: createReference(rebase, repoPath, { refType: 'revision' }), + HEAD: createReference(rebaseHead ?? origHead, repoPath, { refType: 'revision' }), onto: createReference(onto, repoPath, { refType: 'revision' }), - current: - possibleSourceBranch != null - ? createReference(possibleSourceBranch, repoPath, { - refType: 'branch', - name: possibleSourceBranch, - remote: false, - }) - : undefined, - + current: ontoRef, incoming: createReference(branch, repoPath, { refType: 'branch', name: branch, @@ -3639,23 +4194,27 @@ export class LocalGitProvider implements GitProvider, Disposable { }), steps: { current: { - number: stepsNumber ?? 0, - commit: createReference(rebase, repoPath, { - refType: 'revision', - message: stepsMessage, - }), + number: getSettledValue(stepsNumberResult) ?? 0, + commit: + rebaseHead != null + ? createReference(rebaseHead, repoPath, { + refType: 'revision', + message: getSettledValue(stepsMessageResult), + }) + : undefined, }, - total: stepsTotal ?? 0, + total: getSettledValue(stepsTotalResult) ?? 0, }, - }; + } satisfies GitRebaseStatus; } + status = getCore.call(this); if (this.useCaching) { - this._rebaseStatusCache.set(repoPath, status ?? null); + this._rebaseStatusCache.set(repoPath, status); } } - return status ?? undefined; + return status; } @log() @@ -3725,7 +4284,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const relativePath = this.getRelativePath(uri, repoPath); let data = await this.git.log__file(repoPath, relativePath, ref, { - argsOrFormat: GitLogParser.simpleFormat, + argsOrFormat: parseGitLogSimpleFormat, fileMode: 'simple', filters: filters, limit: skip + 1, @@ -3735,11 +4294,11 @@ export class LocalGitProvider implements GitProvider, Disposable { }); if (data == null || data.length === 0) return undefined; - const [nextRef, file, status] = GitLogParser.parseSimple(data, skip); + const [nextRef, file, status] = parseGitLogSimple(data, skip); // If the file was deleted, check for a possible rename if (status === 'D') { data = await this.git.log__file(repoPath, '.', nextRef, { - argsOrFormat: GitLogParser.simpleFormat, + argsOrFormat: parseGitLogSimpleFormat, fileMode: 'simple', filters: ['R', 'C'], limit: 1, @@ -3750,7 +4309,7 @@ export class LocalGitProvider implements GitProvider, Disposable { return GitUri.fromFile(file ?? relativePath, repoPath, nextRef); } - const [nextRenamedRef, renamedFile] = GitLogParser.parseSimpleRenamed(data, file ?? relativePath); + const [nextRenamedRef, renamedFile] = parseGitLogSimpleRenamed(data, file ?? relativePath); return GitUri.fromFile( renamedFile ?? file ?? relativePath, repoPath, @@ -3784,7 +4343,6 @@ export class LocalGitProvider implements GitProvider, Disposable { uri: Uri, ref: string | undefined, skip: number = 0, - firstParent: boolean = false, ): Promise { if (ref === deletedOrMissing) return undefined; @@ -3814,13 +4372,13 @@ export class LocalGitProvider implements GitProvider, Disposable { return { // Diff staged with HEAD (or prior if more skips) current: GitUri.fromFile(relativePath, repoPath, uncommittedStaged), - previous: await this.getPreviousUri(repoPath, uri, ref, skip - 1, undefined, firstParent), + previous: await this.getPreviousUri(repoPath, uri, ref, skip - 1), }; } else if (status.workingTreeStatus != null) { if (skip === 0) { return { current: GitUri.fromFile(relativePath, repoPath, undefined), - previous: await this.getPreviousUri(repoPath, uri, undefined, skip, undefined, firstParent), + previous: await this.getPreviousUri(repoPath, uri, undefined, skip), }; } } @@ -3833,12 +4391,12 @@ export class LocalGitProvider implements GitProvider, Disposable { const current = skip === 0 ? GitUri.fromFile(relativePath, repoPath, ref) - : (await this.getPreviousUri(repoPath, uri, undefined, skip - 1, undefined, firstParent))!; + : (await this.getPreviousUri(repoPath, uri, undefined, skip - 1))!; if (current == null || current.sha === deletedOrMissing) return undefined; return { current: current, - previous: await this.getPreviousUri(repoPath, uri, undefined, skip, undefined, firstParent), + previous: await this.getPreviousUri(repoPath, uri, undefined, skip), }; } @@ -3846,12 +4404,12 @@ export class LocalGitProvider implements GitProvider, Disposable { const current = skip === 0 ? GitUri.fromFile(relativePath, repoPath, ref) - : (await this.getPreviousUri(repoPath, uri, ref, skip - 1, undefined, firstParent))!; + : (await this.getPreviousUri(repoPath, uri, ref, skip - 1))!; if (current == null || current.sha === deletedOrMissing) return undefined; return { current: current, - previous: await this.getPreviousUri(repoPath, uri, ref, skip, undefined, firstParent), + previous: await this.getPreviousUri(repoPath, uri, ref, skip), }; } @@ -3882,54 +4440,37 @@ export class LocalGitProvider implements GitProvider, Disposable { // If line is uncommitted, we need to dig deeper to figure out where to go (because blame can't be trusted) if (blameLine.commit.isUncommitted) { - // If the document is dirty (unsaved), use the status to determine where to go - if (document.isDirty) { - // Check the file status to see if there is anything staged - const status = await this.getStatusForFile(repoPath, uri); - if (status != null) { - // If the file is staged, diff working with staged (index) - // If the file is not staged, diff working with HEAD - if (status.indexStatus != null) { - // Diff working with staged - return { - current: GitUri.fromFile(relativePath, repoPath, undefined), - previous: GitUri.fromFile(relativePath, repoPath, uncommittedStaged), - line: editorLine, - }; - } + // Check the file status to see if there is anything staged + const status = await this.getStatusForFile(repoPath, uri); + if (status != null) { + // If the file is staged, diff working with staged (index) + // If the file is not staged, diff working with HEAD + if (status.indexStatus != null) { + // Diff working with staged + return { + current: GitUri.fromFile(relativePath, repoPath, undefined), + previous: GitUri.fromFile(relativePath, repoPath, uncommittedStaged), + line: editorLine, + }; } - - // Diff working with HEAD (or prior if more skips) - return { - current: GitUri.fromFile(relativePath, repoPath, undefined), - previous: await this.getPreviousUri(repoPath, uri, undefined, skip, editorLine), - line: editorLine, - }; } - // First, check if we have a diff in the working tree - let hunkLine = await this.getDiffForLine(gitUri, editorLine, undefined); - if (hunkLine == null) { - // Next, check if we have a diff in the index (staged) - hunkLine = await this.getDiffForLine(gitUri, editorLine, undefined, uncommittedStaged); - - if (hunkLine != null) { - ref = uncommittedStaged; - } else { - skip++; - } - } + // Diff working with HEAD (or prior if more skips) + return { + current: GitUri.fromFile(relativePath, repoPath, undefined), + previous: await this.getPreviousUri(repoPath, uri, undefined, skip, editorLine), + line: editorLine, + }; } + // If line is committed, diff with line ref with previous - else { - ref = blameLine.commit.sha; - relativePath = blameLine.commit.file?.path ?? blameLine.commit.file?.originalPath ?? relativePath; - uri = this.getAbsoluteUri(relativePath, repoPath); - editorLine = blameLine.line.originalLine - 1; - - if (skip === 0 && blameLine.commit.file?.previousSha) { - previous = GitUri.fromFile(relativePath, repoPath, blameLine.commit.file.previousSha); - } + ref = blameLine.commit.sha; + relativePath = blameLine.commit.file?.path ?? blameLine.commit.file?.originalPath ?? relativePath; + uri = this.getAbsoluteUri(relativePath, repoPath); + editorLine = blameLine.line.originalLine - 1; + + if (skip === 0 && blameLine.commit.file?.previousSha) { + previous = GitUri.fromFile(relativePath, repoPath, blameLine.commit.file.previousSha); } } else { if (isUncommittedStaged(ref)) { @@ -3981,7 +4522,6 @@ export class LocalGitProvider implements GitProvider, Disposable { ref?: string, skip: number = 0, editorLine?: number, - firstParent: boolean = false, ): Promise { if (ref === deletedOrMissing) return undefined; @@ -3997,9 +4537,8 @@ export class LocalGitProvider implements GitProvider, Disposable { let data; try { data = await this.git.log__file(repoPath, relativePath, ref, { - argsOrFormat: GitLogParser.simpleFormat, + argsOrFormat: parseGitLogSimpleFormat, fileMode: 'simple', - firstParent: firstParent, limit: skip + 2, ordering: configuration.get('advanced.commitOrdering'), startLine: editorLine != null ? editorLine + 1 : undefined, @@ -4026,7 +4565,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } if (data == null || data.length === 0) return undefined; - const [previousRef, file] = GitLogParser.parseSimple(data, skip, ref); + const [previousRef, file] = parseGitLogSimple(data, skip, ref); // If the previous ref matches the ref we asked for assume we are at the end of the history if (ref != null && ref === previousRef) return undefined; @@ -4056,7 +4595,7 @@ export class LocalGitProvider implements GitProvider, Disposable { }); if (data == null) return undefined; - const reflog = GitReflogParser.parse(data, repoPath, reflogCommands, limit, limit * 100); + const reflog = parseGitRefLog(data, repoPath, reflogCommands, limit, limit * 100); if (reflog?.hasMore) { reflog.more = this.getReflogMoreFn(reflog, options); } @@ -4122,13 +4661,12 @@ export class LocalGitProvider implements GitProvider, Disposable { try { const data = await this.git.remote(repoPath!); - const remotes = GitRemoteParser.parse( + const remotes = parseGitRemotes( + this.container, data, repoPath!, getRemoteProviderMatcher(this.container, providers), ); - if (remotes == null) return []; - return remotes; } catch (ex) { this._remotesCache.delete(repoPath!); @@ -4146,7 +4684,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const remotes = await remotesPromise; if (options?.sort) { - GitRemote.sort(remotes); + sortRemotes(remotes); } return remotes; @@ -4245,31 +4783,28 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log() - async getStatusForFile(repoPath: string, uri: Uri): Promise { - const porcelainVersion = (await this.git.isAtLeastVersion('2.11')) ? 2 : 1; - - const [relativePath, root] = splitPath(uri, repoPath); - - const data = await this.git.status__file(root, relativePath, porcelainVersion, { - similarityThreshold: configuration.get('advanced.similarityThreshold'), - }); + async getStatusForFile(repoPath: string, pathOrUri: string | Uri): Promise { + const status = await this.getStatusForRepo(repoPath); + if (!status?.files.length) return undefined; - const status = GitStatusParser.parse(data, root, porcelainVersion); - return status?.files?.[0]; + const [relativePath] = splitPath(pathOrUri, repoPath); + const file = status.files.find(f => f.path === relativePath); + return file; } @log() async getStatusForFiles(repoPath: string, pathOrGlob: Uri): Promise { - const porcelainVersion = (await this.git.isAtLeastVersion('2.11')) ? 2 : 1; - - const [relativePath, root] = splitPath(pathOrGlob, repoPath); + let [relativePath] = splitPath(pathOrGlob, repoPath); + if (!relativePath.endsWith('/*')) { + return this.getStatusForFile(repoPath, pathOrGlob).then(f => (f != null ? [f] : undefined)); + } - const data = await this.git.status__file(root, relativePath, porcelainVersion, { - similarityThreshold: configuration.get('advanced.similarityThreshold'), - }); + relativePath = relativePath.substring(0, relativePath.length - 1); + const status = await this.getStatusForRepo(repoPath); + if (!status?.files.length) return undefined; - const status = GitStatusParser.parse(data, root, porcelainVersion); - return status?.files ?? []; + const files = status.files.filter(f => f.path.startsWith(relativePath)); + return files; } @log() @@ -4279,9 +4814,9 @@ export class LocalGitProvider implements GitProvider, Disposable { const porcelainVersion = (await this.git.isAtLeastVersion('2.11')) ? 2 : 1; const data = await this.git.status(repoPath, porcelainVersion, { - similarityThreshold: configuration.get('advanced.similarityThreshold'), + similarityThreshold: configuration.get('advanced.similarityThreshold') ?? undefined, }); - const status = GitStatusParser.parse(data, repoPath, porcelainVersion); + const status = parseGitStatus(data, repoPath, porcelainVersion); if (status?.detached) { const rebaseStatus = await this.getRebaseStatus(repoPath); @@ -4303,7 +4838,11 @@ export class LocalGitProvider implements GitProvider, Disposable { @log({ args: { 1: false } }) async getTags( repoPath: string | undefined, - options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + options?: { + filter?: (t: GitTag) => boolean; + paging?: PagingOptions; + sort?: boolean | TagSortOptions; + }, ): Promise> { if (repoPath == null) return emptyPagedResult; @@ -4312,7 +4851,7 @@ export class LocalGitProvider implements GitProvider, Disposable { async function load(this: LocalGitProvider): Promise> { try { const data = await this.git.tag(repoPath!); - return { values: GitTagParser.parse(data, repoPath!) ?? [] }; + return { values: parseGitTags(data, repoPath!) }; } catch (ex) { this._tagsCache.delete(repoPath!); @@ -4322,7 +4861,7 @@ export class LocalGitProvider implements GitProvider, Disposable { resultsPromise = load.call(this); - if (this.useCaching) { + if (this.useCaching && options?.paging?.cursor == null) { this._tagsCache.set(repoPath, resultsPromise); } } @@ -4348,9 +4887,23 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = splitPath(path, repoPath); + if (isUncommittedStaged(ref)) { + const data = await this.git.ls_files(root, relativePath, { ref: ref }); + const [result] = parseGitLsFiles(data); + if (result == null) return undefined; + + const size = await this.git.cat_file__size(repoPath, result.oid); + return { + ref: ref, + oid: result.oid, + path: relativePath, + size: size, + type: 'blob', + }; + } + const data = await this.git.ls_tree(root, ref, relativePath); - const trees = GitTreeParser.parse(data); - return trees?.length ? trees[0] : undefined; + return parseGitTree(data, ref)[0]; } @log() @@ -4358,13 +4911,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (repoPath == null) return []; const data = await this.git.ls_tree(repoPath, ref); - return GitTreeParser.parse(data) ?? []; - } - - @log() - async getUniqueRepositoryId(repoPath: string): Promise { - const data = await this.git.rev_list(repoPath, 'HEAD', { maxParents: 0 }); - return data?.[0]; + return parseGitTree(data, ref); } @log({ args: { 1: false } }) @@ -4404,17 +4951,19 @@ export class LocalGitProvider implements GitProvider, Disposable { } async isTracked(uri: Uri): Promise { - return (await this.isTrackedPrivate(uri)) != null; + return (await this.isTrackedWithDetails(uri)) != null; } - private async isTrackedPrivate(uri: Uri | GitUri): Promise<[string, string] | undefined>; - private async isTrackedPrivate( + private async isTrackedWithDetails(uri: Uri | GitUri): Promise<[string, string] | undefined>; + private async isTrackedWithDetails( path: string, repoPath?: string, ref?: string, ): Promise<[string, string] | undefined>; - @log({ exit: tracked => `returned ${Boolean(tracked)}` }) - private async isTrackedPrivate( + @log({ + exit: tracked => `returned ${tracked != null ? `[${tracked[0]},[${tracked[1]}]` : 'false'}`, + }) + private async isTrackedWithDetails( pathOrUri: string | Uri | GitUri, repoPath?: string, ref?: string, @@ -4426,13 +4975,13 @@ export class LocalGitProvider implements GitProvider, Disposable { if (ref === deletedOrMissing) return undefined; repository = this.container.git.getRepository(Uri.file(pathOrUri)); - repoPath = repoPath || repository?.path; + repoPath ||= repository?.path; [relativePath, repoPath] = splitPath(pathOrUri, repoPath); } else { if (!this.isTrackable(pathOrUri)) return undefined; - if (pathOrUri instanceof GitUri) { + if (isGitUri(pathOrUri)) { // Always use the ref of the GitUri ref = pathOrUri.sha; if (ref === deletedOrMissing) return undefined; @@ -4730,7 +5279,7 @@ export class LocalGitProvider implements GitProvider, Disposable { shas: shas, stdin: stdin, }); - const log = GitLogParser.parse( + const log = parseGitLog( this.container, data, LogType.Log, @@ -4876,7 +5425,7 @@ export class LocalGitProvider implements GitProvider, Disposable { let data; try { - data = await this.git.log2( + data = await this.git.log( repoPath, { cancellation: options?.cancellation, @@ -4970,6 +5519,24 @@ export class LocalGitProvider implements GitProvider, Disposable { return this.git.check_ref_format(ref, repoPath); } + @log({ args: { 1: false } }) + async validatePatch(repoPath: string | undefined, contents: string): Promise { + try { + await this.git.apply2(repoPath!, { stdin: contents }, '--check'); + return true; + } catch (ex) { + if (ex instanceof Error && ex.message) { + if (ex.message.includes('No valid patches in input')) { + return false; + } + + return true; + } + + return false; + } + } + @log() async validateReference(repoPath: string, ref: string): Promise { if (ref == null || ref.length === 0) return false; @@ -5058,36 +5625,52 @@ export class LocalGitProvider implements GitProvider, Disposable { uris?: Uri[], options?: { includeUntracked?: boolean; keepIndex?: boolean; onlyStaged?: boolean }, ): Promise { - if (uris == null) { - await this.git.stash__push(repoPath, message, options); - this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['stashes', 'status'] }); - return; - } - - await this.ensureGitVersion( - '2.13.2', - 'Stashing individual files', - ' Please retry by stashing everything or install a more recent version of Git and try again.', - ); - - const pathspecs = uris.map(u => `./${splitPath(u, repoPath)[0]}`); + try { + if (!uris?.length) { + await this.git.stash__push(repoPath, message, options); + return; + } - const stdinVersion = '2.30.0'; - const stdin = await this.git.isAtLeastVersion(stdinVersion); - // If we don't support stdin, then error out if we are over the maximum allowed git cli length - if (!stdin && countStringLength(pathspecs) > maxGitCliLength) { await this.ensureGitVersion( - stdinVersion, - `Stashing so many files (${pathspecs.length}) at once`, - ' Please retry by stashing fewer files or install a more recent version of Git and try again.', + '2.13.2', + 'Stashing individual files', + ' Please retry by stashing everything or install a more recent version of Git and try again.', ); + + const pathspecs = uris.map(u => `./${splitPath(u, repoPath)[0]}`); + + const stdinVersion = '2.30.0'; + let stdin = await this.git.isAtLeastVersion(stdinVersion); + if (stdin && options?.onlyStaged && uris.length) { + // Since Git doesn't support --staged with --pathspec-from-file try to pass them in directly + stdin = false; + } + + // If we don't support stdin, then error out if we are over the maximum allowed git cli length + if (!stdin && countStringLength(pathspecs) > maxGitCliLength) { + await this.ensureGitVersion( + stdinVersion, + `Stashing so many files (${pathspecs.length}) at once`, + ' Please retry by stashing fewer files or install a more recent version of Git and try again.', + ); + } + + await this.git.stash__push(repoPath, message, { + ...options, + pathspecs: pathspecs, + stdin: stdin, + }); + } finally { + this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['stashes', 'status'] }); } + } - await this.git.stash__push(repoPath, message, { - ...options, - pathspecs: pathspecs, - stdin: stdin, - }); + @log() + async stashSaveSnapshot(repoPath: string, message?: string): Promise { + const id = await this.git.stash__create(repoPath); + if (id == null) return; + + await this.git.stash__store(repoPath, id, message); this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['stashes'] }); } @@ -5101,6 +5684,7 @@ export class LocalGitProvider implements GitProvider, Disposable { try { await this.git.worktree__add(repoPath, path, options); + this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['worktrees'] }); if (options?.createBranch) { this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['branches'] }); } @@ -5121,7 +5705,6 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @gate() @log() async getWorktrees(repoPath: string): Promise { await this.ensureGitVersion( @@ -5130,12 +5713,35 @@ export class LocalGitProvider implements GitProvider, Disposable { ' Please install a more recent version of Git and try again.', ); - const data = await this.git.worktree__list(repoPath); - return GitWorktreeParser.parse(data, repoPath); + let worktrees = this.useCaching ? this._worktreesCache.get(repoPath) : undefined; + if (worktrees == null) { + async function load(this: LocalGitProvider) { + try { + const [data, branches] = await Promise.all([ + this.git.worktree__list(repoPath), + this.getBranches(repoPath), + ]); + + return parseGitWorktrees(this.container, data, repoPath, branches.values); + } catch (ex) { + this._worktreesCache.delete(repoPath); + + throw ex; + } + } + + worktrees = load.call(this); + + if (this.useCaching) { + this._worktreesCache.set(repoPath, worktrees); + } + } + + return worktrees; } - // eslint-disable-next-line @typescript-eslint/require-await @log() + // eslint-disable-next-line @typescript-eslint/require-await async getWorktreesDefaultUri(repoPath: string): Promise { let location = configuration.get('worktrees.defaultLocation'); if (location == null) return undefined; @@ -5165,7 +5771,8 @@ export class LocalGitProvider implements GitProvider, Disposable { ); try { - await this.git.worktree__remove(repoPath, path, options); + await this.git.worktree__remove(repoPath, normalizePath(path), options); + this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['worktrees'] }); } catch (ex) { Logger.error(ex, scope); @@ -5183,13 +5790,13 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - private _scmGitApi: Promise | undefined; - private async getScmGitApi(): Promise { + private _scmGitApi: Promise | undefined; + private async getScmGitApi(): Promise { return this._scmGitApi ?? (this._scmGitApi = this.getScmGitApiCore()); } @log() - private async getScmGitApiCore(): Promise { + private async getScmGitApiCore(): Promise { try { const extension = extensions.getExtension('vscode.git'); if (extension == null) return undefined; @@ -5229,7 +5836,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log() + @log({ exit: true }) async getScmRepository(repoPath: string): Promise { const scope = getLogScope(); try { @@ -5241,28 +5848,28 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log() - async getOrOpenScmRepository(repoPath: string): Promise { + @log({ exit: true }) + async getOrOpenScmRepository(repoPath: string | Uri): Promise { const scope = getLogScope(); try { + const uri = repoPath instanceof Uri ? repoPath : Uri.file(repoPath); const gitApi = await this.getScmGitApi(); - if (gitApi?.openRepository != null) { - return (await gitApi?.openRepository?.(Uri.file(repoPath))) ?? undefined; + if (gitApi == null) return undefined; + + // `getRepository` will return an opened repository that "contains" that path, so for nested repositories, we need to force the opening of the nested path, otherwise we will only get the root repository + let repo = gitApi.getRepository(uri); + if (repo == null || (repo != null && repo.rootUri.toString() !== uri.toString())) { + Logger.debug( + scope, + repo == null + ? '\u2022 no existing repository found, opening repository...' + : `\u2022 existing, non-matching repository '${repo.rootUri.toString( + true, + )}' found, opening repository...`, + ); + repo = await gitApi.openRepository?.(uri); } - - return gitApi?.getRepository(Uri.file(repoPath)) ?? undefined; - } catch (ex) { - Logger.error(ex, scope); - return undefined; - } - } - - @log() - private async openScmRepository(uri: Uri): Promise { - const scope = getLogScope(); - try { - const gitApi = await this.getScmGitApi(); - return (await gitApi?.openRepository?.(uri)) ?? undefined; + return repo ?? undefined; } catch (ex) { Logger.error(ex, scope); return undefined; @@ -5279,20 +5886,9 @@ export class LocalGitProvider implements GitProvider, Disposable { } async function getEncoding(uri: Uri): Promise { - const encoding = configuration.getAny('files.encoding', uri); + const encoding = configuration.getCore('files.encoding', uri); if (encoding == null || encoding === 'utf8') return 'utf8'; - const encodingExists = (await import(/* webpackChunkName: "encoding" */ 'iconv-lite')).encodingExists; + const encodingExists = (await import(/* webpackChunkName: "lib-encoding" */ 'iconv-lite')).encodingExists; return encodingExists(encoding) ? encoding : 'utf8'; } - -async function disposeRemotes(remotes: (Promise | undefined)[]) { - const remotesResults = await Promise.allSettled(remotes); - for (const remotes of remotesResults) { - for (const remote of getSettledValue(remotes) ?? []) { - if (remote.hasRichIntegration()) { - remote.provider?.dispose(); - } - } - } -} diff --git a/src/env/node/git/shell.ts b/src/env/node/git/shell.ts index 48ef756748172..823c36e3de528 100644 --- a/src/env/node/git/shell.ts +++ b/src/env/node/git/shell.ts @@ -1,7 +1,7 @@ import type { ExecException } from 'child_process'; import { exec, execFile } from 'child_process'; import type { Stats } from 'fs'; -import { exists, existsSync, statSync } from 'fs'; +import { access, constants, existsSync, statSync } from 'fs'; import { join as joinPaths } from 'path'; import * as process from 'process'; import type { CancellationToken } from 'vscode'; @@ -227,7 +227,7 @@ export function run( encoding: BufferEncoding | 'buffer' | string, options?: RunOptions & { exitCodeOnly?: boolean }, ): Promise { - const { stdin, stdinEncoding, ...opts }: RunOptions = { maxBuffer: 100 * 1024 * 1024, ...options }; + const { stdin, stdinEncoding, ...opts }: RunOptions = { maxBuffer: 1000 * 1024 * 1024, ...options }; let killed = false; return new Promise((resolve, reject) => { @@ -252,7 +252,7 @@ export function run( stdoutDecoded = stdout.toString(); stderrDecoded = stderr.toString(); } else { - const decode = (await import(/* webpackChunkName: "encoding" */ 'iconv-lite')).decode; + const decode = (await import(/* webpackChunkName: "lib-encoding" */ 'iconv-lite')).decode; stdoutDecoded = decode(Buffer.from(stdout, 'binary'), encoding); stderrDecoded = decode(Buffer.from(stderr, 'binary'), encoding); } @@ -268,7 +268,7 @@ export function run( if (encoding === 'utf8' || encoding === 'binary' || encoding === 'buffer') { resolve(stdout as T); } else { - const decode = (await import(/* webpackChunkName: "encoding" */ 'iconv-lite')).decode; + const decode = (await import(/* webpackChunkName: "lib-encoding" */ 'iconv-lite')).decode; resolve(decode(Buffer.from(stdout, 'binary'), encoding) as T); } }); @@ -290,6 +290,6 @@ export function run( }); } -export function fsExists(path: string) { - return new Promise(resolve => exists(path, exists => resolve(exists))); +export async function fsExists(path: string) { + return new Promise(resolve => access(path, constants.F_OK, err => resolve(err == null))); } diff --git a/src/env/node/platform.ts b/src/env/node/platform.ts index fedd559bd6302..daec4e98ee097 100644 --- a/src/env/node/platform.ts +++ b/src/env/node/platform.ts @@ -1,11 +1,13 @@ -import * as process from 'process'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { platform } from 'process'; import { env, UIKind } from 'vscode'; export const isWeb = env.uiKind === UIKind.Web; -export const isLinux = process.platform === 'linux'; -export const isMac = process.platform === 'darwin'; -export const isWindows = process.platform === 'win32'; +export const isLinux = platform === 'linux'; +export const isMac = platform === 'darwin'; +export const isWindows = platform === 'win32'; export function getPlatform(): string { if (isWindows) return 'windows'; @@ -13,3 +15,7 @@ export function getPlatform(): string { if (isLinux) return 'linux'; return isWeb ? 'web' : 'unknown'; } + +export function getTempFile(filename: string): string { + return join(tmpdir(), filename); +} diff --git a/src/env/node/providers.ts b/src/env/node/providers.ts index e085df04170e8..dc8447c79a77a 100644 --- a/src/env/node/providers.ts +++ b/src/env/node/providers.ts @@ -40,9 +40,13 @@ export async function getSupportedGitProviders(container: Container): Promise; interface CommitSelectedEventArgs { @@ -14,6 +15,14 @@ interface CommitSelectedEventArgs { readonly preserveVisibility?: boolean; } +export type DraftSelectedEvent = EventBusEvent<'draft:selected'>; +interface DraftSelectedEventArgs { + readonly draft: LocalDraft | Draft; + readonly interaction: 'active' | 'passive'; + readonly preserveFocus?: boolean; + readonly preserveVisibility?: boolean; +} + export type FileSelectedEvent = EventBusEvent<'file:selected'>; interface FileSelectedEventArgs { readonly uri: Uri; @@ -29,6 +38,7 @@ interface GitCacheResetEventArgs { type EventsMapping = { 'commit:selected': CommitSelectedEventArgs; + 'draft:selected': DraftSelectedEventArgs; 'file:selected': FileSelectedEventArgs; 'git:cache:reset': GitCacheResetEventArgs; }; @@ -47,10 +57,15 @@ export type EventBusOptions = { type CacheableEventsMapping = { 'commit:selected': CommitSelectedEventArgs; + 'draft:selected': DraftSelectedEventArgs; 'file:selected': FileSelectedEventArgs; }; -const _cacheableEventNames = new Set(['commit:selected', 'file:selected']); +const _cacheableEventNames = new Set([ + 'commit:selected', + 'draft:selected', + 'file:selected', +]); const _cachedEventArgs = new Map(); export class EventBus implements Disposable { @@ -79,12 +94,7 @@ export class EventBus implements Disposable { return _cachedEventArgs.get(name) as CacheableEventsMapping[T] | undefined; } - on( - name: T, - handler: (e: EventBusEvent) => void, - thisArgs?: unknown, - disposables?: Disposable[], - ) { + on(name: T, handler: (e: EventBusEvent) => void, thisArgs?: unknown) { return this._emitter.event( // eslint-disable-next-line prefer-arrow-callback function (e) { @@ -92,7 +102,6 @@ export class EventBus implements Disposable { handler.call(thisArgs, e as EventBusEvent); }, thisArgs, - disposables, ); } } diff --git a/src/extension.ts b/src/extension.ts index 362eeaffbaa62..f8dfc10dd426b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,8 @@ import { hrtime } from '@env/hrtime'; import { isWeb } from '@env/platform'; import { Api } from './api/api'; import type { CreatePullRequestActionContext, GitLensApi, OpenPullRequestActionContext } from './api/gitlens'; -import type { CreatePullRequestOnRemoteCommandArgs, OpenPullRequestOnRemoteCommandArgs } from './commands'; +import type { CreatePullRequestOnRemoteCommandArgs } from './commands/createPullRequestOnRemote'; +import type { OpenPullRequestOnRemoteCommandArgs } from './commands/openPullRequestOnRemote'; import { fromOutputLevel } from './config'; import { Commands, SyncedStorageKeys } from './constants'; import { Container } from './container'; @@ -20,23 +21,42 @@ import { configuration, Configuration } from './system/configuration'; import { setContext } from './system/context'; import { setDefaultDateLocales } from './system/date'; import { once } from './system/event'; -import { getLoggableName, Logger } from './system/logger'; +import { BufferedLogChannel, getLoggableName, Logger } from './system/logger'; import { flatten } from './system/object'; import { Stopwatch } from './system/stopwatch'; import { Storage } from './system/storage'; import { compare, fromString, satisfies } from './system/version'; -import { isViewNode } from './views/nodes/viewNode'; +import { isViewNode } from './views/nodes/abstract/viewNode'; +import './commands'; export async function activate(context: ExtensionContext): Promise { const gitlensVersion: string = context.extension.packageJSON.version; const prerelease = satisfies(gitlensVersion, '> 2020.0.0'); - const outputLevel = configuration.get('outputLevel'); + const defaultDateLocale = configuration.get('defaultDateLocale'); + const logLevel = fromOutputLevel(configuration.get('outputLevel')); Logger.configure( { name: 'GitLens', createChannel: function (name: string) { - return window.createOutputChannel(name); + const channel = new BufferedLogChannel(window.createOutputChannel(name), 500); + context.subscriptions.push(channel); + + if (logLevel === 'error' || logLevel === 'warn') { + channel.appendLine( + `GitLens${prerelease ? ' (pre-release)' : ''} v${gitlensVersion} activating in ${ + env.appName + } (${codeVersion}) on the ${isWeb ? 'web' : 'desktop'}; language='${ + env.language + }', logLevel='${logLevel}', defaultDateLocale='${defaultDateLocale}' (${env.machineId}|${ + env.sessionId + })`, + ); + channel.appendLine( + 'To enable debug logging, set `"gitlens.outputLevel: "debug"` or run "GitLens: Enable Debug Logging" from the Command Palette', + ); + } + return channel; }, toLoggable: function (o: any) { if (isGitUri(o)) { @@ -48,6 +68,10 @@ export async function activate(context: ExtensionContext): Promise { - if (configuration.get('outputLevel') !== 'debug') return; + if (fromOutputLevel(configuration.get('outputLevel')) !== 'debug') return; if (!container.prereleaseOrDebugging) { if (await showDebugLoggingWarningMessage()) { @@ -184,7 +206,7 @@ export async function activate(context: ExtensionContext): Promise { +export async function copyMessageToClipboard(ref: Ref | GitCommit): Promise { let commit; if (isCommit(ref)) { commit = ref; @@ -72,45 +105,54 @@ export async function copyMessageToClipboard(ref: { repoPath: string; ref: strin await env.clipboard.writeText(message); } -export async function openAllChanges(commit: GitCommit, options?: TextDocumentShowOptions): Promise; +export async function openAllChanges( + commit: GitCommit, + options?: TextDocumentShowOptions & { title?: string }, +): Promise; export async function openAllChanges( files: GitFile[], - refs: { repoPath: string; ref1: string; ref2: string }, - options?: TextDocumentShowOptions, + refs: RefRange, + options?: TextDocumentShowOptions & { title?: string }, ): Promise; export async function openAllChanges( commitOrFiles: GitCommit | GitFile[], - refsOrOptions: { repoPath: string; ref1: string; ref2: string } | TextDocumentShowOptions | undefined, - options?: TextDocumentShowOptions, -) { - let files; - let refs; + refsOrOptions: RefRange | (TextDocumentShowOptions & { title?: string }) | undefined, + maybeOptions?: TextDocumentShowOptions & { title?: string }, +): Promise { if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); + if (configuration.get('views.openChangesInMultiDiffEditor')) { + return openAllChangesInChangesEditor(commitOrFiles, refsOrOptions as TextDocumentShowOptions | undefined); } + return openAllChangesIndividually(commitOrFiles, refsOrOptions as TextDocumentShowOptions | undefined); + } - files = commitOrFiles.files ?? []; - refs = { - repoPath: commitOrFiles.repoPath, - // Don't need to worry about verifying the previous sha, as the DiffWith command will - ref1: commitOrFiles.unresolvedPreviousSha, - ref2: commitOrFiles.sha, - }; - - options = refsOrOptions as TextDocumentShowOptions | undefined; - } else { - files = commitOrFiles; - refs = refsOrOptions as { repoPath: string; ref1: string; ref2: string }; + if (configuration.get('views.openChangesInMultiDiffEditor')) { + return openAllChangesInChangesEditor(commitOrFiles, refsOrOptions as RefRange, maybeOptions); } + return openAllChangesIndividually(commitOrFiles, refsOrOptions as RefRange, maybeOptions); +} - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open the changes for all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; +export async function openAllChangesIndividually(commit: GitCommit, options?: TextDocumentShowOptions): Promise; +export async function openAllChangesIndividually( + files: GitFile[], + refs: RefRange, + options?: TextDocumentShowOptions, +): Promise; +export async function openAllChangesIndividually( + commitOrFiles: GitCommit | GitFile[], + refsOrOptions: RefRange | TextDocumentShowOptions | undefined, + maybeOptions?: TextDocumentShowOptions, +): Promise { + let { files, refs, options } = await getChangesRefsArgs(commitOrFiles, refsOrOptions, maybeOptions); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open the changes for each of the ${files.length} files?`, + confirmButton: 'Open Changes', + threshold: filesOpenDiffsThreshold, + })) + ) { + return; } options = { preserveFocus: true, preview: false, ...options }; @@ -120,37 +162,88 @@ export async function openAllChanges( } } -export async function openAllChangesWithDiffTool(commit: GitCommit): Promise; -export async function openAllChangesWithDiffTool( +export async function openAllChangesInChangesEditor( + commit: GitCommit, + options?: TextDocumentShowOptions & { title?: string }, +): Promise; +export async function openAllChangesInChangesEditor( files: GitFile[], - ref: { repoPath: string; ref: string }, + refs: RefRange, + options?: TextDocumentShowOptions & { title?: string }, ): Promise; -export async function openAllChangesWithDiffTool( +export async function openAllChangesInChangesEditor( commitOrFiles: GitCommit | GitFile[], - ref?: { repoPath: string; ref: string }, -) { - let files; - if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); + refsOrOptions: RefRange | (TextDocumentShowOptions & { title?: string }) | undefined, + maybeOptions?: TextDocumentShowOptions & { title?: string }, +): Promise { + if (!configuration.getCore('multiDiffEditor.experimental.enabled')) { + void window.showErrorMessage( + `Enable the multi-diff editor by setting 'multiDiffEditor.experimental.enabled' to use this command`, + ); + return; + } + + let title; + if (maybeOptions != null) { + ({ title, ...maybeOptions } = maybeOptions); + } + + const { commit, files, refs, options } = await getChangesRefsArgs(commitOrFiles, refsOrOptions, maybeOptions); + + if (title == null) { + if (commit != null) { + title = `Changes in ${shortenRevision(refs.rhs, { strings: { working: 'Working Tree' } })}`; + } else { + title = `Changes between ${shortenRevision(refs.lhs, { strings: { working: 'Working Tree' } })} ${ + GlyphChars.ArrowLeftRightLong + } ${shortenRevision(refs.rhs, { strings: { working: 'Working Tree' } })}`; } + } - files = commitOrFiles.files ?? []; - ref = { - repoPath: commitOrFiles.repoPath, - ref: commitOrFiles.sha, - }; - } else { - files = commitOrFiles; + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to view the changes for all ${files.length} files?`, + confirmButton: 'View All Changes', + threshold: filesOpenMultiDiffThreshold, + })) + ) { + return; } - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open the changes for all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; + const { git } = Container.instance; + + const resources: Parameters[0] = []; + for (const file of files) { + let rhs = file.status === 'D' ? undefined : (await git.getBestRevisionUri(refs.repoPath, file.path, refs.rhs))!; + if (rhs != null && refs.rhs === '') { + rhs = await git.getWorkingUri(refs.repoPath, rhs); + } + + const lhs = + file.status === 'A' + ? undefined + : (await git.getBestRevisionUri(refs.repoPath, file.originalPath ?? file.path, refs.lhs))!; + + const uri = (file.status === 'D' ? lhs : rhs) ?? GitUri.fromFile(file, refs.repoPath); + resources.push({ uri: uri, lhs: lhs, rhs: rhs }); + } + + await openChangesEditor(resources, title, options); +} + +export async function openAllChangesWithDiffTool(commit: GitCommit): Promise; +export async function openAllChangesWithDiffTool(files: GitFile[], ref: Ref): Promise; +export async function openAllChangesWithDiffTool(commitOrFiles: GitCommit | GitFile[], ref?: Ref) { + const { files } = await getChangesRefArgs(commitOrFiles, ref); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to externally open the changes for each of the ${files.length} files?`, + confirmButton: 'Open Changes', + threshold: filesOpenDiffsThreshold, + })) + ) { + return; } for (const file of files) { @@ -158,43 +251,68 @@ export async function openAllChangesWithDiffTool( } } -export async function openAllChangesWithWorking(commit: GitCommit, options?: TextDocumentShowOptions): Promise; +export async function openAllChangesWithWorking( + commit: GitCommit, + options?: TextDocumentShowOptions & { title?: string }, +): Promise; export async function openAllChangesWithWorking( files: GitFile[], - ref: { repoPath: string; ref: string }, - options?: TextDocumentShowOptions, + ref: Ref, + options?: TextDocumentShowOptions & { title?: string }, ): Promise; export async function openAllChangesWithWorking( commitOrFiles: GitCommit | GitFile[], - refOrOptions: { repoPath: string; ref: string } | TextDocumentShowOptions | undefined, - options?: TextDocumentShowOptions, + refOrOptions: Ref | (TextDocumentShowOptions & { title?: string }) | undefined, + maybeOptions?: TextDocumentShowOptions & { title?: string }, ) { - let files; - let ref; if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); + if (configuration.get('views.openChangesInMultiDiffEditor')) { + return openAllChangesInChangesEditor(commitOrFiles, refOrOptions as TextDocumentShowOptions | undefined); } - - files = commitOrFiles.files ?? []; - ref = { - repoPath: commitOrFiles.repoPath, - ref: commitOrFiles.sha, - }; - - options = refOrOptions as TextDocumentShowOptions | undefined; - } else { - files = commitOrFiles; - ref = refOrOptions as { repoPath: string; ref: string }; + return openAllChangesWithWorkingIndividually( + commitOrFiles, + refOrOptions as TextDocumentShowOptions | undefined, + ); } - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open the changes for all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, + if (configuration.get('views.openChangesInMultiDiffEditor')) { + return openAllChangesInChangesEditor( + commitOrFiles, + { + repoPath: (refOrOptions as Ref).repoPath, + lhs: (refOrOptions as Ref).ref, + rhs: '', + }, + maybeOptions, ); - if (result == null || result.title === 'No') return; + } + return openAllChangesWithWorkingIndividually(commitOrFiles, refOrOptions as Ref, maybeOptions); +} + +export async function openAllChangesWithWorkingIndividually( + commit: GitCommit, + options?: TextDocumentShowOptions, +): Promise; +export async function openAllChangesWithWorkingIndividually( + files: GitFile[], + ref: Ref, + options?: TextDocumentShowOptions, +): Promise; +export async function openAllChangesWithWorkingIndividually( + commitOrFiles: GitCommit | GitFile[], + refOrOptions: Ref | TextDocumentShowOptions | undefined, + maybeOptions?: TextDocumentShowOptions, +) { + let { files, ref, options } = await getChangesRefArgs(commitOrFiles, refOrOptions, maybeOptions); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open the changes for each of the ${files.length} files?`, + confirmButton: 'Open Changes', + threshold: filesOpenDiffsThreshold, + })) + ) { + return; } options = { preserveFocus: true, preview: false, ...options }; @@ -211,28 +329,35 @@ export async function openChanges( ): Promise; export async function openChanges( file: GitFile, - refs: { repoPath: string; ref1: string; ref2: string }, - options?: TextDocumentShowOptions, + refs: RefRange, + options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string }, +): Promise; +export async function openChanges( + file: GitFile, + commitOrRefs: GitCommit | RefRange, + options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string }, ): Promise; export async function openChanges( file: string | GitFile, - commitOrRefs: GitCommit | { repoPath: string; ref1: string; ref2: string }, - options?: TextDocumentShowOptions, + commitOrRefs: GitCommit | RefRange, + options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string }, ) { - const isArgCommit = isCommit(commitOrRefs); + const hasCommit = isCommit(commitOrRefs); if (typeof file === 'string') { - if (!isArgCommit) throw new Error('Invalid arguments'); + if (!hasCommit) throw new Error('Invalid arguments'); const f = await commitOrRefs.findFile(file); if (f == null) throw new Error('Invalid arguments'); file = f; + } else if (!hasCommit && commitOrRefs.rhs === '') { + return openChangesWithWorking(file, { repoPath: commitOrRefs.repoPath, ref: commitOrRefs.lhs }, options); } options = { preserveFocus: true, preview: false, ...options }; - if (file.status === 'A' && isArgCommit) { + if (file.status === 'A' && hasCommit) { const commit = await commitOrRefs.getCommitForFile(file); void executeCommand(Commands.DiffWithPrevious, { commit: commit, @@ -242,38 +367,30 @@ export async function openChanges( return; } - const refs = isArgCommit + const refs: RefRange = hasCommit ? { repoPath: commitOrRefs.repoPath, + rhs: commitOrRefs.sha, // Don't need to worry about verifying the previous sha, as the DiffWith command will - ref1: commitOrRefs.unresolvedPreviousSha, - ref2: commitOrRefs.sha, + lhs: commitOrRefs.unresolvedPreviousSha, } : commitOrRefs; const rhsUri = GitUri.fromFile(file, refs.repoPath); const lhsUri = - file.status === 'R' || file.status === 'C' ? GitUri.fromFile(file, refs.repoPath, refs.ref1, true) : rhsUri; + file.status === 'R' || file.status === 'C' ? GitUri.fromFile(file, refs.repoPath, refs.lhs, true) : rhsUri; void (await executeCommand(Commands.DiffWith, { repoPath: refs.repoPath, - lhs: { uri: lhsUri, sha: refs.ref1 }, - rhs: { uri: rhsUri, sha: refs.ref2 }, + lhs: { uri: lhsUri, sha: refs.lhs, title: options?.lhsTitle }, + rhs: { uri: rhsUri, sha: refs.rhs, title: options?.rhsTitle }, showOptions: options, })); } export function openChangesWithDiffTool(file: string | GitFile, commit: GitCommit, tool?: string): Promise; -export function openChangesWithDiffTool( - file: GitFile, - ref: { repoPath: string; ref: string }, - tool?: string, -): Promise; -export async function openChangesWithDiffTool( - file: string | GitFile, - commitOrRef: GitCommit | { repoPath: string; ref: string }, - tool?: string, -) { +export function openChangesWithDiffTool(file: GitFile, ref: Ref, tool?: string): Promise; +export async function openChangesWithDiffTool(file: string | GitFile, commitOrRef: GitCommit | Ref, tool?: string) { if (typeof file === 'string') { if (!isCommit(commitOrRef)) throw new Error('Invalid arguments'); @@ -298,17 +415,17 @@ export async function openChangesWithDiffTool( export async function openChangesWithWorking( file: string | GitFile, commit: GitCommit, - options?: TextDocumentShowOptions, + options?: TextDocumentShowOptions & { lhsTitle?: string }, ): Promise; export async function openChangesWithWorking( file: GitFile, - ref: { repoPath: string; ref: string }, - options?: TextDocumentShowOptions, + ref: Ref, + options?: TextDocumentShowOptions & { lhsTitle?: string }, ): Promise; export async function openChangesWithWorking( file: string | GitFile, - commitOrRef: GitCommit | { repoPath: string; ref: string }, - options?: TextDocumentShowOptions, + commitOrRef: GitCommit | Ref, + options?: TextDocumentShowOptions & { lhsTitle?: string }, ) { if (typeof file === 'string') { if (!isCommit(commitOrRef)) throw new Error('Invalid arguments'); @@ -336,9 +453,28 @@ export async function openChangesWithWorking( void (await executeEditorCommand(Commands.DiffWithWorking, undefined, { uri: GitUri.fromFile(file, ref.repoPath, ref.ref), showOptions: options, + lhsTitle: options?.lhsTitle, })); } +export async function openComparisonChanges( + container: Container, + refs: RefRange, + options?: TextDocumentShowOptions & { title?: string }, +): Promise { + refs.lhs = refs.lhs || 'HEAD'; + refs.rhs = refs.rhs || 'HEAD'; + + const { files } = await getAheadBehindFilesQuery( + container, + refs.repoPath, + createRevisionRange(refs.lhs, refs.rhs, '...'), + refs.rhs === '', + ); + + await openAllChangesInChangesEditor(files ?? [], refs, options); +} + export async function openDirectoryCompare( repoPath: string, ref: string, @@ -348,18 +484,52 @@ export async function openDirectoryCompare( return Container.instance.git.openDirectoryCompare(repoPath, ref, ref2, tool); } -export async function openDirectoryCompareWithPrevious( - ref: { repoPath: string; ref: string } | GitCommit, -): Promise { +export async function openDirectoryCompareWithPrevious(ref: Ref | GitCommit): Promise { return openDirectoryCompare(ref.repoPath, ref.ref, `${ref.ref}^`); } -export async function openDirectoryCompareWithWorking( - ref: { repoPath: string; ref: string } | GitCommit, -): Promise { +export async function openDirectoryCompareWithWorking(ref: Ref | GitCommit): Promise { return openDirectoryCompare(ref.repoPath, ref.ref, undefined); } +export async function openFolderCompare( + pathOrUri: string | Uri, + refs: RefRange, + options?: TextDocumentShowOptions, +): Promise { + const { git } = Container.instance; + + let comparison; + if (refs.lhs === '') { + debugger; + throw new Error('Cannot get files for comparisons of a ref with working tree'); + } else if (refs.rhs === '') { + comparison = refs.lhs; + } else { + comparison = `${refs.lhs}..${refs.rhs}`; + } + + const relativePath = git.getRelativePath(pathOrUri, refs.repoPath); + + const files = await git.getDiffStatus(refs.repoPath, comparison, undefined, { path: relativePath }); + if (files == null) { + void window.showWarningMessage( + `No changes in '${relativePath}' between ${shortenRevision(refs.lhs, { + strings: { working: 'Working Tree' }, + })} ${GlyphChars.ArrowLeftRightLong} ${shortenRevision(refs.rhs, { + strings: { working: 'Working Tree' }, + })}`, + ); + return; + } + + const title = `Changes in ${relativePath} between ${shortenRevision(refs.lhs, { + strings: { working: 'Working Tree' }, + })} ${GlyphChars.ArrowLeftRightLong} ${shortenRevision(refs.rhs, { strings: { working: 'Working Tree' } })}`; + + return openAllChangesInChangesEditor(files, refs, { ...options, title: title }); +} + export async function openFile(uri: Uri, options?: TextDocumentShowOptions): Promise; export async function openFile( file: string | GitFile, @@ -405,7 +575,7 @@ export async function openFileAtRevision( commitOrOptions?: GitCommit | TextDocumentShowOptions, options?: TextDocumentShowOptions & { annotationType?: FileAnnotationType; line?: number }, ): Promise { - let uri; + let uri: Uri; if (fileOrRevisionUri instanceof Uri) { if (isCommit(commitOrOptions)) throw new Error('Invalid arguments'); @@ -443,7 +613,38 @@ export async function openFileAtRevision( opts.selection = new Range(line, 0, line, 0); } - const editor = await findOrOpenEditor(uri, opts); + const gitUri = await GitUri.fromUri(uri); + + let editor: TextEditor | undefined; + try { + editor = await findOrOpenEditor(uri, { throwOnError: true, ...opts }); + } catch (ex) { + if (!ex?.message?.includes('Unable to resolve nonexistent file')) { + void window.showErrorMessage(`Unable to open '${gitUri.relativePath}' in revision '${gitUri.sha}'`); + return; + } + + const pickedUri = await showRevisionFilesPicker( + Container.instance, + createReference(gitUri.sha!, gitUri.repoPath!), + { + ignoreFocusOut: true, + initialPath: gitUri.relativePath, + title: `Open File at Revision \u2022 Unable to open '${gitUri.relativePath}'`, + placeholder: 'Choose a file revision to open', + keyboard: { + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (key, uri) => { + await findOrOpenEditor(uri, { ...opts, preserveFocus: true, preview: true }); + }, + }, + }, + ); + if (pickedUri == null) return; + + editor = await findOrOpenEditor(pickedUri, opts); + } + if (annotationType != null && editor != null) { void (await Container.instance.fileAnnotations.show(editor, annotationType, { selection: { line: line }, @@ -470,77 +671,63 @@ export async function openFileOnRemote(fileOrUri: string | GitFile | Uri, ref?: })); } -export async function openFiles(commit: GitCommit): Promise; -export async function openFiles(files: GitFile[], repoPath: string, ref: string): Promise; -export async function openFiles(commitOrFiles: GitCommit | GitFile[], repoPath?: string, ref?: string): Promise { - let files; - if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); - } - - files = commitOrFiles.files ?? []; - repoPath = commitOrFiles.repoPath; - ref = commitOrFiles.sha; - } else { - files = commitOrFiles; - } - - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; +export async function openFiles(commit: GitCommit, options?: TextDocumentShowOptions): Promise; +export async function openFiles(files: GitFile[], ref: Ref, options?: TextDocumentShowOptions): Promise; +export async function openFiles( + commitOrFiles: GitCommit | GitFile[], + refOrOptions: Ref | TextDocumentShowOptions | undefined, + maybeOptions?: TextDocumentShowOptions, +): Promise { + const { files, ref, options } = await getChangesRefArgs(commitOrFiles, refOrOptions, maybeOptions); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open each of the ${files.length} files?`, + confirmButton: 'Open Files', + threshold: filesOpenThreshold, + })) + ) { + return; } const uris: Uri[] = ( await Promise.all( - files.map(file => Container.instance.git.getWorkingUri(repoPath!, GitUri.fromFile(file, repoPath!, ref))), + files.map(file => + Container.instance.git.getWorkingUri(ref.repoPath, GitUri.fromFile(file, ref.repoPath, ref.ref)), + ), ) ).filter((u?: T): u is T => Boolean(u)); - findOrOpenEditors(uris); + findOrOpenEditors(uris, options); } -export async function openFilesAtRevision(commit: GitCommit): Promise; +export async function openFilesAtRevision(commit: GitCommit, options?: TextDocumentShowOptions): Promise; export async function openFilesAtRevision( files: GitFile[], - repoPath: string, - ref1: string, - ref2: string, + refs: RefRange, + options?: TextDocumentShowOptions, ): Promise; export async function openFilesAtRevision( commitOrFiles: GitCommit | GitFile[], - repoPath?: string, - ref1?: string, - ref2?: string, + refOrOptions: RefRange | TextDocumentShowOptions | undefined, + maybeOptions?: TextDocumentShowOptions, ): Promise { - let files; - if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); - } - - files = commitOrFiles.files ?? []; - repoPath = commitOrFiles.repoPath; - ref1 = commitOrFiles.sha; - ref2 = await commitOrFiles.getPreviousSha(); - } else { - files = commitOrFiles; - } - - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open all ${files.length} file revisions?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; + const { files, refs, options } = await getChangesRefsArgs(commitOrFiles, refOrOptions, maybeOptions); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open each of the ${files.length} file revisions?`, + confirmButton: 'Open Revisions', + threshold: filesOpenThreshold, + })) + ) { + return; } findOrOpenEditors( - files.map(file => Container.instance.git.getRevisionUri(file.status === 'D' ? ref2! : ref1!, file, repoPath!)), + files.map(file => + Container.instance.git.getRevisionUri(file.status === 'D' ? refs.lhs : refs.rhs, file, refs.repoPath), + ), + options, ); } @@ -552,7 +739,20 @@ export async function restoreFile(file: string | GitFile, revision: GitRevisionR ref = revision.ref; } else { path = file.path; - ref = file.status === `?` ? `${revision.ref}^3` : file.status === 'D' ? `${revision.ref}^` : revision.ref; + if (file.status === 'D') { + // If the file is deleted, check to see if it exists, if so, restore it from the previous commit, otherwise restore it from the current commit + const uri = GitUri.fromFile(file, revision.repoPath); + try { + await workspace.fs.stat(uri); + ref = `${revision.ref}^`; + } catch { + ref = revision.ref; + } + } else if (file.status === '?') { + ref = `${revision.ref}^3`; + } else { + ref = revision.ref; + } } await Container.instance.git.checkout(revision.repoPath, ref, { path: path }); @@ -627,21 +827,151 @@ export async function showInCommitGraph( })); } -export async function openOnlyChangedFiles(commit: GitCommit): Promise { - await commit.ensureFullDetails(); +export async function openOnlyChangedFiles(commit: GitCommit): Promise; +export async function openOnlyChangedFiles(files: GitFile[]): Promise; +export async function openOnlyChangedFiles(commitOrFiles: GitCommit | GitFile[]): Promise { + let files; + if (isCommit(commitOrFiles)) { + if (commitOrFiles.files == null) { + await commitOrFiles.ensureFullDetails(); + } - const files = commit.files ?? []; + files = commitOrFiles.files ?? []; + } else { + files = commitOrFiles.map(f => new GitFileChange(f.repoPath!, f.path, f.status, f.originalPath)); + } - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open each of the ${files.length} files?`, + confirmButton: 'Open Files', + threshold: 10, + })) + ) { + return; } void (await executeCommand(Commands.OpenOnlyChangedFiles, { uris: files.filter(f => f.status !== 'D').map(f => f.uri), })); } + +export async function undoCommit(container: Container, commit: GitRevisionReference) { + const repo = await container.git.getOrOpenScmRepository(commit.repoPath); + const scmCommit = await repo?.getCommit('HEAD'); + + if (scmCommit?.hash !== commit.ref) { + void window.showWarningMessage( + `Commit ${getReferenceLabel(commit, { + capitalize: true, + icon: false, + })} cannot be undone, because it is no longer the most recent commit.`, + ); + + return; + } + + const status = await container.git.getStatusForRepo(commit.repoPath); + if (status?.files.length) { + const confirm = { title: 'Undo Commit' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `You have uncommitted changes in the working tree.\n\nDo you still want to undo ${getReferenceLabel( + commit, + { + capitalize: false, + icon: false, + }, + )}?`, + { modal: true }, + confirm, + cancel, + ); + + if (result !== confirm) return; + } + + await executeCoreGitCommand('git.undoCommit', commit.repoPath); +} + +async function confirmOpenIfNeeded( + items: readonly unknown[], + options: { cancelButton?: string; confirmButton?: string; message: string; threshold: number }, +): Promise { + if (items.length <= options.threshold) return true; + + const confirm = { title: options.confirmButton ?? 'Open' }; + const cancel = { title: options.cancelButton ?? 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage(options.message, { modal: true }, confirm, cancel); + return result === confirm; +} + +async function getChangesRefArgs( + commitOrFiles: GitCommit | GitFile[], + refOrOptions: Ref | TextDocumentShowOptions | undefined, + options?: TextDocumentShowOptions, +): Promise<{ + commit?: GitCommit; + files: readonly GitFile[]; + options: TextDocumentShowOptions | undefined; + ref: Ref; +}> { + if (!isCommit(commitOrFiles)) { + return { + files: commitOrFiles, + options: options, + ref: refOrOptions as Ref, + }; + } + + if (commitOrFiles.files == null) { + await commitOrFiles.ensureFullDetails(); + } + + return { + commit: commitOrFiles, + files: commitOrFiles.files ?? [], + options: refOrOptions as TextDocumentShowOptions | undefined, + ref: { + repoPath: commitOrFiles.repoPath, + ref: commitOrFiles.sha, + }, + }; +} + +async function getChangesRefsArgs( + commitOrFiles: GitCommit | GitFile[], + refsOrOptions: RefRange | TextDocumentShowOptions | undefined, + options?: TextDocumentShowOptions, +): Promise<{ + commit?: GitCommit; + files: readonly GitFile[]; + options: TextDocumentShowOptions | undefined; + refs: RefRange; +}> { + if (!isCommit(commitOrFiles)) { + return { + files: commitOrFiles, + options: options, + refs: refsOrOptions as RefRange, + }; + } + + if (commitOrFiles.files == null) { + await commitOrFiles.ensureFullDetails(); + } + + return { + commit: commitOrFiles, + files: commitOrFiles.files ?? [], + options: refsOrOptions as TextDocumentShowOptions | undefined, + refs: { + repoPath: commitOrFiles.repoPath, + rhs: commitOrFiles.sha, + lhs: + commitOrFiles.resolvedPreviousSha ?? + (await commitOrFiles.getPreviousSha()) ?? + commitOrFiles.unresolvedPreviousSha, + }, + }; +} diff --git a/src/git/actions/repository.ts b/src/git/actions/repository.ts index 3eec04ae16650..680b7cbf6659c 100644 --- a/src/git/actions/repository.ts +++ b/src/git/actions/repository.ts @@ -1,6 +1,5 @@ import type { ResetGitCommandArgs } from '../../commands/git/reset'; import { Container } from '../../container'; -import { executeCoreCommand } from '../../system/command'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import { executeGitCommand } from '../actions'; import type { GitBranchReference, GitReference, GitRevisionReference } from '../models/reference'; @@ -83,7 +82,3 @@ export async function reveal( } return node; } - -export async function revealInFileExplorer(repo: Repository) { - void (await executeCoreCommand('revealFileInOS', repo.uri)); -} diff --git a/src/git/actions/stash.ts b/src/git/actions/stash.ts index 75b5a4385fbf9..33a87a59524f3 100644 --- a/src/git/actions/stash.ts +++ b/src/git/actions/stash.ts @@ -13,10 +13,10 @@ export function apply(repo?: string | Repository, ref?: GitStashReference) { }); } -export function drop(repo?: string | Repository, ref?: GitStashReference) { +export function drop(repo?: string | Repository, refs?: GitStashReference[]) { return executeGitCommand({ command: 'stash', - state: { subcommand: 'drop', repo: repo, reference: ref }, + state: { subcommand: 'drop', repo: repo, references: refs }, }); } @@ -38,6 +38,7 @@ export function push( repo?: string | Repository, uris?: Uri[], message?: string, + includeUntracked: boolean = false, keepStaged: boolean = false, onlyStaged: boolean = false, onlyStagedUris?: Uri[], @@ -50,7 +51,11 @@ export function push( uris: uris, onlyStagedUris: onlyStagedUris, message: message, - flags: [...(keepStaged ? ['--keep-index'] : []), ...(onlyStaged ? ['--staged'] : [])] as PushFlags[], + flags: [ + ...(includeUntracked ? ['--include-untracked'] : []), + ...(keepStaged ? ['--keep-index'] : []), + ...(onlyStaged ? ['--staged'] : []), + ] as PushFlags[], }, }); } diff --git a/src/git/actions/worktree.ts b/src/git/actions/worktree.ts index a30ed3ee0f629..dcec8bb6fcee0 100644 --- a/src/git/actions/worktree.ts +++ b/src/git/actions/worktree.ts @@ -1,50 +1,78 @@ import type { Uri } from 'vscode'; import type { WorktreeGitCommandArgs } from '../../commands/git/worktree'; import { Container } from '../../container'; -import { ensure } from '../../system/array'; -import { executeCoreCommand } from '../../system/command'; +import { defer } from '../../system/promise'; import type { OpenWorkspaceLocation } from '../../system/utils'; import { executeGitCommand } from '../actions'; import type { GitReference } from '../models/reference'; import type { Repository } from '../models/repository'; import type { GitWorktree } from '../models/worktree'; -export function create( +export async function create( repo?: string | Repository, uri?: Uri, ref?: GitReference, - options?: { createBranch?: string; reveal?: boolean }, + options?: { addRemote?: { name: string; url: string }; createBranch?: string; reveal?: boolean }, ) { - return executeGitCommand({ + const deferred = defer(); + + await executeGitCommand({ command: 'worktree', state: { subcommand: 'create', repo: repo, uri: uri, reference: ref, + addRemote: options?.addRemote, createBranch: options?.createBranch, flags: options?.createBranch ? ['-b'] : undefined, + result: deferred, reveal: options?.reveal, }, }); + + // If the result is still pending, then the command was cancelled + if (!deferred.pending) return deferred.promise; + + deferred.cancel(); + return undefined; +} + +export function copyChangesToWorktree( + type: 'working-tree' | 'index', + repo?: string | Repository, + worktree?: GitWorktree, +) { + return executeGitCommand({ + command: 'worktree', + state: { + subcommand: 'copy-changes', + repo: repo, + worktree: worktree, + changes: { + type: type, + }, + }, + }); } -export function open(worktree: GitWorktree, options?: { location?: OpenWorkspaceLocation }) { +export function open(worktree: GitWorktree, options?: { location?: OpenWorkspaceLocation; openOnly?: boolean }) { return executeGitCommand({ command: 'worktree', state: { subcommand: 'open', repo: worktree.repoPath, - uri: worktree.uri, + worktree: worktree, flags: convertLocationToOpenFlags(options?.location), + openOnly: options?.openOnly, }, }); } -export function remove(repo?: string | Repository, uri?: Uri) { +export function remove(repo?: string | Repository, uris?: Uri[]) { return executeGitCommand({ command: 'worktree', - state: { subcommand: 'delete', repo: repo, uris: ensure(uri) }, + state: { subcommand: 'delete', repo: repo, uris: uris }, }); } @@ -65,13 +93,14 @@ export async function reveal( return node; } -export async function revealInFileExplorer(worktree: GitWorktree) { - void (await executeCoreCommand('revealFileInOS', worktree.uri)); -} - -type OpenFlagsArray = Extract>, { subcommand: 'open' }>['flags']; +type OpenFlags = Extract< + NonNullable>, + { subcommand: 'open' } +>['flags'][number]; -export function convertLocationToOpenFlags(location: OpenWorkspaceLocation | undefined): OpenFlagsArray | undefined { +export function convertLocationToOpenFlags(location: OpenWorkspaceLocation): OpenFlags[]; +export function convertLocationToOpenFlags(location: OpenWorkspaceLocation | undefined): OpenFlags[] | undefined; +export function convertLocationToOpenFlags(location: OpenWorkspaceLocation | undefined): OpenFlags[] | undefined { if (location == null) return undefined; switch (location) { @@ -85,7 +114,9 @@ export function convertLocationToOpenFlags(location: OpenWorkspaceLocation | und } } -export function convertOpenFlagsToLocation(flags: OpenFlagsArray | undefined): OpenWorkspaceLocation | undefined { +export function convertOpenFlagsToLocation(flags: OpenFlags[]): OpenWorkspaceLocation; +export function convertOpenFlagsToLocation(flags: OpenFlags[] | undefined): OpenWorkspaceLocation | undefined; +export function convertOpenFlagsToLocation(flags: OpenFlags[] | undefined): OpenWorkspaceLocation | undefined { if (flags == null) return undefined; if (flags.includes('--new-window')) return 'newWindow'; diff --git a/src/git/errors.ts b/src/git/errors.ts index 8b4d5cbb4bb09..b177308131583 100644 --- a/src/git/errors.ts +++ b/src/git/errors.ts @@ -6,12 +6,68 @@ export class GitSearchError extends Error { } } +export const enum ApplyPatchCommitErrorReason { + StashFailed, + CreateWorktreeFailed, + ApplyFailed, + ApplyAbortedWouldOverwrite, + AppliedWithConflicts, +} + +export class ApplyPatchCommitError extends Error { + static is(ex: unknown, reason?: ApplyPatchCommitErrorReason): ex is ApplyPatchCommitError { + return ex instanceof ApplyPatchCommitError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: ApplyPatchCommitErrorReason | undefined; + + constructor(reason: ApplyPatchCommitErrorReason, message?: string, original?: Error) { + message ||= 'Unable to apply patch'; + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, ApplyPatchCommitError); + } +} + +export class BlameIgnoreRevsFileError extends Error { + static is(ex: unknown): ex is BlameIgnoreRevsFileError { + return ex instanceof BlameIgnoreRevsFileError; + } + + constructor( + public readonly fileName: string, + public readonly original?: Error, + ) { + super(`Invalid blame.ignoreRevsFile: '${fileName}'`); + + Error.captureStackTrace?.(this, BlameIgnoreRevsFileError); + } +} + +export class BlameIgnoreRevsFileBadRevisionError extends Error { + static is(ex: unknown): ex is BlameIgnoreRevsFileBadRevisionError { + return ex instanceof BlameIgnoreRevsFileBadRevisionError; + } + + constructor( + public readonly revision: string, + public readonly original?: Error, + ) { + super(`Invalid revision in blame.ignoreRevsFile: '${revision}'`); + + Error.captureStackTrace?.(this, BlameIgnoreRevsFileBadRevisionError); + } +} + export const enum StashApplyErrorReason { - WorkingChanges = 1, + WorkingChanges, } export class StashApplyError extends Error { - static is(ex: any, reason?: StashApplyErrorReason): ex is StashApplyError { + static is(ex: unknown, reason?: StashApplyErrorReason): ex is StashApplyError { return ex instanceof StashApplyError && (reason == null || ex.reason === reason); } @@ -42,11 +98,12 @@ export class StashApplyError extends Error { } export const enum StashPushErrorReason { - ConflictingStagedAndUnstagedLines = 1, + ConflictingStagedAndUnstagedLines, + NothingToSave, } export class StashPushError extends Error { - static is(ex: any, reason?: StashPushErrorReason): ex is StashPushError { + static is(ex: unknown, reason?: StashPushErrorReason): ex is StashPushError { return ex instanceof StashPushError && (reason == null || ex.reason === reason); } @@ -68,7 +125,10 @@ export class StashPushError extends Error { switch (reason) { case StashPushErrorReason.ConflictingStagedAndUnstagedLines: message = - 'Stash was created, but the working tree cannot be updated because at least one file has staged and unstaged changes on the same line(s).\n\nDo you want to try again by stashing both your staged and unstaged changes?'; + 'Changes were stashed, but the working tree cannot be updated because at least one file has staged and unstaged changes on the same line(s)'; + break; + case StashPushErrorReason.NothingToSave: + message = 'No files to stash'; break; default: message = 'Unable to stash'; @@ -83,17 +143,19 @@ export class StashPushError extends Error { } export const enum PushErrorReason { - RemoteAhead = 1, - TipBehind = 2, - PushRejected = 3, - PermissionDenied = 4, - RemoteConnection = 5, - NoUpstream = 6, - Other = 7, + RemoteAhead, + TipBehind, + PushRejected, + PushRejectedWithLease, + PushRejectedWithLeaseIfIncludes, + PermissionDenied, + RemoteConnection, + NoUpstream, + Other, } export class PushError extends Error { - static is(ex: any, reason?: PushErrorReason): ex is PushError { + static is(ex: unknown, reason?: PushErrorReason): ex is PushError { return ex instanceof PushError && (reason == null || ex.reason === reason); } @@ -118,15 +180,22 @@ export class PushError extends Error { reason = undefined; } else { reason = messageOrReason; + switch (reason) { case PushErrorReason.RemoteAhead: - message = `${baseMessage} because the remote contains work that you do not have locally. Try doing a fetch first.`; + message = `${baseMessage} because the remote contains work that you do not have locally. Try fetching first.`; break; case PushErrorReason.TipBehind: - message = `${baseMessage} as it is behind its remote counterpart. Try doing a pull first.`; + message = `${baseMessage} as it is behind its remote counterpart. Try pulling first.`; break; case PushErrorReason.PushRejected: - message = `${baseMessage} because some refs failed to push or the push was rejected.`; + message = `${baseMessage} because some refs failed to push or the push was rejected. Try pulling first.`; + break; + case PushErrorReason.PushRejectedWithLease: + case PushErrorReason.PushRejectedWithLeaseIfIncludes: + message = `Unable to force push${branch ? ` branch '${branch}'` : ''}${ + remote ? ` to ${remote}` : '' + } because some refs failed to push or the push was rejected. The tip of the remote-tracking branch has been updated since the last checkout. Try pulling first.`; break; case PushErrorReason.PermissionDenied: message = `${baseMessage} because you don't have permission to push to this remote repository.`; @@ -150,21 +219,21 @@ export class PushError extends Error { } export const enum PullErrorReason { - Conflict = 1, - GitIdentity = 2, - RemoteConnection = 3, - UnstagedChanges = 4, - UnmergedFiles = 5, - UncommittedChanges = 6, - OverwrittenChanges = 7, - RefLocked = 8, - RebaseMultipleBranches = 9, - TagConflict = 10, - Other = 11, + Conflict, + GitIdentity, + RemoteConnection, + UnstagedChanges, + UnmergedFiles, + UncommittedChanges, + OverwrittenChanges, + RefLocked, + RebaseMultipleBranches, + TagConflict, + Other, } export class PullError extends Error { - static is(ex: any, reason?: PullErrorReason): ex is PullError { + static is(ex: unknown, reason?: PullErrorReason): ex is PullError { return ex instanceof PullError && (reason == null || ex.reason === reason); } @@ -233,14 +302,14 @@ export class PullError extends Error { } export const enum FetchErrorReason { - NoFastForward = 1, - NoRemote = 2, - RemoteConnection = 3, - Other = 4, + NoFastForward, + NoRemote, + RemoteConnection, + Other, } export class FetchError extends Error { - static is(ex: any, reason?: FetchErrorReason): ex is FetchError { + static is(ex: unknown, reason?: FetchErrorReason): ex is FetchError { return ex instanceof FetchError && (reason == null || ex.reason === reason); } @@ -287,6 +356,52 @@ export class FetchError extends Error { } } +export const enum CherryPickErrorReason { + Conflicts, + AbortedWouldOverwrite, + Other, +} + +export class CherryPickError extends Error { + static is(ex: unknown, reason?: CherryPickErrorReason): ex is CherryPickError { + return ex instanceof CherryPickError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: CherryPickErrorReason | undefined; + + constructor(reason?: CherryPickErrorReason, original?: Error, sha?: string); + constructor(message?: string, original?: Error); + constructor(messageOrReason: string | CherryPickErrorReason | undefined, original?: Error, sha?: string) { + let message; + const baseMessage = `Unable to cherry-pick${sha ? ` commit '${sha}'` : ''}`; + let reason: CherryPickErrorReason | undefined; + if (messageOrReason == null) { + message = baseMessage; + } else if (typeof messageOrReason === 'string') { + message = messageOrReason; + reason = undefined; + } else { + reason = messageOrReason; + switch (reason) { + case CherryPickErrorReason.AbortedWouldOverwrite: + message = `${baseMessage} as some local changes would be overwritten.`; + break; + case CherryPickErrorReason.Conflicts: + message = `${baseMessage} due to conflicts.`; + break; + default: + message = baseMessage; + } + } + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, CherryPickError); + } +} + export class WorkspaceUntrustedError extends Error { constructor() { super('Unable to perform Git operations because the current workspace is untrusted'); @@ -296,12 +411,12 @@ export class WorkspaceUntrustedError extends Error { } export const enum WorktreeCreateErrorReason { - AlreadyCheckedOut = 1, - AlreadyExists = 2, + AlreadyCheckedOut, + AlreadyExists, } export class WorktreeCreateError extends Error { - static is(ex: any, reason?: WorktreeCreateErrorReason): ex is WorktreeCreateError { + static is(ex: unknown, reason?: WorktreeCreateErrorReason): ex is WorktreeCreateError { return ex instanceof WorktreeCreateError && (reason == null || ex.reason === reason); } @@ -338,12 +453,12 @@ export class WorktreeCreateError extends Error { } export const enum WorktreeDeleteErrorReason { - HasChanges = 1, - MainWorkingTree = 2, + HasChanges, + MainWorkingTree, } export class WorktreeDeleteError extends Error { - static is(ex: any, reason?: WorktreeDeleteErrorReason): ex is WorktreeDeleteError { + static is(ex: unknown, reason?: WorktreeDeleteErrorReason): ex is WorktreeDeleteError { return ex instanceof WorktreeDeleteError && (reason == null || ex.reason === reason); } diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index c85673df833e1..e1017077d6df5 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -7,21 +7,19 @@ import type { OpenPullRequestActionContext, } from '../../api/gitlens'; import { getPresenceDataUri } from '../../avatars'; -import type { ShowQuickCommitCommandArgs } from '../../commands'; -import { - ConnectRemoteProviderCommand, - DiffWithCommand, - OpenCommitOnRemoteCommand, - OpenFileAtRevisionCommand, - ShowCommitsInViewCommand, - ShowQuickCommitFileCommand, -} from '../../commands'; import { Command } from '../../commands/base'; +import { DiffWithCommand } from '../../commands/diffWith'; +import { InspectCommand } from '../../commands/inspect'; +import { OpenCommitOnRemoteCommand } from '../../commands/openCommitOnRemote'; +import { OpenFileAtRevisionCommand } from '../../commands/openFileAtRevision'; +import { ConnectRemoteProviderCommand } from '../../commands/remoteProviders'; +import type { ShowQuickCommitCommandArgs } from '../../commands/showQuickCommit'; +import { ShowQuickCommitFileCommand } from '../../commands/showQuickCommitFile'; import type { DateStyle } from '../../config'; import { Commands, GlyphChars } from '../../constants'; import { Container } from '../../container'; import { emojify } from '../../emojis'; -import { arePlusFeaturesEnabled } from '../../plus/subscription/utils'; +import { arePlusFeaturesEnabled } from '../../plus/gk/utils'; import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol'; import { configuration } from '../../system/configuration'; import { join, map } from '../../system/iterable'; @@ -31,12 +29,14 @@ import { encodeHtmlWeak, escapeMarkdown, getSuperscript } from '../../system/str import type { ContactPresence } from '../../vsls/vsls'; import type { PreviousLineComparisonUrisResult } from '../gitProvider'; import type { GitCommit } from '../models/commit'; -import { isCommit } from '../models/commit'; +import { isCommit, isStash } from '../models/commit'; import { uncommitted, uncommittedStaged } from '../models/constants'; import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; -import { PullRequest } from '../models/pullRequest'; +import type { PullRequest } from '../models/pullRequest'; +import { isPullRequest } from '../models/pullRequest'; import { getReferenceFromRevision, isUncommittedStaged, shortenRevision } from '../models/reference'; -import { GitRemote } from '../models/remote'; +import type { GitRemote } from '../models/remote'; +import { getHighlanderProviders } from '../models/remote'; import type { RemoteProvider } from '../remotes/remoteProvider'; import type { FormatOptions, RequiredTokenOptions } from './formatter'; import { Formatter } from './formatter'; @@ -158,14 +158,14 @@ export class CommitFormatter extends Formatter { private get _pullRequestDate() { const { pullRequest: pr } = this._options; - if (pr == null || !PullRequest.is(pr)) return ''; + if (pr == null || !isPullRequest(pr)) return ''; return pr.formatDate(this._options.dateFormat) ?? ''; } private get _pullRequestDateAgo() { const { pullRequest: pr } = this._options; - if (pr == null || !PullRequest.is(pr)) return ''; + if (pr == null || !isPullRequest(pr)) return ''; return pr.formatDateFromNow() ?? ''; } @@ -365,12 +365,12 @@ export class CommitFormatter extends Formatter { const { previousLineComparisonUris: diffUris } = this._options; if (diffUris?.previous != null) { commands = `[\`${this._padOrTruncate( - shortenRevision(isUncommittedStaged(diffUris.current.sha) ? diffUris.current.sha : uncommitted)!, + shortenRevision(isUncommittedStaged(diffUris.current.sha) ? diffUris.current.sha : uncommitted), this._options.tokenOptions.commands, - )}\`](${ShowCommitsInViewCommand.getMarkdownCommandArgs( + )}\`](${InspectCommand.getMarkdownCommandArgs( this._item.sha, this._item.repoPath, - )} "Open Details")`; + )} "Inspect Commit Details")`; commands += `  [$(chevron-left)$(compare-changes)](${DiffWithCommand.getMarkdownCommandArgs({ lhs: { @@ -392,12 +392,12 @@ export class CommitFormatter extends Formatter { )} "Open Blame Prior to this Change")`; } else { commands = `[\`${this._padOrTruncate( - shortenRevision(this._item.isUncommittedStaged ? uncommittedStaged : uncommitted)!, + shortenRevision(this._item.isUncommittedStaged ? uncommittedStaged : uncommitted), this._options.tokenOptions.commands, - )}\`](${ShowCommitsInViewCommand.getMarkdownCommandArgs( + )}\`](${InspectCommand.getMarkdownCommandArgs( this._item.sha, this._item.repoPath, - )} "Open Details")`; + )} "Inspect Commit Details")`; } return commands; @@ -405,10 +405,10 @@ export class CommitFormatter extends Formatter { const separator = '   |   '; - commands = `---\n\n[\`$(git-commit) ${this.id}\`](${ShowCommitsInViewCommand.getMarkdownCommandArgs( + commands = `---\n\n[\`$(git-commit) ${this.id}\`](${InspectCommand.getMarkdownCommandArgs( this._item.sha, this._item.repoPath, - )} "Open Details")`; + )} "Inspect Commit Details")`; commands += `  [$(chevron-left)$(compare-changes)](${DiffWithCommand.getMarkdownCommandArgs( this._item, @@ -440,14 +440,15 @@ export class CommitFormatter extends Formatter { if (arePlusFeaturesEnabled()) { commands += `  [$(gitlens-graph)](${Command.getMarkdownCommandArgsCore( Commands.ShowInCommitGraph, - { ref: getReferenceFromRevision(this._item) }, + // Avoid including the message here, it just bloats the command url + { ref: getReferenceFromRevision(this._item, { excludeMessage: true }) }, )} "Open in Commit Graph")`; } const { pullRequest: pr, remotes } = this._options; if (remotes?.length) { - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); commands += `  [$(globe)](${OpenCommitOnRemoteCommand.getMarkdownCommandArgs( this._item.sha, @@ -455,7 +456,7 @@ export class CommitFormatter extends Formatter { } if (pr != null) { - if (PullRequest.is(pr)) { + if (isPullRequest(pr)) { commands += `${separator}[$(git-pull-request) PR #${ pr.id }](${getMarkdownActionCommand('openPullRequest', { @@ -473,8 +474,8 @@ export class CommitFormatter extends Formatter { } else if (remotes != null) { const [remote] = remotes; if ( - remote?.hasRichIntegration() && - !remote.provider.maybeConnected && + remote?.hasIntegration() && + !remote.maybeIntegrationConnected && configuration.get('integrations.enabled') ) { commands += `${separator}[$(plug) Connect to ${remote?.provider.name}${ @@ -584,28 +585,41 @@ export class CommitFormatter extends Formatter { } get link(): string { + let icon; + let label; + if (isStash(this._item)) { + icon = 'archive'; + label = this._padOrTruncate( + `Stash${this._item.number ? ` #${this._item.number}` : ''}`, + this._options.tokenOptions.link, + ); + } else { + icon = this._item.sha != null && !this._item.isUncommitted ? 'git-commit' : ''; + label = this._padOrTruncate( + shortenRevision(this._item.sha ?? '', { strings: { working: 'Working Tree' } }), + this._options.tokenOptions.id, + ); + } + let link; switch (this._options.outputFormat) { - case 'markdown': { - const sha = this._padOrTruncate(this._item.shortSha ?? '', this._options.tokenOptions.id); - link = `[\`$(git-commit) ${sha}\`](${ShowCommitsInViewCommand.getMarkdownCommandArgs( - this._item.sha, - this._item.repoPath, - )} "Open Details")`; + case 'markdown': + icon = icon ? `$(${icon}) ` : ''; + link = `[\`${icon}${label}\`](${InspectCommand.getMarkdownCommandArgs({ + ref: getReferenceFromRevision(this._item), + })} "Inspect Commit Details")`; break; - } - case 'html': { - const sha = this._padOrTruncate(this._item.shortSha ?? '', this._options.tokenOptions.id); - link = /*html*/ `` : ''; + link = /*html*/ `${sha}`; + }>${icon}${label}`; break; - } default: - return this.id; + link = this.id; + break; } return this._padOrTruncate(link, this._options.tokenOptions.link); @@ -689,7 +703,7 @@ export class CommitFormatter extends Formatter { } let text; - if (PullRequest.is(pr)) { + if (isPullRequest(pr)) { if (this._options.outputFormat === 'markdown') { text = `PR [**#${pr.id}**](${getMarkdownActionCommand('openPullRequest', { repoPath: this._item.repoPath, @@ -752,7 +766,7 @@ export class CommitFormatter extends Formatter { get pullRequestState(): string { const { pullRequest: pr } = this._options; return this._padOrTruncate( - pr == null || !PullRequest.is(pr) ? '' : pr.state ?? '', + pr == null || !isPullRequest(pr) ? '' : pr.state ?? '', this._options.tokenOptions.pullRequestState, ); } diff --git a/src/git/formatters/formatter.ts b/src/git/formatters/formatter.ts index 586f77f6a4dc6..f48c4508cf474 100644 --- a/src/git/formatters/formatter.ts +++ b/src/git/formatters/formatter.ts @@ -1,13 +1,5 @@ import type { TokenOptions } from '../../system/string'; -import { - getTokensFromTemplate, - getWidth, - interpolate, - interpolateAsync, - padLeft, - padRight, - truncate, -} from '../../system/string'; +import { getTokensFromTemplate, getTruncatedWidth, getWidth, interpolate, interpolateAsync } from '../../system/string'; export interface FormatOptions { dateFormat?: string | null; @@ -22,6 +14,14 @@ const spaceReplacementRegex = / /g; export declare type RequiredTokenOptions = Options & Required>; +const defaultTokenOptions: Required = { + collapseWhitespace: false, + padDirection: 'left', + prefix: undefined, + suffix: undefined, + truncateTo: undefined, +}; + export abstract class Formatter { protected _item!: Item; protected _options!: RequiredTokenOptions; @@ -57,48 +57,52 @@ export abstract class Formatter 0) { - if (options.collapseWhitespace) { - this.collapsableWhitespace = diff; - } + const r = getTruncatedWidth(s, max, suffixWidth + 1); + if (r.truncated) return `${s.slice(0, r.index)}${r.ellipsed ? '\u2026' : ''}${options.suffix ?? ''}`; - if (options.padDirection === 'left') { - s = padLeft(s, max, undefined, width); - } else { - if (options.collapseWhitespace) { - max -= diff; - } - s = padRight(s, max, undefined, width); - } - } else if (diff < 0) { - s = truncate(s, max, undefined, width); - } + let width = r.width; + if (options.suffix) { + s += options.suffix; + width += suffixWidth; } - if (options.prefix || options.suffix) { - s = `${options.prefix ?? ''}${s}${options.suffix ?? ''}`; + if (width === max) return s; + + if (options.collapseWhitespace) { + this.collapsableWhitespace = max - width; + } + + if (options.padDirection === 'left') { + return s.padStart(max, '\u00a0'); } - return s; + if (options.collapseWhitespace) return s; + + return s.padEnd(max, '\u00a0'); } private static _formatter: Formatter | undefined = undefined; diff --git a/src/git/formatters/statusFormatter.ts b/src/git/formatters/statusFormatter.ts index 372865fcf1643..1f5bb0bbc414a 100644 --- a/src/git/formatters/statusFormatter.ts +++ b/src/git/formatters/statusFormatter.ts @@ -8,7 +8,7 @@ import { getGitFileOriginalRelativePath, getGitFileRelativePath, getGitFileStatusText, - GitFileChange, + isGitFileChange, } from '../models/file'; import type { FormatOptions } from './formatter'; import { Formatter } from './formatter'; @@ -88,21 +88,21 @@ export class StatusFileFormatter extends Formatter get changes(): string { return this._padOrTruncate( - GitFileChange.is(this._item) ? this._item.formatStats() : '', + isGitFileChange(this._item) ? this._item.formatStats() : '', this._options.tokenOptions.changes, ); } get changesDetail(): string { return this._padOrTruncate( - GitFileChange.is(this._item) ? this._item.formatStats({ expand: true, separator: ', ' }) : '', + isGitFileChange(this._item) ? this._item.formatStats({ expand: true, separator: ', ' }) : '', this._options.tokenOptions.changesDetail, ); } get changesShort(): string { return this._padOrTruncate( - GitFileChange.is(this._item) ? this._item.formatStats({ compact: true, separator: '' }) : '', + isGitFileChange(this._item) ? this._item.formatStats({ compact: true, separator: '' }) : '', this._options.tokenOptions.changesShort, ); } diff --git a/src/git/fsProvider.ts b/src/git/fsProvider.ts index da1cfc8d6cd1e..2eedee8c2cdd2 100644 --- a/src/git/fsProvider.ts +++ b/src/git/fsProvider.ts @@ -96,13 +96,13 @@ export class GitFileSystemProvider implements FileSystemProvider, Disposable { let treeItem; const searchTree = this._searchTreeMap.get(ref); - if (searchTree !== undefined) { + if (searchTree != null) { // Add the fake root folder to the path treeItem = (await searchTree).get(`/~/${path}`); } else { if (path == null || path.length === 0) { const tree = await this.getTree(path, ref, repoPath); - if (tree === undefined) throw FileSystemError.FileNotFound(uri); + if (tree == null) throw FileSystemError.FileNotFound(uri); return { type: FileType.Directory, @@ -115,9 +115,7 @@ export class GitFileSystemProvider implements FileSystemProvider, Disposable { treeItem = await this.container.git.getTreeEntryForRevision(repoPath, path, ref); } - if (treeItem === undefined) { - throw FileSystemError.FileNotFound(uri); - } + if (treeItem == null) throw FileSystemError.FileNotFound(uri); return { type: typeToFileType(treeItem.type), @@ -144,7 +142,7 @@ export class GitFileSystemProvider implements FileSystemProvider, Disposable { const trees = await this.container.git.getTreeForRevision(repoPath, ref); // Add a fake root folder so that searches will work - searchTree.set('~', { commitSha: '', path: '~', size: 0, type: 'tree' }); + searchTree.set('~', { ref: '', oid: '', path: '~', size: 0, type: 'tree' }); for (const item of trees) { searchTree.set(`~/${item.path}`, item); } diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index cdd28df8c3a61..99441ef0e2108 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -7,13 +7,13 @@ import type { GitBlame, GitBlameLine, GitBlameLines } from './models/blame'; import type { BranchSortOptions, GitBranch } from './models/branch'; import type { GitCommit } from './models/commit'; import type { GitContributor } from './models/contributor'; -import type { GitDiff, GitDiffFile, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat } from './models/diff'; +import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, GitDiffShortStat } from './models/diff'; import type { GitFile } from './models/file'; import type { GitGraph } from './models/graph'; import type { GitLog } from './models/log'; import type { GitMergeStatus } from './models/merge'; import type { GitRebaseStatus } from './models/rebase'; -import type { GitBranchReference } from './models/reference'; +import type { GitBranchReference, GitReference } from './models/reference'; import type { GitReflog } from './models/reflog'; import type { GitRemote } from './models/remote'; import type { Repository, RepositoryChangeEvent } from './models/repository'; @@ -25,7 +25,15 @@ import type { GitUser } from './models/user'; import type { GitWorktree } from './models/worktree'; import type { GitSearch, SearchQuery } from './search'; -export type GitCaches = 'branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags'; +export type GitCaches = + | 'branches' + | 'contributors' + | 'providers' + | 'remotes' + | 'stashes' + | 'status' + | 'tags' + | 'worktrees'; export type GitRepositoryCaches = Extract; export const gitRepositoryCacheKeys = new Set(['branches', 'remotes']); @@ -62,6 +70,10 @@ export interface PagedResult { readonly values: NonNullable[]; } +export interface PagingOptions { + cursor?: string; +} + export interface NextComparisonUrisResult { current: GitUri; next: GitUri | undefined; @@ -138,12 +150,28 @@ export interface GitProvider extends Disposable { pruneRemote(repoPath: string, name: string): Promise; removeRemote(repoPath: string, name: string): Promise; applyChangesToWorkingFile(uri: GitUri, ref1?: string, ref2?: string): Promise; + applyUnreachableCommitForPatch?( + repoPath: string, + ref: string, + options?: { + branchName?: string; + createBranchIfNeeded?: boolean; + createWorktreePath?: string; + stash?: boolean | 'prompt'; + }, + ): Promise; checkout( repoPath: string, ref: string, options?: { createBranch?: string | undefined } | { path?: string | undefined }, ): Promise; clone?(url: string, parentPath: string): Promise; + createUnreachableCommitForPatch?( + repoPath: string, + contents: string, + baseRef: string, + message: string, + ): Promise; excludeIgnoredUris(repoPath: string, uris: Uri[]): Promise; fetch( repoPath: string, @@ -166,13 +194,17 @@ export interface GitProvider extends Disposable { push( repoPath: string, options?: { - branch?: GitBranchReference | undefined; + reference?: GitReference | undefined; force?: boolean | undefined; publish?: { remote: string }; }, ): Promise; findRepositoryUri(uri: Uri, isDirectory?: boolean): Promise; - getAheadBehindCommitCount(repoPath: string, refs: string[]): Promise<{ ahead: number; behind: number } | undefined>; + getAheadBehindCommitCount( + repoPath: string, + refs: string[], + options?: { authors?: GitUser[] | undefined }, + ): Promise<{ ahead: number; behind: number } | undefined>; /** * Returns the blame of a file * @param uri Uri of the file to blame @@ -217,8 +249,8 @@ export interface GitProvider extends Disposable { getBranches( repoPath: string, options?: { - cursor?: string; filter?: ((b: GitBranch) => boolean) | undefined; + paging?: PagingOptions | undefined; sort?: boolean | BranchSortOptions | undefined; }, ): Promise>; @@ -226,14 +258,11 @@ export interface GitProvider extends Disposable { getCommit(repoPath: string, ref: string): Promise; getCommitBranches( repoPath: string, - ref: string, - options?: { - branch?: string | undefined; - commitDate?: Date | undefined; - mode?: 'contains' | 'pointsAt' | undefined; - name?: string | undefined; - remotes?: boolean | undefined; - }, + refs: string[], + branch?: string | undefined, + options?: + | { all?: boolean; commitDate?: Date; mode?: 'contains' | 'pointsAt' } + | { commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, ): Promise; getCommitCount(repoPath: string, ref: string): Promise; getCommitForFile( @@ -255,20 +284,34 @@ export interface GitProvider extends Disposable { ref?: string; }, ): Promise; + getCommitTags( + repoPath: string, + ref: string, + options?: { + commitDate?: Date | undefined; + mode?: 'contains' | 'pointsAt' | undefined; + }, + ): Promise; getConfig?(repoPath: string, key: string): Promise; setConfig?(repoPath: string, key: string, value: string | undefined): Promise; getContributors( repoPath: string, - options?: { all?: boolean | undefined; ref?: string | undefined; stats?: boolean | undefined }, + options?: { + all?: boolean | undefined; + merges?: boolean | 'first-parent'; + ref?: string | undefined; + stats?: boolean | undefined; + }, ): Promise; getCurrentUser(repoPath: string): Promise; getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise; getDiff?( repoPath: string | Uri, - ref1: string, - ref2?: string, - options?: { context?: number }, + to: string, + from?: string, + options?: { context?: number; uris?: Uri[] }, ): Promise; + getDiffFiles?(repoPath: string | Uri, contents: string): Promise; /** * Returns a file diff between two commits * @param uri Uri of the file to diff @@ -295,14 +338,15 @@ export interface GitProvider extends Disposable { editorLine: number, ref1: string | undefined, ref2?: string, - ): Promise; + ): Promise; getDiffStatus( repoPath: string, ref1?: string, ref2?: string, - options?: { filters?: GitDiffFilter[] | undefined; similarityThreshold?: number | undefined }, + options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ): Promise; getFileStatusForCommit(repoPath: string, uri: Uri, ref: string): Promise; + getFirstCommitSha?(repoPath: string): Promise; getGitDir?(repoPath: string): Promise; getLastFetchedTimestamp(repoPath: string): Promise; getLog( @@ -312,7 +356,7 @@ export interface GitProvider extends Disposable { authors?: GitUser[] | undefined; cursor?: string | undefined; limit?: number | undefined; - merges?: boolean | undefined; + merges?: boolean | 'first-parent' | undefined; ordering?: 'date' | 'author-date' | 'topo' | null | undefined; ref?: string | undefined; since?: string | undefined; @@ -324,7 +368,7 @@ export interface GitProvider extends Disposable { authors?: GitUser[] | undefined; cursor?: string | undefined; limit?: number | undefined; - merges?: boolean | undefined; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null | undefined; ref?: string | undefined; since?: string | undefined; @@ -367,7 +411,6 @@ export interface GitProvider extends Disposable { uri: Uri, ref: string | undefined, skip?: number, - firstParent?: boolean, ): Promise; getPreviousComparisonUrisForLine( repoPath: string, @@ -395,14 +438,13 @@ export interface GitProvider extends Disposable { getTags( repoPath: string | undefined, options?: { - cursor?: string; filter?: ((t: GitTag) => boolean) | undefined; + paging?: PagingOptions | undefined; sort?: boolean | TagSortOptions | undefined; }, ): Promise>; getTreeEntryForRevision(repoPath: string, path: string, ref: string): Promise; getTreeForRevision(repoPath: string, ref: string): Promise; - getUniqueRepositoryId(repoPath: string): Promise; hasBranchOrTag( repoPath: string | undefined, options?: { @@ -463,6 +505,7 @@ export interface GitProvider extends Disposable { ): Promise; validateBranchOrTagName(repoPath: string, ref: string): Promise; + validatePatch?(repoPath: string | undefined, contents: string): Promise; validateReference(repoPath: string, ref: string): Promise; stageFile(repoPath: string, pathOrUri: string | Uri): Promise; @@ -477,8 +520,9 @@ export interface GitProvider extends Disposable { repoPath: string, message?: string, uris?: Uri[], - options?: { includeUntracked?: boolean | undefined; keepIndex?: boolean | undefined; onlyStaged?: boolean }, + options?: { includeUntracked?: boolean; keepIndex?: boolean; onlyStaged?: boolean }, ): Promise; + stashSaveSnapshot?(repoPath: string, message?: string): Promise; createWorktree?( repoPath: string, @@ -493,4 +537,5 @@ export interface GitProvider extends Disposable { export interface RevisionUriData { ref?: string; repoPath: string; + uncPath?: string; } diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 30b6a2ca16269..08f3cc3433cd2 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -12,17 +12,17 @@ import type { import { Disposable, EventEmitter, FileType, ProgressLocation, Uri, window, workspace } from 'vscode'; import { isWeb } from '@env/platform'; import { resetAvatarCache } from '../avatars'; -import type { CoreGitConfiguration } from '../constants'; import { GlyphChars, Schemes } from '../constants'; import type { Container } from '../container'; import { AccessDeniedError, CancellationError, ProviderNotFoundError } from '../errors'; import type { FeatureAccess, Features, PlusFeatures, RepoFeatureAccess } from '../features'; -import type { SubscriptionChangeEvent } from '../plus/subscription/subscriptionService'; +import type { Subscription } from '../plus/gk/account/subscription'; +import { isSubscriptionPaidPlan, SubscriptionPlanId } from '../plus/gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../plus/gk/account/subscriptionService'; +import type { HostingIntegration } from '../plus/integrations/integration'; import type { RepoComparisonKey } from '../repositories'; import { asRepoComparisonKey, Repositories } from '../repositories'; -import type { Subscription } from '../subscription'; -import { isSubscriptionPaidPlan, SubscriptionPlanId } from '../subscription'; -import { groupByFilterMap, groupByMap, joinUnique } from '../system/array'; +import { joinUnique } from '../system/array'; import { registerCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; @@ -30,11 +30,12 @@ import { gate } from '../system/decorators/gate'; import { debug, log } from '../system/decorators/log'; import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; -import { count, filter, first, flatMap, join, map, some } from '../system/iterable'; +import { count, filter, first, flatMap, groupByFilterMap, groupByMap, join, map, some } from '../system/iterable'; import { Logger } from '../system/logger'; import { getLogScope, setLogScopeExit } from '../system/logger.scope'; import { getBestPath, getScheme, isAbsolute, maybeUri, normalizePath } from '../system/path'; -import { asSettled, cancellable, defer, getSettledValue, isPromise, PromiseCancelledError } from '../system/promise'; +import type { Deferred } from '../system/promise'; +import { asSettled, defer, getDeferredPromiseIfPending, getSettledValue } from '../system/promise'; import { sortCompare } from '../system/string'; import { VisitedPathsTrie } from '../system/trie'; import type { @@ -45,6 +46,7 @@ import type { GitProviderId, NextComparisonUrisResult, PagedResult, + PagingOptions, PreviousComparisonUrisResult, PreviousLineComparisonUrisResult, RepositoryVisibility, @@ -57,18 +59,17 @@ import type { BranchSortOptions, GitBranch } from './models/branch'; import { GitCommit, GitCommitIdentity } from './models/commit'; import { deletedOrMissing, uncommitted, uncommittedStaged } from './models/constants'; import type { GitContributor } from './models/contributor'; -import type { GitDiff, GitDiffFile, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat } from './models/diff'; +import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, GitDiffShortStat } from './models/diff'; import type { GitFile } from './models/file'; import type { GitGraph } from './models/graph'; -import type { SearchedIssue } from './models/issue'; import type { GitLog } from './models/log'; import type { GitMergeStatus } from './models/merge'; -import type { SearchedPullRequest } from './models/pullRequest'; import type { GitRebaseStatus } from './models/rebase'; import type { GitBranchReference, GitReference } from './models/reference'; import { createRevisionRange, isSha, isUncommitted, isUncommittedParent } from './models/reference'; import type { GitReflog } from './models/reflog'; -import { getVisibilityCacheKey, GitRemote } from './models/remote'; +import type { GitRemote } from './models/remote'; +import { getRemoteThemeIconString, getVisibilityCacheKey } from './models/remote'; import type { RepositoryChangeEvent } from './models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from './models/repository'; import type { GitStash } from './models/stash'; @@ -78,7 +79,6 @@ import type { GitTreeEntry } from './models/tree'; import type { GitUser } from './models/user'; import type { GitWorktree } from './models/worktree'; import type { RemoteProvider } from './remotes/remoteProvider'; -import type { RichRemoteProvider } from './remotes/richRemoteProvider'; import type { GitSearch, SearchQuery } from './search'; const emptyArray = Object.freeze([]) as unknown as any[]; @@ -97,8 +97,6 @@ const weightedDefaultBranches = new Map([ ['development', 1], ]); -const missingRepositoryId = '-'; - export type GitProvidersChangeEvent = { readonly added: readonly GitProvider[]; readonly removed: readonly GitProvider[]; @@ -123,16 +121,19 @@ export class GitProviderService implements Disposable { get onDidChangeProviders(): Event { return this._onDidChangeProviders.event; } + + @debug({ + args: { + 0: added => `(${added?.length ?? 0}) ${added?.map(p => p.descriptor.id).join(', ')}`, + 1: removed => `(${removed?.length ?? 0}) ${removed?.map(p => p.descriptor.id).join(', ')}`, + }, + }) private fireProvidersChanged(added?: GitProvider[], removed?: GitProvider[]) { if (this.container.telemetry.enabled) { this.container.telemetry.setGlobalAttributes({ 'providers.count': this._providers.size, 'providers.ids': join(this._providers.keys(), ','), }); - this.container.telemetry.sendEvent('providers/changed', { - 'providers.added': added?.length ?? 0, - 'providers.removed': removed?.length ?? 0, - }); } this._etag = Date.now(); @@ -144,9 +145,17 @@ export class GitProviderService implements Disposable { get onDidChangeRepositories(): Event { return this._onDidChangeRepositories.event; } + + @debug({ + args: { + 0: added => `(${added?.length ?? 0}) ${added?.map(r => r.id).join(', ')}`, + 1: removed => `(${removed?.length ?? 0}) ${removed?.map(r => r.id).join(', ')}`, + }, + }) private fireRepositoriesChanged(added?: Repository[], removed?: Repository[]) { - const openSchemes = this.openRepositories.map(r => r.uri.scheme); if (this.container.telemetry.enabled) { + const openSchemes = this.openRepositories.map(r => r.uri.scheme); + this.container.telemetry.setGlobalAttributes({ 'repositories.count': openSchemes.length, 'repositories.schemes': joinUnique(openSchemes, ','), @@ -165,7 +174,7 @@ export class GitProviderService implements Disposable { this._onDidChangeRepositories.fire({ added: added ?? [], removed: removed ?? [], etag: this._etag }); if (added?.length && this.container.telemetry.enabled) { - queueMicrotask(async () => { + setTimeout(async () => { for (const repo of added) { const remoteProviders = new Set(); @@ -183,7 +192,7 @@ export class GitProviderService implements Disposable { 'repository.remoteProviders': join(remoteProviders, ','), }); } - }); + }, 0); } } @@ -194,23 +203,22 @@ export class GitProviderService implements Disposable { readonly supportedSchemes = new Set(); - private readonly _bestRemotesCache = new Map< - RepoComparisonKey, - Promise[]> - >(); + private readonly _bestRemotesCache = new Map[]>>(); private readonly _disposable: Disposable; + private _initializing: Deferred | undefined; private readonly _pendingRepositories = new Map>(); private readonly _providers = new Map(); private readonly _repositories = new Repositories(); private readonly _visitedPaths = new VisitedPathsTrie(); constructor(private readonly container: Container) { + this._initializing = defer(); this._disposable = Disposable.from( container.subscription.onDidChange(this.onSubscriptionChanged, this), window.onDidChangeWindowState(this.onWindowStateChanged, this), workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged, this), configuration.onDidChange(this.onConfigurationChanged, this), - container.richRemoteProviders.onAfterDidChangeConnectionState(e => { + container.integrations.onDidChangeConnectionState(e => { if (e.reason === 'connected') { resetAvatarCache('failed'); } @@ -274,10 +282,7 @@ export class GitProviderService implements Disposable { } private registerCommands(): Disposable[] { - return [ - registerCommand('gitlens.plus.resetRepositoryAccess', () => this.clearAllRepoVisibilityCaches()), - registerCommand('gitlens.plus.refreshRepositoryAccess', () => this.clearAllOpenRepoVisibilityCaches()), - ]; + return [registerCommand('gitlens.plus.refreshRepositoryAccess', () => this.clearAllOpenRepoVisibilityCaches())]; } @debug() @@ -365,7 +370,9 @@ export class GitProviderService implements Disposable { } get highlander(): Repository | undefined { - return this.repositoryCount === 1 ? first(this._repositories.values()) : undefined; + return this.repositoryCount === 1 || this.openRepositoryCount === 1 + ? first(this._repositories.values()) + : undefined; } // get readonly() { @@ -415,12 +422,16 @@ export class GitProviderService implements Disposable { provider, ...disposables, provider.onDidChange(() => { + Logger.debug(`GitProvider(${id}).onDidChange()`); + const { workspaceFolders } = workspace; if (workspaceFolders?.length) { void this.discoverRepositories(workspaceFolders, { force: true }); } }), provider.onDidChangeRepository(async e => { + Logger.debug(`GitProvider(${id}).onDidChangeRepository(e=${e.repository.toString()})`); + if ( e.changed( RepositoryChange.Remotes, @@ -455,12 +466,18 @@ export class GitProviderService implements Disposable { }), provider.onDidCloseRepository(e => { const repository = this._repositories.get(e.uri); + Logger.debug( + `GitProvider(${id}).onDidCloseRepository(e=${repository?.toString() ?? e.uri.toString()})`, + ); + if (repository != null) { repository.closed = true; } }), provider.onDidOpenRepository(e => { const repository = this._repositories.get(e.uri); + Logger.debug(`GitProvider(${id}).onDidOpenRepository(e=${repository?.toString() ?? e.uri.toString()})`); + if (repository != null) { repository.closed = false; } else { @@ -472,7 +489,7 @@ export class GitProviderService implements Disposable { this.fireProvidersChanged([provider]); // Don't kick off the discovery if we're still initializing (we'll do it at the end for all "known" providers) - if (!this._initializing) { + if (this._initializing != null) { this.onWorkspaceFoldersChanged({ added: workspace.workspaceFolders ?? [], removed: [] }); } @@ -512,14 +529,10 @@ export class GitProviderService implements Disposable { }; } - private _initializing: boolean = true; - @log({ singleLine: true }) async registrationComplete() { const scope = getLogScope(); - this._initializing = false; - let { workspaceFolders } = workspace; if (workspaceFolders?.length) { await this.discoverRepositories(workspaceFolders); @@ -534,25 +547,27 @@ export class GitProviderService implements Disposable { }, 1000); } } else { + this._initializing?.fulfill(this._etag); + this._initializing = undefined; + this.updateContext(); } - const autoRepositoryDetection = configuration.getAny< - CoreGitConfiguration, - boolean | 'subFolders' | 'openEditors' - >('git.autoRepositoryDetection'); + const autoRepositoryDetection = configuration.getCore('git.autoRepositoryDetection'); if (this.container.telemetry.enabled) { - queueMicrotask(() => - this.container.telemetry.sendEvent('providers/registrationComplete', { - 'config.git.autoRepositoryDetection': autoRepositoryDetection, - }), + setTimeout( + () => + this.container.telemetry.sendEvent('providers/registrationComplete', { + 'config.git.autoRepositoryDetection': autoRepositoryDetection, + }), + 0, ); } setLogScopeExit( scope, - ` ${GlyphChars.Dot} workspaceFolders=${workspaceFolders?.length}, git.autoRepositoryDetection=${autoRepositoryDetection}`, + ` ${GlyphChars.Dot} repositories=${this.repositoryCount}, workspaceFolders=${workspaceFolders?.length}, git.autoRepositoryDetection=${autoRepositoryDetection}`, ); } @@ -578,20 +593,24 @@ export class GitProviderService implements Disposable { private _discoveredWorkspaceFolders = new Map>(); - private _isDiscoveringRepositories: Promise | undefined; - get isDiscoveringRepositories(): Promise | undefined { - return this._isDiscoveringRepositories; + private _discoveringRepositories: Deferred | undefined; + get isDiscoveringRepositories(): Promise | undefined { + return ( + getDeferredPromiseIfPending(this._discoveringRepositories) ?? + getDeferredPromiseIfPending(this._initializing) + ); } @log({ args: { 0: folders => folders.length } }) async discoverRepositories(folders: readonly WorkspaceFolder[], options?: { force?: boolean }): Promise { - if (this._isDiscoveringRepositories != null) { - await this._isDiscoveringRepositories; - this._isDiscoveringRepositories = undefined; + if (this._discoveringRepositories?.pending) { + await this._discoveringRepositories.promise; + this._discoveringRepositories = undefined; } - const deferred = defer(); - this._isDiscoveringRepositories = deferred.promise; + const deferred = this._initializing ?? defer(); + this._discoveringRepositories = deferred; + this._initializing = undefined; try { const promises = []; @@ -632,7 +651,9 @@ export class GitProviderService implements Disposable { queueMicrotask(() => this.fireRepositoriesChanged(added)); } } finally { - deferred.fulfill(); + queueMicrotask(() => { + deferred.fulfill(this._etag); + }); } } @@ -819,17 +840,18 @@ export class GitProviderService implements Disposable { } } - private clearRepoVisibilityCache(keys?: string[]): void { + private async clearRepoVisibilityCache(keys?: string[]): Promise { if (keys == null) { this._repoVisibilityCache = undefined; void this.container.storage.delete('repoVisibility'); } else { keys?.forEach(key => this._repoVisibilityCache?.delete(key)); + const repoVisibility = Array.from(this._repoVisibilityCache?.entries() ?? []); if (repoVisibility.length === 0) { - void this.container.storage.delete('repoVisibility'); + await this.container.storage.delete('repoVisibility'); } else { - void this.container.storage.store('repoVisibility', repoVisibility); + await this.container.storage.store('repoVisibility', repoVisibility); } } } @@ -842,7 +864,7 @@ export class GitProviderService implements Disposable { const now = Date.now(); if (now - visibilityInfo.timestamp > 1000 * 60 * 60 * 24 * 30 /* TTL is 30 days */) { - this.clearRepoVisibilityCache([key]); + void this.clearRepoVisibilityCache([key]); return undefined; } @@ -858,13 +880,13 @@ export class GitProviderService implements Disposable { if (visibilityInfo.visibility === 'public') { if (remotes.length == 0 || !remotes.some(r => r.remoteKey === visibilityInfo.remotesHash)) { - this.clearRepoVisibilityCache([key]); + void this.clearRepoVisibilityCache([key]); return false; } } else if (visibilityInfo.visibility === 'private') { const remotesHash = getVisibilityCacheKey(remotes); if (remotesHash !== visibilityInfo.remotesHash) { - this.clearRepoVisibilityCache([key]); + void this.clearRepoVisibilityCache([key]); return false; } } @@ -879,14 +901,14 @@ export class GitProviderService implements Disposable { } @debug() - clearAllRepoVisibilityCaches(): void { - this.clearRepoVisibilityCache(); + clearAllRepoVisibilityCaches(): Promise { + return this.clearRepoVisibilityCache(); } @debug() - clearAllOpenRepoVisibilityCaches(): void { + clearAllOpenRepoVisibilityCaches(): Promise { const openRepoProviderPaths = this.openRepositories.map(r => this.getProvider(r.path).path); - this.clearRepoVisibilityCache(openRepoProviderPaths); + return this.clearRepoVisibilityCache(openRepoProviderPaths); } visibility(): Promise; @@ -899,7 +921,9 @@ export class GitProviderService implements Disposable { visibility = await this.visibilityCore(); if (this.container.telemetry.enabled) { this.container.telemetry.setGlobalAttribute('repositories.visibility', visibility); - this.container.telemetry.sendEvent('repositories/visibility'); + this.container.telemetry.sendEvent('repositories/visibility', { + 'repositories.visibility': visibility, + }); } this._reposVisibilityCache = visibility; } @@ -912,7 +936,7 @@ export class GitProviderService implements Disposable { if (visibility == null) { visibility = await this.visibilityCore(repoPath); if (this.container.telemetry.enabled) { - queueMicrotask(() => { + setTimeout(() => { const repo = this.getRepository(repoPath); this.container.telemetry.sendEvent('repository/visibility', { 'repository.visibility': visibility, @@ -922,7 +946,7 @@ export class GitProviderService implements Disposable { 'repository.folder.scheme': repo?.folder?.uri.scheme, 'repository.provider.id': repo?.provider.id, }); - }); + }, 0); } } return visibility; @@ -999,7 +1023,7 @@ export class GitProviderService implements Disposable { async setEnabledContext(enabled: boolean): Promise { let disabled = !enabled; // If we think we should be disabled during startup, check if we have a saved value from the last time this repo was loaded - if (!enabled && this._initializing) { + if (!enabled && this._initializing != null) { disabled = !(this.container.storage.getWorkspace('assumeRepositoriesOnStartup') ?? false); } @@ -1021,7 +1045,7 @@ export class GitProviderService implements Disposable { await Promise.allSettled(promises); - if (!this._initializing) { + if (this._initializing == null) { void this.container.storage.storeWorkspace('assumeRepositoriesOnStartup', enabled).catch(); } } @@ -1037,7 +1061,7 @@ export class GitProviderService implements Disposable { void this.setEnabledContext(hasRepositories); // Don't bother trying to set the values if we're still starting up - if (this._initializing) return; + if (this._initializing != null) return; this.container.telemetry.setGlobalAttributes({ enabled: hasRepositories, @@ -1050,52 +1074,60 @@ export class GitProviderService implements Disposable { async function updateRemoteContext(this: GitProviderService) { const integrations = configuration.get('integrations.enabled'); - const telemetryEnabled = this.container.telemetry.enabled; const remoteProviders = new Set(); + const reposWithRemotes = new Set(); + const reposWithHostingIntegrations = new Set(); + const reposWithHostingIntegrationsConnected = new Set(); - let hasRemotes = false; - let hasRichRemotes = false; - let hasConnectedRemotes = false; - - if (hasRepositories) { - for (const repo of this._repositories.values()) { - if (telemetryEnabled) { - const remotes = await repo.getRemotes(); - for (const remote of remotes) { - remoteProviders.add(remote.provider?.id ?? 'unknown'); - } - } + async function scanRemotes(repo: Repository) { + let hasSupportedIntegration = false; + let hasConnectedIntegration = false; - if (!hasConnectedRemotes && integrations) { - hasConnectedRemotes = await repo.hasRichRemote(true); - - if (hasConnectedRemotes) { - hasRichRemotes = true; - hasRemotes = true; + const remotes = await repo.getRemotes(); + for (const remote of remotes) { + remoteProviders.add(remote.provider?.id ?? 'unknown'); + reposWithRemotes.add(repo.uri.toString()); + reposWithRemotes.add(repo.path); + + // Skip if integrations are disabled or if we've already found a connected integration + if (!integrations || (hasSupportedIntegration && hasConnectedIntegration)) continue; + + if (remote.hasIntegration()) { + hasSupportedIntegration = true; + reposWithHostingIntegrations.add(repo.uri.toString()); + reposWithHostingIntegrations.add(repo.path); + + let connected = remote.maybeIntegrationConnected; + // If we don't know if we are connected, only check if the remote is the default or there is only one + // TODO@eamodio is the above still a valid requirement? + if (connected == null && (remote.default || remotes.length === 1)) { + const integration = await remote.getIntegration(); + connected = await integration?.isConnected(); } - } - - if (!hasRichRemotes && integrations) { - hasRichRemotes = await repo.hasRichRemote(); - if (hasRichRemotes) { - hasRemotes = true; + if (connected) { + hasConnectedIntegration = true; + reposWithHostingIntegrationsConnected.add(repo.uri.toString()); + reposWithHostingIntegrationsConnected.add(repo.path); } } - - if (!hasRemotes) { - hasRemotes = await repo.hasRemotes(); - } - - if (hasRemotes && ((hasRichRemotes && hasConnectedRemotes) || !integrations)) break; } } - if (telemetryEnabled) { + if (hasRepositories) { + void (await Promise.allSettled(map(this._repositories.values(), scanRemotes))); + } + + if (this.container.telemetry.enabled) { this.container.telemetry.setGlobalAttributes({ - 'repositories.hasRemotes': hasRemotes, - 'repositories.hasRichRemotes': hasRichRemotes, - 'repositories.hasConnectedRemotes': hasConnectedRemotes, + 'repositories.hasRemotes': reposWithRemotes.size !== 0, + 'repositories.hasRichRemotes': reposWithHostingIntegrations.size !== 0, + 'repositories.hasConnectedRemotes': reposWithHostingIntegrationsConnected.size !== 0, + + 'repositories.withRemotes': reposWithRemotes.size / 2, + 'repositories.withHostingIntegrations': reposWithHostingIntegrations.size / 2, + 'repositories.withHostingIntegrationsConnected': reposWithHostingIntegrationsConnected.size / 2, + 'repositories.remoteProviders': join(remoteProviders, ','), }); if (this._sendProviderContextTelemetryDebounced == null) { @@ -1108,9 +1140,15 @@ export class GitProviderService implements Disposable { } await Promise.allSettled([ - setContext('gitlens:hasRemotes', hasRemotes), - setContext('gitlens:hasRichRemotes', hasRichRemotes), - setContext('gitlens:hasConnectedRemotes', hasConnectedRemotes), + setContext('gitlens:repos:withRemotes', reposWithRemotes.size ? [...reposWithRemotes] : undefined), + setContext( + 'gitlens:repos:withHostingIntegrations', + reposWithHostingIntegrations.size ? [...reposWithHostingIntegrations] : undefined, + ), + setContext( + 'gitlens:repos:withHostingIntegrationsConnected', + reposWithHostingIntegrationsConnected.size ? [...reposWithHostingIntegrationsConnected] : undefined, + ), ]); } @@ -1254,6 +1292,21 @@ export class GitProviderService implements Disposable { return provider.applyChangesToWorkingFile(uri, ref1, ref2); } + @log() + async applyUnreachableCommitForPatch( + repoPath: string | Uri, + ref: string, + options?: { + branchName?: string; + createBranchIfNeeded?: boolean; + createWorktreePath?: string; + stash?: boolean | 'prompt'; + }, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.applyUnreachableCommitForPatch?.(path, ref, options); + } + @log() checkout( repoPath: string | Uri, @@ -1270,6 +1323,17 @@ export class GitProviderService implements Disposable { return provider.clone?.(url, parentPath); } + @log({ args: { 1: '', 3: '' } }) + async createUnreachableCommitForPatch( + repoPath: string | Uri, + contents: string, + baseRef: string, + message: string, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.createUnreachableCommitForPatch?.(path, contents, baseRef, message); + } + @log({ singleLine: true }) resetCaches(...caches: GitCaches[]): void { if (caches.length === 0 || caches.includes('providers')) { @@ -1316,7 +1380,7 @@ export class GitProviderService implements Disposable { location: ProgressLocation.Notification, title: `Fetching ${repositories.length} repositories`, }, - () => Promise.all(repositories!.map(r => r.fetch({ progress: false, ...options }))), + () => Promise.all(repositories.map(r => r.fetch({ progress: false, ...options }))), ); } @@ -1351,7 +1415,7 @@ export class GitProviderService implements Disposable { location: ProgressLocation.Notification, title: `Pulling ${repositories.length} repositories`, }, - () => Promise.all(repositories!.map(r => r.pull({ progress: false, ...options }))), + () => Promise.all(repositories.map(r => r.pull({ progress: false, ...options }))), ); } @@ -1359,13 +1423,13 @@ export class GitProviderService implements Disposable { @log() push( repoPath: string | Uri, - options?: { branch?: GitBranchReference; force?: boolean; publish?: { remote: string } }, + options?: { reference?: GitReference; force?: boolean; publish?: { remote: string } }, ): Promise { const { provider, path } = this.getProvider(repoPath); return provider.push(path, options); } - @gate(repos => `${repos == null ? '' : repos.map(r => r.id).join(',')}`) + @gate(repos => (repos == null ? '' : repos.map(r => r.id).join(','))) @log({ args: { 0: repos => repos?.map(r => r.name).join(', ') } }) async pushAll( repositories?: Repository[], @@ -1393,7 +1457,7 @@ export class GitProviderService implements Disposable { location: ProgressLocation.Notification, title: `Pushing ${repositories.length} repositories`, }, - () => Promise.all(repositories!.map(r => r.push({ progress: false, ...options }))), + () => Promise.all(repositories.map(r => r.push({ progress: false, ...options }))), ); } @@ -1401,9 +1465,10 @@ export class GitProviderService implements Disposable { getAheadBehindCommitCount( repoPath: string | Uri, refs: string[], + options?: { authors?: GitUser[] | undefined }, ): Promise<{ ahead: number; behind: number } | undefined> { const { provider, path } = this.getProvider(repoPath); - return provider.getAheadBehindCommitCount(path, refs); + return provider.getAheadBehindCommitCount(path, refs, options); } @log({ args: { 1: d => d?.isDirty } }) @@ -1527,6 +1592,7 @@ export class GitProviderService implements Disposable { repoPath: string | Uri | undefined, options?: { filter?: (b: GitBranch) => boolean; + paging?: PagingOptions; sort?: boolean | BranchSortOptions; }, ): Promise> { @@ -1539,60 +1605,69 @@ export class GitProviderService implements Disposable { @log() async getBranchesAndTagsTipsFn( repoPath: string | Uri | undefined, - currentName?: string, + suppressName?: string, ): Promise< (sha: string, options?: { compact?: boolean | undefined; icons?: boolean | undefined }) => string | undefined > { - const [branchesResult, tagsResult] = await Promise.allSettled([ + if (repoPath == null) return () => undefined; + + const [branchesResult, tagsResult, remotesResult] = await Promise.allSettled([ this.getBranches(repoPath), this.getTags(repoPath), + this.getRemotes(repoPath), ]); const branches = getSettledValue(branchesResult)?.values ?? []; const tags = getSettledValue(tagsResult)?.values ?? []; + const remotes = getSettledValue(remotesResult) ?? []; const branchesAndTagsBySha = groupByFilterMap( (branches as (GitBranch | GitTag)[]).concat(tags as (GitBranch | GitTag)[]), bt => bt.sha, bt => { - if (currentName) { - if (bt.name === currentName) return undefined; - if (bt.refType === 'branch' && bt.getNameWithoutRemote() === currentName) { - return { name: bt.name, compactName: bt.getRemoteName(), type: bt.refType }; + let icon; + if (bt.refType === 'branch') { + if (bt.remote) { + const remote = remotes.find(r => r.name === bt.getRemoteName()); + icon = `$(${getRemoteThemeIconString(remote)}) `; + } else { + icon = '$(git-branch) '; } + } else { + icon = '$(tag) '; } - return { name: bt.name, compactName: undefined, type: bt.refType }; + return { + name: bt.name, + icon: icon, + compactName: + suppressName && bt.refType === 'branch' && bt.getNameWithoutRemote() === suppressName + ? bt.getRemoteName() + : undefined, + type: bt.refType, + }; }, ); return (sha: string, options?: { compact?: boolean; icons?: boolean }): string | undefined => { const branchesAndTags = branchesAndTagsBySha.get(sha); - if (branchesAndTags == null || branchesAndTags.length === 0) return undefined; + if (!branchesAndTags?.length) return undefined; + + const tips = + suppressName && options?.compact + ? branchesAndTags.filter(bt => bt.name !== suppressName) + : branchesAndTags; if (!options?.compact) { - return branchesAndTags - .map( - bt => `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${bt.name}`, - ) - .join(', '); + return tips.map(bt => `${options?.icons ? bt.icon : ''}${bt.name}`).join(', '); } - if (branchesAndTags.length > 1) { - const [bt] = branchesAndTags; - return `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${ - bt.compactName ?? bt.name - }, ${GlyphChars.Ellipsis}`; + if (tips.length > 1) { + const [bt] = tips; + return `${options?.icons ? bt.icon : ''}${bt.compactName ?? bt.name}, ${GlyphChars.Ellipsis}`; } - return branchesAndTags - .map( - bt => - `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${ - bt.compactName ?? bt.name - }`, - ) - .join(', '); + return tips.map(bt => `${options?.icons ? bt.icon : ''}${bt.compactName ?? bt.name}`).join(', '); }; } @@ -1630,11 +1705,14 @@ export class GitProviderService implements Disposable { @log() getCommitBranches( repoPath: string | Uri, - ref: string, - options?: { branch?: string; commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + refs: string | string[], + branch?: string | undefined, + options?: + | { all?: boolean; commitDate?: Date; mode?: 'contains' | 'pointsAt' } + | { commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, ): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.getCommitBranches(path, ref, options); + return provider.getCommitBranches(path, typeof refs === 'string' ? [refs] : refs, branch, options); } @log() @@ -1670,6 +1748,16 @@ export class GitProviderService implements Disposable { return provider.getCommitsForGraph(path, asWebviewUri, options); } + @log() + getCommitTags( + repoPath: string | Uri, + ref: string, + options?: { commitDate?: Date; mode?: 'contains' | 'pointsAt' }, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.getCommitTags(path, ref, options); + } + @log() async getConfig(repoPath: string | Uri, key: string): Promise { const { provider, path } = this.getProvider(repoPath); @@ -1685,7 +1773,7 @@ export class GitProviderService implements Disposable { @log() async getContributors( repoPath: string | Uri, - options?: { all?: boolean; ref?: string; stats?: boolean }, + options?: { all?: boolean; merges?: boolean | 'first-parent'; ref?: string; stats?: boolean }, ): Promise { if (repoPath == null) return []; @@ -1711,12 +1799,18 @@ export class GitProviderService implements Disposable { @log() async getDiff( repoPath: string | Uri, - ref1: string, - ref2?: string, - options?: { context?: number }, + to: string, + from?: string, + options?: { context?: number; uris?: Uri[] }, ): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.getDiff?.(path, ref1, ref2, options); + return provider.getDiff?.(path, to, from, options); + } + + @log({ args: { 1: false } }) + async getDiffFiles(repoPath: string | Uri, contents: string): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.getDiffFiles?.(path, contents); } @log() @@ -1756,7 +1850,7 @@ export class GitProviderService implements Disposable { editorLine: number, ref1: string | undefined, ref2?: string, - ): Promise { + ): Promise { const { provider } = this.getProvider(uri); return provider.getDiffForLine(uri, editorLine, ref1, ref2); } @@ -1766,7 +1860,7 @@ export class GitProviderService implements Disposable { repoPath: string | Uri, ref1?: string, ref2?: string, - options?: { filters?: GitDiffFilter[]; similarityThreshold?: number }, + options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ): Promise { const { provider, path } = this.getProvider(repoPath); return provider.getDiffStatus(path, ref1, ref2, options); @@ -1799,7 +1893,7 @@ export class GitProviderService implements Disposable { all?: boolean; authors?: GitUser[]; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -1815,7 +1909,7 @@ export class GitProviderService implements Disposable { options?: { authors?: GitUser[]; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -1898,12 +1992,11 @@ export class GitProviderService implements Disposable { uri: Uri, ref: string | undefined, skip: number = 0, - firstParent: boolean = false, ): Promise { if (ref === deletedOrMissing) return Promise.resolve(undefined); const { provider, path } = this.getProvider(repoPath); - return provider.getPreviousComparisonUris(path, uri, ref, skip, firstParent); + return provider.getPreviousComparisonUris(path, uri, ref, skip); } @log() @@ -1920,78 +2013,6 @@ export class GitProviderService implements Disposable { return provider.getPreviousComparisonUrisForLine(path, uri, editorLine, ref, skip); } - @debug({ args: { 0: remoteOrProvider => remoteOrProvider.name } }) - async getMyPullRequests( - remoteOrProvider: GitRemote | RichRemoteProvider, - options?: { timeout?: number }, - ): Promise { - let provider; - if (GitRemote.is(remoteOrProvider)) { - ({ provider } = remoteOrProvider); - if (!provider?.hasRichIntegration()) return undefined; - } else { - provider = remoteOrProvider; - } - - let timeout; - if (options != null) { - ({ timeout, ...options } = options); - } - - let promiseOrPRs = provider.searchMyPullRequests(); - if (promiseOrPRs == null || !isPromise(promiseOrPRs)) { - return promiseOrPRs; - } - - if (timeout != null && timeout > 0) { - promiseOrPRs = cancellable(promiseOrPRs, timeout); - } - - try { - return await promiseOrPRs; - } catch (ex) { - if (ex instanceof PromiseCancelledError) throw ex; - - return undefined; - } - } - - @debug({ args: { 0: remoteOrProvider => remoteOrProvider.name } }) - async getMyIssues( - remoteOrProvider: GitRemote | RichRemoteProvider, - options?: { timeout?: number }, - ): Promise { - let provider; - if (GitRemote.is(remoteOrProvider)) { - ({ provider } = remoteOrProvider); - if (!provider?.hasRichIntegration()) return undefined; - } else { - provider = remoteOrProvider; - } - - let timeout; - if (options != null) { - ({ timeout, ...options } = options); - } - - let promiseOrPRs = provider.searchMyIssues(); - if (promiseOrPRs == null || !isPromise(promiseOrPRs)) { - return promiseOrPRs; - } - - if (timeout != null && timeout > 0) { - promiseOrPRs = cancellable(promiseOrPRs, timeout); - } - - try { - return await promiseOrPRs; - } catch (ex) { - if (ex instanceof PromiseCancelledError) throw ex; - - return undefined; - } - } - @log() async getIncomingActivity( repoPath: string | Uri, @@ -2064,15 +2085,17 @@ export class GitProviderService implements Disposable { // Only check remotes that have extra weighting and less than the default if (weight > 0 && weight < 1000 && !originalFound) { - const p = remote.provider; + const integration = await remote.getIntegration(); if ( - p.hasRichIntegration() && - (p.maybeConnected || - (p.maybeConnected === undefined && p.shouldConnect && (await p.isConnected()))) + integration != null && + (integration.maybeConnected || + (integration.maybeConnected === undefined && (await integration.isConnected()))) ) { if (cancellation?.isCancellationRequested) throw new CancellationError(); - const repo = await p.getRepositoryMetadata(cancellation); + const repo = await integration.getRepositoryMetadata(remote.provider.repoDesc, { + cancellation: cancellation, + }); if (cancellation?.isCancellationRequested) throw new CancellationError(); @@ -2100,19 +2123,27 @@ export class GitProviderService implements Disposable { } @log() - async getBestRemoteWithRichProvider( + async getBestRemoteWithIntegration( repoPath: string | Uri, - options?: { includeDisconnected?: boolean }, + options?: { + filter?: (remote: GitRemote, integration: HostingIntegration) => boolean; + includeDisconnected?: boolean; + }, cancellation?: CancellationToken, - ): Promise | undefined> { + ): Promise | undefined> { const remotes = await this.getBestRemotesWithProviders(repoPath, cancellation); const includeDisconnected = options?.includeDisconnected ?? false; for (const r of remotes) { - if (r.hasRichIntegration()) { - if (includeDisconnected || r.provider.maybeConnected === true) return r; - if (r.provider.maybeConnected === undefined && r.default) { - if (await r.provider.isConnected()) return r; + if (r.hasIntegration()) { + const integration = await this.container.integrations.getByRemote(r); + if (integration != null) { + if (options?.filter?.(r, integration) === false) continue; + + if (includeDisconnected || integration.maybeConnected === true) return r; + if (integration.maybeConnected === undefined && (r.default || remotes.length === 1)) { + if (await integration.isConnected()) return r; + } } } } @@ -2143,13 +2174,13 @@ export class GitProviderService implements Disposable { } @log() - async getRemotesWithRichProviders( + async getRemotesWithIntegrations( repoPath: string | Uri, options?: { sort?: boolean }, cancellation?: CancellationToken, - ): Promise[]> { + ): Promise[]> { const remotes = await this.getRemotes(repoPath, options, cancellation); - return remotes.filter((r: GitRemote): r is GitRemote => r.hasRichIntegration()); + return remotes.filter((r: GitRemote): r is GitRemote => r.hasIntegration()); } getBestRepository(): Repository | undefined; @@ -2232,8 +2263,8 @@ export class GitProviderService implements Disposable { let repository: Repository | undefined; repository = this.getRepository(uri); - if (repository == null && this._isDiscoveringRepositories != null) { - await this._isDiscoveringRepositories; + if (repository == null && this._discoveringRepositories?.pending) { + await this._discoveringRepositories.promise; repository = this.getRepository(uri); } @@ -2246,10 +2277,20 @@ export class GitProviderService implements Disposable { return repository; } else { const stats = await workspace.fs.stat(uri); + + const bestPath = getBestPath(uri); + + Logger.debug( + scope, + `Ensuring URI is a folder; repository=${repository?.toString()}, uri=${uri.toString(true)} stats.type=${ + stats.type + }, bestPath=${bestPath}, visitedPaths.has=${this._visitedPaths.has(bestPath)}`, + ); + // If the uri isn't a directory, go up one level if ((stats.type & FileType.Directory) !== FileType.Directory) { uri = Uri.joinPath(uri, '..'); - if (!options?.force && this._visitedPaths.has(getBestPath(uri))) return repository; + if (!options?.force && this._visitedPaths.has(bestPath)) return repository; } isDirectory = true; @@ -2275,10 +2316,7 @@ export class GitProviderService implements Disposable { root = this._repositories.getClosest(provider.getAbsoluteUri(uri, repoUri)); } - const autoRepositoryDetection = - configuration.getAny( - 'git.autoRepositoryDetection', - ) ?? true; + const autoRepositoryDetection = configuration.getCore('git.autoRepositoryDetection') ?? true; const closed = options?.closeOnOpen ?? @@ -2390,7 +2428,11 @@ export class GitProviderService implements Disposable { @log({ args: { 1: false } }) async getTags( repoPath: string | Uri | undefined, - options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + options?: { + filter?: (t: GitTag) => boolean; + paging?: PagingOptions; + sort?: boolean | TagSortOptions; + }, ): Promise> { if (repoPath == null) return { values: [] }; @@ -2425,16 +2467,22 @@ export class GitProviderService implements Disposable { return provider.getRevisionContent(rp, path, ref); } - @log() - async getUniqueRepositoryId(repoPath: string | Uri): Promise { + @log({ exit: true }) + async getFirstCommitSha(repoPath: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); - const id = await provider.getUniqueRepositoryId(path); - if (id != null) return id; + try { + return await provider.getFirstCommitSha?.(path); + } catch { + return undefined; + } + } - return missingRepositoryId; + @log({ exit: true }) + getUniqueRepositoryId(repoPath: string | Uri): Promise { + return this.getFirstCommitSha(repoPath); } - @log({ args: { 1: false } }) + @log({ args: { 1: false }, exit: true }) async hasBranchOrTag( repoPath: string | Uri | undefined, options?: { @@ -2447,7 +2495,7 @@ export class GitProviderService implements Disposable { return provider.hasBranchOrTag(path, options); } - @log({ args: { 1: false } }) + @log({ args: { 1: false }, exit: true }) async hasCommitBeenPushed(repoPath: string | Uri, ref: string): Promise { if (repoPath == null) return false; @@ -2455,7 +2503,7 @@ export class GitProviderService implements Disposable { return provider.hasCommitBeenPushed(path, ref); } - @log() + @log({ exit: true }) async hasRemotes(repoPath: string | Uri | undefined): Promise { if (repoPath == null) return false; @@ -2465,7 +2513,7 @@ export class GitProviderService implements Disposable { return repository.hasRemotes(); } - @log() + @log({ exit: true }) async hasTrackingBranch(repoPath: string | undefined): Promise { if (repoPath == null) return false; @@ -2475,7 +2523,7 @@ export class GitProviderService implements Disposable { return repository.hasUpstreamBranch(); } - @log() + @log({ exit: true }) hasUnsafeRepositories(): boolean { for (const provider of this._providers.values()) { if (provider.hasUnsafeRepositories?.()) return true; @@ -2488,6 +2536,7 @@ export class GitProviderService implements Disposable { 0: r => r.uri.toString(true), 1: e => (e != null ? `TextEditor(${Logger.toLoggable(e.document.uri)})` : undefined), }, + exit: true, }) isRepositoryForEditor(repository: Repository, editor?: TextEditor): boolean { editor = editor ?? window.activeTextEditor; @@ -2613,14 +2662,24 @@ export class GitProviderService implements Disposable { return provider.runGitCommandViaTerminal?.(path, command, args, options); } - @log() + @log({ exit: true }) validateBranchOrTagName(repoPath: string | Uri, ref: string): Promise { const { provider, path } = this.getProvider(repoPath); return provider.validateBranchOrTagName(path, ref); } - @log() - async validateReference(repoPath: string | Uri, ref: string) { + @log({ args: { 1: false }, exit: true }) + async validatePatch(repoPath: string | Uri, contents: string): Promise { + try { + const { provider, path } = this.getProvider(repoPath); + return (await provider.validatePatch?.(path || undefined, contents)) ?? false; + } catch { + return false; + } + } + + @log({ exit: true }) + async validateReference(repoPath: string | Uri, ref: string): Promise { if (ref == null || ref.length === 0) return false; if (ref === deletedOrMissing || isUncommitted(ref)) return true; @@ -2695,6 +2754,12 @@ export class GitProviderService implements Disposable { return provider.stashSave?.(path, message, uris, options); } + @log() + async stashSaveSnapshot(repoPath: string | Uri, message?: string): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.stashSaveSnapshot?.(path, message); + } + @log() createWorktree( repoPath: string | Uri, @@ -2720,7 +2785,7 @@ export class GitProviderService implements Disposable { return (await provider.getWorktrees?.(path)) ?? []; } - @log() + @log({ exit: true }) async getWorktreesDefaultUri(path: string | Uri): Promise { const { provider, path: rp } = this.getProvider(path); let defaultUri = await provider.getWorktreesDefaultUri?.(rp); @@ -2739,6 +2804,7 @@ export class GitProviderService implements Disposable { const { provider, path: rp } = this.getProvider(repoPath); return Promise.resolve(provider.deleteWorktree?.(rp, path, options)); } + @log() async getOpenScmRepositories(): Promise { const results = await Promise.allSettled([...this._providers.values()].map(p => p.getOpenScmRepositories())); diff --git a/src/git/gitUri.ts b/src/git/gitUri.ts index bc283f361cd5f..7a4f4a90bf4ac 100644 --- a/src/git/gitUri.ts +++ b/src/git/gitUri.ts @@ -57,15 +57,21 @@ export class GitUri extends (Uri as any as UriEx) { } if (uri.scheme === Schemes.GitLens) { + let path = uri.path; + + const metadata = decodeGitLensRevisionUriAuthority(uri.authority); + if (metadata.uncPath != null && !path.startsWith(metadata.uncPath)) { + path = `${metadata.uncPath}${uri.path}`; + } + super({ scheme: uri.scheme, authority: uri.authority, - path: uri.path, + path: path, query: uri.query, fragment: uri.fragment, }); - const metadata = decodeGitLensRevisionUriAuthority(uri.authority); this.repoPath = metadata.repoPath; let ref = metadata.ref; @@ -73,7 +79,7 @@ export class GitUri extends (Uri as any as UriEx) { ref = commitOrRepoPath.sha; } - if (isUncommittedStaged(ref) || !isUncommitted(ref)) { + if (!isUncommitted(ref) || isUncommittedStaged(ref)) { this.sha = ref; } @@ -93,7 +99,7 @@ export class GitUri extends (Uri as any as UriEx) { ref = commitOrRepoPath.sha; } - if (ref && (isUncommittedStaged(ref) || !isUncommitted(ref))) { + if (ref && (!isUncommitted(ref) || isUncommittedStaged(ref))) { this.sha = ref; } @@ -158,7 +164,7 @@ export class GitUri extends (Uri as any as UriEx) { fragment: uri.fragment, }); this.repoPath = commitOrRepoPath.repoPath; - if (isUncommittedStaged(commitOrRepoPath.sha) || !isUncommitted(commitOrRepoPath.sha)) { + if (!isUncommitted(commitOrRepoPath.sha) || isUncommittedStaged(commitOrRepoPath.sha)) { this.sha = commitOrRepoPath.sha; } } diff --git a/src/git/models/author.ts b/src/git/models/author.ts index 8d2ef94dda48f..ef480dbf78142 100644 --- a/src/git/models/author.ts +++ b/src/git/models/author.ts @@ -1,8 +1,9 @@ -import type { RemoteProviderReference } from './remoteProvider'; +import type { ProviderReference } from './remoteProvider'; export interface Account { - provider: RemoteProviderReference; + provider: ProviderReference; name: string | undefined; email: string | undefined; avatarUrl: string | undefined; + username: string | undefined; } diff --git a/src/git/models/branch.ts b/src/git/models/branch.ts index e8e647a5d1040..a7a94dbfa7aac 100644 --- a/src/git/models/branch.ts +++ b/src/git/models/branch.ts @@ -5,6 +5,7 @@ import { formatDate, fromNow } from '../../system/date'; import { debug } from '../../system/decorators/log'; import { memoize } from '../../system/decorators/memoize'; import { getLoggableName } from '../../system/logger'; +import { PageableResult } from '../../system/paging'; import { sortCompare } from '../../system/string'; import type { PullRequest, PullRequestState } from './pullRequest'; import type { GitBranchReference, GitReference } from './reference'; @@ -13,8 +14,7 @@ import type { GitRemote } from './remote'; import type { Repository } from './repository'; import { getUpstreamStatus } from './status'; -const whitespaceRegex = /\s/; -const detachedHEADRegex = /^(?=.*\bHEAD\b)?(?=.*\bdetached\b).*$/; +const detachedHEADRegex = /^(HEAD|\(.*\))$/; export interface GitTrackingState { ahead: number; @@ -35,6 +35,7 @@ export interface BranchSortOptions { current?: boolean; missingUpstream?: boolean; orderBy?: BranchSorting; + openWorktreeBranches?: string[]; } export function getBranchId(repoPath: string, remote: boolean, name: string): string { @@ -103,14 +104,16 @@ export class GitBranch implements GitBranchReference { async getAssociatedPullRequest(options?: { avatarSize?: number; include?: PullRequestState[]; + expiryOverride?: boolean | number; }): Promise { const remote = await this.getRemote(); - return remote?.hasRichIntegration() - ? remote.provider.getPullRequestForBranch( - this.getTrackingWithoutRemote() ?? this.getNameWithoutRemote(), - options, - ) - : undefined; + if (remote?.provider == null) return undefined; + + return (await this.container.integrations.getByRemote(remote))?.getPullRequestForBranch( + remote.provider.repoDesc, + this.getTrackingWithoutRemote() ?? this.getNameWithoutRemote(), + options, + ); } @memoize() @@ -231,7 +234,8 @@ export function isBranch(branch: any): branch is GitBranch { export function isDetachedHead(name: string): boolean { // If there is whitespace in the name assume this is not a valid branch name // Deals with detached HEAD states - return whitespaceRegex.test(name) || detachedHEADRegex.test(name); + name = name.trim(); + return name.length ? detachedHEADRegex.test(name) : true; } export function isOfBranchRefType(branch: GitReference | undefined) { @@ -245,17 +249,26 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) case 'date:asc': return branches.sort( (a, b) => - (options!.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.openWorktreeBranches + ? (options.openWorktreeBranches.includes(a.name) ? -1 : 1) - + (options.openWorktreeBranches.includes(b.name) ? -1 : 1) + : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || - (a.date == null ? -1 : a.date.getTime()) - (b.date == null ? -1 : b.date.getTime()), + (a.date == null ? -1 : a.date.getTime()) - (b.date == null ? -1 : b.date.getTime()) || + sortCompare(a.name, b.name), ); case 'name:asc': return branches.sort( (a, b) => - (options!.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.openWorktreeBranches + ? (options.openWorktreeBranches.includes(a.name) ? -1 : 1) - + (options.openWorktreeBranches.includes(b.name) ? -1 : 1) + : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || @@ -266,8 +279,12 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) case 'name:desc': return branches.sort( (a, b) => - (options!.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.openWorktreeBranches + ? (options.openWorktreeBranches.includes(a.name) ? -1 : 1) - + (options.openWorktreeBranches.includes(b.name) ? -1 : 1) + : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || @@ -279,11 +296,16 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) default: return branches.sort( (a, b) => - (options!.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.openWorktreeBranches + ? (options.openWorktreeBranches.includes(a.name) ? -1 : 1) - + (options.openWorktreeBranches.includes(b.name) ? -1 : 1) + : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || - (b.date == null ? -1 : b.date.getTime()) - (a.date == null ? -1 : a.date.getTime()), + (b.date == null ? -1 : b.date.getTime()) - (a.date == null ? -1 : a.date.getTime()) || + sortCompare(b.name, a.name), ); } } @@ -291,6 +313,7 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) export async function getLocalBranchByUpstream( repo: Repository, remoteBranchName: string, + branches?: PageableResult | Map, ): Promise { let qualifiedRemoteBranchName; if (remoteBranchName.startsWith('remotes/')) { @@ -300,19 +323,16 @@ export async function getLocalBranchByUpstream( qualifiedRemoteBranchName = `remotes/${remoteBranchName}`; } - let branches; - do { - branches = await repo.getBranches(branches != null ? { paging: branches.paging } : undefined); - for (const branch of branches.values) { - if ( - !branch.remote && - branch.upstream?.name != null && - (branch.upstream.name === remoteBranchName || branch.upstream.name === qualifiedRemoteBranchName) - ) { - return branch; - } + branches ??= new PageableResult(p => repo.getBranches(p != null ? { paging: p } : undefined)); + for await (const branch of branches.values()) { + if ( + !branch.remote && + branch.upstream?.name != null && + (branch.upstream.name === remoteBranchName || branch.upstream.name === qualifiedRemoteBranchName) + ) { + return branch; } - } while (branches.paging?.more); + } return undefined; } diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index e23eebd82ae43..30cc2b8832f11 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -173,6 +173,10 @@ export class GitCommit implements GitRevisionReference { } private _resolvedPreviousSha: string | undefined; + get resolvedPreviousSha(): string | undefined { + return this._resolvedPreviousSha; + } + get unresolvedPreviousSha(): string { const previousSha = this._resolvedPreviousSha ?? @@ -216,6 +220,8 @@ export class GitCommit implements GitRevisionReference { this._files = this.file != null ? [this.file] : []; } + this._recomputeStats = true; + return; } @@ -415,16 +421,25 @@ export class GitCommit implements GitRevisionReference { return status; } - async getAssociatedPullRequest(remote?: GitRemote): Promise { - remote ??= await this.container.git.getBestRemoteWithRichProvider(this.repoPath); - return remote?.hasRichIntegration() ? remote.provider.getPullRequestForCommit(this.ref) : undefined; + async getAssociatedPullRequest( + remote?: GitRemote, + options?: { expiryOverride?: boolean | number }, + ): Promise { + remote ??= await this.container.git.getBestRemoteWithIntegration(this.repoPath); + if (!remote?.hasIntegration()) return undefined; + + return (await this.container.integrations.getByRemote(remote))?.getPullRequestForCommit( + remote.provider.repoDesc, + this.ref, + options, + ); } async getEnrichedAutolinks(remote?: GitRemote): Promise | undefined> { if (this.isUncommitted) return undefined; - remote ??= await this.container.git.getBestRemoteWithRichProvider(this.repoPath); - if (!remote?.hasRichIntegration()) return undefined; + remote ??= await this.container.git.getBestRemoteWithIntegration(this.repoPath); + if (remote?.provider == null) return undefined; // TODO@eamodio should we cache these? Seems like we would use more memory than it's worth // async function getCore(this: GitCommit): Promise | undefined> { @@ -514,7 +529,10 @@ export class GitCommit implements GitRevisionReference { } const parent = this.parents[0]; - if (parent != null && isSha(parent)) return parent; + if (parent != null && isSha(parent)) { + this._resolvedPreviousSha = parent; + return parent; + } const sha = await this.container.git.resolveReference( this.repoPath, @@ -545,6 +563,7 @@ export class GitCommit implements GitRevisionReference { parents?: string[]; files?: { file?: GitFileChange | null; files?: GitFileChange[] | null } | null; lines?: GitCommitLine[]; + stats?: GitCommitStats; }): GitCommit { let files; if (changes.files != null) { @@ -575,7 +594,7 @@ export class GitCommit implements GitRevisionReference { this.getChangedValue(changes.parents, this.parents) ?? [], this.message, files, - this.stats, + this.getChangedValue(changes.stats, this.stats), this.getChangedValue(changes.lines, this.lines), this.tips, this.stashName, diff --git a/src/git/models/contributor.ts b/src/git/models/contributor.ts index 4c359357002e2..89e81c58784e6 100644 --- a/src/git/models/contributor.ts +++ b/src/git/models/contributor.ts @@ -1,69 +1,15 @@ +import type { QuickInputButton } from 'vscode'; import { Uri } from 'vscode'; import { getAvatarUri } from '../../avatars'; import type { ContributorSorting, GravatarDefaultStyle } from '../../config'; +import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { configuration } from '../../system/configuration'; import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import { sortCompare } from '../../system/string'; - -export interface ContributorSortOptions { - current?: true; - orderBy?: ContributorSorting; -} +import type { GitUser } from './user'; export class GitContributor { - static is(contributor: any): contributor is GitContributor { - return contributor instanceof GitContributor; - } - - static sort(contributors: GitContributor[], options?: ContributorSortOptions) { - options = { current: true, orderBy: configuration.get('sortContributorsBy'), ...options }; - - switch (options.orderBy) { - case 'count:asc': - return contributors.sort( - (a, b) => - (a.current ? -1 : 1) - (b.current ? -1 : 1) || - a.count - b.count || - (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0), - ); - case 'date:desc': - return contributors.sort( - (a, b) => - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || - (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0) || - b.count - a.count, - ); - case 'date:asc': - return contributors.sort( - (a, b) => - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || - (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0) || - b.count - a.count, - ); - case 'name:asc': - return contributors.sort( - (a, b) => - (a.current ? -1 : 1) - (b.current ? -1 : 1) || - sortCompare(a.name ?? a.username!, b.name ?? b.username!), - ); - case 'name:desc': - return contributors.sort( - (a, b) => - (a.current ? -1 : 1) - (b.current ? -1 : 1) || - sortCompare(b.name ?? b.username!, a.name ?? a.username!), - ); - case 'count:desc': - default: - return contributors.sort( - (a, b) => - (a.current ? -1 : 1) - (b.current ? -1 : 1) || - b.count - a.count || - (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0), - ); - } - } - constructor( public readonly repoPath: string, public readonly name: string | undefined, @@ -104,3 +50,147 @@ export class GitContributor { return `${this.name}${this.email ? ` <${this.email}>` : ''}`; } } + +export function matchContributor(c: GitContributor, user: GitUser): boolean { + return c.name === user.name && c.email === user.email && c.username === user.username; +} + +export function isContributor(contributor: any): contributor is GitContributor { + return contributor instanceof GitContributor; +} + +export type ContributorQuickPickItem = QuickPickItemOfT; + +export async function createContributorQuickPickItem( + contributor: GitContributor, + picked?: boolean, + options?: { alwaysShow?: boolean; buttons?: QuickInputButton[] }, +): Promise { + const item: ContributorQuickPickItem = { + label: contributor.label, + description: contributor.current ? 'you' : contributor.email, + alwaysShow: options?.alwaysShow, + buttons: options?.buttons, + picked: picked, + item: contributor, + iconPath: configuration.get('gitCommands.avatars') ? await contributor.getAvatarUri() : undefined, + }; + + if (options?.alwaysShow == null && picked) { + item.alwaysShow = true; + } + return item; +} + +export interface ContributorSortOptions { + current?: true; + orderBy?: ContributorSorting; +} + +interface ContributorQuickPickSortOptions extends ContributorSortOptions { + picked?: boolean; +} + +export function sortContributors(contributors: GitContributor[], options?: ContributorSortOptions): GitContributor[]; +export function sortContributors( + contributors: ContributorQuickPickItem[], + options?: ContributorQuickPickSortOptions, +): ContributorQuickPickItem[]; +export function sortContributors( + contributors: GitContributor[] | ContributorQuickPickItem[], + options?: (ContributorSortOptions & { picked?: never }) | ContributorQuickPickSortOptions, +) { + options = { picked: true, current: true, orderBy: configuration.get('sortContributorsBy'), ...options }; + + const getContributor = (contributor: GitContributor | ContributorQuickPickItem): GitContributor => { + return isContributor(contributor) ? contributor : contributor.item; + }; + + const comparePicked = ( + a: GitContributor | ContributorQuickPickItem, + b: GitContributor | ContributorQuickPickItem, + ): number => { + if (!options.picked || isContributor(a) || isContributor(b)) return 0; + return (a.picked ? -1 : 1) - (b.picked ? -1 : 1); + }; + + switch (options.orderBy) { + case 'count:asc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + a.count - b.count || + (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0) + ); + }); + case 'date:desc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0) || + b.count - a.count + ); + }); + case 'date:asc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0) || + b.count - a.count + ); + }); + case 'name:asc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + sortCompare(a.name ?? a.username!, b.name ?? b.username!) + ); + }); + case 'name:desc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + sortCompare(b.name ?? b.username!, a.name ?? a.username!) + ); + }); + case 'count:desc': + default: + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + b.count - a.count || + (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0) + ); + }); + } +} diff --git a/src/git/models/defaultBranch.ts b/src/git/models/defaultBranch.ts index 95fd4a0ac51b5..9e6efcdfb4606 100644 --- a/src/git/models/defaultBranch.ts +++ b/src/git/models/defaultBranch.ts @@ -1,6 +1,6 @@ -import type { RemoteProviderReference } from './remoteProvider'; +import type { ProviderReference } from './remoteProvider'; export interface DefaultBranch { - provider: RemoteProviderReference; + provider: ProviderReference; name: string; } diff --git a/src/git/models/diff.ts b/src/git/models/diff.ts index 7b202d8a38ac4..afe146d4e801e 100644 --- a/src/git/models/diff.ts +++ b/src/git/models/diff.ts @@ -1,49 +1,28 @@ -import { parseDiffHunk } from '../parsers/diffParser'; +import type { GitFileChange } from './file'; -export interface GitDiffLine { - line: string; - state: 'added' | 'removed' | 'unchanged'; +export interface GitDiff { + readonly contents: string; + readonly from: string; + readonly to: string; } export interface GitDiffHunkLine { - hunk: GitDiffHunk; - current: GitDiffLine | undefined; - previous: GitDiffLine | undefined; + current: string | undefined; + previous: string | undefined; + state: 'added' | 'changed' | 'removed' | 'unchanged'; } -export class GitDiffHunk { - constructor( - public readonly contents: string, - public current: { - count: number; - position: { start: number; end: number }; - }, - public previous: { - count: number; - position: { start: number; end: number }; - }, - ) {} - - get lines(): GitDiffHunkLine[] { - return this.parseHunk().lines; - } - - get state(): 'added' | 'changed' | 'removed' { - return this.parseHunk().state; - } - - private parsedHunk: { lines: GitDiffHunkLine[]; state: 'added' | 'changed' | 'removed' } | undefined; - private parseHunk() { - if (this.parsedHunk == null) { - this.parsedHunk = parseDiffHunk(this); - } - return this.parsedHunk; - } -} - -export interface GitDiff { - readonly baseSha: string; +export interface GitDiffHunk { readonly contents: string; + readonly current: { + readonly count: number; + readonly position: { readonly start: number; readonly end: number }; + }; + readonly previous: { + readonly count: number; + readonly position: { readonly start: number; readonly end: number }; + }; + readonly lines: Map; } export interface GitDiffFile { @@ -51,10 +30,19 @@ export interface GitDiffFile { readonly contents?: string; } +export interface GitDiffLine { + readonly hunk: GitDiffHunk; + readonly line: GitDiffHunkLine; +} + export interface GitDiffShortStat { readonly additions: number; readonly deletions: number; readonly changedFiles: number; } +export interface GitDiffFiles { + readonly files: GitFileChange[]; +} + export type GitDiffFilter = 'A' | 'C' | 'D' | 'M' | 'R' | 'T' | 'U' | 'X' | 'B' | '*'; diff --git a/src/git/models/file.ts b/src/git/models/file.ts index 9dacfeb204683..d51c8e2fb0978 100644 --- a/src/git/models/file.ts +++ b/src/git/models/file.ts @@ -1,4 +1,5 @@ import type { Uri } from 'vscode'; +import { ThemeIcon } from 'vscode'; import { GlyphChars } from '../../constants'; import { Container } from '../../container'; import { memoize } from '../../system/decorators/memoize'; @@ -119,26 +120,27 @@ export function getGitFileStatusIcon(status: GitFileStatus): string { const statusCodiconsMap = { '.': undefined, - '!': '$(diff-ignored)', - '?': '$(diff-added)', - A: '$(diff-added)', - D: '$(diff-removed)', - M: '$(diff-modified)', - R: '$(diff-renamed)', - C: '$(diff-added)', - AA: '$(warning)', - AU: '$(warning)', - UA: '$(warning)', - DD: '$(warning)', - DU: '$(warning)', - UD: '$(warning)', - UU: '$(warning)', - T: '$(diff-modified)', - U: '$(diff-modified)', + '!': 'diff-ignored', + '?': 'diff-added', + A: 'diff-added', + D: 'diff-removed', + M: 'diff-modified', + R: 'diff-renamed', + C: 'diff-added', + AA: 'warning', + AU: 'warning', + UA: 'warning', + DD: 'warning', + DU: 'warning', + UD: 'warning', + UU: 'warning', + T: 'diff-modified', + U: 'diff-modified', }; -export function getGitFileStatusCodicon(status: GitFileStatus, missing: string = GlyphChars.Space.repeat(4)): string { - return statusCodiconsMap[status] ?? missing; +export function getGitFileStatusThemeIcon(status: GitFileStatus): ThemeIcon | undefined { + const codicon = statusCodiconsMap[status]; + return codicon != null ? new ThemeIcon(codicon) : undefined; } const statusTextMap = { @@ -181,10 +183,6 @@ export interface GitFileChangeShape { } export class GitFileChange implements GitFileChangeShape { - static is(file: any): file is GitFileChange { - return file instanceof GitFileChange; - } - constructor( public readonly repoPath: string, public readonly path: string, @@ -270,3 +268,7 @@ export class GitFileChange implements GitFileChangeShape { return status; } } + +export function isGitFileChange(file: any): file is GitFileChange { + return file instanceof GitFileChange; +} diff --git a/src/git/models/graph.ts b/src/git/models/graph.ts index ebaa95f64ad52..ce04e3e653f2a 100644 --- a/src/git/models/graph.ts +++ b/src/git/models/graph.ts @@ -1,7 +1,19 @@ -import type { GraphRow, Head, Remote, RowContexts, RowStats, Tag } from '@gitkraken/gitkraken-components'; +import type { + GraphRow, + Head, + HostingServiceType, + Remote, + RowContexts, + RowStats, + Tag, +} from '@gitkraken/gitkraken-components'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { Brand, Unbrand } from '../../system/brand'; import type { GitBranch } from './branch'; import type { GitRemote } from './remote'; +export type GitGraphHostingServiceType = HostingServiceType; + export type GitGraphRowHead = Head; export type GitGraphRowRemoteHead = Remote; export type GitGraphRowTag = Tag; @@ -54,3 +66,44 @@ export interface GitGraph { } export type GitGraphRowsStats = Map; + +export function convertHostingServiceTypeToGkProviderId(type: GitGraphHostingServiceType): GkProviderId | undefined { + switch (type) { + case 'github': + return 'github' satisfies Unbrand as Brand; + case 'githubEnterprise': + return 'githubEnterprise' satisfies Unbrand as Brand; + case 'gitlab': + return 'gitlab' satisfies Unbrand as Brand; + case 'gitlabSelfHosted': + return 'gitlabSelfHosted' satisfies Unbrand as Brand; + case 'bitbucket': + return 'bitbucket' satisfies Unbrand as Brand; + case 'bitbucketServer': + return 'bitbucketServer' satisfies Unbrand as Brand; + case 'azureDevops': + return 'azureDevops' satisfies Unbrand as Brand; + default: + return undefined; + } +} + +export function getGkProviderThemeIconString( + providerIdOrHostingType: GkProviderId | GitGraphHostingServiceType | undefined, +): string { + switch (providerIdOrHostingType) { + case 'azureDevops': + return 'gitlens-provider-azdo'; + case 'bitbucket': + case 'bitbucketServer': + return 'gitlens-provider-bitbucket'; + case 'github': + case 'githubEnterprise': + return 'gitlens-provider-github'; + case 'gitlab': + case 'gitlabSelfHosted': + return 'gitlens-provider-gitlab'; + default: + return 'cloud'; + } +} diff --git a/src/git/models/issue.ts b/src/git/models/issue.ts index 1860991000896..6ed0d9ccd3c32 100644 --- a/src/git/models/issue.ts +++ b/src/git/models/issue.ts @@ -1,46 +1,56 @@ import { ColorThemeKind, ThemeColor, ThemeIcon, window } from 'vscode'; import type { Colors } from '../../constants'; -import type { RemoteProviderReference } from './remoteProvider'; +import type { ProviderReference } from './remoteProvider'; export type IssueOrPullRequestType = 'issue' | 'pullrequest'; export type IssueOrPullRequestState = 'opened' | 'closed' | 'merged'; +export enum RepositoryAccessLevel { + Admin = 100, + Maintain = 40, + Write = 30, + Triage = 20, + Read = 10, + None = 0, +} export interface IssueOrPullRequest { readonly type: IssueOrPullRequestType; - readonly provider: RemoteProviderReference; + readonly provider: ProviderReference; readonly id: string; + readonly nodeId: string | undefined; readonly title: string; readonly url: string; - readonly date: Date; + readonly createdDate: Date; + readonly updatedDate: Date; readonly closedDate?: Date; readonly closed: boolean; readonly state: IssueOrPullRequestState; + readonly commentsCount?: number; + readonly thumbsUpCount?: number; } export interface IssueLabel { - color: string; + color?: string; name: string; } export interface IssueMember { name: string; - avatarUrl: string; - url: string; + avatarUrl?: string; + url?: string; } export interface IssueRepository { owner: string; repo: string; + accessLevel?: RepositoryAccessLevel; } export interface IssueShape extends IssueOrPullRequest { - updatedDate: Date; author: IssueMember; assignees: IssueMember[]; - repository: IssueRepository; + repository?: IssueRepository; labels?: IssueLabel[]; - commentsCount?: number; - thumbsUpCount?: number; } export interface SearchedIssue { @@ -58,9 +68,11 @@ export function serializeIssueOrPullRequest(value: IssueOrPullRequest): IssueOrP icon: value.provider.icon, }, id: value.id, + nodeId: value.nodeId, title: value.title, url: value.url, - date: value.date, + createdDate: value.createdDate, + updatedDate: value.updatedDate, closedDate: value.closedDate, closed: value.closed, state: value.state, @@ -183,22 +195,26 @@ export function serializeIssue(value: IssueShape): IssueShape { icon: value.provider.icon, }, id: value.id, + nodeId: value.nodeId, title: value.title, url: value.url, - date: value.date, + createdDate: value.createdDate, + updatedDate: value.updatedDate, closedDate: value.closedDate, closed: value.closed, state: value.state, - updatedDate: value.updatedDate, author: { name: value.author.name, avatarUrl: value.author.avatarUrl, url: value.author.url, }, - repository: { - owner: value.repository.owner, - repo: value.repository.repo, - }, + repository: + value.repository == null + ? undefined + : { + owner: value.repository.owner, + repo: value.repository.repo, + }, assignees: value.assignees.map(assignee => ({ name: assignee.name, avatarUrl: assignee.avatarUrl, @@ -221,14 +237,15 @@ export class Issue implements IssueShape { readonly type = 'issue'; constructor( - public readonly provider: RemoteProviderReference, + public readonly provider: ProviderReference, public readonly id: string, + public readonly nodeId: string | undefined, public readonly title: string, public readonly url: string, - public readonly date: Date, + public readonly createdDate: Date, + public readonly updatedDate: Date, public readonly closed: boolean, public readonly state: IssueOrPullRequestState, - public readonly updatedDate: Date, public readonly author: IssueMember, public readonly repository: IssueRepository, public readonly assignees: IssueMember[], diff --git a/src/git/models/patch.ts b/src/git/models/patch.ts new file mode 100644 index 0000000000000..ad9319f8ad036 --- /dev/null +++ b/src/git/models/patch.ts @@ -0,0 +1,27 @@ +import type { Uri } from 'vscode'; +import type { GitCommit } from './commit'; +import type { GitDiffFiles } from './diff'; +import type { Repository } from './repository'; + +/** + * For a single commit `to` is the commit SHA and `from` is its parent `^` + * For a commit range `to` is the tip SHA and `from` is the base SHA + * For a WIP `to` is the "uncommitted" SHA and `from` is the current HEAD SHA + */ +export interface PatchRevisionRange { + from: string; + to: string; +} + +export interface GitPatch { + readonly type: 'local'; + readonly contents: string; + + readonly id?: undefined; + readonly uri?: Uri; + + baseRef?: string; + commit?: GitCommit; + files?: GitDiffFiles['files']; + repository?: Repository; +} diff --git a/src/git/models/pullRequest.ts b/src/git/models/pullRequest.ts index b78ed86ee30e8..133ba0fca626c 100644 --- a/src/git/models/pullRequest.ts +++ b/src/git/models/pullRequest.ts @@ -1,8 +1,9 @@ import { Container } from '../../container'; import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; -import type { IssueOrPullRequest, IssueOrPullRequestState as PullRequestState } from './issue'; -import type { RemoteProviderReference } from './remoteProvider'; +import type { IssueOrPullRequest, IssueRepository, IssueOrPullRequestState as PullRequestState } from './issue'; +import { shortenRevision } from './reference'; +import type { ProviderReference } from './remoteProvider'; export type { PullRequestState }; @@ -18,6 +19,27 @@ export const enum PullRequestMergeableState { Conflicting = 'Conflicting', } +export const enum PullRequestStatusCheckRollupState { + Success = 'success', + Pending = 'pending', + Failed = 'failed', +} + +export const enum PullRequestMergeMethod { + Merge = 'merge', + Squash = 'squash', + Rebase = 'rebase', +} + +export const enum PullRequestReviewState { + Approved = 'APPROVED', + ChangesRequested = 'CHANGES_REQUESTED', + Commented = 'COMMENTED', + Dismissed = 'DISMISSED', + Pending = 'PENDING', + ReviewRequested = 'REVIEW_REQUESTED', +} + export interface PullRequestRef { owner: string; repo: string; @@ -35,13 +57,14 @@ export interface PullRequestRefs { export interface PullRequestMember { name: string; - avatarUrl: string; - url: string; + avatarUrl?: string; + url?: string; } export interface PullRequestReviewer { - isCodeOwner: boolean; + isCodeOwner?: boolean; reviewer: PullRequestMember; + state: PullRequestReviewState; } export interface PullRequestShape extends IssueOrPullRequest { @@ -51,7 +74,6 @@ export interface PullRequestShape extends IssueOrPullRequest { readonly isDraft?: boolean; readonly additions?: number; readonly deletions?: number; - readonly comments?: number; readonly mergeableState?: PullRequestMergeableState; readonly reviewDecision?: PullRequestReviewDecision; readonly reviewRequests?: PullRequestReviewer[]; @@ -73,9 +95,11 @@ export function serializePullRequest(value: PullRequest): PullRequestShape { icon: value.provider.icon, }, id: value.id, + nodeId: value.nodeId, title: value.title, url: value.url, - date: value.date, + createdDate: value.createdDate, + updatedDate: value.updatedDate, closedDate: value.closedDate, closed: value.closed, author: { @@ -110,7 +134,8 @@ export function serializePullRequest(value: PullRequest): PullRequestShape { isDraft: value.isDraft, additions: value.additions, deletions: value.deletions, - comments: value.comments, + commentsCount: value.commentsCount, + thumbsUpCount: value.thumbsUpCount, reviewDecision: value.reviewDecision, reviewRequests: value.reviewRequests, assignees: value.assignees, @@ -119,35 +144,38 @@ export function serializePullRequest(value: PullRequest): PullRequestShape { } export class PullRequest implements PullRequestShape { - static is(pr: any): pr is PullRequest { - return pr instanceof PullRequest; - } - readonly type = 'pullrequest'; constructor( - public readonly provider: RemoteProviderReference, + public readonly provider: ProviderReference, public readonly author: { readonly name: string; - readonly avatarUrl: string; - readonly url: string; + readonly avatarUrl?: string; + readonly url?: string; }, public readonly id: string, + public readonly nodeId: string | undefined, public readonly title: string, public readonly url: string, + public readonly repository: IssueRepository, public readonly state: PullRequestState, - public readonly date: Date, + public readonly createdDate: Date, + public readonly updatedDate: Date, public readonly closedDate?: Date, public readonly mergedDate?: Date, public readonly mergeableState?: PullRequestMergeableState, + public readonly viewerCanUpdate?: boolean, public readonly refs?: PullRequestRefs, public readonly isDraft?: boolean, public readonly additions?: number, public readonly deletions?: number, - public readonly comments?: number, + public readonly commentsCount?: number, + public readonly thumbsUpCount?: number, public readonly reviewDecision?: PullRequestReviewDecision, public readonly reviewRequests?: PullRequestReviewer[], + public readonly latestReviews?: PullRequestReviewer[], public readonly assignees?: PullRequestMember[], + public readonly statusCheckRollupState?: PullRequestStatusCheckRollupState, ) {} get closed(): boolean { @@ -162,11 +190,11 @@ export class PullRequest implements PullRequestShape { @memoize(format => format ?? 'MMMM Do, YYYY h:mma') formatDate(format?: string | null) { - return formatDate(this.mergedDate ?? this.closedDate ?? this.date, format ?? 'MMMM Do, YYYY h:mma'); + return formatDate(this.mergedDate ?? this.closedDate ?? this.updatedDate, format ?? 'MMMM Do, YYYY h:mma'); } formatDateFromNow() { - return fromNow(this.mergedDate ?? this.closedDate ?? this.date); + return fromNow(this.mergedDate ?? this.closedDate ?? this.updatedDate); } @memoize(format => format ?? 'MMMM Do, YYYY h:mma') @@ -193,10 +221,42 @@ export class PullRequest implements PullRequestShape { @memoize(format => format ?? 'MMMM Do, YYYY h:mma') formatUpdatedDate(format?: string | null) { - return formatDate(this.date, format ?? 'MMMM Do, YYYY h:mma') ?? ''; + return formatDate(this.updatedDate, format ?? 'MMMM Do, YYYY h:mma') ?? ''; } formatUpdatedDateFromNow() { - return fromNow(this.date); + return fromNow(this.updatedDate); } } + +export function isPullRequest(pr: any): pr is PullRequest { + return pr instanceof PullRequest; +} + +export interface PullRequestComparisonRefs { + repoPath: string; + base: { ref: string; label: string }; + head: { ref: string; label: string }; +} + +export async function getComparisonRefsForPullRequest( + container: Container, + repoPath: string, + prRefs: PullRequestRefs, +): Promise { + const refs: PullRequestComparisonRefs = { + repoPath: repoPath, + base: { ref: prRefs.base.sha, label: `${prRefs.base.branch} (${shortenRevision(prRefs.base.sha)})` }, + head: { ref: prRefs.head.sha, label: prRefs.head.branch }, + }; + + // Find the merge base to show a more accurate comparison for the PR + const mergeBase = + (await container.git.getMergeBase(refs.repoPath, refs.base.ref, refs.head.ref, { forkPoint: true })) ?? + (await container.git.getMergeBase(refs.repoPath, refs.base.ref, refs.head.ref)); + if (mergeBase != null) { + refs.base = { ref: mergeBase, label: `${prRefs.base.branch} (${shortenRevision(mergeBase)})` }; + } + + return refs; +} diff --git a/src/git/models/rebase.ts b/src/git/models/rebase.ts index 81bf4d3b8a035..ccb8e59bb0d01 100644 --- a/src/git/models/rebase.ts +++ b/src/git/models/rebase.ts @@ -1,4 +1,4 @@ -import type { GitBranchReference, GitRevisionReference } from './reference'; +import type { GitBranchReference, GitRevisionReference, GitTagReference } from './reference'; export interface GitRebaseStatus { type: 'rebase'; @@ -6,11 +6,11 @@ export interface GitRebaseStatus { HEAD: GitRevisionReference; onto: GitRevisionReference; mergeBase: string | undefined; - current: GitBranchReference | undefined; + current: GitBranchReference | GitTagReference | undefined; incoming: GitBranchReference; steps: { - current: { number: number; commit: GitRevisionReference }; + current: { number: number; commit: GitRevisionReference | undefined }; total: number; }; } diff --git a/src/git/models/reference.ts b/src/git/models/reference.ts index b6cab92dd6a5b..df70ff52bf6cf 100644 --- a/src/git/models/reference.ts +++ b/src/git/models/reference.ts @@ -126,7 +126,7 @@ export interface GitStashReference { name: string; ref: string; repoPath: string; - number: string | undefined; + number: string; message?: string | undefined; stashOnRef?: string | undefined; @@ -233,20 +233,20 @@ export function getReferenceFromBranch(branch: GitBranchReference) { }); } -export function getReferenceFromRevision(revision: GitRevisionReference) { +export function getReferenceFromRevision(revision: GitRevisionReference, options?: { excludeMessage?: boolean }) { if (revision.refType === 'stash') { return createReference(revision.ref, revision.repoPath, { refType: revision.refType, name: revision.name, number: revision.number, - message: revision.message, + message: options?.excludeMessage ? undefined : revision.message, }); } return createReference(revision.ref, revision.repoPath, { refType: revision.refType, name: revision.name, - message: revision.message, + message: options?.excludeMessage ? undefined : revision.message, }); } @@ -301,6 +301,17 @@ export function isTagReference(ref: GitReference | undefined): ref is GitTagRefe return ref?.refType === 'tag'; } +export function getReferenceTypeLabel(ref: GitReference | undefined) { + switch (ref?.refType) { + case 'branch': + return 'Branch'; + case 'tag': + return 'Tag'; + default: + return 'Commit'; + } +} + export function getReferenceLabel( refs: GitReference | GitReference[] | undefined, options?: { capitalize?: boolean; expand?: boolean; icon?: boolean; label?: boolean; quoted?: boolean } | false, @@ -317,16 +328,26 @@ export function getReferenceLabel( const ref = Array.isArray(refs) ? refs[0] : refs; let refName = options?.quoted ? `'${ref.name}'` : ref.name; switch (ref.refType) { - case 'branch': + case 'branch': { if (ref.remote) { refName = `${getRemoteNameFromBranchName(refName)}: ${getBranchNameWithoutRemote(refName)}`; refName = options?.quoted ? `'${refName}'` : refName; } - result = `${options.label ? `${ref.remote ? 'remote ' : ''}branch ` : ''}${ - options.icon ? `$(git-branch)${GlyphChars.Space}${refName}` : refName - }`; + let label; + if (options.label) { + if (options.capitalize && options.expand) { + label = `${ref.remote ? 'Remote ' : ''}Branch `; + } else { + label = `${ref.remote ? 'remote ' : ''}branch `; + } + } else { + label = ''; + } + + result = `${label}${options.icon ? `$(git-branch)${GlyphChars.Space}${refName}` : refName}`; break; + } case 'tag': result = `${options.label ? 'tag ' : ''}${ options.icon ? `$(tag)${GlyphChars.Space}${refName}` : refName @@ -346,7 +367,7 @@ export function getReferenceLabel( result = `${options.label ? 'stash ' : ''}${ options.icon ? `$(archive)${GlyphChars.Space}${message ?? ref.name}` - : `${message ?? (ref.number ? `#${ref.number}` : ref.name)}` + : message ?? (ref.number ? `#${ref.number}` : ref.name) }`; } else if (isRevisionRange(ref.ref)) { result = refName; diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts index 4f4bd7acda69a..27930d9b2fdd2 100644 --- a/src/git/models/remote.ts +++ b/src/git/models/remote.ts @@ -2,56 +2,19 @@ import type { ColorTheme } from 'vscode'; import { Uri, window } from 'vscode'; import { GlyphChars } from '../../constants'; import { Container } from '../../container'; +import type { HostingIntegration } from '../../plus/integrations/integration'; import { memoize } from '../../system/decorators/memoize'; import { equalsIgnoreCase, sortCompare } from '../../system/string'; import { isLightTheme } from '../../system/utils'; import { parseGitRemoteUrl } from '../parsers/remoteParser'; import type { RemoteProvider } from '../remotes/remoteProvider'; -import type { RichRemoteProvider } from '../remotes/richRemoteProvider'; +import { getRemoteProviderThemeIconString } from '../remotes/remoteProvider'; export type GitRemoteType = 'fetch' | 'push'; -export class GitRemote { - static getHighlanderProviders(remotes: GitRemote[]) { - if (remotes.length === 0) return undefined; - - const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); - if (remote != null) return [remote.provider]; - - const providerName = remotes[0].provider.name; - if (remotes.every(r => r.provider.name === providerName)) return remotes.map(r => r.provider); - - return undefined; - } - - static getHighlanderProviderName(remotes: GitRemote[]) { - if (remotes.length === 0) return undefined; - - const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); - if (remote != null) return remote.provider.name; - - const providerName = remotes[0].provider.name; - // Only use the real provider name if there is only 1 type of provider - if (remotes.every(r => r.provider.name === providerName)) return providerName; - - return undefined; - } - - static is(remote: any): remote is GitRemote { - return remote instanceof GitRemote; - } - - static sort(remotes: GitRemote[]) { - return remotes.sort( - (a, b) => - (a.default ? -1 : 1) - (b.default ? -1 : 1) || - (a.name === 'origin' ? -1 : 1) - (b.name === 'origin' ? -1 : 1) || - (a.name === 'upstream' ? -1 : 1) - (b.name === 'upstream' ? -1 : 1) || - sortCompare(a.name, b.name), - ); - } - +export class GitRemote { constructor( + private readonly container: Container, public readonly repoPath: string, public readonly name: string, public readonly scheme: string, @@ -77,6 +40,10 @@ export class GitRemote { - return this.provider?.hasRichIntegration() ?? false; + async getIntegration(): Promise { + return this.provider != null ? this.container.integrations.getByRemote(this) : undefined; } - get maybeConnected(): boolean | undefined { - return this.provider == null ? false : this.provider.maybeConnected; + hasIntegration(): this is GitRemote { + return this.provider != null && this.container.integrations.supports(this.provider.id); } matches(url: string): boolean; @@ -128,6 +95,31 @@ export class GitRemote[]) { + if (remotes.length === 0) return undefined; + + const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); + if (remote != null) return [remote.provider]; + + const providerName = remotes[0].provider.name; + if (remotes.every(r => r.provider.name === providerName)) return remotes.map(r => r.provider); + + return undefined; +} + +export function getHighlanderProviderName(remotes: GitRemote[]) { + if (remotes.length === 0) return undefined; + + const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); + if (remote != null) return remote.provider.name; + + const providerName = remotes[0].provider.name; + // Only use the real provider name if there is only 1 type of provider + if (remotes.every(r => r.provider.name === providerName)) return providerName; + + return undefined; +} + export function getRemoteArrowsGlyph(remote: GitRemote): GlyphChars { let arrows; let left; @@ -157,19 +149,6 @@ export function getRemoteArrowsGlyph(remote: GitRemote): GlyphChars { return arrows; } -export function getRemoteUpstreamDescription(remote: GitRemote): string { - const arrows = getRemoteArrowsGlyph(remote); - - const { provider } = remote; - if (provider != null) { - return `${arrows}${GlyphChars.Space} ${provider.name} ${GlyphChars.Space}${GlyphChars.Dot}${GlyphChars.Space} ${provider.displayPath}`; - } - - return `${arrows}${GlyphChars.Space} ${ - remote.domain ? `${remote.domain} ${GlyphChars.Space}${GlyphChars.Dot}${GlyphChars.Space} ` : '' - }${remote.path}`; -} - export function getRemoteIconUri( container: Container, remote: GitRemote, @@ -185,6 +164,23 @@ export function getRemoteIconUri( return asWebviewUri != null ? asWebviewUri(uri) : uri; } +export function getRemoteThemeIconString(remote: GitRemote | undefined): string { + return getRemoteProviderThemeIconString(remote?.provider); +} + +export function getRemoteUpstreamDescription(remote: GitRemote): string { + const arrows = getRemoteArrowsGlyph(remote); + + const { provider } = remote; + if (provider != null) { + return `${arrows}${GlyphChars.Space} ${provider.name} ${GlyphChars.Space}${GlyphChars.Dot}${GlyphChars.Space} ${provider.displayPath}`; + } + + return `${arrows}${GlyphChars.Space} ${ + remote.domain ? `${remote.domain} ${GlyphChars.Space}${GlyphChars.Dot}${GlyphChars.Space} ` : '' + }${remote.path}`; +} + export function getVisibilityCacheKey(remote: GitRemote): string; export function getVisibilityCacheKey(remotes: GitRemote[]): string; export function getVisibilityCacheKey(remotes: GitRemote | GitRemote[]): string { @@ -194,3 +190,17 @@ export function getVisibilityCacheKey(remotes: GitRemote | GitRemote[]): string .sort() .join(','); } + +export function isRemote(remote: any): remote is GitRemote { + return remote instanceof GitRemote; +} + +export function sortRemotes(remotes: T[]) { + return remotes.sort( + (a, b) => + (a.default ? -1 : 1) - (b.default ? -1 : 1) || + (a.name === 'origin' ? -1 : 1) - (b.name === 'origin' ? -1 : 1) || + (a.name === 'upstream' ? -1 : 1) - (b.name === 'upstream' ? -1 : 1) || + sortCompare(a.name, b.name), + ); +} diff --git a/src/git/models/remoteProvider.ts b/src/git/models/remoteProvider.ts index 4e76512a0262e..644db72aee269 100644 --- a/src/git/models/remoteProvider.ts +++ b/src/git/models/remoteProvider.ts @@ -1,6 +1,12 @@ -export interface RemoteProviderReference { +export interface ProviderReference { readonly id: string; readonly name: string; readonly domain: string; readonly icon: string; } + +export interface Provider extends ProviderReference { + getIgnoreSSLErrors(): boolean | 'force'; + reauthenticate(): Promise; + trackRequestException(): void; +} diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index 5975f8e6f8e92..866de40c8200f 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -1,28 +1,30 @@ import type { CancellationToken, ConfigurationChangeEvent, Event, Uri, WorkspaceFolder } from 'vscode'; import { Disposable, EventEmitter, ProgressLocation, RelativePattern, window, workspace } from 'vscode'; -import { md5 } from '@env/crypto'; -import { ForcePushMode } from '../../@types/vscode.git.enums'; +import { md5, uuid } from '@env/crypto'; import type { CreatePullRequestActionContext } from '../../api/gitlens'; -import type { CoreGitConfiguration } from '../../constants'; +import type { RepositoriesSorting } from '../../config'; import { Schemes } from '../../constants'; import type { Container } from '../../container'; import type { FeatureAccess, Features, PlusFeatures } from '../../features'; import { showCreatePullRequestPrompt, showGenericErrorMessage } from '../../messages'; +import type { HostingIntegration } from '../../plus/integrations/integration'; +import type { RepoComparisonKey } from '../../repositories'; import { asRepoComparisonKey } from '../../repositories'; -import { groupByMap } from '../../system/array'; -import { executeActionCommand, executeCoreGitCommand } from '../../system/command'; +import { executeActionCommand } from '../../system/command'; import { configuration } from '../../system/configuration'; import { formatDate, fromNow } from '../../system/date'; import { gate } from '../../system/decorators/gate'; import { debug, log, logName } from '../../system/decorators/log'; +import type { Deferrable } from '../../system/function'; import { debounce } from '../../system/function'; -import { filter, join, some } from '../../system/iterable'; +import { filter, groupByMap, join, map, min, some } from '../../system/iterable'; import { getLoggableName, Logger } from '../../system/logger'; import { getLogScope } from '../../system/logger.scope'; import { updateRecordValue } from '../../system/object'; import { basename, normalizePath } from '../../system/path'; -import type { GitDir, GitProviderDescriptor, GitRepositoryCaches } from '../gitProvider'; -import type { RichRemoteProvider } from '../remotes/richRemoteProvider'; +import { sortCompare } from '../../system/string'; +import type { GitDir, GitProviderDescriptor, GitRepositoryCaches, PagingOptions } from '../gitProvider'; +import type { RemoteProvider } from '../remotes/remoteProvider'; import type { GitSearch, SearchQuery } from '../search'; import type { BranchSortOptions, GitBranch } from './branch'; import { getBranchNameWithoutRemote, getRemoteNameFromBranchName } from './branch'; @@ -40,6 +42,10 @@ import type { GitStatus } from './status'; import type { GitTag, TagSortOptions } from './tag'; import type { GitWorktree } from './worktree'; +export interface RepositoriesSortOptions { + orderBy?: RepositoriesSorting; +} + const emptyArray = Object.freeze([]) as unknown as any[]; const millisecondsPerMinute = 60 * 1000; @@ -66,9 +72,7 @@ export const enum RepositoryChange { Remotes = 5, Worktrees = 6, Config = 7, - /* - * Union of Cherry, Merge, and Rebase - */ + /** Union of Cherry, Merge, and Rebase */ Status = 8, CherryPick = 9, Merge = 10, @@ -84,10 +88,12 @@ export const enum RepositoryChange { export const enum RepositoryChangeComparisonMode { Any, - All, Exclusive, } +const defaultFileSystemChangeDelay = 2500; +const defaultRepositoryChangeDelay = 250; + export class RepositoryChangeEvent { private readonly _changes: Set; @@ -175,8 +181,32 @@ export class Repository implements Disposable { : 0; } - static sort(repositories: Repository[]) { - return repositories.sort((a, b) => (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || a.index - b.index); + static sort(repositories: Repository[], options?: RepositoriesSortOptions) { + options = { orderBy: configuration.get('sortRepositoriesBy'), ...options }; + + switch (options.orderBy) { + case 'name:asc': + return repositories.sort( + (a, b) => (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || sortCompare(a.name, b.name), + ); + case 'name:desc': + return repositories.sort( + (a, b) => (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || sortCompare(b.name, a.name), + ); + case 'lastFetched:asc': + return repositories.sort( + (a, b) => + (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (a._lastFetched ?? 0) - (b._lastFetched ?? 0), + ); + case 'lastFetched:desc': + return repositories.sort( + (a, b) => + (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (b._lastFetched ?? 0) - (a._lastFetched ?? 0), + ); + case 'discovered': + default: + return repositories; + } } private _onDidChange = new EventEmitter(); @@ -194,9 +224,13 @@ export class Repository implements Disposable { return this.name; } - readonly id: string; + readonly id: RepoComparisonKey; readonly index: number; - readonly name: string; + + private _name: string; + get name(): string { + return this._name; + } private _idHash: string | undefined; get idHash() { @@ -208,10 +242,8 @@ export class Repository implements Disposable { private _branch: Promise | undefined; private readonly _disposable: Disposable; - private _fireChangeDebounced: (() => void) | undefined = undefined; - private _fireFileSystemChangeDebounced: (() => void) | undefined = undefined; - private _fsWatchCounter = 0; - private _fsWatcherDisposable: Disposable | undefined; + private _fireChangeDebounced: Deferrable<() => void> | undefined = undefined; + private _fireFileSystemChangeDebounced: Deferrable<() => void> | undefined = undefined; private _pendingFileSystemChange?: RepositoryFileSystemChangeEvent; private _pendingRepoChange?: RepositoryChangeEvent; private _suspended: boolean; @@ -226,16 +258,15 @@ export class Repository implements Disposable { suspended: boolean, closed: boolean = false, ) { - folder = workspace.getWorkspaceFolder(uri) ?? folder; if (folder != null) { if (root) { - this.name = folder.name; + this._name = folder.name; } else { const relativePath = container.git.getRelativePath(uri, folder.uri); - this.name = relativePath ? relativePath : folder.name; + this._name = relativePath ? relativePath : folder.name; } } else { - this.name = basename(uri.path); + this._name = basename(uri.path); // TODO@eamodio should we create a fake workspace folder? // folder = { @@ -244,6 +275,22 @@ export class Repository implements Disposable { // index: container.git.repositoryCount, // }; } + + // Update the name if it is a worktree + void this.getGitDir().then(gd => { + if (gd?.commonUri == null) return; + + let path = gd.commonUri.path; + if (path.endsWith('/.git')) { + path = path.substring(0, path.length - 5); + } + + const prefix = `${basename(path)}: `; + if (!this._name.startsWith(prefix)) { + this._name = `${prefix}${this._name}`; + } + }); + this.index = folder?.index ?? container.git.repositoryCount; this.id = asRepoComparisonKey(uri); @@ -270,6 +317,9 @@ export class Repository implements Disposable { ); this.onConfigurationChanged(); + if (this._orderByLastFetched) { + void this.getLastFetched(); + } } private setupRepoWatchers() { @@ -336,13 +386,13 @@ export class Repository implements Disposable { } dispose() { - this.stopWatchingFileSystem(); + this.unWatchFileSystem(true); this._disposable.dispose(); } toString(): string { - return `${getLoggableName(this)}(${this.id})`; + return getLoggableName(this); } get virtual(): boolean { @@ -357,12 +407,21 @@ export class Repository implements Disposable { return this._updatedAt; } + private _orderByLastFetched = false; + get orderByLastFetched(): boolean { + return this._orderByLastFetched; + } + private _updatedAt: number = 0; get updatedAt(): number { return this._updatedAt; } private onConfigurationChanged(e?: ConfigurationChangeEvent) { + if (configuration.changed(e, 'sortRepositoriesBy')) { + this._orderByLastFetched = configuration.get('sortRepositoriesBy')?.startsWith('lastFetched:') ?? false; + } + if (e != null && configuration.changed(e, 'remotes', this.folder?.uri)) { this.resetCaches('remotes'); this.fireChange(RepositoryChange.Remotes); @@ -390,6 +449,9 @@ export class Repository implements Disposable { } this._lastFetched = undefined; + if (this._orderByLastFetched) { + void this.getLastFetched(); + } const match = uri != null @@ -472,6 +534,7 @@ export class Repository implements Disposable { const changed = this._closed !== value; this._closed = value; if (changed) { + Logger.debug(`Repository(${this.id}).closed(${value})`); this.fireChange(this._closed ? RepositoryChange.Closed : RepositoryChange.Opened); } } @@ -593,11 +656,7 @@ export class Repository implements Disposable { remote?: string; }) { try { - if (options?.branch != null || configuration.get('experimental.nativeGit')) { - await this.container.git.fetch(this.uri, options); - } else { - void (await executeCoreGitCommand('git.fetch', this.path)); - } + await this.container.git.fetch(this.uri, options); this.fireChange(RepositoryChange.Unknown); } catch (ex) { @@ -606,6 +665,13 @@ export class Repository implements Disposable { } } + async getBestRemoteWithIntegration(options?: { + filter?: (remote: GitRemote, integration: HostingIntegration) => boolean; + includeDisconnected?: boolean; + }): Promise | undefined> { + return this.container.git.getBestRemoteWithIntegration(this.uri, options); + } + async getBranch(name?: string): Promise { if (name) { const { @@ -622,7 +688,7 @@ export class Repository implements Disposable { getBranches(options?: { filter?: (b: GitBranch) => boolean; - paging?: { cursor?: string; limit?: number }; + paging?: PagingOptions; sort?: boolean | BranchSortOptions; }) { return this.container.git.getBranches(this.uri, options); @@ -636,7 +702,38 @@ export class Repository implements Disposable { return this.container.git.getCommit(this.uri, ref); } - getContributors(options?: { all?: boolean; ref?: string; stats?: boolean }): Promise { + @gate() + @log({ exit: true }) + async getCommonRepository(): Promise { + const gitDir = await this.getGitDir(); + if (gitDir?.commonUri == null) return this; + + // If the repository isn't already opened, then open it as a "closed" repo (won't show up in the UI) + return this.container.git.getOrOpenRepository(gitDir.commonUri, { + detectNested: false, + force: true, + closeOnOpen: true, + }); + } + + @log({ exit: true }) + async getCommonRepositoryUri(): Promise { + const gitDir = await this.getGitDir(); + if (gitDir?.commonUri?.path.endsWith('/.git')) { + return gitDir.commonUri.with({ + path: gitDir.commonUri.path.substring(0, gitDir.commonUri.path.length - 5), + }); + } + + return gitDir?.commonUri; + } + + getContributors(options?: { + all?: boolean; + merges?: boolean | 'first-parent'; + ref?: string; + stats?: boolean; + }): Promise { return this.container.git.getContributors(this.uri, options); } @@ -652,37 +749,15 @@ export class Repository implements Disposable { private _lastFetched: number | undefined; @gate() async getLastFetched(): Promise { - if (this._lastFetched == null) { - if (!(await this.hasRemotes())) return 0; - } - - try { - const lastFetched = await this.container.git.getLastFetchedTimestamp(this.uri); - // If we don't get a number, assume the fetch failed, and don't update the timestamp - if (lastFetched != null) { - this._lastFetched = lastFetched; - } - } catch { - this._lastFetched = undefined; + const lastFetched = await this.container.git.getLastFetchedTimestamp(this.uri); + // If we don't get a number, assume the fetch failed, and don't update the timestamp + if (lastFetched != null) { + this._lastFetched = lastFetched; } return this._lastFetched ?? 0; } - @gate() - @log({ exit: true }) - async getMainRepository(): Promise { - const gitDir = await this.getGitDir(); - if (gitDir?.commonUri == null) return this; - - // If the repository isn't already opened, then open it as a "closed" repo (won't show up in the UI) - return this.container.git.getOrOpenRepository(gitDir.commonUri, { - detectNested: false, - force: true, - closeOnOpen: true, - }); - } - getMergeStatus(): Promise { return this.container.git.getMergeStatus(this.uri); } @@ -703,10 +778,6 @@ export class Repository implements Disposable { return options?.filter != null ? remotes.filter(options.filter) : remotes; } - async getRichRemote(connectedOnly: boolean = false): Promise | undefined> { - return this.container.git.getBestRemoteWithRichProvider(this.uri, { includeDisconnected: !connectedOnly }); - } - getStash(): Promise { return this.container.git.getStash(this.uri); } @@ -722,7 +793,7 @@ export class Repository implements Disposable { return tag; } - getTags(options?: { filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }) { + getTags(options?: { filter?: (t: GitTag) => boolean; paging?: PagingOptions; sort?: boolean | TagSortOptions }) { return this.container.git.getTags(this.uri, options); } @@ -753,8 +824,11 @@ export class Repository implements Disposable { return remotes?.length > 0; } - async hasRichRemote(connectedOnly: boolean = false): Promise { - const remote = await this.getRichRemote(connectedOnly); + async hasRemoteWithIntegration(options?: { + filter?: (remote: GitRemote, integration: HostingIntegration) => boolean; + includeDisconnected?: boolean; + }): Promise { + const remote = await this.getBestRemoteWithIntegration(options); return remote?.provider != null; } @@ -795,22 +869,13 @@ export class Repository implements Disposable { private async pullCore(options?: { rebase?: boolean }) { try { - if (configuration.get('experimental.nativeGit')) { - const withTags = configuration.getAny('git.pullTags', this.uri); - if (configuration.getAny('git.fetchOnPull', this.uri)) { - await this.container.git.fetch(this.uri); - } - - await this.container.git.pull(this.uri, { ...options, tags: withTags }); - } else { - const upstream = await this.hasUpstreamBranch(); - if (upstream) { - void (await executeCoreGitCommand(options?.rebase ? 'git.pullRebase' : 'git.pull', this.path)); - } else if (configuration.getAny('git.fetchOnPull', this.uri)) { - await this.container.git.fetch(this.uri); - } + const withTags = configuration.getCore('git.pullTags', this.uri); + if (configuration.getCore('git.fetchOnPull', this.uri)) { + await this.container.git.fetch(this.uri); } + await this.container.git.pull(this.uri, { ...options, tags: withTags }); + this.fireChange(RepositoryChange.Unknown); } catch (ex) { Logger.error(ex); @@ -855,9 +920,7 @@ export class Repository implements Disposable { force?: boolean; progress?: boolean; reference?: GitReference; - publish?: { - remote: string; - }; + publish?: { remote: string }; }) { const { progress, ...opts } = { progress: true, ...options }; if (!progress) return this.pushCore(opts); @@ -873,54 +936,16 @@ export class Repository implements Disposable { ); } - private async pushCore(options?: { - force?: boolean; - reference?: GitReference; - publish?: { - remote: string; - }; - }) { + private async pushCore(options?: { force?: boolean; reference?: GitReference; publish?: { remote: string } }) { try { - if (configuration.get('experimental.nativeGit')) { - const branch = await this.getBranch(options?.reference?.name); - await this.container.git.push(this.uri, { - force: options?.force, - branch: isBranchReference(options?.reference) ? options?.reference : branch, - ...(options?.publish && { publish: options.publish }), - }); - } else if (isBranchReference(options?.reference)) { - const repo = await this.container.git.getOrOpenScmRepository(this.uri); - if (repo == null) return; - - if (options?.publish != null) { - await repo?.push(options.publish.remote, options.reference.name, true); - void this.showCreatePullRequestPrompt(options.publish.remote, options.reference); - } else { - const branch = await this.getBranch(options?.reference.name); - if (branch == null) return; - - await repo?.push( - branch.getRemoteName(), - branch.name, - undefined, - options?.force ? ForcePushMode.ForceWithLease : undefined, - ); - } - } else if (options?.reference != null) { - const repo = await this.container.git.getOrOpenScmRepository(this.uri); - if (repo == null) return; - - const branch = await this.getBranch(); - if (branch == null) return; - - await repo?.push( - branch.getRemoteName(), - `${options.reference.ref}:${branch.getNameWithoutRemote()}`, - undefined, - options?.force ? ForcePushMode.ForceWithLease : undefined, - ); - } else { - void (await executeCoreGitCommand(options?.force ? 'git.pushForce' : 'git.push', this.path)); + await this.container.git.push(this.uri, { + reference: options?.reference, + force: options?.force, + publish: options?.publish, + }); + + if (isBranchReference(options?.reference) && options?.publish != null) { + void this.showCreatePullRequestPrompt(options.publish.remote, options.reference); } this.fireChange(RepositoryChange.Unknown); @@ -968,7 +993,7 @@ export class Repository implements Disposable { } if (this._pendingFileSystemChange != null) { - this._fireFileSystemChangeDebounced!(); + this._fireFileSystemChangeDebounced?.(); } } @@ -1038,12 +1063,20 @@ export class Repository implements Disposable { message?: string, uris?: Uri[], options?: { includeUntracked?: boolean; keepIndex?: boolean; onlyStaged?: boolean }, - ) { + ): Promise { await this.container.git.stashSave(this.uri, message, uris, options); this.fireChange(RepositoryChange.Stash); } + @gate() + @log() + async stashSaveSnapshot(message?: string): Promise { + await this.container.git.stashSaveSnapshot(this.uri, message); + + this.fireChange(RepositoryChange.Stash); + } + @gate() @log() async switch(ref: string, options?: { createBranch?: string | undefined; progress?: boolean }) { @@ -1104,8 +1137,32 @@ export class Repository implements Disposable { return this._etagFileSystem; } - startWatchingFileSystem(): Disposable { - this._fsWatchCounter++; + suspend() { + this._suspended = true; + } + + @log() + tag(...args: string[]) { + void this.runTerminalCommand('tag', ...args); + } + + @log() + tagDelete(tags: GitTagReference | GitTagReference[]) { + if (!Array.isArray(tags)) { + tags = [tags]; + } + + const args = ['--delete']; + void this.runTerminalCommand('tag', ...args, ...tags.map(t => t.ref)); + } + + private _fsWatcherDisposable: Disposable | undefined; + private _fsWatchers = new Map(); + private _fsChangeDelay: number = defaultFileSystemChangeDelay; + + watchFileSystem(delay: number = defaultFileSystemChangeDelay): Disposable { + const id = uuid(); + this._fsWatchers.set(id, delay); if (this._fsWatcherDisposable == null) { const watcher = workspace.createFileSystemWatcher(new RelativePattern(this.uri, '**')); this._fsWatcherDisposable = Disposable.from( @@ -1118,36 +1175,35 @@ export class Repository implements Disposable { this._etagFileSystem = Date.now(); } - return { dispose: () => this.stopWatchingFileSystem() }; + this.ensureMinFileSystemChangeDelay(); + + return { dispose: () => this.unWatchFileSystem(id) }; } - stopWatchingFileSystem(force: boolean = false) { - if (this._fsWatcherDisposable == null) return; - if (--this._fsWatchCounter > 0 && !force) return; + private unWatchFileSystem(forceOrId: true | string) { + if (typeof forceOrId !== 'boolean') { + this._fsWatchers.delete(forceOrId); + if (this._fsWatchers.size !== 0) { + this.ensureMinFileSystemChangeDelay(); + return; + } + } this._etagFileSystem = undefined; - this._fsWatchCounter = 0; - this._fsWatcherDisposable.dispose(); + this._fsChangeDelay = defaultFileSystemChangeDelay; + this._fsWatchers.clear(); + this._fsWatcherDisposable?.dispose(); this._fsWatcherDisposable = undefined; } - suspend() { - this._suspended = true; - } + private ensureMinFileSystemChangeDelay() { + const minDelay = min(this._fsWatchers.values()); + if (minDelay === this._fsChangeDelay) return; - @log() - tag(...args: string[]) { - void this.runTerminalCommand('tag', ...args); - } - - @log() - tagDelete(tags: GitTagReference | GitTagReference[]) { - if (!Array.isArray(tags)) { - tags = [tags]; - } - - const args = ['--delete']; - void this.runTerminalCommand('tag', ...args, ...tags.map(t => t.ref)); + this._fsChangeDelay = minDelay; + this._fireFileSystemChangeDebounced?.flush(); + this._fireFileSystemChangeDebounced?.cancel(); + this._fireFileSystemChangeDebounced = undefined; } @debug() @@ -1157,7 +1213,7 @@ export class Repository implements Disposable { this._updatedAt = Date.now(); if (this._fireChangeDebounced == null) { - this._fireChangeDebounced = debounce(this.fireChangeCore.bind(this), 250); + this._fireChangeDebounced = debounce(this.fireChangeCore.bind(this), defaultRepositoryChangeDelay); } this._pendingRepoChange = this._pendingRepoChange?.with(changes) ?? new RepositoryChangeEvent(this, changes); @@ -1190,7 +1246,10 @@ export class Repository implements Disposable { this._updatedAt = Date.now(); if (this._fireFileSystemChangeDebounced == null) { - this._fireFileSystemChangeDebounced = debounce(this.fireFileSystemChangeCore.bind(this), 2500); + this._fireFileSystemChangeDebounced = debounce( + this.fireFileSystemChangeCore.bind(this), + this._fsChangeDelay, + ); } if (this._pendingFileSystemChange == null) { @@ -1236,3 +1295,40 @@ export class Repository implements Disposable { export function isRepository(repository: unknown): repository is Repository { return repository instanceof Repository; } + +export async function groupRepositories(repositories: Repository[]): Promise>> { + const repos = new Map(repositories.map(r => [r.id, r])); + + // Group worktree repos under the common repo when the common repo is also in the list + const result = new Map }>(); + for (const [, repo] of repos) { + const commonUri = await repo.getCommonRepositoryUri(); + if (commonUri == null) { + if (result.has(repo.id)) { + debugger; + } + result.set(repo.id, { repo: repo, worktrees: new Map() }); + continue; + } + + const commonId = asRepoComparisonKey(commonUri); + const commonRepo = repos.get(commonId); + if (commonRepo == null) { + if (result.has(repo.id)) { + debugger; + } + result.set(repo.id, { repo: repo, worktrees: new Map() }); + continue; + } + + let r = result.get(commonRepo.id); + if (r == null) { + r = { repo: commonRepo, worktrees: new Map() }; + result.set(commonRepo.id, r); + } else { + r.worktrees.set(repo.path, repo); + } + } + + return new Map(map(result, ([, r]) => [r.repo, r.worktrees])); +} diff --git a/src/git/models/repositoryMetadata.ts b/src/git/models/repositoryMetadata.ts index 393040e7cc4e4..44002f99d2ec0 100644 --- a/src/git/models/repositoryMetadata.ts +++ b/src/git/models/repositoryMetadata.ts @@ -1,7 +1,7 @@ -import type { RemoteProviderReference } from './remoteProvider'; +import type { ProviderReference } from './remoteProvider'; export interface RepositoryMetadata { - provider: RemoteProviderReference; + provider: ProviderReference; owner: string; name: string; isFork: boolean; diff --git a/src/git/models/status.ts b/src/git/models/status.ts index b30f784644ffc..408ab6a0b5ea8 100644 --- a/src/git/models/status.ts +++ b/src/git/models/status.ts @@ -11,7 +11,6 @@ import type { GitFile, GitFileStatus } from './file'; import { getGitFileFormattedDirectory, getGitFileFormattedPath, - getGitFileStatusCodicon, getGitFileStatusText, GitFileChange, GitFileConflictStatus, @@ -44,7 +43,7 @@ export class GitStatus { public readonly sha: string, public readonly files: GitStatusFile[], public readonly state: GitTrackingState, - public readonly upstream?: string, + public readonly upstream?: { name: string; missing: boolean }, public readonly rebasing: boolean = false, ) { this.detached = isDetachedHead(branch); @@ -254,11 +253,11 @@ export class GitStatus { const remotes = await Container.instance.git.getRemotesWithProviders(this.repoPath); if (remotes.length === 0) return undefined; - const remoteName = getRemoteNameFromBranchName(this.upstream); + const remoteName = getRemoteNameFromBranchName(this.upstream?.name); return remotes.find(r => r.name === remoteName); } - getUpstreamStatus(options: { + getUpstreamStatus(options?: { empty?: string; expand?: boolean; icons?: boolean; @@ -266,11 +265,7 @@ export class GitStatus { separator?: string; suffix?: string; }): string { - return getUpstreamStatus( - this.upstream ? { name: this.upstream, missing: false } : undefined, - this.state, - options, - ); + return getUpstreamStatus(this.upstream, this.state, options); } } @@ -312,7 +307,7 @@ export function getUpstreamStatus( status += `${status.length === 0 ? '' : separator}${pluralize('commit', state.ahead, { infix: icons ? '$(arrow-up) ' : undefined, })} ahead`; - if (suffix.startsWith(` ${upstream.name.split('/')[0]}`)) { + if (suffix.includes(upstream.name.split('/')[0])) { status += ' of'; } } @@ -392,7 +387,7 @@ export class GitStatusFile implements GitFile { switch (y) { case 'A': - case '?': + // case '?': this.workingTreeStatus = GitFileWorkingTreeStatus.Added; break; case 'D': @@ -435,10 +430,6 @@ export class GitStatusFile implements GitFile { return getGitFileFormattedPath(this, options); } - getOcticon() { - return getGitFileStatusCodicon(this.status); - } - getStatusText(): string { return getGitFileStatusText(this.status); } @@ -447,6 +438,15 @@ export class GitStatusFile implements GitFile { const now = new Date(); if (this.conflicted) { + const file = new GitFileChange( + this.repoPath, + this.path, + this.status, + this.originalPath, + 'HEAD', + undefined, + false, + ); return [ new GitCommit( container, @@ -457,15 +457,7 @@ export class GitStatusFile implements GitFile { 'Uncommitted changes', ['HEAD'], 'Uncommitted changes', - new GitFileChange( - this.repoPath, - this.path, - this.status, - this.originalPath, - 'HEAD', - undefined, - false, - ), + { file: file, files: [file] }, undefined, [], ), @@ -477,6 +469,15 @@ export class GitStatusFile implements GitFile { if (this.wip) { const previousSha = staged ? uncommittedStaged : 'HEAD'; + const file = new GitFileChange( + this.repoPath, + this.path, + this.status, + this.originalPath, + previousSha, + undefined, + false, + ); commits.push( new GitCommit( container, @@ -487,15 +488,7 @@ export class GitStatusFile implements GitFile { 'Uncommitted changes', [previousSha], 'Uncommitted changes', - new GitFileChange( - this.repoPath, - this.path, - this.status, - this.originalPath, - previousSha, - undefined, - false, - ), + { file: file, files: [file] }, undefined, [], ), @@ -506,6 +499,15 @@ export class GitStatusFile implements GitFile { } if (staged) { + const file = new GitFileChange( + this.repoPath, + this.path, + this.status, + this.originalPath, + 'HEAD', + undefined, + true, + ); commits.push( new GitCommit( container, @@ -516,15 +518,7 @@ export class GitStatusFile implements GitFile { 'Uncommitted changes', ['HEAD'], 'Uncommitted changes', - new GitFileChange( - this.repoPath, - this.path, - this.status, - this.originalPath, - 'HEAD', - undefined, - true, - ), + { file: file, files: [file] }, undefined, [], ), diff --git a/src/git/models/tree.ts b/src/git/models/tree.ts index 6eaee3cb2a018..ec2f2ad4a3f51 100644 --- a/src/git/models/tree.ts +++ b/src/git/models/tree.ts @@ -1,6 +1,14 @@ export interface GitTreeEntry { - commitSha: string; + ref: string; + oid: string; path: string; size: number; type: 'blob' | 'tree'; } + +export interface GitLsFilesEntry { + mode: string; + oid: string; + path: string; + stage: number; +} diff --git a/src/git/models/worktree.ts b/src/git/models/worktree.ts index 4c38a1a947bd9..23e35049a9e4b 100644 --- a/src/git/models/worktree.ts +++ b/src/git/models/worktree.ts @@ -1,19 +1,24 @@ -import type { Uri, WorkspaceFolder } from 'vscode'; -import { workspace } from 'vscode'; +import type { QuickInputButton, Uri, WorkspaceFolder } from 'vscode'; +import { ThemeIcon, workspace } from 'vscode'; +import type { BranchSorting } from '../../config'; +import { GlyphChars } from '../../constants'; import { Container } from '../../container'; +import type { QuickPickItemOfT } from '../../quickpicks/items/common'; +import { configuration } from '../../system/configuration'; +import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; +import { PageableResult } from '../../system/paging'; import { normalizePath, relative } from '../../system/path'; +import { pad, sortCompare } from '../../system/string'; +import { getWorkspaceFriendlyPath } from '../../system/utils'; import type { GitBranch } from './branch'; import { shortenRevision } from './reference'; import type { Repository } from './repository'; import type { GitStatus } from './status'; export class GitWorktree { - static is(worktree: any): worktree is GitWorktree { - return worktree instanceof GitWorktree; - } - constructor( + private readonly container: Container, public readonly main: boolean, public readonly type: 'bare' | 'branch' | 'detached', public readonly repoPath: string, @@ -21,9 +26,32 @@ export class GitWorktree { public readonly locked: boolean | string, public readonly prunable: boolean | string, public readonly sha?: string, - public readonly branch?: string, + public readonly branch?: GitBranch, ) {} + get date(): Date | undefined { + return this.branch?.date; + } + + @memoize() + get friendlyPath(): string { + const folder = this.workspaceFolder; + if (folder != null) return getWorkspaceFriendlyPath(this.uri); + + const relativePath = normalizePath(relative(this.repoPath, this.uri.fsPath)); + return relativePath || normalizePath(this.uri.fsPath); + } + + get formattedDate(): string { + return this.container.BranchDateFormatting.dateStyle === 'absolute' + ? this.formatDate(this.container.BranchDateFormatting.dateFormat) + : this.formatDateFromNow(); + } + + get hasChanges(): boolean | undefined { + return this._status?.hasChanges; + } + get opened(): boolean { return this.workspaceFolder?.uri.toString() === this.uri.toString(); } @@ -35,77 +63,270 @@ export class GitWorktree { case 'detached': return shortenRevision(this.sha); default: - return this.branch || this.friendlyPath; + return this.branch?.name || this.friendlyPath; } } - @memoize() - get friendlyPath(): string { - const path = GitWorktree.getFriendlyPath(this.uri); - return path; - } - @memoize() get workspaceFolder(): WorkspaceFolder | undefined { return workspace.getWorkspaceFolder(this.uri); } - private _branch: Promise | undefined; - getBranch(): Promise { - if (this.type !== 'branch' || this.branch == null) return Promise.resolve(undefined); + @memoize(format => format ?? 'MMMM Do, YYYY h:mma') + formatDate(format?: string | null): string { + return this.date != null ? formatDate(this.date, format ?? 'MMMM Do, YYYY h:mma') : ''; + } - if (this._branch == null) { - this._branch = Container.instance.git - .getBranches(this.repoPath, { filter: b => b.name === this.branch }) - .then(b => b.values[0]); - } - return this._branch; + formatDateFromNow(): string { + return this.date != null ? fromNow(this.date) : ''; } - private _status: Promise | undefined; - getStatus(options?: { force?: boolean }): Promise { + private _status: GitStatus | undefined; + private _statusPromise: Promise | undefined; + async getStatus(options?: { force?: boolean }): Promise { if (this.type === 'bare') return Promise.resolve(undefined); - if (this._status == null || options?.force) { - this._status = Container.instance.git.getStatusForRepo(this.uri.fsPath); + if (this._statusPromise == null || options?.force) { + // eslint-disable-next-line no-async-promise-executor + this._statusPromise = new Promise(async (resolve, reject) => { + try { + const status = await Container.instance.git.getStatusForRepo(this.uri.fsPath); + this._status = status; + resolve(status); + } catch (ex) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(ex); + } + }); } - return this._status; + return this._statusPromise; } +} - static getFriendlyPath(uri: Uri): string { - const folder = workspace.getWorkspaceFolder(uri); - if (folder == null) return normalizePath(uri.fsPath); +export interface WorktreeQuickPickItem extends QuickPickItemOfT { + readonly opened: boolean; + readonly hasChanges: boolean | undefined; +} - const relativePath = normalizePath(relative(folder.uri.fsPath, uri.fsPath)); - return relativePath.length === 0 ? folder.name : relativePath; +export function createWorktreeQuickPickItem( + worktree: GitWorktree, + picked?: boolean, + missing?: boolean, + options?: { + alwaysShow?: boolean; + buttons?: QuickInputButton[]; + checked?: boolean; + includeStatus?: boolean; + message?: boolean; + path?: boolean; + type?: boolean; + status?: GitStatus; + }, +) { + let description = ''; + let detail = ''; + if (options?.type) { + description = 'worktree'; } + + if (options?.includeStatus) { + let status = ''; + let blank = 0; + if (options?.status != null) { + if (options.status.upstream?.missing) { + status += GlyphChars.Warning; + blank += 3; + } else { + if (options.status.state.behind) { + status += GlyphChars.ArrowDown; + } else { + blank += 2; + } + + if (options.status.state.ahead) { + status += GlyphChars.ArrowUp; + } else { + blank += 2; + } + + if (options.status.hasChanges) { + status += '\u00B1'; + } else { + blank += 2; + } + } + } else { + blank += 6; + } + + if (blank > 0) { + status += ' '.repeat(blank); + } + + const formattedDate = worktree.formattedDate; + if (formattedDate) { + if (description) { + description += ` ${GlyphChars.Dot} ${worktree.formattedDate}`; + } else { + description = formattedDate; + } + } + + if (status) { + detail += detail ? ` ${GlyphChars.Dot} ${status}` : status; + } + } + + let iconPath; + let label; + switch (worktree.type) { + case 'bare': + label = '(bare)'; + iconPath = new ThemeIcon('folder'); + break; + case 'branch': + label = worktree.branch?.name ?? 'unknown'; + iconPath = new ThemeIcon('git-branch'); + break; + case 'detached': + label = shortenRevision(worktree.sha); + iconPath = new ThemeIcon('git-commit'); + break; + } + + const item: WorktreeQuickPickItem = { + label: options?.checked ? `${label}${pad('$(check)', 2)}` : label, + description: description ? ` ${description}` : undefined, + detail: options?.path + ? `${detail ? `${detail} ` : ''}${missing ? `${GlyphChars.Warning} (missing)` : '$(folder)'} ${ + worktree.friendlyPath + }` + : detail, + alwaysShow: options?.alwaysShow, + buttons: options?.buttons, + picked: picked, + item: worktree, + opened: worktree.opened, + hasChanges: options?.status?.hasChanges, + iconPath: iconPath, + }; + + return item; } export async function getWorktreeForBranch( repo: Repository, branchName: string, - upstreamNames?: string | string[], + upstreamNames: string | string[], + worktrees?: GitWorktree[], + branches?: PageableResult | Map, ): Promise { if (upstreamNames != null && !Array.isArray(upstreamNames)) { upstreamNames = [upstreamNames]; } - const worktrees = await repo.getWorktrees(); + worktrees ??= await repo.getWorktrees(); for (const worktree of worktrees) { - if (worktree.branch === branchName) return worktree; + if (worktree.branch?.name === branchName) return worktree; if (upstreamNames == null || worktree.branch == null) continue; - const branch = await repo.getBranch(worktree.branch); - if ( - branch?.upstream?.name != null && - (upstreamNames.includes(branch.upstream.name) || - (branch.upstream.name.startsWith('remotes/') && - upstreamNames.includes(branch.upstream.name.substring(8)))) - ) { - return worktree; + branches ??= new PageableResult(p => repo.getBranches(p != null ? { paging: p } : undefined)); + for await (const branch of branches.values()) { + if (branch.name === worktree.branch.name) { + if ( + branch.upstream?.name != null && + (upstreamNames.includes(branch.upstream.name) || + (branch.upstream.name.startsWith('remotes/') && + upstreamNames.includes(branch.upstream.name.substring(8)))) + ) { + return worktree; + } + + break; + } } } return undefined; } + +export function isWorktree(worktree: any): worktree is GitWorktree { + return worktree instanceof GitWorktree; +} + +export interface WorktreeSortOptions { + orderBy?: BranchSorting; +} +export function sortWorktrees(worktrees: GitWorktree[], options?: WorktreeSortOptions): GitWorktree[]; +export function sortWorktrees( + worktrees: WorktreeQuickPickItem[], + options?: WorktreeSortOptions, +): WorktreeQuickPickItem[]; +export function sortWorktrees(worktrees: GitWorktree[] | WorktreeQuickPickItem[], options?: WorktreeSortOptions) { + options = { orderBy: configuration.get('sortBranchesBy'), ...options }; + + const getWorktree = (worktree: GitWorktree | WorktreeQuickPickItem): GitWorktree => { + return isWorktree(worktree) ? worktree : worktree.item; + }; + + switch (options.orderBy) { + case 'date:asc': + return worktrees.sort((a, b) => { + a = getWorktree(a); + b = getWorktree(b); + + return ( + (a.opened ? -1 : 1) - (b.opened ? -1 : 1) || + (a.hasChanges === null ? 0 : a.hasChanges ? -1 : 1) - + (b.hasChanges === null ? 0 : b.hasChanges ? -1 : 1) || + (a.date == null ? -1 : a.date.getTime()) - (b.date == null ? -1 : b.date.getTime()) || + sortCompare(a.name, b.name) + ); + }); + case 'name:asc': + return worktrees.sort((a, b) => { + a = getWorktree(a); + b = getWorktree(b); + + return ( + (a.opened ? -1 : 1) - (b.opened ? -1 : 1) || + (a.hasChanges === null ? 0 : a.hasChanges ? -1 : 1) - + (b.hasChanges === null ? 0 : b.hasChanges ? -1 : 1) || + (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || + (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || + (a.name === 'develop' ? -1 : 1) - (b.name === 'develop' ? -1 : 1) || + sortCompare(a.name, b.name) + ); + }); + case 'name:desc': + return worktrees.sort((a, b) => { + a = getWorktree(a); + b = getWorktree(b); + + return ( + (a.opened ? -1 : 1) - (b.opened ? -1 : 1) || + (a.hasChanges === null ? 0 : a.hasChanges ? -1 : 1) - + (b.hasChanges === null ? 0 : b.hasChanges ? -1 : 1) || + (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || + (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || + (a.name === 'develop' ? -1 : 1) - (b.name === 'develop' ? -1 : 1) || + sortCompare(b.name, a.name) + ); + }); + case 'date:desc': + default: + return worktrees.sort((a, b) => { + a = getWorktree(a); + b = getWorktree(b); + + return ( + (a.opened ? -1 : 1) - (b.opened ? -1 : 1) || + (b.date == null ? -1 : b.date.getTime()) - (a.date == null ? -1 : a.date.getTime()) || + (a.hasChanges === null ? 0 : a.hasChanges ? -1 : 1) - + (b.hasChanges === null ? 0 : b.hasChanges ? -1 : 1) || + sortCompare(b.name, a.name) + ); + }); + } +} diff --git a/src/git/parsers/blameParser.ts b/src/git/parsers/blameParser.ts index b673a078674ec..4b1955c9bc5da 100644 --- a/src/git/parsers/blameParser.ts +++ b/src/git/parsers/blameParser.ts @@ -1,5 +1,5 @@ import type { Container } from '../../container'; -import { debug } from '../../system/decorators/log'; +import { maybeStopWatch } from '../../system/stopwatch'; import { getLines } from '../../system/string'; import type { GitBlame, GitBlameAuthor } from '../models/blame'; import type { GitCommitLine } from '../models/commit'; @@ -17,12 +17,12 @@ interface BlameEntry { lineCount: number; author: string; - authorDate?: string; + authorTime: number; authorTimeZone?: string; authorEmail?: string; committer: string; - committerDate?: string; + committerTime: number; committerTimeZone?: string; committerEmail?: string; @@ -34,228 +34,232 @@ interface BlameEntry { summary?: string; } -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitBlameParser { - @debug({ args: false, singleLine: true }) - static parse( - container: Container, - data: string, - repoPath: string, - currentUser: GitUser | undefined, - ): GitBlame | undefined { - if (!data) return undefined; - - const authors = new Map(); - const commits = new Map(); - const lines: GitCommitLine[] = []; - - let entry: BlameEntry | undefined = undefined; - let key: string; - let line: string; - let lineParts: string[]; - - for (line of getLines(data)) { - lineParts = line.split(' '); - if (lineParts.length < 2) continue; - - [key] = lineParts; - if (entry == null) { - entry = { - sha: key, - originalLine: parseInt(lineParts[1], 10), - line: parseInt(lineParts[2], 10), - lineCount: parseInt(lineParts[3], 10), - } as unknown as BlameEntry; - - continue; - } +export function parseGitBlame( + container: Container, + repoPath: string, + data: string | undefined, + currentUser: GitUser | undefined, + modifiedTime?: number, +): GitBlame | undefined { + using sw = maybeStopWatch(`Git.parseBlame(${repoPath})`, { log: false, logLevel: 'debug' }); + if (!data) return undefined; + + const authors = new Map(); + const commits = new Map(); + const lines: GitCommitLine[] = []; + + let entry: BlameEntry | undefined = undefined; + let key: string; + let line: string; + let lineParts: string[]; + + for (line of getLines(data)) { + lineParts = line.split(' '); + if (lineParts.length < 2) continue; + + [key] = lineParts; + if (entry == null) { + entry = { + sha: key, + originalLine: parseInt(lineParts[1], 10), + line: parseInt(lineParts[2], 10), + lineCount: parseInt(lineParts[3], 10), + } as unknown as BlameEntry; + + continue; + } - switch (key) { - case 'author': - if (entry.sha === uncommitted) { - entry.author = 'You'; - } else { - entry.author = line.slice(key.length + 1).trim(); - } - break; + switch (key) { + case 'author': + if (entry.sha === uncommitted) { + entry.author = 'You'; + } else { + entry.author = line.slice(key.length + 1).trim(); + } + break; - case 'author-mail': { - if (entry.sha === uncommitted) { - entry.authorEmail = currentUser?.email; - continue; - } + case 'author-mail': { + if (entry.sha === uncommitted) { + entry.authorEmail = currentUser?.email; + continue; + } - entry.authorEmail = line.slice(key.length + 1).trim(); - const start = entry.authorEmail.indexOf('<'); - if (start >= 0) { - const end = entry.authorEmail.indexOf('>', start); - if (end > start) { - entry.authorEmail = entry.authorEmail.substring(start + 1, end); - } else { - entry.authorEmail = entry.authorEmail.substring(start + 1); - } + entry.authorEmail = line.slice(key.length + 1).trim(); + const start = entry.authorEmail.indexOf('<'); + if (start >= 0) { + const end = entry.authorEmail.indexOf('>', start); + if (end > start) { + entry.authorEmail = entry.authorEmail.substring(start + 1, end); + } else { + entry.authorEmail = entry.authorEmail.substring(start + 1); } + } - break; + break; + } + case 'author-time': + if (entry.sha === uncommitted && modifiedTime != null) { + entry.authorTime = modifiedTime; + } else { + entry.authorTime = parseInt(lineParts[1], 10) * 1000; } - case 'author-time': - entry.authorDate = lineParts[1]; - break; + break; - case 'author-tz': - entry.authorTimeZone = lineParts[1]; - break; + case 'author-tz': + entry.authorTimeZone = lineParts[1]; + break; - case 'committer': - if (isUncommitted(entry.sha)) { - entry.committer = 'You'; - } else { - entry.committer = line.slice(key.length + 1).trim(); - } - break; + case 'committer': + if (isUncommitted(entry.sha)) { + entry.committer = 'You'; + } else { + entry.committer = line.slice(key.length + 1).trim(); + } + break; - case 'committer-mail': { - if (isUncommitted(entry.sha)) { - entry.committerEmail = currentUser?.email; - continue; - } + case 'committer-mail': { + if (isUncommitted(entry.sha)) { + entry.committerEmail = currentUser?.email; + continue; + } - entry.committerEmail = line.slice(key.length + 1).trim(); - const start = entry.committerEmail.indexOf('<'); - if (start >= 0) { - const end = entry.committerEmail.indexOf('>', start); - if (end > start) { - entry.committerEmail = entry.committerEmail.substring(start + 1, end); - } else { - entry.committerEmail = entry.committerEmail.substring(start + 1); - } + entry.committerEmail = line.slice(key.length + 1).trim(); + const start = entry.committerEmail.indexOf('<'); + if (start >= 0) { + const end = entry.committerEmail.indexOf('>', start); + if (end > start) { + entry.committerEmail = entry.committerEmail.substring(start + 1, end); + } else { + entry.committerEmail = entry.committerEmail.substring(start + 1); } + } - break; + break; + } + case 'committer-time': + if (entry.sha === uncommitted && modifiedTime != null) { + entry.committerTime = modifiedTime; + } else { + entry.committerTime = parseInt(lineParts[1], 10) * 1000; } - case 'committer-time': - entry.committerDate = lineParts[1]; - break; + break; - case 'committer-tz': - entry.committerTimeZone = lineParts[1]; - break; + case 'committer-tz': + entry.committerTimeZone = lineParts[1]; + break; - case 'summary': - entry.summary = line.slice(key.length + 1).trim(); - break; + case 'summary': + entry.summary = line.slice(key.length + 1).trim(); + break; - case 'previous': - entry.previousSha = lineParts[1]; - entry.previousPath = lineParts.slice(2).join(' '); - break; + case 'previous': + entry.previousSha = lineParts[1]; + entry.previousPath = lineParts.slice(2).join(' '); + break; - case 'filename': - // Don't trim to allow spaces in the filename - entry.path = line.slice(key.length + 1); + case 'filename': + // Don't trim to allow spaces in the filename + entry.path = line.slice(key.length + 1); - // Since the filename marks the end of a commit, parse the entry and clear it for the next - GitBlameParser.parseEntry(container, entry, repoPath, commits, authors, lines, currentUser); + // Since the filename marks the end of a commit, parse the entry and clear it for the next + parseBlameEntry(container, entry, repoPath, commits, authors, lines, currentUser); - entry = undefined; - break; + entry = undefined; + break; - default: - break; - } + default: + break; } + } - for (const [, c] of commits) { - if (!c.author.name) continue; + for (const [, c] of commits) { + if (!c.author.name) continue; - const author = authors.get(c.author.name); - if (author == undefined) return undefined; + const author = authors.get(c.author.name); + if (author == undefined) return undefined; - author.lineCount += c.lines.length; - } + author.lineCount += c.lines.length; + } - const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); + const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); - const blame: GitBlame = { - repoPath: repoPath, - authors: sortedAuthors, - commits: commits, - lines: lines, - }; - return blame; - } + sw?.stop({ suffix: ` parsed ${lines.length} lines, ${commits.size} commits` }); - private static parseEntry( - container: Container, - entry: BlameEntry, - repoPath: string, - commits: Map, - authors: Map, - lines: GitCommitLine[], - currentUser: { name?: string; email?: string } | undefined, - ) { - let commit = commits.get(entry.sha); - if (commit == null) { - if (entry.author != null) { - if ( - currentUser != null && - // Name or e-mail is configured - (currentUser.name != null || currentUser.email != null) && - // Match on name if configured - (currentUser.name == null || currentUser.name === entry.author) && - // Match on email if configured - (currentUser.email == null || currentUser.email === entry.authorEmail) - ) { - entry.author = 'You'; - } + const blame: GitBlame = { + repoPath: repoPath, + authors: sortedAuthors, + commits: commits, + lines: lines, + }; + return blame; +} - let author = authors.get(entry.author); - if (author == null) { - author = { - name: entry.author, - lineCount: 0, - }; - authors.set(entry.author, author); - } +function parseBlameEntry( + container: Container, + entry: BlameEntry, + repoPath: string, + commits: Map, + authors: Map, + lines: GitCommitLine[], + currentUser: { name?: string; email?: string } | undefined, +) { + let commit = commits.get(entry.sha); + if (commit == null) { + if (entry.author != null) { + if ( + currentUser != null && + // Name or e-mail is configured + (currentUser.name != null || currentUser.email != null) && + // Match on name if configured + (currentUser.name == null || currentUser.name === entry.author) && + // Match on email if configured + (currentUser.email == null || currentUser.email === entry.authorEmail) + ) { + entry.author = 'You'; } - commit = new GitCommit( - container, - repoPath, - entry.sha, - new GitCommitIdentity(entry.author, entry.authorEmail, new Date((entry.authorDate as any) * 1000)), - new GitCommitIdentity( - entry.committer, - entry.committerEmail, - new Date((entry.committerDate as any) * 1000), - ), - entry.summary!, - [], - undefined, - new GitFileChange( - repoPath, - entry.path, - GitFileIndexStatus.Modified, - entry.previousPath && entry.previousPath !== entry.path ? entry.previousPath : undefined, - entry.previousSha, - ), - undefined, - [], - ); - - commits.set(entry.sha, commit); + let author = authors.get(entry.author); + if (author == null) { + author = { + name: entry.author, + lineCount: 0, + }; + authors.set(entry.author, author); + } } - for (let i = 0, len = entry.lineCount; i < len; i++) { - const line: GitCommitLine = { - sha: entry.sha, - previousSha: commit.file!.previousSha, - originalLine: entry.originalLine + i, - line: entry.line + i, - }; + commit = new GitCommit( + container, + repoPath, + entry.sha, + new GitCommitIdentity(entry.author, entry.authorEmail, new Date(entry.authorTime)), + new GitCommitIdentity(entry.committer, entry.committerEmail, new Date(entry.committerTime)), + entry.summary!, + [], + undefined, + new GitFileChange( + repoPath, + entry.path, + GitFileIndexStatus.Modified, + entry.previousPath && entry.previousPath !== entry.path ? entry.previousPath : undefined, + entry.previousSha, + ), + undefined, + [], + ); + + commits.set(entry.sha, commit); + } - commit.lines.push(line); - lines[line.line - 1] = line; - } + for (let i = 0, len = entry.lineCount; i < len; i++) { + const line: GitCommitLine = { + sha: entry.sha, + previousSha: commit.file!.previousSha, + originalLine: entry.originalLine + i, + line: entry.line + i, + }; + + commit.lines.push(line); + lines[line.line - 1] = line; } } diff --git a/src/git/parsers/branchParser.ts b/src/git/parsers/branchParser.ts index 96bc889c90688..ffb1199a1b9d6 100644 --- a/src/git/parsers/branchParser.ts +++ b/src/git/parsers/branchParser.ts @@ -1,5 +1,5 @@ import type { Container } from '../../container'; -import { debug } from '../../system/decorators/log'; +import { maybeStopWatch } from '../../system/stopwatch'; import { GitBranch } from '../models/branch'; const branchWithTrackingRegex = @@ -9,73 +9,72 @@ const branchWithTrackingRegex = const lb = '%3c'; // `%${'<'.charCodeAt(0).toString(16)}`; const rb = '%3e'; // `%${'>'.charCodeAt(0).toString(16)}`; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitBranchParser { - static defaultFormat = [ - `${lb}h${rb}%(HEAD)`, // HEAD indicator - `${lb}n${rb}%(refname)`, // branch name - `${lb}u${rb}%(upstream:short)`, // branch upstream - `${lb}t${rb}%(upstream:track)`, // branch upstream tracking state - `${lb}r${rb}%(objectname)`, // ref - `${lb}d${rb}%(committerdate:iso8601)`, // committer date - ].join(''); +export const parseGitBranchesDefaultFormat = [ + `${lb}h${rb}%(HEAD)`, // HEAD indicator + `${lb}n${rb}%(refname)`, // branch name + `${lb}u${rb}%(upstream:short)`, // branch upstream + `${lb}t${rb}%(upstream:track)`, // branch upstream tracking state + `${lb}r${rb}%(objectname)`, // ref + `${lb}d${rb}%(committerdate:iso8601)`, // committer date +].join(''); - @debug({ args: false, singleLine: true }) - static parse(container: Container, data: string, repoPath: string): GitBranch[] { - const branches: GitBranch[] = []; +export function parseGitBranches(container: Container, data: string, repoPath: string): GitBranch[] { + using sw = maybeStopWatch(`Git.parseBranches(${repoPath})`, { log: false, logLevel: 'debug' }); - if (!data) return branches; + const branches: GitBranch[] = []; + if (!data) return branches; - let current; - let name; - let upstream; - let ahead; - let behind; - let missing; - let ref; - let date; + let current; + let name; + let upstream; + let ahead; + let behind; + let missing; + let ref; + let date; - let remote; + let remote; - let match; - do { - match = branchWithTrackingRegex.exec(data); - if (match == null) break; + let match; + do { + match = branchWithTrackingRegex.exec(data); + if (match == null) break; - [, current, name, upstream, ahead, behind, missing, ref, date] = match; + [, current, name, upstream, ahead, behind, missing, ref, date] = match; - if (name.startsWith('refs/remotes/')) { - // Strip off refs/remotes/ - name = name.substr(13); - if (name.endsWith('/HEAD')) continue; + if (name.startsWith('refs/remotes/')) { + // Strip off refs/remotes/ + name = name.substr(13); + if (name.endsWith('/HEAD')) continue; - remote = true; - } else { - // Strip off refs/heads/ - name = name.substr(11); - remote = false; - } + remote = true; + } else { + // Strip off refs/heads/ + name = name.substr(11); + remote = false; + } - branches.push( - new GitBranch( - container, - repoPath, - name, - remote, - current.charCodeAt(0) === 42, // '*', - date ? new Date(date) : undefined, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ref == null || ref.length === 0 ? undefined : ` ${ref}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - upstream == null || upstream.length === 0 - ? undefined - : { name: ` ${upstream}`.substr(1), missing: Boolean(missing) }, - Number(ahead) || 0, - Number(behind) || 0, - ), - ); - } while (true); + branches.push( + new GitBranch( + container, + repoPath, + name, + remote, + current.charCodeAt(0) === 42, // '*', + date ? new Date(date) : undefined, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ref == null || ref.length === 0 ? undefined : ` ${ref}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + upstream == null || upstream.length === 0 + ? undefined + : { name: ` ${upstream}`.substr(1), missing: Boolean(missing) }, + Number(ahead) || 0, + Number(behind) || 0, + ), + ); + } while (true); - return branches; - } + sw?.stop({ suffix: ` parsed ${branches.length} branches` }); + + return branches; } diff --git a/src/git/parsers/diffParser.ts b/src/git/parsers/diffParser.ts index 915a3aa4d5175..3fd1ff463478e 100644 --- a/src/git/parsers/diffParser.ts +++ b/src/git/parsers/diffParser.ts @@ -1,158 +1,143 @@ +import { joinPaths, normalizePath } from '../../system/path'; import { maybeStopWatch } from '../../system/stopwatch'; -import { getLines, pluralize } from '../../system/string'; -import type { GitDiffFile, GitDiffHunkLine, GitDiffLine, GitDiffShortStat } from '../models/diff'; -import { GitDiffHunk } from '../models/diff'; +import type { GitDiffFile, GitDiffHunk, GitDiffHunkLine, GitDiffShortStat } from '../models/diff'; import type { GitFile, GitFileStatus } from '../models/file'; +import { GitFileChange } from '../models/file'; const shortStatDiffRegex = /(\d+)\s+files? changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/; -const unifiedDiffRegex = /^@@ -([\d]+)(?:,([\d]+))? \+([\d]+)(?:,([\d]+))? @@(?:.*?)\n([\s\S]*?)(?=^@@)/gm; -export function parseFileDiff(data: string, includeContents: boolean = false): GitDiffFile | undefined { - if (!data) return undefined; - - const sw = maybeStopWatch('parseFileDiff', { log: false, logLevel: 'debug' }); - - const hunks: GitDiffHunk[] = []; - - let previousStart; - let previousCount; - let currentStart; - let currentCount; - let hunk; - - let match; - do { - match = unifiedDiffRegex.exec(`${data}\n@@`); - if (match == null) break; - - [, previousStart, previousCount, currentStart, currentCount, hunk] = match; - - previousCount = Number(previousCount) || 0; - previousStart = Number(previousStart) || 0; - currentCount = Number(currentCount) || 0; - currentStart = Number(currentStart) || 0; - - hunks.push( - new GitDiffHunk( - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${hunk}`.substr(1), - { - count: currentCount === 0 ? 1 : currentCount, - position: { - start: currentStart, - end: currentStart + (currentCount > 0 ? currentCount - 1 : 0), - }, - }, - { - count: previousCount === 0 ? 1 : previousCount, - position: { - start: previousStart, - end: previousStart + (previousCount > 0 ? previousCount - 1 : 0), - }, - }, - ), - ); - } while (true); - - sw?.stop({ suffix: ` parsed ${pluralize('hunk', hunks.length)}` }); - - if (!hunks.length) return undefined; - - const diff: GitDiffFile = { - contents: includeContents ? data : undefined, - hunks: hunks, - }; - return diff; +function parseHunkHeaderPart(headerPart: string) { + const [startS, countS] = headerPart.split(','); + const start = Number(startS); + const count = Number(countS) || 1; + return { count: count, position: { start: start, end: start + count - 1 } }; } -export function parseDiffHunk(hunk: GitDiffHunk): { lines: GitDiffHunkLine[]; state: 'added' | 'changed' | 'removed' } { - const sw = maybeStopWatch('parseDiffHunk', { log: false, logLevel: 'debug' }); - - const currentStart = hunk.current.position.start; - const previousStart = hunk.previous.position.start; - - const currentLines: (GitDiffLine | undefined)[] = - currentStart > previousStart - ? new Array(currentStart - previousStart).fill(undefined, 0, currentStart - previousStart) - : []; - const previousLines: (GitDiffLine | undefined)[] = - previousStart > currentStart - ? new Array(previousStart - currentStart).fill(undefined, 0, previousStart - currentStart) - : []; - - let hasAddedOrChanged; - let hasRemoved; - - let removed = 0; - for (const l of getLines(hunk.contents)) { - switch (l[0]) { - case '+': - hasAddedOrChanged = true; - currentLines.push({ - line: ` ${l.substring(1)}`, - state: 'added', - }); - - if (removed > 0) { - removed--; - } else { - previousLines.push(undefined); - } +export function parseGitFileDiff(data: string, includeContents = false): GitDiffFile | undefined { + using sw = maybeStopWatch('Git.parseFileDiff', { log: false, logLevel: 'debug' }); + if (!data) return undefined; - break; + const hunks: GitDiffHunk[] = []; - case '-': - hasRemoved = true; - removed++; + const lines = data.split('\n'); - previousLines.push({ - line: ` ${l.substring(1)}`, - state: 'removed', - }); + // Skip header + let i = -1; + while (++i < lines.length) { + if (lines[i].startsWith('@@')) { + break; + } + } - break; + // Parse hunks + let line; + while (i < lines.length) { + line = lines[i]; + if (!line.startsWith('@@')) { + i++; + continue; + } - default: - while (removed > 0) { - removed--; - currentLines.push(undefined); + const header = line.split('@@')[1].trim(); + const [previousHeaderPart, currentHeaderPart] = header.split(' '); + + const current = parseHunkHeaderPart(currentHeaderPart.slice(1)); + const previous = parseHunkHeaderPart(previousHeaderPart.slice(1)); + + const hunkLines = new Map(); + let fileLineNumber = current.position.start; + + line = lines[++i]; + const contentStartLine = i; + + // Parse hunks lines + while (i < lines.length && !line.startsWith('@@')) { + switch (line[0]) { + // deleted + case '-': { + let deletedLineNumber = fileLineNumber; + while (line?.startsWith('-')) { + hunkLines.set(deletedLineNumber++, { + current: undefined, + previous: line.slice(1), + state: 'removed', + }); + line = lines[++i]; + } + + if (line?.startsWith('+')) { + let addedLineNumber = fileLineNumber; + while (line?.startsWith('+')) { + const hunkLine = hunkLines.get(addedLineNumber); + if (hunkLine != null) { + hunkLine.current = line.slice(1); + hunkLine.state = 'changed'; + } else { + hunkLines.set(addedLineNumber, { + current: line.slice(1), + previous: undefined, + state: 'added', + }); + } + addedLineNumber++; + line = lines[++i]; + } + fileLineNumber = addedLineNumber; + } else { + fileLineNumber = deletedLineNumber; + } + break; } - - currentLines.push({ line: l, state: 'unchanged' }); - previousLines.push({ line: l, state: 'unchanged' }); - - break; + // added + case '+': + hunkLines.set(fileLineNumber++, { + current: line.slice(1), + previous: undefined, + state: 'added', + }); + + line = lines[++i]; + break; + + // unchanged (context) + case ' ': + hunkLines.set(fileLineNumber++, { + current: line.slice(1), + previous: line.slice(1), + state: 'unchanged', + }); + + line = lines[++i]; + break; + + default: + line = lines[++i]; + break; + } } - } - while (removed > 0) { - removed--; - currentLines.push(undefined); - } - - const hunkLines: GitDiffHunkLine[] = []; + const hunk: GitDiffHunk = { + contents: `${lines.slice(contentStartLine, i).join('\n')}\n`, + current: current, + previous: previous, + lines: hunkLines, + }; - for (let i = 0; i < Math.max(currentLines.length, previousLines.length); i++) { - hunkLines.push({ - hunk: hunk, - current: currentLines[i], - previous: previousLines[i], - }); + hunks.push(hunk); } - sw?.stop({ suffix: ` parsed ${pluralize('line', hunkLines.length)}` }); + sw?.stop({ suffix: ` parsed ${hunks.length} hunks` }); return { - lines: hunkLines, - state: hasAddedOrChanged && hasRemoved ? 'changed' : hasAddedOrChanged ? 'added' : 'removed', + contents: includeContents ? data : undefined, + hunks: hunks, }; } -export function parseDiffNameStatusFiles(data: string, repoPath: string): GitFile[] | undefined { +export function parseGitDiffNameStatusFiles(data: string, repoPath: string): GitFile[] | undefined { + using sw = maybeStopWatch('Git.parseDiffNameStatusFiles', { log: false, logLevel: 'debug' }); if (!data) return undefined; - const sw = maybeStopWatch('parseDiffNameStatusFiles', { log: false, logLevel: 'debug' }); - const files: GitFile[] = []; let status; @@ -172,15 +157,85 @@ export function parseDiffNameStatusFiles(data: string, repoPath: string): GitFil }); } - sw?.stop({ suffix: ` parsed ${pluralize('file', files.length)}` }); + sw?.stop({ suffix: ` parsed ${files.length} files` }); return files; } -export function parseDiffShortStat(data: string): GitDiffShortStat | undefined { - if (!data) return undefined; +export function parseGitApplyFiles(data: string, repoPath: string): GitFileChange[] { + using sw = maybeStopWatch('Git.parseApplyFiles', { log: false, logLevel: 'debug' }); + if (!data) return []; + + const files = new Map(); + + const lines = data.split('\0'); + // remove the summary (last) line to parse later + const summary = lines.pop(); + + for (let line of lines) { + line = line.trim(); + if (!line) continue; - const sw = maybeStopWatch('parseDiffShortStat', { log: false, logLevel: 'debug' }); + const [insertions, deletions, path] = line.split('\t'); + files.set( + normalizePath(path), + new GitFileChange(repoPath, path, 'M' as GitFileStatus, undefined, undefined, { + changes: 0, + additions: parseInt(insertions, 10), + deletions: parseInt(deletions, 10), + }), + ); + } + + for (let line of summary!.split('\n')) { + line = line.trim(); + if (!line) continue; + + const match = /(rename) (.*?)\{(.+?)\s+=>\s+(.+?)\}(?: \(\d+%\))|(create|delete) mode \d+ (.+)/.exec(line); + if (match == null) continue; + + let [, rename, renameRoot, renameOriginalPath, renamePath, createOrDelete, createOrDeletePath] = match; + + if (rename != null) { + renamePath = normalizePath(joinPaths(renameRoot, renamePath)); + renameOriginalPath = normalizePath(joinPaths(renameRoot, renameOriginalPath)); + + const file = files.get(renamePath)!; + files.set( + renamePath, + new GitFileChange( + repoPath, + renamePath, + 'R' as GitFileStatus, + renameOriginalPath, + undefined, + file.stats, + ), + ); + } else { + const file = files.get(normalizePath(createOrDeletePath))!; + files.set( + createOrDeletePath, + new GitFileChange( + repoPath, + file.path, + (createOrDelete === 'create' ? 'A' : 'D') as GitFileStatus, + undefined, + undefined, + file.stats, + ), + ); + } + } + + sw?.stop({ suffix: ` parsed ${files.size} files` }); + + return [...files.values()]; +} + +export function parseGitDiffShortStat(data: string): GitDiffShortStat | undefined { + using sw = maybeStopWatch('Git.parseDiffShortStat', { log: false, logLevel: 'debug' }); + if (!data) return undefined; const match = shortStatDiffRegex.exec(data); if (match == null) return undefined; @@ -194,9 +249,7 @@ export function parseDiffShortStat(data: string): GitDiffShortStat | undefined { }; sw?.stop({ - suffix: ` parsed ${pluralize('file', diffShortStat.changedFiles)}, +${diffShortStat.additions} -${ - diffShortStat.deletions - }`, + suffix: ` parsed ${diffShortStat.changedFiles} files, +${diffShortStat.additions} -${diffShortStat.deletions}`, }); return diffShortStat; diff --git a/src/git/parsers/logParser.ts b/src/git/parsers/logParser.ts index 98abbccadb254..f05476f50143b 100644 --- a/src/git/parsers/logParser.ts +++ b/src/git/parsers/logParser.ts @@ -1,8 +1,8 @@ import type { Range } from 'vscode'; import type { Container } from '../../container'; import { filterMap } from '../../system/array'; -import { debug } from '../../system/decorators/log'; import { normalizePath, relative } from '../../system/path'; +import { maybeStopWatch } from '../../system/stopwatch'; import { getLines } from '../../system/string'; import type { GitCommitLine, GitStashCommit } from '../models/commit'; import { GitCommit, GitCommitIdentity } from '../models/commit'; @@ -19,7 +19,7 @@ const diffRangeRegex = /^@@ -(\d+?),(\d+?) \+(\d+?),(\d+?) @@/; export const fileStatusRegex = /(\S)\S*\t([^\t\n]+)(?:\t(.+))?/; const fileStatusAndSummaryRegex = /^(\d+?|-)\s+?(\d+?|-)\s+?(.*)(?:\n\s(delete|rename|copy|create))?/; const fileStatusAndSummaryRenamedFileRegex = /(.+)\s=>\s(.+)/; -const fileStatusAndSummaryRenamedFilePathRegex = /(.*?){(.+?)\s=>\s(.*?)}(.*)/; +const fileStatusAndSummaryRenamedFilePathRegex = /(.*?){(.+?)?\s=>\s(.*?)?}(.*)/; const logFileSimpleRegex = /^ (.*)\s*(?:(?:diff --git a\/(.*) b\/(.*))|(?:(\S)\S*\t([^\t\n]+)(?:\t(.+))?))/gm; const logFileSimpleRenamedRegex = /^ (\S+)\s*(.*)$/s; @@ -363,566 +363,539 @@ export function createLogParserWithStats>( }); } -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitLogParser { - // private static _defaultParser: ParserWithFiles<{ - // sha: string; - // author: string; - // authorEmail: string; - // authorDate: string; - // committer: string; - // committerEmail: string; - // committerDate: string; - // message: string; - // parents: string[]; - // }>; - // static get defaultParser() { - // if (this._defaultParser == null) { - // this._defaultParser = GitLogParser.createWithFiles({ - // sha: '%H', - // author: '%aN', - // authorEmail: '%aE', - // authorDate: '%at', - // committer: '%cN', - // committerEmail: '%cE', - // committerDate: '%ct', - // message: '%B', - // parents: '%P', - // }); - // } - // return this._defaultParser; - // } - - static allFormat = [ - `${lb}${sl}f${rb}`, - `${lb}r${rb}${sp}%H`, // ref - `${lb}a${rb}${sp}%aN`, // author - `${lb}e${rb}${sp}%aE`, // author email - `${lb}d${rb}${sp}%at`, // author date - `${lb}n${rb}${sp}%cN`, // committer - `${lb}m${rb}${sp}%cE`, // committer email - `${lb}c${rb}${sp}%ct`, // committer date - `${lb}p${rb}${sp}%P`, // parents - `${lb}t${rb}${sp}%D`, // tips - `${lb}s${rb}`, - '%B', // summary - `${lb}${sl}s${rb}`, - `${lb}f${rb}`, - ].join('%n'); - - static defaultFormat = [ - `${lb}${sl}f${rb}`, - `${lb}r${rb}${sp}%H`, // ref - `${lb}a${rb}${sp}%aN`, // author - `${lb}e${rb}${sp}%aE`, // author email - `${lb}d${rb}${sp}%at`, // author date - `${lb}n${rb}${sp}%cN`, // committer - `${lb}m${rb}${sp}%cE`, // committer email - `${lb}c${rb}${sp}%ct`, // committer date - `${lb}p${rb}${sp}%P`, // parents - `${lb}s${rb}`, - '%B', // summary - `${lb}${sl}s${rb}`, - `${lb}f${rb}`, - ].join('%n'); - - static simpleRefs = `${lb}r${rb}${sp}%H`; - static simpleFormat = `${lb}r${rb}${sp}%H`; - - static shortlog = '%H%x00%aN%x00%aE%x00%at'; - - @debug({ args: false }) - static parse( - container: Container, - data: string, - type: LogType, - repoPath: string | undefined, - fileName: string | undefined, - sha: string | undefined, - currentUser: GitUser | undefined, - limit: number | undefined, - reverse: boolean, - range: Range | undefined, - stashes?: Map, - hasMoreOverride?: boolean, - ): GitLog | undefined { - if (!data) return undefined; - - let relativeFileName: string | undefined; - - let entry: LogEntry = {}; - let line: string | undefined = undefined; - let token: number; - - let i = 0; - let first = true; - - const lines = getLines(`${data}`); - // Skip the first line since it will always be - let next = lines.next(); - if (next.done) return undefined; - - if (repoPath !== undefined) { - repoPath = normalizePath(repoPath); - } +export const parseGitLogAllFormat = [ + `${lb}${sl}f${rb}`, + `${lb}r${rb}${sp}%H`, // ref + `${lb}a${rb}${sp}%aN`, // author + `${lb}e${rb}${sp}%aE`, // author email + `${lb}d${rb}${sp}%at`, // author date + `${lb}n${rb}${sp}%cN`, // committer + `${lb}m${rb}${sp}%cE`, // committer email + `${lb}c${rb}${sp}%ct`, // committer date + `${lb}p${rb}${sp}%P`, // parents + `${lb}t${rb}${sp}%D`, // tips + `${lb}s${rb}`, + '%B', // summary + `${lb}${sl}s${rb}`, + `${lb}f${rb}`, +].join('%n'); +export const parseGitLogDefaultFormat = [ + `${lb}${sl}f${rb}`, + `${lb}r${rb}${sp}%H`, // ref + `${lb}a${rb}${sp}%aN`, // author + `${lb}e${rb}${sp}%aE`, // author email + `${lb}d${rb}${sp}%at`, // author date + `${lb}n${rb}${sp}%cN`, // committer + `${lb}m${rb}${sp}%cE`, // committer email + `${lb}c${rb}${sp}%ct`, // committer date + `${lb}p${rb}${sp}%P`, // parents + `${lb}s${rb}`, + '%B', // summary + `${lb}${sl}s${rb}`, + `${lb}f${rb}`, +].join('%n'); +export const parseGitLogSimpleFormat = `${lb}r${rb}${sp}%H`; + +export function parseGitLog( + container: Container, + data: string, + type: LogType, + repoPath: string | undefined, + fileName: string | undefined, + sha: string | undefined, + currentUser: GitUser | undefined, + limit: number | undefined, + reverse: boolean, + range: Range | undefined, + stashes?: Map, + hasMoreOverride?: boolean, +): GitLog | undefined { + using sw = maybeStopWatch(`Git.parseLog(${repoPath}, fileName=${fileName}, sha=${sha})`, { + log: false, + logLevel: 'debug', + }); + if (!data) return undefined; - const commits = new Map(); - let truncationCount = limit; + let relativeFileName: string | undefined; - let match; - let renamedFileName; - let renamedMatch; + let entry: LogEntry = {}; + let line: string | undefined = undefined; + let token: number; - loop: while (true) { - next = lines.next(); - if (next.done) break; + let i = 0; + let first = true; - line = next.value; + const lines = getLines(`${data}`); + // Skip the first line since it will always be + let next = lines.next(); + if (next.done) return undefined; - // Since log --reverse doesn't properly honor a max count -- enforce it here - if (reverse && limit && i >= limit) break; + if (repoPath !== undefined) { + repoPath = normalizePath(repoPath); + } - // <1-char token> data - // e.g. bd1452a2dc - token = line.charCodeAt(1); + const commits = new Map(); + let truncationCount = limit; - switch (token) { - case 114: // 'r': // ref - entry = { - sha: line.substring(4), - }; - break; + let match; + let renamedFileName; + let renamedMatch; - case 97: // 'a': // author - if (uncommitted === entry.sha) { - entry.author = 'You'; - } else { - entry.author = line.substring(4); - } - break; + loop: while (true) { + next = lines.next(); + if (next.done) break; - case 101: // 'e': // author-mail - entry.authorEmail = line.substring(4); - break; + line = next.value; - case 100: // 'd': // author-date - entry.authorDate = line.substring(4); - break; + // Since log --reverse doesn't properly honor a max count -- enforce it here + if (reverse && limit && i >= limit) break; - case 110: // 'n': // committer - entry.committer = line.substring(4); - break; + // <1-char token> data + // e.g. bd1452a2dc + token = line.charCodeAt(1); - case 109: // 'm': // committer-mail - entry.committedDate = line.substring(4); - break; + switch (token) { + case 114: // 'r': // ref + entry = { + sha: line.substring(4), + }; + break; - case 99: // 'c': // committer-date - entry.committedDate = line.substring(4); - break; + case 97: // 'a': // author + if (uncommitted === entry.sha) { + entry.author = 'You'; + } else { + entry.author = line.substring(4); + } + break; - case 112: // 'p': // parents - line = line.substring(4); - entry.parentShas = line.length !== 0 ? line.split(' ') : undefined; - break; + case 101: // 'e': // author-mail + entry.authorEmail = line.substring(4); + break; - case 116: // 't': // tips - line = line.substring(4); - entry.tips = line.length !== 0 ? line.split(', ') : undefined; - break; + case 100: // 'd': // author-date + entry.authorDate = line.substring(4); + break; - case 115: // 's': // summary - while (true) { - next = lines.next(); - if (next.done) break; + case 110: // 'n': // committer + entry.committer = line.substring(4); + break; - line = next.value; - if (line === '') break; + case 109: // 'm': // committer-mail + entry.committedDate = line.substring(4); + break; - if (entry.summary === undefined) { - entry.summary = line; - } else { - entry.summary += `\n${line}`; - } - } + case 99: // 'c': // committer-date + entry.committedDate = line.substring(4); + break; - // Remove the trailing newline - if (entry.summary != null && entry.summary.charCodeAt(entry.summary.length - 1) === 10) { - entry.summary = entry.summary.slice(0, -1); - } - break; + case 112: // 'p': // parents + line = line.substring(4); + entry.parentShas = line.length !== 0 ? line.split(' ') : undefined; + break; - case 102: { - // 'f': // files - // Skip the blank line git adds before the files - next = lines.next(); + case 116: // 't': // tips + line = line.substring(4); + entry.tips = line.length !== 0 ? line.split(', ') : undefined; + break; - let hasFiles = true; - if (next.done || next.value === '') { - // If this is a merge commit and there are no files returned, skip the commit and reduce our truncationCount to ensure accurate truncation detection - if ((entry.parentShas?.length ?? 0) > 1) { - if (truncationCount) { - truncationCount--; - } + case 115: // 's': // summary + while (true) { + next = lines.next(); + if (next.done) break; - break; - } + line = next.value; + if (line === '') break; - hasFiles = false; + if (entry.summary === undefined) { + entry.summary = line; + } else { + entry.summary += `\n${line}`; } + } - // eslint-disable-next-line no-unmodified-loop-condition - while (hasFiles) { - next = lines.next(); - if (next.done) break; + // Remove the trailing newline + if (entry.summary != null && entry.summary.charCodeAt(entry.summary.length - 1) === 10) { + entry.summary = entry.summary.slice(0, -1); + } + break; - line = next.value; - if (line === '') break; + case 102: { + // 'f': // files + // Skip the blank line git adds before the files + next = lines.next(); - if (line.startsWith('warning:')) continue; + let hasFiles = true; + if (next.done || next.value === '') { + hasFiles = false; + } - if (type === LogType.Log) { - match = fileStatusRegex.exec(line); - if (match != null) { - if (entry.files === undefined) { - entry.files = []; - } + // eslint-disable-next-line no-unmodified-loop-condition + while (hasFiles) { + next = lines.next(); + if (next.done) break; - renamedFileName = match[3]; - if (renamedFileName !== undefined) { - entry.files.push({ - status: match[1] as GitFileIndexStatus, - path: renamedFileName, - originalPath: match[2], - }); - } else { - entry.files.push({ - status: match[1] as GitFileIndexStatus, - path: match[2], - }); - } - } - } else { - match = diffRegex.exec(line); - if (match != null) { - [, entry.originalPath, entry.path] = match; - if (entry.path === entry.originalPath) { - entry.originalPath = undefined; - entry.status = GitFileIndexStatus.Modified; - } else { - entry.status = GitFileIndexStatus.Renamed; - } + line = next.value; + if (line === '') break; - void lines.next(); - void lines.next(); - next = lines.next(); + if (line.startsWith('warning:')) continue; - match = diffRangeRegex.exec(next.value); - if (match !== null) { - entry.line = { - sha: entry.sha!, - originalLine: parseInt(match[1], 10), - // count: parseInt(match[2], 10), - line: parseInt(match[3], 10), - // count: parseInt(match[4], 10), - }; - } + if (type === LogType.Log) { + match = fileStatusRegex.exec(line); + if (match != null) { + if (entry.files === undefined) { + entry.files = []; + } - while (true) { - next = lines.next(); - if (next.done || next.value === '') break; - } - break; + renamedFileName = match[3]; + if (renamedFileName !== undefined) { + entry.files.push({ + status: match[1] as GitFileIndexStatus, + path: renamedFileName, + originalPath: match[2], + }); } else { + entry.files.push({ + status: match[1] as GitFileIndexStatus, + path: match[2], + }); + } + } + } else { + match = diffRegex.exec(line); + if (match != null) { + [, entry.originalPath, entry.path] = match; + if (entry.path === entry.originalPath) { + entry.originalPath = undefined; + entry.status = GitFileIndexStatus.Modified; + } else { + entry.status = GitFileIndexStatus.Renamed; + } + + void lines.next(); + void lines.next(); + next = lines.next(); + + match = diffRangeRegex.exec(next.value); + if (match !== null) { + entry.line = { + sha: entry.sha!, + originalLine: parseInt(match[1], 10), + // count: parseInt(match[2], 10), + line: parseInt(match[3], 10), + // count: parseInt(match[4], 10), + }; + } + + while (true) { next = lines.next(); - match = fileStatusAndSummaryRegex.exec(`${line}\n${next.value}`); - if (match != null) { - entry.fileStats = { - additions: Number(match[1]) || 0, - deletions: Number(match[2]) || 0, - changes: 0, - }; - - switch (match[4]) { - case undefined: - entry.status = 'M' as GitFileIndexStatus; - entry.path = match[3]; - break; - case 'copy': - case 'rename': - entry.status = (match[4] === 'copy' ? 'C' : 'R') as GitFileIndexStatus; - - renamedFileName = match[3]; - renamedMatch = - fileStatusAndSummaryRenamedFilePathRegex.exec(renamedFileName); - if (renamedMatch != null) { - // If there is no new path, the path part was removed so ensure we don't end up with // - entry.path = - renamedMatch[3] === '' - ? `${renamedMatch[1]}${renamedMatch[4]}`.replace('//', '/') - : `${renamedMatch[1]}${renamedMatch[3]}${renamedMatch[4]}`; - entry.originalPath = `${renamedMatch[1]}${renamedMatch[2]}${renamedMatch[4]}`; + if (next.done || next.value === '') break; + } + break; + } else { + next = lines.next(); + match = fileStatusAndSummaryRegex.exec(`${line}\n${next.value}`); + if (match != null) { + entry.fileStats = { + additions: Number(match[1]) || 0, + deletions: Number(match[2]) || 0, + changes: 0, + }; + + switch (match[4]) { + case undefined: + entry.status = 'M' as GitFileIndexStatus; + entry.path = match[3]; + break; + case 'copy': + case 'rename': + entry.status = (match[4] === 'copy' ? 'C' : 'R') as GitFileIndexStatus; + + renamedFileName = match[3]; + renamedMatch = fileStatusAndSummaryRenamedFilePathRegex.exec(renamedFileName); + if (renamedMatch != null) { + const [, start, from, to, end] = renamedMatch; + // If there is no new path, the path part was removed so ensure we don't end up with // + if (!to) { + entry.path = `${ + start.endsWith('/') && end.startsWith('/') + ? start.slice(0, -1) + : start + }${end}`; } else { - renamedMatch = - fileStatusAndSummaryRenamedFileRegex.exec(renamedFileName); - if (renamedMatch != null) { - entry.path = renamedMatch[2]; - entry.originalPath = renamedMatch[1]; - } else { - entry.path = renamedFileName; - } + entry.path = `${start}${to}${end}`; } - break; - case 'create': - entry.status = 'A' as GitFileIndexStatus; - entry.path = match[3]; - break; - case 'delete': - entry.status = 'D' as GitFileIndexStatus; - entry.path = match[3]; - break; - default: - entry.status = 'M' as GitFileIndexStatus; - entry.path = match[3]; - break; - } + if (!from) { + entry.originalPath = `${ + start.endsWith('/') && end.startsWith('/') + ? start.slice(0, -1) + : start + }${end}`; + } else { + entry.originalPath = `${start}${from}${end}`; + } + } else { + renamedMatch = fileStatusAndSummaryRenamedFileRegex.exec(renamedFileName); + if (renamedMatch != null) { + entry.path = renamedMatch[2]; + entry.originalPath = renamedMatch[1]; + } else { + entry.path = renamedFileName; + } + } + + break; + case 'create': + entry.status = 'A' as GitFileIndexStatus; + entry.path = match[3]; + break; + case 'delete': + entry.status = 'D' as GitFileIndexStatus; + entry.path = match[3]; + break; + default: + entry.status = 'M' as GitFileIndexStatus; + entry.path = match[3]; + break; } - - if (next.done || next.value === '') break; } - } - } - if (entry.files !== undefined) { - entry.path = filterMap(entry.files, f => (f.path ? f.path : undefined)).join(', '); + if (next.done || next.value === '') break; + } } + } - if (first && repoPath === undefined && type === LogType.LogFile && fileName !== undefined) { - // Try to get the repoPath from the most recent commit - repoPath = normalizePath( - fileName.replace(fileName.startsWith('/') ? `/${entry.path}` : entry.path!, ''), - ); - relativeFileName = normalizePath(relative(repoPath, fileName)); - } else { - relativeFileName = - entry.path ?? - (repoPath != null && fileName != null - ? normalizePath(relative(repoPath, fileName)) - : undefined); - } - first = false; - - const commit = commits.get(entry.sha!); - if (commit === undefined) { - i++; - if (limit && i > limit) break loop; - } else if (truncationCount) { - // Since this matches an existing commit it will be skipped, so reduce our truncationCount to ensure accurate truncation detection - truncationCount--; - } + if (entry.files !== undefined) { + entry.path = filterMap(entry.files, f => (f.path ? f.path : undefined)).join(', '); + } - GitLogParser.parseEntry( - container, - entry, - commit, - type, - repoPath, - relativeFileName, - commits, - currentUser, - stashes, + if (first && repoPath === undefined && type === LogType.LogFile && fileName !== undefined) { + // Try to get the repoPath from the most recent commit + repoPath = normalizePath( + fileName.replace(fileName.startsWith('/') ? `/${entry.path}` : entry.path!, ''), ); - - break; + relativeFileName = normalizePath(relative(repoPath, fileName)); + } else { + relativeFileName = + entry.path ?? + (repoPath != null && fileName != null + ? normalizePath(relative(repoPath, fileName)) + : undefined); + } + first = false; + + const commit = commits.get(entry.sha!); + if (commit === undefined) { + i++; + if (limit && i > limit) break loop; + } else if (truncationCount) { + // Since this matches an existing commit it will be skipped, so reduce our truncationCount to ensure accurate truncation detection + truncationCount--; } + + parseLogEntry( + container, + entry, + commit, + type, + repoPath, + relativeFileName, + commits, + currentUser, + stashes, + ); + + break; } } - - const log: GitLog = { - repoPath: repoPath!, - commits: commits, - sha: sha, - count: i, - limit: limit, - range: range, - hasMore: hasMoreOverride ?? Boolean(truncationCount && i > truncationCount && truncationCount !== 1), - }; - return log; } - private static parseEntry( - container: Container, - entry: LogEntry, - commit: GitCommit | undefined, - type: LogType, - repoPath: string | undefined, - relativeFileName: string | undefined, - commits: Map, - currentUser: GitUser | undefined, - stashes: Map | undefined, - ): void { - if (commit == null) { - if (entry.author != null) { - if (isUserMatch(currentUser, entry.author, entry.authorEmail)) { - entry.author = 'You'; - } + sw?.stop({ suffix: ` parsed ${commits.size} commits` }); + + const log: GitLog = { + repoPath: repoPath!, + commits: commits, + sha: sha, + count: i, + limit: limit, + range: range, + hasMore: hasMoreOverride ?? Boolean(truncationCount && i > truncationCount && truncationCount !== 1), + }; + return log; +} + +function parseLogEntry( + container: Container, + entry: LogEntry, + commit: GitCommit | undefined, + type: LogType, + repoPath: string | undefined, + relativeFileName: string | undefined, + commits: Map, + currentUser: GitUser | undefined, + stashes: Map | undefined, +): void { + if (commit == null) { + if (entry.author != null) { + if (isUserMatch(currentUser, entry.author, entry.authorEmail)) { + entry.author = 'You'; } + } - if (entry.committer != null) { - if (isUserMatch(currentUser, entry.committer, entry.committerEmail)) { - entry.committer = 'You'; - } + if (entry.committer != null) { + if (isUserMatch(currentUser, entry.committer, entry.committerEmail)) { + entry.committer = 'You'; } + } - const originalFileName = entry.originalPath ?? (relativeFileName !== entry.path ? entry.path : undefined); + const originalFileName = entry.originalPath ?? (relativeFileName !== entry.path ? entry.path : undefined); - const files: { file?: GitFileChange; files?: GitFileChange[] } = { - files: entry.files?.map(f => new GitFileChange(repoPath!, f.path, f.status, f.originalPath)), - }; - if (type === LogType.LogFile && relativeFileName != null) { - files.file = new GitFileChange( - repoPath!, - relativeFileName, - entry.status!, - originalFileName, - undefined, - entry.fileStats, - ); - } + const files: { file?: GitFileChange; files?: GitFileChange[] } = { + files: entry.files?.map(f => new GitFileChange(repoPath!, f.path, f.status, f.originalPath)), + }; + if (type === LogType.LogFile && relativeFileName != null) { + files.file = new GitFileChange( + repoPath!, + relativeFileName, + entry.status!, + originalFileName, + undefined, + entry.fileStats, + ); + } - const stash = stashes?.get(entry.sha!); - if (stash != null) { - commit = new GitCommit( - container, - repoPath!, - stash.sha, - stash.author, - stash.committer, - stash.summary, - stash.parents, - stash.message, - files, - undefined, - entry.line != null ? [entry.line] : [], - entry.tips, - stash.stashName, - stash.stashOnRef, - ); - commits.set(stash.sha, commit); - } else { - commit = new GitCommit( - container, - repoPath!, - entry.sha!, - - new GitCommitIdentity( - entry.author!, - entry.authorEmail, - new Date((entry.authorDate! as any) * 1000), - ), - - new GitCommitIdentity( - entry.committer!, - entry.committerEmail, - new Date((entry.committedDate! as any) * 1000), - ), - entry.summary?.split('\n', 1)[0] ?? '', - entry.parentShas ?? [], - entry.summary ?? '', - files, - undefined, - entry.line != null ? [entry.line] : [], - entry.tips, - ); - commits.set(entry.sha!, commit); - } + const stash = stashes?.get(entry.sha!); + if (stash != null) { + commit = new GitCommit( + container, + repoPath!, + stash.sha, + stash.author, + stash.committer, + stash.summary, + stash.parents, + stash.message, + files, + undefined, + entry.line != null ? [entry.line] : [], + entry.tips, + stash.stashName, + stash.stashOnRef, + ); + commits.set(stash.sha, commit); + } else { + commit = new GitCommit( + container, + repoPath!, + entry.sha!, + + new GitCommitIdentity(entry.author!, entry.authorEmail, new Date((entry.authorDate! as any) * 1000)), + + new GitCommitIdentity( + entry.committer!, + entry.committerEmail, + new Date((entry.committedDate! as any) * 1000), + ), + entry.summary?.split('\n', 1)[0] ?? '', + entry.parentShas ?? [], + entry.summary ?? '', + files, + undefined, + entry.line != null ? [entry.line] : [], + entry.tips, + ); + commits.set(entry.sha!, commit); } } +} + +export function parseGitLogSimple( + data: string, + skip: number, + skipRef?: string, +): [string | undefined, string | undefined, GitFileIndexStatus | undefined] { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + using _sw = maybeStopWatch('Git.parseLogSimple', { log: false, logLevel: 'debug' }); - @debug({ args: false }) - static parseSimple( - data: string, - skip: number, - skipRef?: string, - ): [string | undefined, string | undefined, GitFileIndexStatus | undefined] { - let ref; - let diffFile; - let diffRenamed; - let status; - let file; - let renamed; - - let match; - do { - match = logFileSimpleRegex.exec(data); - if (match == null) break; - - if (match[1] === skipRef) continue; - if (skip-- > 0) continue; - - [, ref, diffFile, diffRenamed, status, file, renamed] = match; - - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - file = ` ${diffRenamed || diffFile || renamed || file}`.substr(1); - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - status = status == null || status.length === 0 ? undefined : ` ${status}`.substr(1); - } while (skip >= 0); - - // Ensure the regex state is reset - logFileSimpleRegex.lastIndex = 0; + let ref; + let diffFile; + let diffRenamed; + let status; + let file; + let renamed; + + let match; + do { + match = logFileSimpleRegex.exec(data); + if (match == null) break; + + if (match[1] === skipRef) continue; + if (skip-- > 0) continue; + + [, ref, diffFile, diffRenamed, status, file, renamed] = match; // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - return [ - ref == null || ref.length === 0 ? undefined : ` ${ref}`.substr(1), - file, - status as GitFileIndexStatus | undefined, - ]; - } + file = ` ${diffRenamed || diffFile || renamed || file}`.substr(1); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + status = status == null || status.length === 0 ? undefined : ` ${status}`.substr(1); + } while (skip >= 0); + + // Ensure the regex state is reset + logFileSimpleRegex.lastIndex = 0; + + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + return [ + ref == null || ref.length === 0 ? undefined : ` ${ref}`.substr(1), + file, + status as GitFileIndexStatus | undefined, + ]; +} - @debug({ args: false }) - static parseSimpleRenamed( - data: string, - originalFileName: string, - ): [string | undefined, string | undefined, GitFileIndexStatus | undefined] { - let match = logFileSimpleRenamedRegex.exec(data); - if (match == null) return [undefined, undefined, undefined]; +export function parseGitLogSimpleRenamed( + data: string, + originalFileName: string, +): [string | undefined, string | undefined, GitFileIndexStatus | undefined] { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + using _sw = maybeStopWatch('Git.parseLogSimpleRenamed', { log: false, logLevel: 'debug' }); - const [, ref, files] = match; + let match = logFileSimpleRenamedRegex.exec(data); + if (match == null) return [undefined, undefined, undefined]; - let status; - let file; - let renamed; + const [, ref, files] = match; - do { - match = logFileSimpleRenamedFilesRegex.exec(files); - if (match == null) break; + let status; + let file; + let renamed; - [, status, file, renamed] = match; + do { + match = logFileSimpleRenamedFilesRegex.exec(files); + if (match == null) break; - if (originalFileName !== file) { - status = undefined; - file = undefined; - renamed = undefined; - continue; - } + [, status, file, renamed] = match; - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - file = ` ${renamed || file}`.substr(1); - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - status = status == null || status.length === 0 ? undefined : ` ${status}`.substr(1); + if (originalFileName !== file) { + status = undefined; + file = undefined; + renamed = undefined; + continue; + } + + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + file = ` ${renamed || file}`.substr(1); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + status = status == null || status.length === 0 ? undefined : ` ${status}`.substr(1); - break; - } while (true); + break; + } while (true); - // Ensure the regex state is reset - logFileSimpleRenamedFilesRegex.lastIndex = 0; + // Ensure the regex state is reset + logFileSimpleRenamedFilesRegex.lastIndex = 0; - return [ - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ref == null || ref.length === 0 || file == null ? undefined : ` ${ref}`.substr(1), - file, - status as GitFileIndexStatus | undefined, - ]; - } + return [ + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ref == null || ref.length === 0 || file == null ? undefined : ` ${ref}`.substr(1), + file, + status as GitFileIndexStatus | undefined, + ]; } diff --git a/src/git/parsers/reflogParser.ts b/src/git/parsers/reflogParser.ts index 1bbcf49792983..3f56201a3b87c 100644 --- a/src/git/parsers/reflogParser.ts +++ b/src/git/parsers/reflogParser.ts @@ -1,4 +1,4 @@ -import { debug } from '../../system/decorators/log'; +import { maybeStopWatch } from '../../system/stopwatch'; import type { GitReflog } from '../models/reflog'; import { GitReflogRecord } from '../models/reflog'; @@ -10,119 +10,118 @@ const reflogHEADRegex = /.*?\/?HEAD$/; const lb = '%x3c'; // `%x${'<'.charCodeAt(0).toString(16)}`; const rb = '%x3e'; // `%x${'>'.charCodeAt(0).toString(16)}`; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitReflogParser { - static defaultFormat = [ - `${lb}r${rb}%H`, // ref - `${lb}d${rb}%gD`, // reflog selector (with iso8601 timestamp) - `${lb}s${rb}%gs`, // reflog subject - // `${lb}n${rb}%D` // ref names - ].join(''); - - @debug({ args: false }) - static parse( - data: string, - repoPath: string, - commands: string[], - limit: number, - totalLimit: number, - ): GitReflog | undefined { - if (!data) return undefined; - - const records: GitReflogRecord[] = []; - - let sha; - let selector; - let date; - let command; - let commandArgs; - let details; - - let head; - let headDate; - let headSha; - - let count = 0; - let total = 0; - let recordDate; - let record: GitReflogRecord | undefined; - - let match; - do { - match = reflogRegex.exec(data); - if (match == null) break; - - [, sha, selector, date, command, commandArgs, details] = match; - - total++; - - if (record !== undefined) { - // If the next record has the same sha as the previous, use it if it is not pointing to just HEAD and the previous is +export const parseGitRefLogDefaultFormat = [ + `${lb}r${rb}%H`, // ref + `${lb}d${rb}%gD`, // reflog selector (with iso8601 timestamp) + `${lb}s${rb}%gs`, // reflog subject + // `${lb}n${rb}%D` // ref names +].join(''); + +export function parseGitRefLog( + data: string, + repoPath: string, + commands: string[], + limit: number, + totalLimit: number, +): GitReflog | undefined { + using sw = maybeStopWatch(`Git.parseRefLog(${repoPath})`, { log: false, logLevel: 'debug' }); + if (!data) return undefined; + + const records: GitReflogRecord[] = []; + + let sha; + let selector; + let date; + let command; + let commandArgs; + let details; + + let head; + let headDate; + let headSha; + + let count = 0; + let total = 0; + let recordDate; + let record: GitReflogRecord | undefined; + + let match; + do { + match = reflogRegex.exec(data); + if (match == null) break; + + [, sha, selector, date, command, commandArgs, details] = match; + + total++; + + if (record !== undefined) { + // If the next record has the same sha as the previous, use it if it is not pointing to just HEAD and the previous is + if ( + sha === record.sha && + (date !== recordDate || !reflogHEADRegex.test(record.selector) || reflogHEADRegex.test(selector)) + ) { + continue; + } + + if (sha !== record.sha) { if ( - sha === record.sha && - (date !== recordDate || !reflogHEADRegex.test(record.selector) || reflogHEADRegex.test(selector)) + head != null && + headDate === recordDate && + headSha == record.sha && + reflogHEADRegex.test(record.selector) ) { - continue; + record.update(sha, head); + } else { + record.update(sha); } - if (sha !== record.sha) { - if ( - head != null && - headDate === recordDate && - headSha == record.sha && - reflogHEADRegex.test(record.selector) - ) { - record.update(sha, head); - } else { - record.update(sha); - } - - records.push(record); - record = undefined; - recordDate = undefined; - - count++; - if (limit !== 0 && count >= limit) break; - } - } - - if (command === 'HEAD') { - head = selector; - headDate = date; - headSha = sha; - - continue; - } + records.push(record); + record = undefined; + recordDate = undefined; - if (commands.includes(command)) { - record = new GitReflogRecord( - repoPath, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${sha}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${selector}`.substr(1), - new Date(date), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${command}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - commandArgs == null || commandArgs.length === 0 ? undefined : commandArgs.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - details == null || details.length === 0 ? undefined : details.substr(1), - ); - recordDate = date; + count++; + if (limit !== 0 && count >= limit) break; } - } while (true); - - // Ensure the regex state is reset - reflogRegex.lastIndex = 0; - - return { - repoPath: repoPath, - records: records, - count: count, - total: total, - limit: limit, - hasMore: (limit !== 0 && count >= limit) || (totalLimit !== 0 && total >= totalLimit), - }; - } + } + + if (command === 'HEAD') { + head = selector; + headDate = date; + headSha = sha; + + continue; + } + + if (commands.includes(command)) { + record = new GitReflogRecord( + repoPath, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${sha}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${selector}`.substr(1), + new Date(date), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${command}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + commandArgs == null || commandArgs.length === 0 ? undefined : commandArgs.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + details == null || details.length === 0 ? undefined : details.substr(1), + ); + recordDate = date; + } + } while (true); + + // Ensure the regex state is reset + reflogRegex.lastIndex = 0; + + sw?.stop({ suffix: ` parsed ${records.length} records` }); + + return { + repoPath: repoPath, + records: records, + count: count, + total: total, + limit: limit, + hasMore: (limit !== 0 && count >= limit) || (totalLimit !== 0 && total >= totalLimit), + }; } diff --git a/src/git/parsers/remoteParser.ts b/src/git/parsers/remoteParser.ts index 7f0059df5d8ab..82b37241fa7ce 100644 --- a/src/git/parsers/remoteParser.ts +++ b/src/git/parsers/remoteParser.ts @@ -1,74 +1,76 @@ -import { debug } from '../../system/decorators/log'; +import type { Container } from '../../container'; +import { maybeStopWatch } from '../../system/stopwatch'; import type { GitRemoteType } from '../models/remote'; import { GitRemote } from '../models/remote'; import type { getRemoteProviderMatcher } from '../remotes/remoteProviders'; -const emptyStr = ''; - const remoteRegex = /^(.*)\t(.*)\s\((.*)\)$/gm; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitRemoteParser { - @debug({ args: false, singleLine: true }) - static parse( - data: string, - repoPath: string, - remoteProviderMatcher: ReturnType, - ): GitRemote[] | undefined { - if (!data) return undefined; - - const remotes = new Map(); - - let name; - let url; - let type; - - let scheme; - let domain; - let path; - - let remote: GitRemote | undefined; - - let match; - do { - match = remoteRegex.exec(data); - if (match == null) break; - - [, name, url, type] = match; - - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - name = ` ${name}`.substr(1); - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - url = ` ${url}`.substr(1); - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - type = ` ${type}`.substr(1); - - [scheme, domain, path] = parseGitRemoteUrl(url); - - remote = remotes.get(name); - if (remote == null) { - remote = new GitRemote(repoPath, name, scheme, domain, path, remoteProviderMatcher(url, domain, path), [ - { url: url, type: type as GitRemoteType }, - ]); - remotes.set(name, remote); - } else { - remote.urls.push({ url: url, type: type as GitRemoteType }); - if (remote.provider != null && type !== 'push') continue; - - if (remote.provider?.hasRichIntegration()) { - remote.provider.dispose(); - } - - const provider = remoteProviderMatcher(url, domain, path); - if (provider == null) continue; - - remote = new GitRemote(repoPath, name, scheme, domain, path, provider, remote.urls); - remotes.set(name, remote); - } - } while (true); - - return [...remotes.values()]; - } +export function parseGitRemotes( + container: Container, + data: string, + repoPath: string, + remoteProviderMatcher: ReturnType, +): GitRemote[] { + using sw = maybeStopWatch(`Git.parseRemotes(${repoPath})`, { log: false, logLevel: 'debug' }); + if (!data) return []; + + const remotes = new Map(); + + let name; + let url; + let type; + + let scheme; + let domain; + let path; + + let remote: GitRemote | undefined; + + let match; + do { + match = remoteRegex.exec(data); + if (match == null) break; + + [, name, url, type] = match; + + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + name = ` ${name}`.substr(1); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + url = ` ${url}`.substr(1); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + type = ` ${type}`.substr(1); + + [scheme, domain, path] = parseGitRemoteUrl(url); + + remote = remotes.get(name); + if (remote == null) { + remote = new GitRemote( + container, + repoPath, + name, + scheme, + domain, + path, + remoteProviderMatcher(url, domain, path), + [{ url: url, type: type as GitRemoteType }], + ); + remotes.set(name, remote); + } else { + remote.urls.push({ url: url, type: type as GitRemoteType }); + if (remote.provider != null && type !== 'push') continue; + + const provider = remoteProviderMatcher(url, domain, path); + if (provider == null) continue; + + remote = new GitRemote(container, repoPath, name, scheme, domain, path, provider, remote.urls); + remotes.set(name, remote); + } + } while (true); + + sw?.stop({ suffix: ` parsed ${remotes.size} remotes` }); + + return [...remotes.values()]; } // Test git urls @@ -113,11 +115,11 @@ export const remoteUrlRegex = export function parseGitRemoteUrl(url: string): [scheme: string, domain: string, path: string] { const match = remoteUrlRegex.exec(url); - if (match == null) return [emptyStr, emptyStr, url]; + if (match == null) return ['', '', url]; return [ match[1] || match[3] || match[6], match[2] || match[4] || match[5] || match[7] || match[8], - match[9].replace(/\.git\/?$/, emptyStr), + match[9].replace(/\.git\/?$/, ''), ]; } diff --git a/src/git/parsers/statusParser.ts b/src/git/parsers/statusParser.ts index e295b9f5924d6..081fbe37c8b57 100644 --- a/src/git/parsers/statusParser.ts +++ b/src/git/parsers/statusParser.ts @@ -1,143 +1,163 @@ -import { debug } from '../../system/decorators/log'; import { normalizePath } from '../../system/path'; +import { maybeStopWatch } from '../../system/stopwatch'; import { GitStatus, GitStatusFile } from '../models/status'; -const emptyStr = ''; - const aheadStatusV1Regex = /(?:ahead ([0-9]+))/; const behindStatusV1Regex = /(?:behind ([0-9]+))/; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitStatusParser { - @debug({ args: false, singleLine: true }) - static parse(data: string, repoPath: string, porcelainVersion: number): GitStatus | undefined { - if (!data) return undefined; +export function parseGitStatus(data: string, repoPath: string, porcelainVersion: number): GitStatus | undefined { + using sw = maybeStopWatch(`Git.parseStatus(${repoPath}, v=${porcelainVersion})`, { + log: false, + logLevel: 'debug', + }); + if (!data) return undefined; - const lines = data.split('\n').filter((i?: T): i is T => Boolean(i)); - if (lines.length === 0) return undefined; + const lines = data.split('\n').filter((i?: T): i is T => Boolean(i)); + if (lines.length === 0) return undefined; - if (porcelainVersion < 2) return this.parseV1(lines, repoPath); + const status = porcelainVersion < 2 ? parseStatusV1(lines, repoPath) : parseStatusV2(lines, repoPath); - return this.parseV2(lines, repoPath); - } + sw?.stop({ suffix: ` parsed ${status.files.length} files` }); - @debug({ args: false, singleLine: true }) - private static parseV1(lines: string[], repoPath: string): GitStatus { - let branch: string | undefined; - const files = []; - const state = { - ahead: 0, - behind: 0, - }; - let upstream; - - let position = -1; - while (++position < lines.length) { - const line = lines[position]; - // Header - if (line.startsWith('##')) { - const lineParts = line.split(' '); - [branch, upstream] = lineParts[1].split('...'); - if (lineParts.length > 2) { - const upstreamStatus = lineParts.slice(2).join(' '); + return status; +} +function parseStatusV1(lines: string[], repoPath: string): GitStatus { + let branch: string | undefined; + const files = []; + const state = { + ahead: 0, + behind: 0, + }; + let upstream; + let missing = false; + + let position = -1; + while (++position < lines.length) { + const line = lines[position]; + // Header + if (line.startsWith('##')) { + const lineParts = line.split(' '); + [branch, upstream] = lineParts[1].split('...'); + if (lineParts.length > 2) { + const upstreamStatus = lineParts.slice(2).join(' '); + if (upstreamStatus === '[gone]') { + missing = true; + state.ahead = 0; + state.behind = 0; + } else { const aheadStatus = aheadStatusV1Regex.exec(upstreamStatus); state.ahead = aheadStatus == null ? 0 : Number(aheadStatus[1]) || 0; const behindStatus = behindStatusV1Regex.exec(upstreamStatus); state.behind = behindStatus == null ? 0 : Number(behindStatus[1]) || 0; } + } + } else { + const rawStatus = line.substring(0, 2); + const fileName = line.substring(3); + if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { + const [file1, file2] = fileName.replace(/"/g, '').split('->'); + files.push(parseStatusFile(repoPath, rawStatus, file2.trim(), file1.trim())); } else { - const rawStatus = line.substring(0, 2); - const fileName = line.substring(3); - if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { - const [file1, file2] = fileName.replace(/"/g, emptyStr).split('->'); - files.push(this.parseStatusFile(repoPath, rawStatus, file2.trim(), file1.trim())); - } else { - files.push(this.parseStatusFile(repoPath, rawStatus, fileName)); - } + files.push(parseStatusFile(repoPath, rawStatus, fileName)); } } - - return new GitStatus(normalizePath(repoPath), branch ?? emptyStr, emptyStr, files, state, upstream); } - @debug({ args: false, singleLine: true }) - private static parseV2(lines: string[], repoPath: string): GitStatus { - let branch: string | undefined; - const files = []; - let sha: string | undefined; - const state = { - ahead: 0, - behind: 0, - }; - let upstream; - - let position = -1; - while (++position < lines.length) { - const line = lines[position]; - // Headers - if (line.startsWith('#')) { - const lineParts = line.split(' '); - switch (lineParts[1]) { - case 'branch.oid': - sha = lineParts[2]; - break; - case 'branch.head': - branch = lineParts[2]; - break; - case 'branch.upstream': - upstream = lineParts[2]; - break; - case 'branch.ab': - state.ahead = Number(lineParts[2].substring(1)); - state.behind = Number(lineParts[3].substring(1)); - break; - } - } else { - const lineParts = line.split(' '); - switch (lineParts[0][0]) { - case '1': // normal - files.push(this.parseStatusFile(repoPath, lineParts[1], lineParts.slice(8).join(' '))); - break; - case '2': { - // rename - const file = lineParts.slice(9).join(' ').split('\t'); - files.push(this.parseStatusFile(repoPath, lineParts[1], file[0], file[1])); - break; - } - case 'u': // unmerged - files.push(this.parseStatusFile(repoPath, lineParts[1], lineParts.slice(10).join(' '))); - break; - case '?': // untracked - files.push(this.parseStatusFile(repoPath, '??', lineParts.slice(1).join(' '))); - break; + return new GitStatus( + normalizePath(repoPath), + branch ?? '', + '', + files, + state, + upstream ? { name: upstream, missing: missing } : undefined, + ); +} + +function parseStatusV2(lines: string[], repoPath: string): GitStatus { + let branch: string | undefined; + const files = []; + let sha: string | undefined; + const state = { + ahead: 0, + behind: 0, + }; + let missing = true; + let upstream; + + let position = -1; + while (++position < lines.length) { + const line = lines[position]; + // Headers + if (line.startsWith('#')) { + const lineParts = line.split(' '); + switch (lineParts[1]) { + case 'branch.oid': + sha = lineParts[2]; + break; + case 'branch.head': + branch = lineParts[2]; + break; + case 'branch.upstream': + upstream = lineParts[2]; + break; + case 'branch.ab': + missing = false; + state.ahead = Number(lineParts[2].substring(1)); + state.behind = Number(lineParts[3].substring(1)); + break; + } + } else { + const lineParts = line.split(' '); + switch (lineParts[0][0]) { + case '1': // normal + files.push(parseStatusFile(repoPath, lineParts[1], lineParts.slice(8).join(' '))); + break; + case '2': { + // rename + const file = lineParts.slice(9).join(' ').split('\t'); + files.push(parseStatusFile(repoPath, lineParts[1], file[0], file[1])); + break; } + case 'u': // unmerged + files.push(parseStatusFile(repoPath, lineParts[1], lineParts.slice(10).join(' '))); + break; + case '?': // untracked + files.push(parseStatusFile(repoPath, '??', lineParts.slice(1).join(' '))); + break; } } - - return new GitStatus(normalizePath(repoPath), branch ?? emptyStr, sha ?? emptyStr, files, state, upstream); } - static parseStatusFile( - repoPath: string, - rawStatus: string, - fileName: string, - originalFileName?: string, - ): GitStatusFile { - let x = !rawStatus.startsWith('.') ? rawStatus[0].trim() : undefined; - if (x == null || x.length === 0) { - x = undefined; - } + return new GitStatus( + normalizePath(repoPath), + branch ?? '', + sha ?? '', + files, + state, + upstream ? { name: upstream, missing: missing } : undefined, + ); +} - let y = undefined; - if (rawStatus.length > 1) { - y = rawStatus[1] !== '.' ? rawStatus[1].trim() : undefined; - if (y == null || y.length === 0) { - y = undefined; - } - } +function parseStatusFile( + repoPath: string, + rawStatus: string, + fileName: string, + originalFileName?: string, +): GitStatusFile { + let x = !rawStatus.startsWith('.') ? rawStatus[0].trim() : undefined; + if (x == null || x.length === 0) { + x = undefined; + } - return new GitStatusFile(repoPath, x, y, fileName, originalFileName); + let y = undefined; + if (rawStatus.length > 1) { + y = rawStatus[1] !== '.' ? rawStatus[1].trim() : undefined; + if (y == null || y.length === 0) { + y = undefined; + } } + + return new GitStatusFile(repoPath, x, y, fileName, originalFileName); } diff --git a/src/git/parsers/tagParser.ts b/src/git/parsers/tagParser.ts index 26a59fcc825ce..a63ce47ebda06 100644 --- a/src/git/parsers/tagParser.ts +++ b/src/git/parsers/tagParser.ts @@ -1,4 +1,4 @@ -import { debug } from '../../system/decorators/log'; +import { maybeStopWatch } from '../../system/stopwatch'; import { GitTag } from '../models/tag'; const tagRegex = /^(.+)<\*r>(.*)(.*)(.*)(.*)(.*)$/gm; @@ -7,54 +7,53 @@ const tagRegex = /^(.+)<\*r>(.*)(.*)(.*)(.*)(.*)$/gm; const lb = '%3c'; // `%${'<'.charCodeAt(0).toString(16)}`; const rb = '%3e'; // `%${'>'.charCodeAt(0).toString(16)}`; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitTagParser { - static defaultFormat = [ - `${lb}n${rb}%(refname)`, // tag name - `${lb}*r${rb}%(*objectname)`, // ref - `${lb}r${rb}%(objectname)`, // ref - `${lb}d${rb}%(creatordate:iso8601)`, // created date - `${lb}ad${rb}%(authordate:iso8601)`, // author date - `${lb}s${rb}%(subject)`, // message - ].join(''); - - @debug({ args: false, singleLine: true }) - static parse(data: string, repoPath: string): GitTag[] | undefined { - if (!data) return undefined; - - const tags: GitTag[] = []; - - let name; - let ref1; - let ref2; - let date; - let commitDate; - let message; - - let match; - do { - match = tagRegex.exec(data); - if (match == null) break; - - [, name, ref1, ref2, date, commitDate, message] = match; - - // Strip off refs/tags/ - name = name.substr(10); - - tags.push( - new GitTag( - repoPath, - name, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${ref1 || ref2}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${message}`.substr(1), - date ? new Date(date) : undefined, - commitDate == null || commitDate.length === 0 ? undefined : new Date(commitDate), - ), - ); - } while (true); - - return tags; - } +export const parseGitTagsDefaultFormat = [ + `${lb}n${rb}%(refname)`, // tag name + `${lb}*r${rb}%(*objectname)`, // ref + `${lb}r${rb}%(objectname)`, // ref + `${lb}d${rb}%(creatordate:iso8601)`, // created date + `${lb}ad${rb}%(authordate:iso8601)`, // author date + `${lb}s${rb}%(subject)`, // message +].join(''); + +export function parseGitTags(data: string, repoPath: string): GitTag[] { + using sw = maybeStopWatch(`Git.parseTags(${repoPath})`, { log: false, logLevel: 'debug' }); + + const tags: GitTag[] = []; + if (!data) return tags; + + let name; + let ref1; + let ref2; + let date; + let commitDate; + let message; + + let match; + do { + match = tagRegex.exec(data); + if (match == null) break; + + [, name, ref1, ref2, date, commitDate, message] = match; + + // Strip off refs/tags/ + name = name.substr(10); + + tags.push( + new GitTag( + repoPath, + name, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${ref1 || ref2}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${message}`.substr(1), + date ? new Date(date) : undefined, + commitDate == null || commitDate.length === 0 ? undefined : new Date(commitDate), + ), + ); + } while (true); + + sw?.stop({ suffix: ` parsed ${tags.length} tags` }); + + return tags; } diff --git a/src/git/parsers/treeParser.ts b/src/git/parsers/treeParser.ts index 25b4baeb60992..837d8a938fcdc 100644 --- a/src/git/parsers/treeParser.ts +++ b/src/git/parsers/treeParser.ts @@ -1,40 +1,74 @@ -import { debug } from '../../system/decorators/log'; -import type { GitTreeEntry } from '../models/tree'; +import { maybeStopWatch } from '../../system/stopwatch'; +import type { GitLsFilesEntry, GitTreeEntry } from '../models/tree'; -const emptyStr = ''; const treeRegex = /(?:.+?)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+)/gm; +const filesRegex = /^(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/gm; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitTreeParser { - @debug({ args: false, singleLine: true }) - static parse(data: string | undefined): GitTreeEntry[] | undefined { - if (!data) return undefined; - - const trees: GitTreeEntry[] = []; - - let type; - let sha; - let size; - let filePath; - - let match; - do { - match = treeRegex.exec(data); - if (match == null) break; - - [, type, sha, size, filePath] = match; - - trees.push({ - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - commitSha: sha == null || sha.length === 0 ? emptyStr : ` ${sha}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - path: filePath == null || filePath.length === 0 ? emptyStr : ` ${filePath}`.substr(1), - size: Number(size) || 0, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - type: (type == null || type.length === 0 ? emptyStr : ` ${type}`.substr(1)) as 'blob' | 'tree', - }); - } while (true); - - return trees; - } +export function parseGitTree(data: string | undefined, ref: string): GitTreeEntry[] { + using sw = maybeStopWatch(`Git.parseTree`, { log: false, logLevel: 'debug' }); + + const trees: GitTreeEntry[] = []; + if (!data) return trees; + + let type; + let oid; + let size; + let filePath; + + let match; + do { + match = treeRegex.exec(data); + if (match == null) break; + + [, type, oid, size, filePath] = match; + + trees.push({ + ref: ref, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + oid: oid == null || oid.length === 0 ? '' : ` ${oid}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + path: filePath == null || filePath.length === 0 ? '' : ` ${filePath}`.substr(1), + size: Number(size) || 0, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + type: (type == null || type.length === 0 ? '' : ` ${type}`.substr(1)) as 'blob' | 'tree', + }); + } while (true); + + sw?.stop({ suffix: ` parsed ${trees.length} trees` }); + + return trees; +} + +export function parseGitLsFiles(data: string | undefined): GitLsFilesEntry[] { + using sw = maybeStopWatch(`Git.parseLsFiles`, { log: false, logLevel: 'debug' }); + + const files: GitLsFilesEntry[] = []; + if (!data) return files; + + let filePath; + let mode; + let oid; + let stage; + + let match; + do { + match = filesRegex.exec(data); + if (match == null) break; + + [, mode, oid, stage, filePath] = match; + + files.push({ + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + mode: mode == null || mode.length === 0 ? '' : ` ${mode}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + oid: oid == null || oid.length === 0 ? '' : ` ${oid}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + path: filePath == null || filePath.length === 0 ? '' : ` ${filePath}`.substr(1), + stage: parseInt(stage, 10), + }); + } while (true); + + sw?.stop({ suffix: ` parsed ${files.length} files` }); + + return files; } diff --git a/src/git/parsers/worktreeParser.ts b/src/git/parsers/worktreeParser.ts index 230037b3acd2c..7afdacafd2f27 100644 --- a/src/git/parsers/worktreeParser.ts +++ b/src/git/parsers/worktreeParser.ts @@ -1,7 +1,9 @@ import { Uri } from 'vscode'; -import { debug } from '../../system/decorators/log'; +import type { Container } from '../../container'; import { normalizePath } from '../../system/path'; +import { maybeStopWatch } from '../../system/stopwatch'; import { getLines } from '../../system/string'; +import type { GitBranch } from '../models/branch'; import { GitWorktree } from '../models/worktree'; interface WorktreeEntry { @@ -14,88 +16,96 @@ interface WorktreeEntry { prunable?: boolean | string; } -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitWorktreeParser { - @debug({ args: false, singleLine: true }) - static parse(data: string, repoPath: string): GitWorktree[] { - if (!data) return []; +export function parseGitWorktrees( + container: Container, + data: string, + repoPath: string, + branches: GitBranch[], +): GitWorktree[] { + using sw = maybeStopWatch(`Git.parseWorktrees(${repoPath})`, { log: false, logLevel: 'debug' }); - if (repoPath != null) { - repoPath = normalizePath(repoPath); - } + const worktrees: GitWorktree[] = []; + if (!data) return worktrees; - const worktrees: GitWorktree[] = []; + if (repoPath != null) { + repoPath = normalizePath(repoPath); + } - let entry: Partial | undefined = undefined; - let line: string; - let index: number; - let key: string; - let value: string; - let locked: string; - let prunable: string; - let main = true; // the first worktree is the main worktree + let entry: Partial | undefined = undefined; + let line: string; + let index: number; + let key: string; + let value: string; + let locked: string; + let prunable: string; + let main = true; // the first worktree is the main worktree - for (line of getLines(data)) { - index = line.indexOf(' '); - if (index === -1) { - key = line; - value = ''; - } else { - key = line.substring(0, index); - value = line.substring(index + 1); - } + for (line of getLines(data)) { + index = line.indexOf(' '); + if (index === -1) { + key = line; + value = ''; + } else { + key = line.substring(0, index); + value = line.substring(index + 1); + } - if (key.length === 0 && entry != null) { - worktrees.push( - new GitWorktree( - main, - entry.bare ? 'bare' : entry.detached ? 'detached' : 'branch', - repoPath, - Uri.file(entry.path!), - entry.locked ?? false, - entry.prunable ?? false, - entry.sha, - entry.branch, - ), - ); + if (key.length === 0 && entry != null) { + // eslint-disable-next-line no-loop-func + const branch = entry.branch ? branches?.find(b => b.name === entry!.branch) : undefined; - entry = undefined; - main = false; - continue; - } + worktrees.push( + new GitWorktree( + container, + main, + entry.bare ? 'bare' : entry.detached ? 'detached' : 'branch', + repoPath, + Uri.file(entry.path!), + entry.locked ?? false, + entry.prunable ?? false, + entry.sha, + branch, + ), + ); - if (entry == null) { - entry = {}; - } + entry = undefined; + main = false; + continue; + } - switch (key) { - case 'worktree': - entry.path = value; - break; - case 'bare': - entry.bare = true; - break; - case 'HEAD': - entry.sha = value; - break; - case 'branch': - // Strip off refs/heads/ - entry.branch = value.substr(11); - break; - case 'detached': - entry.detached = true; - break; - case 'locked': - [, locked] = value.split(' ', 2); - entry.locked = locked?.trim() || true; - break; - case 'prunable': - [, prunable] = value.split(' ', 2); - entry.prunable = prunable?.trim() || true; - break; - } + if (entry == null) { + entry = {}; } - return worktrees; + switch (key) { + case 'worktree': + entry.path = value; + break; + case 'bare': + entry.bare = true; + break; + case 'HEAD': + entry.sha = value; + break; + case 'branch': + // Strip off refs/heads/ + entry.branch = value.substr(11); + break; + case 'detached': + entry.detached = true; + break; + case 'locked': + [, locked] = value.split(' ', 2); + entry.locked = locked?.trim() || true; + break; + case 'prunable': + [, prunable] = value.split(' ', 2); + entry.prunable = prunable?.trim() || true; + break; + } } + + sw?.stop({ suffix: ` parsed ${worktrees.length} worktrees` }); + + return worktrees; } diff --git a/src/git/queryResults.ts b/src/git/queryResults.ts new file mode 100644 index 0000000000000..ad35c6f02ea96 --- /dev/null +++ b/src/git/queryResults.ts @@ -0,0 +1,135 @@ +import type { Container } from '../container'; +import { getSettledValue } from '../system/promise'; +import { pluralize } from '../system/string'; +import type { FilesQueryFilter } from '../views/nodes/resultsFilesNode'; +import type { GitDiffShortStat } from './models/diff'; +import type { GitFile } from './models/file'; +import type { GitLog } from './models/log'; +import type { GitUser } from './models/user'; + +export interface CommitsQueryResults { + readonly label?: string; + readonly log: GitLog | undefined; + readonly hasMore: boolean; + more?(limit: number | undefined): Promise; +} + +export interface FilesQueryResults { + label: string; + files: GitFile[] | undefined; + stats?: (GitDiffShortStat & { approximated?: boolean }) | undefined; + + filtered?: Map; +} + +export async function getAheadBehindFilesQuery( + container: Container, + repoPath: string, + comparison: string, + compareWithWorkingTree: boolean, +): Promise { + const [filesResult, workingFilesResult, statsResult, workingStatsResult] = await Promise.allSettled([ + container.git.getDiffStatus(repoPath, comparison), + compareWithWorkingTree ? container.git.getDiffStatus(repoPath, 'HEAD') : undefined, + container.git.getChangedFilesCount(repoPath, comparison), + compareWithWorkingTree ? container.git.getChangedFilesCount(repoPath, 'HEAD') : undefined, + ]); + + let files = getSettledValue(filesResult) ?? []; + let stats: FilesQueryResults['stats'] = getSettledValue(statsResult); + + if (compareWithWorkingTree) { + const workingFiles = getSettledValue(workingFilesResult); + if (workingFiles != null) { + if (files.length === 0) { + files = workingFiles ?? []; + } else { + for (const wf of workingFiles) { + const index = files.findIndex(f => f.path === wf.path); + if (index !== -1) { + files.splice(index, 1, wf); + } else { + files.push(wf); + } + } + } + } + + const workingStats = getSettledValue(workingStatsResult); + if (workingStats != null) { + if (stats == null) { + stats = workingStats; + } else { + stats = { + additions: stats.additions + workingStats.additions, + deletions: stats.deletions + workingStats.deletions, + changedFiles: files.length, + approximated: true, + }; + } + } + } + + return { + label: `${pluralize('file', files.length, { zero: 'No' })} changed`, + files: files, + stats: stats, + }; +} + +export function getCommitsQuery( + container: Container, + repoPath: string, + range: string, + filterByAuthors?: GitUser[] | undefined, +): (limit: number | undefined) => Promise { + return async (limit: number | undefined) => { + const log = await container.git.getLog(repoPath, { + limit: limit, + ref: range, + authors: filterByAuthors, + }); + + const results: Mutable = { + log: log, + hasMore: log?.hasMore ?? true, + }; + if (results.hasMore) { + results.more = async (limit: number | undefined) => { + results.log = (await results.log?.more?.(limit)) ?? results.log; + results.hasMore = results.log?.hasMore ?? true; + }; + } + + return results satisfies CommitsQueryResults; + }; +} + +export async function getFilesQuery( + container: Container, + repoPath: string, + ref1: string, + ref2: string, +): Promise { + let comparison; + if (ref2 === '') { + debugger; + throw new Error('Cannot get files for comparisons of a ref with working tree'); + } else if (ref1 === '') { + comparison = ref2; + } else { + comparison = `${ref2}..${ref1}`; + } + + const [filesResult, statsResult] = await Promise.allSettled([ + container.git.getDiffStatus(repoPath, comparison), + container.git.getChangedFilesCount(repoPath, comparison), + ]); + + const files = getSettledValue(filesResult) ?? []; + return { + label: `${pluralize('file', files.length, { zero: 'No' })} changed`, + files: files, + stats: getSettledValue(statsResult), + }; +} diff --git a/src/git/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index 2d9558cf0dedf..1346c6c7c5da4 100644 --- a/src/git/remotes/azure-devops.ts +++ b/src/git/remotes/azure-devops.ts @@ -1,7 +1,10 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { Brand, Unbrand } from '../../system/brand'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const gitRegex = /\/_git\/?/i; @@ -73,10 +76,14 @@ export class AzureDevOpsRemote extends RemoteProvider { return 'azdo'; } - get id() { + get id(): RemoteProviderId { return 'azure-devops'; } + get gkProviderId(): GkProviderId { + return 'azureDevops' satisfies Unbrand as Brand; + } + get name() { return 'Azure DevOps'; } diff --git a/src/git/remotes/bitbucket-server.ts b/src/git/remotes/bitbucket-server.ts index 97cda9d89dac0..a0eb0a6948013 100644 --- a/src/git/remotes/bitbucket-server.ts +++ b/src/git/remotes/bitbucket-server.ts @@ -1,8 +1,11 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { Brand, Unbrand } from '../../system/brand'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; @@ -40,20 +43,32 @@ export class BitbucketServerRemote extends RemoteProvider { } protected override get baseUrl(): string { - const [project, repo] = this.path.startsWith('scm/') - ? this.path.replace('scm/', '').split('/') - : this.splitPath(); + const [project, repo] = this.splitPath(); return `${this.protocol}://${this.domain}/projects/${project}/repos/${repo}`; } + protected override splitPath(): [string, string] { + if (this.path.startsWith('scm/')) { + const path = this.path.replace('scm/', ''); + const index = path.indexOf('/'); + return [this.path.substring(0, index), this.path.substring(index + 1)]; + } + + return super.splitPath(); + } + override get icon() { return 'bitbucket'; } - get id() { + get id(): RemoteProviderId { return 'bitbucket-server'; } + get gkProviderId(): GkProviderId { + return 'bitbucketServer' satisfies Unbrand as Brand; + } + get name() { return this.formatName('Bitbucket Server'); } diff --git a/src/git/remotes/bitbucket.ts b/src/git/remotes/bitbucket.ts index 1b533ceb90e36..8678fea445cfb 100644 --- a/src/git/remotes/bitbucket.ts +++ b/src/git/remotes/bitbucket.ts @@ -1,8 +1,11 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { Brand, Unbrand } from '../../system/brand'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; @@ -42,10 +45,14 @@ export class BitbucketRemote extends RemoteProvider { return 'bitbucket'; } - get id() { + get id(): RemoteProviderId { return 'bitbucket'; } + get gkProviderId(): GkProviderId { + return 'bitbucket' satisfies Unbrand as Brand; + } + get name() { return this.formatName('Bitbucket'); } diff --git a/src/git/remotes/custom.ts b/src/git/remotes/custom.ts index 91ad10c44f615..10beef3facece 100644 --- a/src/git/remotes/custom.ts +++ b/src/git/remotes/custom.ts @@ -1,7 +1,9 @@ import type { Range, Uri } from 'vscode'; import type { RemotesUrlsConfig } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; import { getTokensFromTemplate, interpolate } from '../../system/string'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; export class CustomRemote extends RemoteProvider { @@ -12,10 +14,14 @@ export class CustomRemote extends RemoteProvider { this.urls = urls; } - get id() { + get id(): RemoteProviderId { return 'custom'; } + get gkProviderId(): GkProviderId | undefined { + return undefined; + } + get name() { return this.formatName('Custom'); } @@ -106,7 +112,7 @@ export class CustomRemote extends RemoteProvider { repo: this.path, repoBase: repoBase, repoPath: repoPath, - ...(additionalContext ?? {}), + ...additionalContext, }; for (const [key, value] of Object.entries(context)) { diff --git a/src/git/remotes/gerrit.ts b/src/git/remotes/gerrit.ts index f4fb023ee37e2..88b7bfcb1189f 100644 --- a/src/git/remotes/gerrit.ts +++ b/src/git/remotes/gerrit.ts @@ -1,8 +1,10 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/\+(.+)$/i; @@ -53,10 +55,14 @@ export class GerritRemote extends RemoteProvider { return 'gerrit'; } - get id() { + get id(): RemoteProviderId { return 'gerrit'; } + get gkProviderId(): GkProviderId | undefined { + return undefined; // TODO@eamodio DRAFTS add this when supported by backend + } + get name() { return this.formatName('Gerrit'); } diff --git a/src/git/remotes/gitea.ts b/src/git/remotes/gitea.ts index 6f78f943fe6ba..281326772c6fd 100644 --- a/src/git/remotes/gitea.ts +++ b/src/git/remotes/gitea.ts @@ -1,8 +1,10 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; @@ -34,10 +36,14 @@ export class GiteaRemote extends RemoteProvider { return 'gitea'; } - get id() { + get id(): RemoteProviderId { return 'gitea'; } + get gkProviderId(): GkProviderId | undefined { + return undefined; // TODO@eamodio DRAFTS add this when supported by backend + } + get name() { return this.formatName('Gitea'); } diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index e70badc00c4a8..a6e655f42f03c 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -1,52 +1,32 @@ -import type { AuthenticationSession, Disposable, QuickInputButton, Range } from 'vscode'; -import { env, ThemeIcon, Uri, window } from 'vscode'; -import type { Autolink, DynamicAutolinkReference } from '../../annotations/autolinks'; +import type { Range } from 'vscode'; +import { Uri } from 'vscode'; +import type { Autolink, DynamicAutolinkReference, MaybeEnrichedAutolink } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import type { Container } from '../../container'; -import type { - IntegrationAuthenticationProvider, - IntegrationAuthenticationSessionDescriptor, -} from '../../plus/integrationAuthentication'; -import { log } from '../../system/decorators/log'; +import { GlyphChars } from '../../constants'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { GitHubRepositoryDescriptor } from '../../plus/integrations/providers/github'; +import type { Brand, Unbrand } from '../../system/brand'; +import { fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; -import { equalsIgnoreCase } from '../../system/string'; -import { supportedInVSCodeVersion } from '../../system/utils'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; +import { equalsIgnoreCase, escapeMarkdown, unescapeMarkdown } from '../../system/string'; +import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; -import type { RepositoryMetadata } from '../models/repositoryMetadata'; -import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider'; +import type { RemoteProviderId } from './remoteProvider'; +import { RemoteProvider } from './remoteProvider'; -const autolinkFullIssuesRegex = /\b(?[^/\s]+\/[^/\s]+)#(?[0-9]+)\b(?!]\()/g; +const autolinkFullIssuesRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?#([0-9]+)\b(?!]\()/g; const fileRegex = /^\/([^/]+)\/([^/]+?)\/blob(.+)$/i; const rangeRegex = /^L(\d+)(?:-L(\d+))?$/; -const authProvider = Object.freeze({ id: 'github', scopes: ['repo', 'read:user', 'user:email'] }); -const enterpriseAuthProvider = Object.freeze({ id: 'github-enterprise', scopes: ['repo', 'read:user', 'user:email'] }); - function isGitHubDotCom(domain: string): boolean { return equalsIgnoreCase(domain, 'github.com'); } -export class GitHubRemote extends RichRemoteProvider { - @memoize() - protected get authProvider() { - return isGitHubDotCom(this.domain) ? authProvider : enterpriseAuthProvider; - } - - constructor( - container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom: boolean = false, - ) { - super(container, domain, path, protocol, name, custom); +export class GitHubRemote extends RemoteProvider { + constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { + super(domain, path, protocol, name, custom); } get apiBaseUrl() { @@ -77,11 +57,16 @@ export class GitHubRemote extends RichRemoteProvider { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, + enrichedAutolinks?: Map, + prs?: Set, + footnotes?: Map, ) => { return outputFormat === 'plaintext' ? text : text.replace(autolinkFullIssuesRegex, (linkText: string, repo: string, num: string) => { - const url = encodeUrl(`${this.protocol}://${this.domain}/${repo}/issues/${num}`); + const url = encodeUrl( + `${this.protocol}://${this.domain}/${unescapeMarkdown(repo)}/issues/${num}`, + ); const title = ` "Open Issue or Pull Request #${num} from ${repo} on ${this.name}"`; const token = `\x00${tokenMapping.size}\x00`; @@ -91,28 +76,76 @@ export class GitHubRemote extends RichRemoteProvider { tokenMapping.set(token, `${linkText}`); } + let footnoteIndex: number; + + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value != null) { + if (issueResult.paused) { + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${ + this.name + } Issue or Pull Request ${repo}#${num} $(loading~spin)](${url}${title}")`, + ); + } + } else { + const issue = issueResult.value; + const issueTitle = escapeMarkdown(issue.title.trim()); + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon( + issue, + )} **${issueTitle}**](${url}${title})\\\n${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.state} ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`, + ); + } + } + } else if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${ + this.name + } Issue or Pull Request ${repo}#${num}](${url}${title})`, + ); + } + return token; }); }, parse: (text: string, autolinks: Map) => { - let repo: string; + let ownerAndRepo: string; let num: string; let match; do { match = autolinkFullIssuesRegex.exec(text); - if (match?.groups == null) break; + if (match == null) break; - ({ repo, num } = match.groups); + [, ownerAndRepo, num] = match; + const [owner, repo] = ownerAndRepo.split('/', 2); autolinks.set(num, { provider: this, id: num, - prefix: `${repo}#`, - url: `${this.protocol}://${this.domain}/${repo}/issues/${num}`, - title: `Open Issue or Pull Request # from ${repo} on ${this.name}`, + prefix: `${ownerAndRepo}#`, + url: `${this.protocol}://${this.domain}/${ownerAndRepo}/issues/${num}`, + title: `Open Issue or Pull Request # from ${ownerAndRepo} on ${this.name}`, + + description: `${this.name} Issue or Pull Request ${ownerAndRepo}#${num}`, - description: `${this.name} Issue or Pull Request ${repo}#${num}`, + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitHubRepositoryDescriptor, }); } while (true); }, @@ -131,23 +164,24 @@ export class GitHubRemote extends RichRemoteProvider { return 'github'; } - get id() { + get id(): RemoteProviderId { return 'github'; } + get gkProviderId(): GkProviderId { + return (!isGitHubDotCom(this.domain) + ? 'githubEnterprise' + : 'github') satisfies Unbrand as Brand; + } + get name() { return this.formatName('GitHub'); } - @log() - override async connect(): Promise { - if (!isGitHubDotCom(this.domain)) { - if (!(await ensurePaidPlan('GitHub Enterprise instance', this.container))) { - return false; - } - } - - return super.connect(); + @memoize() + override get repoDesc(): GitHubRepositoryDescriptor { + const [owner, repo] = this.splitPath(); + return { key: this.remoteKey, owner: owner, name: repo }; } async getLocalInfoFromRemoteUri( @@ -259,110 +293,6 @@ export class GitHubRemote extends RichRemoteProvider { if (branch) return `${this.encodeUrl(`${this.baseUrl}/blob/${branch}/${fileName}`)}${line}`; return `${this.encodeUrl(`${this.baseUrl}?path=${fileName}`)}${line}`; } - - protected override async getProviderAccountForCommit( - { accessToken }: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getAccountForCommit(this, accessToken, owner, repo, ref, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderAccountForEmail( - { accessToken }: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getAccountForEmail(this, accessToken, owner, repo, email, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getDefaultBranch(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderIssueOrPullRequest( - { accessToken }: AuthenticationSession, - id: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getIssueOrPullRequest(this, accessToken, owner, repo, Number(id), { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderPullRequestForBranch( - { accessToken }: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - const { include, ...opts } = options ?? {}; - - const toGitHubPullRequestState = (await import(/* webpackChunkName: "github" */ '../../plus/github/models')) - .toGitHubPullRequestState; - return (await this.container.github)?.getPullRequestForBranch(this, accessToken, owner, repo, branch, { - ...opts, - include: include?.map(s => toGitHubPullRequestState(s)), - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderPullRequestForCommit( - { accessToken }: AuthenticationSession, - ref: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getPullRequestForCommit(this, accessToken, owner, repo, ref, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderRepositoryMetadata({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getRepositoryMetadata(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async searchProviderMyPullRequests({ - accessToken, - }: AuthenticationSession): Promise { - return (await this.container.github)?.searchMyPullRequests(this, accessToken, { - repos: [this.path], - baseUrl: this.apiBaseUrl, - }); - } - - protected override async searchProviderMyIssues({ - accessToken, - }: AuthenticationSession): Promise { - return (await this.container.github)?.searchMyIssues(this, accessToken, { - repos: [this.path], - baseUrl: this.apiBaseUrl, - }); - } } const gitHubNoReplyAddressRegex = /^(?:(\d+)\+)?([a-zA-Z\d-]{1,39})@users\.noreply\.(.*)$/i; @@ -376,87 +306,3 @@ export function getGitHubNoReplyAddressParts( const [, userId, login, authority] = match; return { userId: userId, login: login, authority: authority }; } - -export class GitHubAuthenticationProvider implements Disposable, IntegrationAuthenticationProvider { - private readonly _disposable: Disposable; - - constructor(container: Container) { - this._disposable = container.integrationAuthentication.registerProvider('github-enterprise', this); - } - - dispose() { - this._disposable.dispose(); - } - - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { - return descriptor?.domain ?? ''; - } - - async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - let token; - try { - const infoButton: QuickInputButton = { - iconPath: new ThemeIcon(`link-external`), - tooltip: 'Open the GitHub Access Tokens Page', - }; - - token = await new Promise(resolve => { - disposables.push( - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(() => (input.validationMessage = undefined)), - input.onDidAccept(() => { - const value = input.value.trim(); - if (!value) { - input.validationMessage = 'A personal access token is required'; - return; - } - - resolve(value); - }), - input.onDidTriggerButton(e => { - if (e === infoButton) { - void env.openExternal( - Uri.parse(`https://${descriptor?.domain ?? 'github.com'}/settings/tokens`), - ); - } - }), - ); - - input.password = true; - input.title = `GitHub Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; - input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - input.prompt = supportedInVSCodeVersion('input-prompt-links') - ? `Paste your [GitHub Personal Access Token](https://${ - descriptor?.domain ?? 'github.com' - }/settings/tokens "Get your GitHub Access Token")` - : 'Paste your GitHub Personal Access Token'; - - input.buttons = [infoButton]; - - input.show(); - }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - - if (!token) return undefined; - - return { - id: this.getSessionId(descriptor), - accessToken: token, - scopes: [], - account: { - id: '', - label: '', - }, - }; - } -} diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index 0a31e9c1d37d0..22a130ad28250 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -1,46 +1,32 @@ -import type { AuthenticationSession, Disposable, QuickInputButton, Range } from 'vscode'; -import { env, ThemeIcon, Uri, window } from 'vscode'; -import type { Autolink, DynamicAutolinkReference } from '../../annotations/autolinks'; +import type { Range, Uri } from 'vscode'; +import type { Autolink, DynamicAutolinkReference, MaybeEnrichedAutolink } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import type { Container } from '../../container'; -import type { - IntegrationAuthenticationProvider, - IntegrationAuthenticationSessionDescriptor, -} from '../../plus/integrationAuthentication'; -import { log } from '../../system/decorators/log'; +import { GlyphChars } from '../../constants'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { GitLabRepositoryDescriptor } from '../../plus/integrations/providers/gitlab'; +import type { Brand, Unbrand } from '../../system/brand'; +import { fromNow } from '../../system/date'; +import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; -import { equalsIgnoreCase } from '../../system/string'; -import { supportedInVSCodeVersion } from '../../system/utils'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; +import { equalsIgnoreCase, escapeMarkdown, unescapeMarkdown } from '../../system/string'; +import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; -import type { RepositoryMetadata } from '../models/repositoryMetadata'; -import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider'; +import type { RemoteProviderId } from './remoteProvider'; +import { RemoteProvider } from './remoteProvider'; -const autolinkFullIssuesRegex = /\b(?[^/\s]+\/[^/\s]+)#(?[0-9]+)\b(?!]\()/g; -const autolinkFullMergeRequestsRegex = /\b(?[^/\s]+\/[^/\s]+)!(?[0-9]+)\b(?!]\()/g; +const autolinkFullIssuesRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?#([0-9]+)\b(?!]\()/g; +const autolinkFullMergeRequestsRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?!([0-9]+)\b(?!]\()/g; const fileRegex = /^\/([^/]+)\/([^/]+?)\/-\/blob(.+)$/i; const rangeRegex = /^L(\d+)(?:-(\d+))?$/; -const authProvider = Object.freeze({ id: 'gitlab', scopes: ['read_api', 'read_user', 'read_repository'] }); - -export class GitLabRemote extends RichRemoteProvider { - protected get authProvider() { - return authProvider; - } +function isGitLabDotCom(domain: string): boolean { + return equalsIgnoreCase(domain, 'gitlab.com'); +} - constructor( - container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom: boolean = false, - ) { - super(container, domain, path, protocol, name, custom); +export class GitLabRemote extends RemoteProvider { + constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { + super(domain, path, protocol, name, custom); } get apiBaseUrl() { @@ -72,11 +58,16 @@ export class GitLabRemote extends RichRemoteProvider { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, + enrichedAutolinks?: Map, + prs?: Set, + footnotes?: Map, ) => { return outputFormat === 'plaintext' ? text : text.replace(autolinkFullIssuesRegex, (linkText: string, repo: string, num: string) => { - const url = encodeUrl(`${this.protocol}://${this.domain}/${repo}/-/issues/${num}`); + const url = encodeUrl( + `${this.protocol}://${this.domain}/${unescapeMarkdown(repo)}/-/issues/${num}`, + ); const title = ` "Open Issue #${num} from ${repo} on ${this.name}"`; const token = `\x00${tokenMapping.size}\x00`; @@ -86,29 +77,72 @@ export class GitLabRemote extends RichRemoteProvider { tokenMapping.set(token, `${linkText}`); } + let footnoteIndex: number; + + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value != null) { + if (issueResult.paused) { + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} GitLab Issue ${repo}#${num} $(loading~spin)](${url}${title}")`, + ); + } + } else { + const issue = issueResult.value; + const issueTitle = escapeMarkdown(issue.title.trim()); + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon( + issue, + )} **${issueTitle}**](${url}${title})\\\n${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.state} ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`, + ); + } + } + } else if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} GitLab Issue ${repo}#${num}](${url}${title})`, + ); + } + return token; }); }, parse: (text: string, autolinks: Map) => { - let repo: string; + let ownerAndRepo: string; let num: string; let match; do { match = autolinkFullIssuesRegex.exec(text); - if (match?.groups == null) break; + if (match == null) break; - ({ repo, num } = match.groups); + [, ownerAndRepo, num] = match; + const [owner, repo] = ownerAndRepo.split('/', 2); autolinks.set(num, { provider: this, id: num, - prefix: `${repo}#`, - url: `${this.protocol}://${this.domain}/${repo}/-/issues/${num}`, - title: `Open Issue # from ${repo} on ${this.name}`, + prefix: `${ownerAndRepo}#`, + url: `${this.protocol}://${this.domain}/${ownerAndRepo}/-/issues/${num}`, + title: `Open Issue # from ${ownerAndRepo} on ${this.name}`, type: 'issue', - description: `${this.name} Issue ${repo}#${num}`, + description: `${this.name} Issue ${ownerAndRepo}#${num}`, + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitLabRepositoryDescriptor, }); } while (true); }, @@ -118,6 +152,9 @@ export class GitLabRemote extends RichRemoteProvider { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, + enrichedAutolinks?: Map, + prs?: Set, + footnotes?: Map, ) => { return outputFormat === 'plaintext' ? text @@ -136,30 +173,77 @@ export class GitLabRemote extends RichRemoteProvider { tokenMapping.set(token, `${linkText}`); } + let footnoteIndex: number; + + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value != null) { + if (issueResult.paused) { + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${ + this.name + } Merge Request ${repo}!${num} $(loading~spin)](${url}${title}")`, + ); + } + } else { + const issue = issueResult.value; + const issueTitle = escapeMarkdown(issue.title.trim()); + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon( + issue, + )} **${issueTitle}**](${url}${title})\\\n${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.state} ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`, + ); + } + } + } else if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${ + this.name + } Merge Request ${repo}!${num}](${url}${title})`, + ); + } return token; }, ); }, parse: (text: string, autolinks: Map) => { - let repo: string; + let ownerAndRepo: string; let num: string; let match; do { match = autolinkFullMergeRequestsRegex.exec(text); - if (match?.groups == null) break; + if (match == null) break; - ({ repo, num } = match.groups); + [, ownerAndRepo, num] = match; + const [owner, repo] = ownerAndRepo.split('/', 2); autolinks.set(num, { provider: this, id: num, - prefix: `${repo}!`, - url: `${this.protocol}://${this.domain}/${repo}/-/merge_requests/${num}`, - title: `Open Merge Request ! from ${repo} on ${this.name}`, + prefix: `${ownerAndRepo}!`, + url: `${this.protocol}://${this.domain}/${ownerAndRepo}/-/merge_requests/${num}`, + title: `Open Merge Request ! from ${ownerAndRepo} on ${this.name}`, type: 'pullrequest', - description: `Merge Request !${num} from ${repo} on ${this.name}`, + description: `${this.name} Merge Request !${num} from ${ownerAndRepo}`, + + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitLabRepositoryDescriptor, }); } while (true); }, @@ -173,23 +257,24 @@ export class GitLabRemote extends RichRemoteProvider { return 'gitlab'; } - get id() { + get id(): RemoteProviderId { return 'gitlab'; } + get gkProviderId(): GkProviderId { + return (!isGitLabDotCom(this.domain) + ? 'gitlabSelfHosted' + : 'gitlab') satisfies Unbrand as Brand; + } + get name() { return this.formatName('GitLab'); } - @log() - override async connect(): Promise { - if (!equalsIgnoreCase(this.domain, 'gitlab.com')) { - if (!(await ensurePaidPlan('GitLab self-managed instance', this.container))) { - return false; - } - } - - return super.connect(); + @memoize() + override get repoDesc(): GitLabRepositoryDescriptor { + const [owner, repo] = this.splitPath(); + return { key: this.remoteKey, owner: owner, name: repo }; } async getLocalInfoFromRemoteUri( @@ -289,187 +374,4 @@ export class GitLabRemote extends RichRemoteProvider { if (branch) return `${this.encodeUrl(`${this.baseUrl}/-/blob/${branch}/${fileName}`)}${line}`; return `${this.encodeUrl(`${this.baseUrl}?path=${fileName}`)}${line}`; } - - protected override async getProviderAccountForCommit( - { accessToken }: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getAccountForCommit(this, accessToken, owner, repo, ref, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderAccountForEmail( - { accessToken }: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getAccountForEmail(this, accessToken, owner, repo, email, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getDefaultBranch(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderIssueOrPullRequest( - { accessToken }: AuthenticationSession, - id: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getIssueOrPullRequest(this, accessToken, owner, repo, Number(id), { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderPullRequestForBranch( - { accessToken }: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - const { include, ...opts } = options ?? {}; - - const toGitLabMergeRequestState = (await import(/* webpackChunkName: "gitlab" */ '../../plus/gitlab/models')) - .toGitLabMergeRequestState; - return (await this.container.gitlab)?.getPullRequestForBranch(this, accessToken, owner, repo, branch, { - ...opts, - include: include?.map(s => toGitLabMergeRequestState(s)), - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderPullRequestForCommit( - { accessToken }: AuthenticationSession, - ref: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getPullRequestForCommit(this, accessToken, owner, repo, ref, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderRepositoryMetadata({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getRepositoryMetadata(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async searchProviderMyPullRequests( - _session: AuthenticationSession, - ): Promise { - return Promise.resolve(undefined); - } - - protected override async searchProviderMyIssues( - _session: AuthenticationSession, - ): Promise { - return Promise.resolve(undefined); - } -} - -export class GitLabAuthenticationProvider implements Disposable, IntegrationAuthenticationProvider { - private readonly _disposable: Disposable; - - constructor(container: Container) { - this._disposable = container.integrationAuthentication.registerProvider('gitlab', this); - } - - dispose() { - this._disposable.dispose(); - } - - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { - return descriptor?.domain ?? ''; - } - - async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - let token; - try { - const infoButton: QuickInputButton = { - iconPath: new ThemeIcon(`link-external`), - tooltip: 'Open the GitLab Access Tokens Page', - }; - - token = await new Promise(resolve => { - disposables.push( - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(() => (input.validationMessage = undefined)), - input.onDidAccept(() => { - const value = input.value.trim(); - if (!value) { - input.validationMessage = 'A personal access token is required'; - return; - } - - resolve(value); - }), - input.onDidTriggerButton(e => { - if (e === infoButton) { - void env.openExternal( - Uri.parse( - `https://${descriptor?.domain ?? 'gitlab.com'}/-/profile/personal_access_tokens`, - ), - ); - } - }), - ); - - input.password = true; - input.title = `GitLab Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; - input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - input.prompt = input.prompt = supportedInVSCodeVersion('input-prompt-links') - ? `Paste your [GitLab Personal Access Token](https://${ - descriptor?.domain ?? 'gitlab.com' - }/-/profile/personal_access_tokens "Get your GitLab Access Token")` - : 'Paste your GitLab Personal Access Token'; - input.buttons = [infoButton]; - - input.show(); - }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - - if (!token) return undefined; - - return { - id: this.getSessionId(descriptor), - accessToken: token, - scopes: [], - account: { - id: '', - label: '', - }, - }; - } } diff --git a/src/git/remotes/google-source.ts b/src/git/remotes/google-source.ts index 87523e2467535..7d0995cb7a9e9 100644 --- a/src/git/remotes/google-source.ts +++ b/src/git/remotes/google-source.ts @@ -1,14 +1,20 @@ +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; import { GerritRemote } from './gerrit'; +import type { RemoteProviderId } from './remoteProvider'; export class GoogleSourceRemote extends GerritRemote { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { super(domain, path, protocol, name, custom, false); } - override get id() { + override get id(): RemoteProviderId { return 'google-source'; } + override get gkProviderId(): GkProviderId | undefined { + return undefined; // TODO@eamodio DRAFTS add this when supported by backend + } + override get name() { return this.formatName('Google Source'); } diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index 3c0ce79fece58..0e2311cae2796 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -2,16 +2,29 @@ import type { Range, Uri } from 'vscode'; import { env } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { ResourceDescriptor } from '../../plus/integrations/integration'; import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; -import type { RemoteProviderReference } from '../models/remoteProvider'; +import { getSettledValue } from '../../system/promise'; +import { openUrl } from '../../system/utils'; +import type { ProviderReference } from '../models/remoteProvider'; import type { RemoteResource } from '../models/remoteResource'; import { RemoteResourceType } from '../models/remoteResource'; import type { Repository } from '../models/repository'; -import type { RichRemoteProvider } from './richRemoteProvider'; -export abstract class RemoteProvider implements RemoteProviderReference { - readonly type: 'simple' | 'rich' = 'simple'; +export type RemoteProviderId = + | 'azure-devops' + | 'bitbucket' + | 'bitbucket-server' + | 'custom' + | 'gerrit' + | 'gitea' + | 'github' + | 'gitlab' + | 'google-source'; + +export abstract class RemoteProvider implements ProviderReference { protected readonly _name: string | undefined; constructor( @@ -24,11 +37,6 @@ export abstract class RemoteProvider implements RemoteProviderReference { this._name = name; } - @memoize() - get remoteKey() { - return this.domain ? `${this.domain}/${this.path}` : this.path; - } - get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { return []; } @@ -46,27 +54,31 @@ export abstract class RemoteProvider implements RemoteProviderReference { } get owner(): string | undefined { - return this.path.split('/')[0]; + return this.splitPath()[0]; } - abstract get id(): string; - abstract get name(): string; - - async copy(resource: RemoteResource): Promise { - const url = this.url(resource); - if (url == null) { - return; - } + @memoize() + get remoteKey() { + return this.domain ? `${this.domain}/${this.path}` : this.path; + } - await env.clipboard.writeText(url); + get repoDesc(): T { + return { owner: this.owner, name: this.repoName } as unknown as T; } - hasRichIntegration(): this is RichRemoteProvider { - return this.type === 'rich'; + get repoName(): string | undefined { + return this.splitPath()[1]; } - get maybeConnected(): boolean | undefined { - return false; + abstract get id(): RemoteProviderId; + abstract get gkProviderId(): GkProviderId | undefined; + abstract get name(): string; + + async copy(resource: RemoteResource | RemoteResource[]): Promise { + const urls = this.getUrlsFromResources(resource); + if (!urls.length) return; + + await env.clipboard.writeText(urls.join('\n')); } abstract getLocalInfoFromRemoteUri( @@ -75,8 +87,12 @@ export abstract class RemoteProvider implements RemoteProviderReference { options?: { validate?: boolean }, ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined>; - open(resource: RemoteResource): Promise { - return this.openUrl(this.url(resource)); + async open(resource: RemoteResource | RemoteResource[]): Promise { + const urls = this.getUrlsFromResources(resource); + if (!urls.length) return false; + + const results = await Promise.allSettled(urls.map(openUrl)); + return results.every(r => getSettledValue(r) === true); } url(resource: RemoteResource): string | undefined { @@ -152,17 +168,32 @@ export abstract class RemoteProvider implements RemoteProviderReference { return this.baseUrl; } - private async openUrl(url?: string): Promise { - if (url == null) return undefined; - - // Pass a string to openExternal to avoid double encoding issues: https://github.com/microsoft/vscode/issues/85930 - // vscode.d.ts currently says it only supports a Uri, but it actually accepts a string too - return (env.openExternal as unknown as (target: string) => Thenable)(url); - } - protected encodeUrl(url: string): string; protected encodeUrl(url: string | undefined): string | undefined; protected encodeUrl(url: string | undefined): string | undefined { return encodeUrl(url)?.replace(/#/g, '%23'); } + + private getUrlsFromResources(resource: RemoteResource | RemoteResource[]): string[] { + const urls: string[] = []; + + if (Array.isArray(resource)) { + for (const r of resource) { + const url = this.url(r); + if (url == null) continue; + + urls.push(url); + } + } else { + const url = this.url(resource); + if (url != null) { + urls.push(url); + } + } + return urls; + } +} + +export function getRemoteProviderThemeIconString(provider: RemoteProvider | undefined): string { + return provider != null ? `gitlens-provider-${provider.icon}` : 'cloud'; } diff --git a/src/git/remotes/remoteProviderService.ts b/src/git/remotes/remoteProviderService.ts deleted file mode 100644 index a92d036d28ed4..0000000000000 --- a/src/git/remotes/remoteProviderService.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Event } from 'vscode'; -import { EventEmitter } from 'vscode'; -import type { Container } from '../../container'; - -export interface ConnectionStateChangeEvent { - key: string; - reason: 'connected' | 'disconnected'; -} - -export class RichRemoteProviderService { - private readonly _onDidChangeConnectionState = new EventEmitter(); - get onDidChangeConnectionState(): Event { - return this._onDidChangeConnectionState.event; - } - - private readonly _onAfterDidChangeConnectionState = new EventEmitter(); - get onAfterDidChangeConnectionState(): Event { - return this._onAfterDidChangeConnectionState.event; - } - - private readonly _connectedCache = new Set(); - - constructor(private readonly container: Container) {} - - connected(key: string): void { - // Only fire events if the key is being connected for the first time - if (this._connectedCache.has(key)) return; - - this._connectedCache.add(key); - this.container.telemetry.sendEvent('remoteProviders/connected', { 'remoteProviders.key': key }); - - this._onDidChangeConnectionState.fire({ key: key, reason: 'connected' }); - setTimeout(() => this._onAfterDidChangeConnectionState.fire({ key: key, reason: 'connected' }), 250); - } - - disconnected(key: string): void { - // Probably shouldn't bother to fire the event if we don't already think we are connected, but better to be safe - // if (!_connectedCache.has(key)) return; - this._connectedCache.delete(key); - this.container.telemetry.sendEvent('remoteProviders/disconnected', { 'remoteProviders.key': key }); - - this._onDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }); - setTimeout(() => this._onAfterDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }), 250); - } - - isConnected(key?: string): boolean { - return key == null ? this._connectedCache.size !== 0 : this._connectedCache.has(key); - } -} diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index f679a8a99f407..c4a61241dfd0a 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -1,5 +1,6 @@ import type { RemotesConfig } from '../../config'; import type { Container } from '../../container'; +import { configuration } from '../../system/configuration'; import { Logger } from '../../system/logger'; import { AzureDevOpsRemote } from './azure-devops'; import { BitbucketRemote } from './bitbucket'; @@ -27,12 +28,12 @@ const builtInProviders: RemoteProviders = [ { custom: false, matcher: 'github.com', - creator: (container: Container, domain: string, path: string) => new GitHubRemote(container, domain, path), + creator: (container: Container, domain: string, path: string) => new GitHubRemote(domain, path), }, { custom: false, matcher: 'gitlab.com', - creator: (container: Container, domain: string, path: string) => new GitLabRemote(container, domain, path), + creator: (container: Container, domain: string, path: string) => new GitLabRemote(domain, path), }, { custom: false, @@ -47,7 +48,7 @@ const builtInProviders: RemoteProviders = [ { custom: false, matcher: /\bgitlab\b/i, - creator: (container: Container, domain: string, path: string) => new GitLabRemote(container, domain, path), + creator: (container: Container, domain: string, path: string) => new GitLabRemote(domain, path), }, { custom: false, @@ -126,10 +127,10 @@ function getCustomProviderCreator(cfg: RemotesConfig) { new GiteaRemote(domain, path, cfg.protocol, cfg.name, true); case 'GitHub': return (container: Container, domain: string, path: string) => - new GitHubRemote(container, domain, path, cfg.protocol, cfg.name, true); + new GitHubRemote(domain, path, cfg.protocol, cfg.name, true); case 'GitLab': return (container: Container, domain: string, path: string) => - new GitLabRemote(container, domain, path, cfg.protocol, cfg.name, true); + new GitLabRemote(domain, path, cfg.protocol, cfg.name, true); default: return undefined; } @@ -137,8 +138,12 @@ function getCustomProviderCreator(cfg: RemotesConfig) { export function getRemoteProviderMatcher( container: Container, - providers: RemoteProviders, + providers?: RemoteProviders, ): (url: string, domain: string, path: string) => RemoteProvider | undefined { + if (providers == null) { + providers = loadRemoteProviders(configuration.get('remotes', null)); + } + return (url: string, domain: string, path: string) => createBestRemoteProvider(container, providers, url, domain, path); } diff --git a/src/git/remotes/richRemoteProvider.ts b/src/git/remotes/richRemoteProvider.ts deleted file mode 100644 index 9c552f3ae284c..0000000000000 --- a/src/git/remotes/richRemoteProvider.ts +++ /dev/null @@ -1,614 +0,0 @@ -/* eslint-disable @typescript-eslint/no-confusing-void-expression */ -import type { - AuthenticationSession, - AuthenticationSessionsChangeEvent, - CancellationToken, - Event, - MessageItem, -} from 'vscode'; -import { authentication, CancellationError, Disposable, EventEmitter, window } from 'vscode'; -import { wrapForForcedInsecureSSL } from '@env/fetch'; -import { isWeb } from '@env/platform'; -import type { Container } from '../../container'; -import { AuthenticationError, ProviderRequestClientError } from '../../errors'; -import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; -import type { IntegrationAuthenticationSessionDescriptor } from '../../plus/integrationAuthentication'; -import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../../subscription'; -import { configuration } from '../../system/configuration'; -import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import type { LogScope } from '../../system/logger.scope'; -import { getLogScope } from '../../system/logger.scope'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; -import type { RepositoryMetadata } from '../models/repositoryMetadata'; -import { RemoteProvider } from './remoteProvider'; - -// TODO@eamodio revisit how once authenticated, all remotes are always connected, even after a restart - -export abstract class RichRemoteProvider extends RemoteProvider implements Disposable { - override readonly type: 'simple' | 'rich' = 'rich'; - - private readonly _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - - private readonly _disposable: Disposable; - - constructor( - protected readonly container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom?: boolean, - ) { - super(domain, path, protocol, name, custom); - - this._disposable = Disposable.from( - configuration.onDidChange(e => { - if (configuration.changed(e, 'remotes')) { - this._ignoreSSLErrors.clear(); - } - }), - // TODO@eamodio revisit how connections are linked or not - container.richRemoteProviders.onDidChangeConnectionState(e => { - if (e.key !== this.key) return; - - if (e.reason === 'disconnected') { - void this.disconnect({ silent: true }); - } else if (e.reason === 'connected') { - void this.ensureSession(false); - } - }), - authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), - ); - - container.context.subscriptions.push(this._disposable); - - // If we think we should be connected, try to - if (this.shouldConnect) { - void this.isConnected(); - } - } - - disposed = false; - - dispose() { - this._disposable.dispose(); - this.disposed = true; - } - - abstract get apiBaseUrl(): string; - protected abstract get authProvider(): { id: string; scopes: string[] }; - protected get authProviderDescriptor(): IntegrationAuthenticationSessionDescriptor { - return { domain: this.domain, scopes: this.authProvider.scopes }; - } - - private get key() { - return this.custom ? `${this.name}:${this.domain}` : this.name; - } - - private get connectedKey(): `connected:${string}` { - return `connected:${this.key}`; - } - - override get maybeConnected(): boolean | undefined { - return this._session === undefined ? undefined : this._session !== null; - } - - // This is a hack for now, since providers come and go with remotes - get shouldConnect(): boolean { - return this.container.richRemoteProviders.isConnected(this.key); - } - - protected _session: AuthenticationSession | null | undefined; - protected session() { - if (this._session === undefined) { - return this.ensureSession(false); - } - return this._session ?? undefined; - } - - private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { - if (e.provider.id === this.authProvider.id) { - void this.ensureSession(false); - } - } - - @log() - async connect(): Promise { - try { - const session = await this.ensureSession(true); - return Boolean(session); - } catch (ex) { - return false; - } - } - - @gate() - @log() - async disconnect(options?: { silent?: boolean; currentSessionOnly?: boolean }): Promise { - if (options?.currentSessionOnly && this._session === null) return; - - const connected = this._session != null; - - if (connected && !options?.silent) { - if (options?.currentSessionOnly) { - void showIntegrationDisconnectedTooManyFailedRequestsWarningMessage(this.name); - } else { - const disable = { title: 'Disable' }; - const signout = { title: 'Disable & Sign Out' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - - let result: MessageItem | undefined; - if (this.container.integrationAuthentication.hasProvider(this.authProvider.id)) { - result = await window.showWarningMessage( - `Are you sure you want to disable the rich integration with ${this.name}?\n\nNote: signing out clears the saved authentication.`, - { modal: true }, - disable, - signout, - cancel, - ); - } else { - result = await window.showWarningMessage( - `Are you sure you want to disable the rich integration with ${this.name}?`, - { modal: true }, - disable, - cancel, - ); - } - - if (result == null || result === cancel) return; - if (result === signout) { - void this.container.integrationAuthentication.deleteSession( - this.authProvider.id, - this.authProviderDescriptor, - ); - } - } - } - - this.resetRequestExceptionCount(); - this._session = null; - - if (connected) { - // Don't store the disconnected flag if this only for this current VS Code session (will be re-connected on next restart) - if (!options?.currentSessionOnly) { - void this.container.storage.storeWorkspace(this.connectedKey, false); - } - - this._onDidChange.fire(); - if (!options?.silent && !options?.currentSessionOnly) { - this.container.richRemoteProviders.disconnected(this.key); - } - } - } - - @log() - async reauthenticate(): Promise { - if (this._session === undefined) return; - - this._session = undefined; - void (await this.ensureSession(true, true)); - } - - private requestExceptionCount = 0; - - resetRequestExceptionCount() { - this.requestExceptionCount = 0; - } - - private handleProviderException(ex: Error, scope: LogScope | undefined, defaultValue: T): T { - if (ex instanceof CancellationError) return defaultValue; - - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return defaultValue; - } - - @debug() - trackRequestException() { - this.requestExceptionCount++; - - if (this.requestExceptionCount >= 5 && this._session !== null) { - void this.disconnect({ currentSessionOnly: true }); - } - } - - @gate() - @debug({ exit: true }) - async isConnected(): Promise { - return (await this.session()) != null; - } - - @gate() - @debug() - async getAccountForCommit( - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const author = await this.getProviderAccountForCommit(this._session!, ref, options); - this.resetRequestExceptionCount(); - return author; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - } - - protected abstract getProviderAccountForCommit( - session: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise; - - @gate() - @debug() - async getAccountForEmail( - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const author = await this.getProviderAccountForEmail(this._session!, email, options); - this.resetRequestExceptionCount(); - return author; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - } - - protected abstract getProviderAccountForEmail( - session: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise; - - @debug() - async getDefaultBranch(): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const defaultBranch = this.container.cache.getRepositoryDefaultBranch(this, () => ({ - value: (async () => { - try { - const result = await this.getProviderDefaultBranch(this._session!); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return defaultBranch; - } - - protected abstract getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise; - - private _ignoreSSLErrors = new Map(); - getIgnoreSSLErrors(): boolean | 'force' { - if (isWeb) return false; - - let ignoreSSLErrors = this._ignoreSSLErrors.get(this.id); - if (ignoreSSLErrors === undefined) { - const cfg = configuration - .get('remotes') - ?.find(remote => remote.type.toLowerCase() === this.id && remote.domain === this.domain); - ignoreSSLErrors = cfg?.ignoreSSLErrors ?? false; - this._ignoreSSLErrors.set(this.id, ignoreSSLErrors); - } - - return ignoreSSLErrors; - } - - @debug() - async getRepositoryMetadata(_cancellation?: CancellationToken): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const metadata = this.container.cache.getRepositoryMetadata(this, () => ({ - value: (async () => { - try { - const result = await this.getProviderRepositoryMetadata(this._session!); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return metadata; - } - - protected abstract getProviderRepositoryMetadata({ - accessToken, - }: AuthenticationSession): Promise; - - @debug() - async getIssueOrPullRequest(id: string): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const issueOrPR = this.container.cache.getIssueOrPullRequest(id, this, () => ({ - value: (async () => { - try { - const result = await this.getProviderIssueOrPullRequest(this._session!, id); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return issueOrPR; - } - - protected abstract getProviderIssueOrPullRequest( - session: AuthenticationSession, - id: string, - ): Promise; - - @debug() - async getPullRequestForBranch( - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const pr = this.container.cache.getPullRequestForBranch(branch, this, () => ({ - value: (async () => { - try { - const result = await this.getProviderPullRequestForBranch(this._session!, branch, options); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return pr; - } - - protected abstract getProviderPullRequestForBranch( - session: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise; - - @debug() - async getPullRequestForCommit(ref: string): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const pr = this.container.cache.getPullRequestForSha(ref, this, () => ({ - value: (async () => { - try { - const result = await this.getProviderPullRequestForCommit(this._session!, ref); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return pr; - } - - protected abstract getProviderPullRequestForCommit( - session: AuthenticationSession, - ref: string, - ): Promise; - - @gate() - @debug() - async searchMyIssues(): Promise { - const scope = getLogScope(); - - try { - const issues = await this.searchProviderMyIssues(this._session!); - this.resetRequestExceptionCount(); - return issues; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - } - protected abstract searchProviderMyIssues(session: AuthenticationSession): Promise; - - @gate() - @debug() - async searchMyPullRequests(): Promise { - const scope = getLogScope(); - - try { - const pullRequests = await this.searchProviderMyPullRequests(this._session!); - this.resetRequestExceptionCount(); - return pullRequests; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - } - protected abstract searchProviderMyPullRequests( - session: AuthenticationSession, - ): Promise; - - @gate() - private async ensureSession( - createIfNeeded: boolean, - forceNewSession: boolean = false, - ): Promise { - if (this._session != null) return this._session; - if (!configuration.get('integrations.enabled')) return undefined; - - if (createIfNeeded) { - await this.container.storage.deleteWorkspace(this.connectedKey); - } else if (this.container.storage.getWorkspace(this.connectedKey) === false) { - return undefined; - } - - let session: AuthenticationSession | undefined | null; - try { - if (this.container.integrationAuthentication.hasProvider(this.authProvider.id)) { - session = await this.container.integrationAuthentication.getSession( - this.authProvider.id, - this.authProviderDescriptor, - { createIfNeeded: createIfNeeded, forceNewSession: forceNewSession }, - ); - } else { - session = await wrapForForcedInsecureSSL(this.getIgnoreSSLErrors(), () => - authentication.getSession(this.authProvider.id, this.authProvider.scopes, { - createIfNone: forceNewSession ? undefined : createIfNeeded, - silent: !createIfNeeded && !forceNewSession ? true : undefined, - forceNewSession: forceNewSession ? true : undefined, - }), - ); - } - } catch (ex) { - await this.container.storage.deleteWorkspace(this.connectedKey); - - if (ex instanceof Error && ex.message.includes('User did not consent')) { - return undefined; - } - - session = null; - } - - if (session === undefined && !createIfNeeded) { - await this.container.storage.deleteWorkspace(this.connectedKey); - } - - this._session = session ?? null; - this.resetRequestExceptionCount(); - - if (session != null) { - await this.container.storage.storeWorkspace(this.connectedKey, true); - - queueMicrotask(() => { - this._onDidChange.fire(); - this.container.richRemoteProviders.connected(this.key); - }); - } - - return session ?? undefined; - } -} - -export async function ensurePaidPlan(providerName: string, container: Container): Promise { - const title = `Connecting to a ${providerName} instance for rich integration features requires a trial or paid plan.`; - - while (true) { - const subscription = await container.subscription.getSubscription(); - if (subscription.account?.verified === false) { - const resend = { title: 'Resend Verification' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nYou must verify your email before you can continue.`, - { modal: true }, - resend, - cancel, - ); - - if (result === resend) { - if (await container.subscription.resendVerification()) { - continue; - } - } - - return false; - } - - const plan = subscription.plan.effective.id; - if (isSubscriptionPaidPlan(plan)) break; - - if (subscription.account == null && !isSubscriptionPreviewTrialExpired(subscription)) { - const startTrial = { title: 'Preview Pro' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to preview ✨ features for 3 days?`, - { modal: true }, - startTrial, - cancel, - ); - - if (result !== startTrial) return false; - - void container.subscription.startPreviewTrial(); - break; - } else if (subscription.account == null) { - const signIn = { title: 'Start Free Pro Trial' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to continue to use ✨ features on privately hosted repos, free for an additional 7 days?`, - { modal: true }, - signIn, - cancel, - ); - - if (result === signIn) { - if (await container.subscription.loginOrSignUp()) { - continue; - } - } - } else { - const upgrade = { title: 'Upgrade to Pro' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to continue to use ✨ features on privately hosted repos?`, - { modal: true }, - upgrade, - cancel, - ); - - if (result === upgrade) { - void container.subscription.purchase(); - } - } - - return false; - } - - return true; -} diff --git a/src/git/search.ts b/src/git/search.ts index dca4b915639eb..b51222fa23dcd 100644 --- a/src/git/search.ts +++ b/src/git/search.ts @@ -2,18 +2,8 @@ import type { GitRevisionReference } from './models/reference'; import { isSha, shortenRevision } from './models/reference'; import type { GitUser } from './models/user'; -export type SearchOperators = - | '' - | '=:' - | 'message:' - | '@:' - | 'author:' - | '#:' - | 'commit:' - | '?:' - | 'file:' - | '~:' - | 'change:'; +export type NormalizedSearchOperators = 'message:' | 'author:' | 'commit:' | 'file:' | 'change:'; +export type SearchOperators = NormalizedSearchOperators | '' | '=:' | '@:' | '#:' | '?:' | '~:'; export const searchOperators = new Set([ '', @@ -100,7 +90,7 @@ export function createSearchQueryForCommits(refsOrCommits: (string | GitRevision return refsOrCommits.map(r => `#:${typeof r === 'string' ? shortenRevision(r) : r.name}`).join(' '); } -const normalizeSearchOperatorsMap = new Map([ +const normalizeSearchOperatorsMap = new Map([ ['', 'message:'], ['=:', 'message:'], ['message:', 'message:'], @@ -115,10 +105,10 @@ const normalizeSearchOperatorsMap = new Map([ ]); const searchOperationRegex = - /(?:(?=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:)\s?(?".+?"|\S+}?))|(?\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change):)/gi; + /(?:(?=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:)\s?(?".+?"|\S+}?))|(?\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change):)/g; -export function parseSearchQuery(search: SearchQuery): Map { - const operations = new Map(); +export function parseSearchQuery(search: SearchQuery): Map> { + const operations = new Map>(); let op: SearchOperators | undefined; let value: string | undefined; @@ -133,17 +123,19 @@ export function parseSearchQuery(search: SearchQuery): Map { ({ value, text } = match.groups); if (text) { - op = text === '@me' ? 'author:' : isSha(text) ? 'commit:' : 'message:'; - value = text; + if (!normalizeSearchOperatorsMap.has(text.trim() as SearchOperators)) { + op = text === '@me' ? 'author:' : isSha(text) ? 'commit:' : 'message:'; + value = text; + } } if (op && value) { - const values = operations.get(op); + let values = operations.get(op); if (values == null) { - operations.set(op, [value]); - } else { - values.push(value); + values = new Set(); + operations.set(op, values); } + values.add(value); } } while (match != null); @@ -242,7 +234,18 @@ export function getGitArgsFromSearchQuery( files.push(value); } else { - files.push(`${search.matchCase ? '' : ':(icase)'}${value}`); + const prefix = search.matchCase ? '' : ':(icase)'; + if (/[/\\*?|![\]{}]/.test(value)) { + files.push(`${prefix}${value}`); + } else { + const index = value.indexOf('.'); + if (index > 0) { + // maybe a file extension + files.push(`${prefix}**/${value}`); + } else { + files.push(`${prefix}*${value}*`); + } + } } } diff --git a/src/gk/models/drafts.ts b/src/gk/models/drafts.ts new file mode 100644 index 0000000000000..0cdf719187ffd --- /dev/null +++ b/src/gk/models/drafts.ts @@ -0,0 +1,264 @@ +import type { Uri } from 'vscode'; +import type { GitCommit } from '../../git/models/commit'; +import type { GitFileChangeShape } from '../../git/models/file'; +import type { GitPatch, PatchRevisionRange } from '../../git/models/patch'; +import type { Repository } from '../../git/models/repository'; +import type { GitUser } from '../../git/models/user'; +import type { GkRepositoryId, RepositoryIdentity, RepositoryIdentityRequest } from './repositoryIdentities'; + +export interface LocalDraft { + readonly draftType: 'local'; + + patch: GitPatch; +} + +export type DraftRole = 'owner' | 'admin' | 'editor' | 'viewer'; + +export type DraftArchiveReason = 'committed' | 'rejected' | 'accepted'; + +export interface Draft { + readonly draftType: 'cloud'; + readonly type: DraftType; + readonly id: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly author: { + id: string; + name: string; + email: string | undefined; + avatarUri?: Uri; + }; + readonly isMine: boolean; + readonly organizationId?: string; + readonly role: DraftRole; + readonly isPublished: boolean; + + readonly title: string; + readonly description?: string; + + readonly deepLinkUrl: string; + readonly visibility: DraftVisibility; + + readonly isArchived: boolean; + readonly archivedBy?: string; + readonly archivedReason?: DraftArchiveReason; + readonly archivedAt?: Date; + + readonly prEntityId?: string; + + readonly latestChangesetId: string; + changesets?: DraftChangeset[]; + + // readonly user?: { + // readonly id: string; + // readonly name: string; + // readonly email: string; + // }; +} + +export interface DraftChangeset { + readonly id: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly draftId: string; + readonly parentChangesetId: string | undefined; + + readonly userId: string; + readonly gitUserName: string; + readonly gitUserEmail: string; + + readonly deepLinkUrl?: string; + + readonly patches: DraftPatch[]; +} + +export interface DraftPatch { + readonly type: 'cloud'; + readonly id: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly draftId: string; + readonly changesetId: string; + readonly userId: string; + readonly prEntityId?: string; + + readonly baseBranchName: string; + /*readonly*/ baseRef: string; + + readonly gkRepositoryId: GkRepositoryId; + // repoData?: GitRepositoryData; + readonly secureLink: DraftPatchResponse['secureDownloadData']; + + commit?: GitCommit; + contents?: string; + files?: DraftPatchFileChange[]; + repository?: Repository | RepositoryIdentity; +} + +export interface DraftPatchDetails { + id: string; + contents: string; + files: DraftPatchFileChange[]; + repository: Repository | RepositoryIdentity; +} + +export interface DraftPatchFileChange extends GitFileChangeShape { + readonly gkRepositoryId: GkRepositoryId; +} + +export interface CreateDraftChange { + revision: PatchRevisionRange; + contents?: string; + repository: Repository; + prEntityId?: string; +} + +export interface CreateDraftPatchRequestFromChange { + contents: string; + patch: DraftPatchCreateRequest; + repository: Repository; + user: GitUser | undefined; +} + +export type DraftVisibility = 'public' | 'private' | 'invite_only' | 'provider_access'; + +export type DraftType = 'patch' | 'stash' | 'suggested_pr_change'; + +export interface CreateDraftRequest { + type: DraftType; + title: string; + description?: string; + visibility: DraftVisibility; +} + +export interface CreateDraftResponse { + id: string; + deepLink: string; +} + +export interface DraftResponse { + readonly type: DraftType; + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly createdBy: string; + readonly organizationId?: string; + readonly role: DraftRole; + + readonly deepLink: string; + readonly isPublished: boolean; + readonly latestChangesetId: string; + readonly visibility: DraftVisibility; + + readonly title: string; + readonly description?: string; + + readonly isArchived: boolean; + readonly archivedBy?: string; + readonly archivedReason?: DraftArchiveReason; + readonly archivedAt?: string; +} + +export interface DraftUser { + readonly id: string; + readonly userId: string; + readonly draftId: string; + readonly role: DraftRole; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface DraftPendingUser { + userId: string; + role: Exclude; +} + +export interface DraftChangesetCreateRequest { + parentChangesetId?: string | null; + gitUserName?: string; + gitUserEmail?: string; + patches: DraftPatchCreateRequest[]; +} + +export interface DraftChangesetCreateResponse { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly draftId: string; + readonly parentChangesetId: string | undefined; + readonly userId: string; + readonly gitUserName: string; + readonly gitUserEmail: string; + + readonly deepLink?: string; + readonly patches: DraftPatchCreateResponse[]; +} + +export interface DraftChangesetResponse { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly draftId: string; + readonly parentChangesetId: string | undefined; + readonly userId: string; + readonly gitUserName: string; + readonly gitUserEmail: string; + + readonly deepLink?: string; + readonly patches: DraftPatchResponse[]; +} + +export interface DraftPatchCreateRequest { + baseCommitSha: string; + baseBranchName: string; + gitRepoData: RepositoryIdentityRequest; + prEntityId?: string; +} + +export interface DraftPatchCreateResponse { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly draftId: string; + readonly changesetId: string; + readonly userId: string; + + readonly baseCommitSha: string; + readonly baseBranchName: string; + readonly gitRepositoryId: GkRepositoryId; + + readonly secureUploadData: { + readonly headers: Record; + readonly method: string; + readonly url: string; + }; +} + +export interface DraftPatchResponse { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly draftId: string; + readonly changesetId: string; + readonly userId: string; + + readonly baseCommitSha: string; + readonly baseBranchName: string; + readonly gitRepositoryId: GkRepositoryId; + + readonly secureDownloadData: { + readonly headers: Record; + readonly method: string; + readonly url: string; + }; +} + +export type CodeSuggestionCountsResponse = { + counts: CodeSuggestionCounts; +}; + +export type CodeSuggestionCounts = { + [entityId: string]: { + count: number; + }; +}; diff --git a/src/gk/models/repositoryIdentities.ts b/src/gk/models/repositoryIdentities.ts new file mode 100644 index 0000000000000..7f60da011ede9 --- /dev/null +++ b/src/gk/models/repositoryIdentities.ts @@ -0,0 +1,99 @@ +import type { Branded } from '../../system/brand'; + +export const missingRepositoryId = '-'; + +export type GkProviderId = Branded< + 'github' | 'githubEnterprise' | 'gitlab' | 'gitlabSelfHosted' | 'bitbucket' | 'bitbucketServer' | 'azureDevops', + 'GkProviderId' +>; +export type GkRepositoryId = Branded; + +export interface RepositoryIdentityRemoteDescriptor { + readonly url?: string; + readonly domain?: string; + readonly path?: string; +} + +export interface RepositoryIdentityProviderDescriptor { + readonly id?: ID; + readonly domain?: string; + readonly repoDomain?: string; + readonly repoName?: string; + readonly repoOwnerDomain?: string; +} + +// TODO: replace this string with GkProviderId eventually once we wrangle our backend provider ids +export interface RepositoryIdentityDescriptor { + readonly name: string; + + readonly initialCommitSha?: string; + readonly remote?: RepositoryIdentityRemoteDescriptor; + readonly provider?: RepositoryIdentityProviderDescriptor; +} + +export interface RepositoryIdentity extends RepositoryIdentityDescriptor { + readonly id: GkRepositoryId; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +type BaseRepositoryIdentityRequest = { + // name: string; + initialCommitSha?: string; +}; + +type BaseRepositoryIdentityRequestWithCommitSha = BaseRepositoryIdentityRequest & { + initialCommitSha: string; +}; + +type BaseRepositoryIdentityRequestWithRemote = BaseRepositoryIdentityRequest & { + remote: { url: string; domain: string; path: string }; +}; + +type BaseRepositoryIdentityRequestWithRemoteProvider = BaseRepositoryIdentityRequestWithRemote & { + provider: { + id: GkProviderId; + repoDomain: string; + repoName: string; + repoOwnerDomain?: string; + }; +}; + +type BaseRepositoryIdentityRequestWithoutRemoteProvider = BaseRepositoryIdentityRequestWithRemote & { + provider?: never; +}; + +export type RepositoryIdentityRequest = + | BaseRepositoryIdentityRequestWithCommitSha + | BaseRepositoryIdentityRequestWithRemote + | BaseRepositoryIdentityRequestWithRemoteProvider + | BaseRepositoryIdentityRequestWithoutRemoteProvider; + +export interface RepositoryIdentityResponse { + readonly id: GkRepositoryId; + readonly createdAt: string; + readonly updatedAt: string; + + // readonly name: string; + + readonly initialCommitSha?: string; + readonly remote?: { + readonly url?: string; + readonly domain?: string; + readonly path?: string; + }; + readonly provider?: { + readonly id?: GkProviderId; + readonly repoDomain?: string; + readonly repoName?: string; + readonly repoOwnerDomain?: string; + }; +} + +export function getPathFromProviderIdentity( + provider: RepositoryIdentityProviderDescriptor, +): string { + return provider.repoOwnerDomain + ? `${provider.repoOwnerDomain}/${provider.repoDomain}/${provider.repoName}` + : `${provider.repoDomain}/${provider.repoName}`; +} diff --git a/src/hovers/hovers.ts b/src/hovers/hovers.ts index 6debcc6bc89fc..9751c23d76b5c 100644 --- a/src/hovers/hovers.ts +++ b/src/hovers/hovers.ts @@ -1,21 +1,26 @@ import type { CancellationToken, TextDocument } from 'vscode'; import { MarkdownString } from 'vscode'; import type { EnrichedAutolink } from '../annotations/autolinks'; -import { DiffWithCommand, ShowQuickCommitCommand } from '../commands'; +import { DiffWithCommand } from '../commands/diffWith'; +import { ShowQuickCommitCommand } from '../commands/showQuickCommit'; import { GlyphChars } from '../constants'; import type { Container } from '../container'; import { CommitFormatter } from '../git/formatters/commitFormatter'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; import { uncommittedStaged } from '../git/models/constants'; -import type { GitDiffHunk, GitDiffHunkLine } from '../git/models/diff'; +import type { GitDiffHunk, GitDiffLine } from '../git/models/diff'; import type { PullRequest } from '../git/models/pullRequest'; import { isUncommittedStaged, shortenRevision } from '../git/models/reference'; import type { GitRemote } from '../git/models/remote'; import type { RemoteProvider } from '../git/remotes/remoteProvider'; -import { pauseOnCancelOrTimeout, pauseOnCancelOrTimeoutMapTuplePromise } from '../system/cancellation'; import { configuration } from '../system/configuration'; -import { getSettledValue } from '../system/promise'; +import { + cancellable, + getSettledValue, + pauseOnCancelOrTimeout, + pauseOnCancelOrTimeoutMapTuplePromise, +} from '../system/promise'; export async function changesMessage( container: Container, @@ -31,6 +36,9 @@ export async function changesMessage( async function getDiff() { if (commit.file == null) return undefined; + const line = editorLine + 1; + const commitLine = commit.lines.find(l => l.line === line) ?? commit.lines[0]; + // TODO: Figure out how to optimize this let ref; if (commit.isUncommitted) { @@ -38,16 +46,13 @@ export async function changesMessage( ref = documentRef; } } else { - previousSha = await commit.getPreviousSha(); + previousSha = commitLine.previousSha; ref = previousSha; if (ref == null) { return `\`\`\`diff\n+ ${document.lineAt(editorLine).text}\n\`\`\``; } } - const line = editorLine + 1; - const commitLine = commit.lines.find(l => l.line === line) ?? commit.lines[0]; - let originalPath = commit.file.originalPath; if (originalPath == null) { if (uri.fsPath !== commit.file.uri.fsPath) { @@ -57,14 +62,14 @@ export async function changesMessage( editorLine = commitLine.line - 1; // TODO: Doesn't work with dirty files -- pass in editor? or contents? - let hunkLine = await container.git.getDiffForLine(uri, editorLine, ref, documentRef); + let lineDiff = await container.git.getDiffForLine(uri, editorLine, ref, documentRef); // If we didn't find a diff & ref is undefined (meaning uncommitted), check for a staged diff - if (hunkLine == null && ref == null && documentRef !== uncommittedStaged) { - hunkLine = await container.git.getDiffForLine(uri, editorLine, undefined, uncommittedStaged); + if (lineDiff == null && ref == null && documentRef !== uncommittedStaged) { + lineDiff = await container.git.getDiffForLine(uri, editorLine, undefined, uncommittedStaged); } - return hunkLine != null ? getDiffFromHunkLine(hunkLine) : undefined; + return lineDiff != null ? getDiffFromLine(lineDiff) : undefined; } const diff = await getDiff(); @@ -228,8 +233,8 @@ export async function detailsMessage( (options?.autolinks || (options?.autolinks !== false && cfg.autolinks.enabled && cfg.autolinks.enhanced)) && CommitFormatter.has(cfg.detailsMarkdownFormat, 'message'); const prs = - remote?.hasRichIntegration() && - remote.provider.maybeConnected !== false && + remote?.hasIntegration() && + remote.maybeIntegrationConnected !== false && (options?.pullRequests || (options?.pullRequests !== false && cfg.pullRequests.enabled)) && CommitFormatter.has( options.format, @@ -256,7 +261,9 @@ export async function detailsMessage( options?.timeout, ) : undefined, - container.vsls.maybeGetPresence(commit.author.email), + container.vsls.enabled + ? cancellable(container.vsls.getContactPresence(commit.author.email), 250, options?.cancellation) + : undefined, commit.isUncommitted ? commit.getPreviousComparisonUrisForLine(editorLine, uri.sha) : undefined, commit.message == null ? commit.ensureFullDetails() : undefined, ]); @@ -294,12 +301,12 @@ function getDiffFromHunk(hunk: GitDiffHunk): string { return `\`\`\`diff\n${hunk.contents.trim()}\n\`\`\``; } -function getDiffFromHunkLine(hunkLine: GitDiffHunkLine, diffStyle?: 'line' | 'hunk'): string { +function getDiffFromLine(lineDiff: GitDiffLine, diffStyle?: 'line' | 'hunk'): string { if (diffStyle === 'hunk' || (diffStyle == null && configuration.get('hovers.changesDiff') === 'hunk')) { - return getDiffFromHunk(hunkLine.hunk); + return getDiffFromHunk(lineDiff.hunk); } - return `\`\`\`diff${hunkLine.previous == null ? '' : `\n- ${hunkLine.previous.line.trim()}`}${ - hunkLine.current == null ? '' : `\n+ ${hunkLine.current.line.trim()}` + return `\`\`\`diff${lineDiff.line.previous == null ? '' : `\n- ${lineDiff.line.previous.trim()}`}${ + lineDiff.line.current == null ? '' : `\n+ ${lineDiff.line.current.trim()}` }\n\`\`\``; } diff --git a/src/hovers/lineHoverController.ts b/src/hovers/lineHoverController.ts index 15b589564aea5..c0f482527f92a 100644 --- a/src/hovers/lineHoverController.ts +++ b/src/hovers/lineHoverController.ts @@ -6,10 +6,10 @@ import { configuration } from '../system/configuration'; import { debug } from '../system/decorators/log'; import { once } from '../system/event'; import { Logger } from '../system/logger'; -import type { LinesChangeEvent } from '../trackers/gitLineTracker'; +import type { LinesChangeEvent } from '../trackers/lineTracker'; import { changesMessage, detailsMessage } from './hovers'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) export class LineHoverController implements Disposable { private readonly _disposable: Disposable; @@ -120,7 +120,7 @@ export class LineHoverController implements Disposable { const commitLine = commit.lines.find(l => l.line === line) ?? commit.lines[0]; editorLine = commitLine.originalLine - 1; - const trackedDocument = await this.container.tracker.get(document); + const trackedDocument = await this.container.documentTracker.get(document); if (trackedDocument == null || token.isCancellationRequested) return undefined; const message = @@ -175,7 +175,7 @@ export class LineHoverController implements Disposable { ); if (!wholeLine && range.start.character !== position.character) return undefined; - const trackedDocument = await this.container.tracker.get(document); + const trackedDocument = await this.container.documentTracker.get(document); if (trackedDocument == null) return undefined; const message = await changesMessage( diff --git a/src/messages.ts b/src/messages.ts index 70b614099831c..3ca0075f89ed5 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,11 +1,32 @@ import type { MessageItem } from 'vscode'; import { ConfigurationTarget, window } from 'vscode'; import type { SuppressedMessages } from './config'; -import { Commands } from './constants'; +import { Commands, urls } from './constants'; +import type { BlameIgnoreRevsFileError } from './git/errors'; +import { BlameIgnoreRevsFileBadRevisionError } from './git/errors'; import type { GitCommit } from './git/models/commit'; import { executeCommand } from './system/command'; import { configuration } from './system/configuration'; import { Logger } from './system/logger'; +import { openUrl } from './system/utils'; + +export function showBlameInvalidIgnoreRevsFileWarningMessage( + ex: BlameIgnoreRevsFileError | BlameIgnoreRevsFileBadRevisionError, +): Promise { + if (ex instanceof BlameIgnoreRevsFileBadRevisionError) { + return showMessage( + 'error', + `Unable to show blame. Invalid revision (${ex.revision}) specified in the blame.ignoreRevsFile in your Git config.`, + 'suppressBlameInvalidIgnoreRevsFileBadRevisionWarning', + ); + } + + return showMessage( + 'error', + `Unable to show blame. Invalid or missing blame.ignoreRevsFile (${ex.fileName}) specified in your Git config.`, + 'suppressBlameInvalidIgnoreRevsFileWarning', + ); +} export function showCommitHasNoPreviousCommitWarningMessage(commit?: GitCommit): Promise { if (commit == null) { @@ -171,17 +192,23 @@ export function showIntegrationRequestTimedOutWarningMessage(providerName: strin } export async function showWhatsNewMessage(version: string) { - const reset = { title: 'Switch to New Layout' }; + const confirm = { title: 'OK', isCloseAffordance: true }; + const announcement = { title: 'Read Announcement', isCloseAffordance: true }; const result = await showMessage( 'info', - `Upgraded to GitLens ${version} — [see what's new](https://help.gitkraken.com/gitlens/gitlens-release-notes-current/ "See what's new in GitLens ${version}").\nWe've reimagined and rearranged our views for greater focus and productivity, and recommend switching to the new layout — [learn more and tell us what you think](https://github.com/gitkraken/vscode-gitlens/discussions/2721 "Learn more about what's changed")!`, + `Upgraded to GitLens ${version}${ + version === '15' + ? `, with a host of new [Pro features](${urls.proFeatures}) including [Launchpad](${urls.codeSuggest}), [Code Suggest](${urls.codeSuggest}), and more` + : '' + } — [see what's new](${urls.releaseNotes} "See what's new in GitLens ${version}").`, undefined, null, - reset, + confirm, + announcement, ); - if (result === reset) { - void executeCommand(Commands.ResetViewsLayout); + if (result === announcement) { + void openUrl(urls.releaseAnnouncement); } } diff --git a/src/partners.ts b/src/partners.ts index 4be6fa2f06c77..64462177f2fe6 100644 --- a/src/partners.ts +++ b/src/partners.ts @@ -1,7 +1,7 @@ import type { CancellationTokenSource, Extension, ExtensionContext, Uri } from 'vscode'; import { extensions } from 'vscode'; import type { ActionContext, HoverCommandsActionContext } from './api/gitlens'; -import type { InviteToLiveShareCommandArgs } from './commands'; +import type { InviteToLiveShareCommandArgs } from './commands/inviteToLiveShare'; import { Commands } from './constants'; import { Container } from './container'; import { executeCommand, executeCoreCommand } from './system/command'; diff --git a/src/plus/LICENSE.plus b/src/plus/LICENSE.plus index 814b362324cca..7d3d6faa994dd 100644 --- a/src/plus/LICENSE.plus +++ b/src/plus/LICENSE.plus @@ -1,6 +1,6 @@ GitLens+ License -Copyright (c) 2021-2023 Axosoft, LLC dba GitKraken ("GitKraken") +Copyright (c) 2021-2024 Axosoft, LLC dba GitKraken ("GitKraken") With regard to the software set forth in or under any directory named "plus". diff --git a/src/plus/drafts/actions.ts b/src/plus/drafts/actions.ts new file mode 100644 index 0000000000000..ec04cfc218a3e --- /dev/null +++ b/src/plus/drafts/actions.ts @@ -0,0 +1,29 @@ +import type { MessageItem } from 'vscode'; +import { window } from 'vscode'; +import { Container } from '../../container'; +import { configuration } from '../../system/configuration'; +import type { WebviewViewShowOptions } from '../../webviews/webviewsController'; +import type { ShowCreateDraft, ShowViewDraft } from '../webviews/patchDetails/registration'; + +type ShowCreateOrOpen = ShowCreateDraft | ShowViewDraft; + +export async function showPatchesView(createOrOpen: ShowCreateOrOpen, options?: WebviewViewShowOptions): Promise { + if (!configuration.get('cloudPatches.enabled')) { + const confirm: MessageItem = { title: 'Enable' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + 'Cloud Patches are currently disabled. Would you like to enable them?', + { modal: true }, + confirm, + cancel, + ); + + if (result !== confirm) return; + await configuration.updateEffective('cloudPatches.enabled', true); + } + + if (createOrOpen.mode === 'create') { + options = { ...options, preserveFocus: false, preserveVisibility: false }; + } + return Container.instance.patchDetailsView.show(options, createOrOpen); +} diff --git a/src/plus/drafts/draftsService.ts b/src/plus/drafts/draftsService.ts new file mode 100644 index 0000000000000..86e5eca770895 --- /dev/null +++ b/src/plus/drafts/draftsService.ts @@ -0,0 +1,1044 @@ +import type { EntityIdentifier } from '@gitkraken/provider-apis'; +import { EntityIdentifierUtils } from '@gitkraken/provider-apis'; +import type { Disposable } from 'vscode'; +import type { HeadersInit } from '@env/fetch'; +import { getAvatarUri } from '../../avatars'; +import type { Container } from '../../container'; +import type { GitCommit } from '../../git/models/commit'; +import type { PullRequest } from '../../git/models/pullRequest'; +import { isSha, isUncommitted, shortenRevision } from '../../git/models/reference'; +import { isRepository, Repository } from '../../git/models/repository'; +import type { GitUser } from '../../git/models/user'; +import { getRemoteProviderMatcher } from '../../git/remotes/remoteProviders'; +import type { + CodeSuggestionCounts, + CodeSuggestionCountsResponse, + CreateDraftChange, + CreateDraftPatchRequestFromChange, + CreateDraftRequest, + CreateDraftResponse, + Draft, + DraftChangeset, + DraftChangesetCreateRequest, + DraftChangesetCreateResponse, + DraftChangesetResponse, + DraftPatch, + DraftPatchDetails, + DraftPatchFileChange, + DraftPatchResponse, + DraftPendingUser, + DraftResponse, + DraftType, + DraftUser, + DraftVisibility, +} from '../../gk/models/drafts'; +import type { + GkRepositoryId, + RepositoryIdentity, + RepositoryIdentityRequest, + RepositoryIdentityResponse, +} from '../../gk/models/repositoryIdentities'; +import { log } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import type { LogScope } from '../../system/logger.scope'; +import { getLogScope } from '../../system/logger.scope'; +import { getSettledValue } from '../../system/promise'; +import type { FocusItem } from '../focus/focusProvider'; +import type { OrganizationMember } from '../gk/account/organization'; +import type { SubscriptionAccount } from '../gk/account/subscription'; +import type { ServerConnection } from '../gk/serverConnection'; +import type { IntegrationId } from '../integrations/providers/models'; +import { providersMetadata } from '../integrations/providers/models'; +import { getEntityIdentifierInput } from '../integrations/providers/utils'; + +export interface ProviderAuth { + provider: IntegrationId; + token: string; +} + +export class DraftService implements Disposable { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose(): void {} + + @log({ args: { 2: false } }) + async createDraft( + type: DraftType, + title: string, + changes: CreateDraftChange[], + options?: { description?: string; visibility?: DraftVisibility; prEntityId?: string }, + ): Promise { + const scope = getLogScope(); + + try { + const results = await Promise.allSettled(changes.map(c => this.getCreateDraftPatchRequestFromChange(c))); + if (!results.length) throw new Error('No changes found'); + + const patchRequests: CreateDraftPatchRequestFromChange[] = []; + const failed: Error[] = []; + let user: GitUser | undefined; + + for (const r of results) { + if (r.status === 'fulfilled') { + // Don't include empty patches -- happens when there are changes in a range that undo each other + if (r.value.contents) { + patchRequests.push(r.value); + if (user == null) { + user = r.value.user; + } + } + } else { + failed.push(r.reason); + } + } + + if (failed.length) { + debugger; + throw new AggregateError(failed, 'Unable to create draft'); + } + + type DraftResult = { data: CreateDraftResponse }; + + let providerAuthHeader: HeadersInit | undefined; + let prEntityIdBody: { prEntityId: string } | undefined; + if (type === 'suggested_pr_change') { + if (options?.prEntityId == null) { + throw new Error('No pull request info provided'); + } + prEntityIdBody = { + prEntityId: options.prEntityId, + }; + + const repo = patchRequests[0].repository; + const providerAuth = await this.getProviderAuthFromRepoOrIntegrationId(repo); + if (providerAuth == null) { + throw new Error('No provider integration found'); + } + providerAuthHeader = { + 'Provider-Auth': Buffer.from(JSON.stringify(providerAuth)).toString('base64'), + }; + } + + // POST v1/drafts + const createDraftRsp = await this.connection.fetchGkDevApi('v1/drafts', { + method: 'POST', + body: JSON.stringify({ + type: type, + title: title, + description: options?.description, + visibility: options?.visibility ?? 'public', + } satisfies CreateDraftRequest), + }); + + if (!createDraftRsp.ok) { + await handleBadDraftResponse('Unable to create draft', createDraftRsp, scope); + } + + const createDraft = ((await createDraftRsp.json()) as DraftResult).data; + const draftId = createDraft.id; + + type ChangesetResult = { data: DraftChangesetCreateResponse }; + + // POST /v1/drafts/:draftId/changesets + const createChangesetRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}/changesets`, { + method: 'POST', + body: JSON.stringify({ + // parentChangesetId: null, + gitUserName: user?.name, + gitUserEmail: user?.email, + patches: patchRequests.map(p => p.patch), + } satisfies DraftChangesetCreateRequest), + headers: providerAuthHeader, + }); + + if (!createChangesetRsp.ok) { + await handleBadDraftResponse( + `Unable to create changeset for draft '${draftId}'`, + createChangesetRsp, + scope, + ); + } + + const createChangeset = ((await createChangesetRsp.json()) as ChangesetResult).data; + + const patches: DraftPatch[] = []; + + let i = 0; + for (const patch of createChangeset.patches) { + const { url, method, headers } = patch.secureUploadData; + + const { contents, repository } = patchRequests[i++]; + if (contents == null) { + debugger; + throw new Error(`No contents found for ${patch.baseCommitSha}`); + } + + const diffFiles = await this.container.git.getDiffFiles(repository.path, contents); + const files = diffFiles?.files.map(f => ({ ...f, gkRepositoryId: patch.gitRepositoryId })) ?? []; + + // Upload patch to returned S3 url + await this.connection.fetch(url, { + method: method, + headers: { + 'Content-Type': 'text/plain', + ...headers, + }, + body: contents, + }); + + const newPatch = formatPatch( + { + ...patch, + secureDownloadData: undefined!, + }, + { + contents: contents, + files: files, + repository: repository, + }, + ); + + patches.push(newPatch); + } + + // POST /v1/drafts/:draftId/publish + const publishRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}/publish`, { + method: 'POST', + headers: providerAuthHeader, + body: prEntityIdBody != null ? JSON.stringify(prEntityIdBody) : undefined, + }); + if (!publishRsp.ok) { + await handleBadDraftResponse(`Failed to publish draft '${draftId}'`, publishRsp, scope); + } + + type Result = { data: DraftResponse }; + + const draftRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}`, { + method: 'GET', + headers: providerAuthHeader, + }); + + if (!draftRsp.ok) { + await handleBadDraftResponse(`Unable to open draft '${draftId}'`, draftRsp, scope); + } + + const draft = ((await draftRsp.json()) as Result).data; + + const { account } = await this.container.subscription.getSubscription(); + + const newDraft = formatDraft(draft, { account: account }); + newDraft.changesets = [ + { + ...formatChangeset({ ...createChangeset, patches: [] }), + patches: patches, + }, + ]; + + return newDraft; + } catch (ex) { + debugger; + Logger.error(ex, scope); + + throw ex; + } + } + + private async getCreateDraftPatchRequestFromChange( + change: CreateDraftChange, + ): Promise { + const isWIP = isUncommitted(change.revision.to); + + const [branchNamesResult, diffResult, firstShaResult, remoteResult, userResult] = await Promise.allSettled([ + isWIP + ? this.container.git.getBranch(change.repository.uri).then(b => (b != null ? [b.name] : undefined)) + : this.container.git.getCommitBranches(change.repository.uri, [ + change.revision.to, + change.revision.from, + ]), + change.contents == null + ? this.container.git.getDiff(change.repository.path, change.revision.to, change.revision.from) + : undefined, + this.container.git.getFirstCommitSha(change.repository.uri), + this.container.git.getBestRemoteWithProvider(change.repository.uri), + this.container.git.getCurrentUser(change.repository.uri), + ]); + + const firstSha = getSettledValue(firstShaResult); + // TODO: what happens if there are multiple remotes -- which one should we use? Do we need to ask? See more notes below + const remote = getSettledValue(remoteResult); + + let repoData: RepositoryIdentityRequest; + if (remote == null) { + if (firstSha == null) throw new Error('No remote or initial commit found'); + + repoData = { + initialCommitSha: firstSha, + }; + } else { + repoData = { + initialCommitSha: firstSha, + remote: { + url: remote.url, + domain: remote.domain, + path: remote.path, + }, + provider: + remote.provider.gkProviderId != null && + remote.provider.owner != null && + remote.provider.repoName != null + ? { + id: remote.provider.gkProviderId, + repoDomain: remote.provider.owner, + repoName: remote.provider.repoName, + // repoOwnerDomain: ?? + } + : undefined, + }; + } + + const diff = getSettledValue(diffResult); + const contents = change.contents ?? diff?.contents; + if (contents == null) throw new Error(`Unable to diff ${change.revision.from} and ${change.revision.to}`); + + const user = getSettledValue(userResult); + + // We need to get the branch name if possible, otherwise default to HEAD + const branchNames = getSettledValue(branchNamesResult); + const branchName = branchNames?.[0] ?? 'HEAD'; + + let baseSha = change.revision.from; + if (!isSha(baseSha)) { + const commit = await this.container.git.getCommit(change.repository.uri, baseSha); + if (commit != null) { + baseSha = commit.sha; + } else { + debugger; + } + } + + return { + patch: { + baseCommitSha: baseSha, + baseBranchName: branchName, + gitRepoData: repoData, + prEntityId: change.prEntityId, + }, + contents: contents, + repository: change.repository, + user: user, + }; + } + + @log() + async deleteDraft(id: string): Promise { + await this.connection.fetchGkDevApi(`v1/drafts/${id}`, { method: 'DELETE' }); + } + + @log({ args: { 1: opts => JSON.stringify({ ...opts, providerAuth: undefined }) } }) + async archiveDraft(draft: Draft, options?: { providerAuth?: ProviderAuth; archiveReason?: string }): Promise { + const scope = getLogScope(); + + try { + let providerAuth = options?.providerAuth; + if (draft.visibility === 'provider_access' && providerAuth == null) { + providerAuth = await this.getProviderAuthForDraft(draft); + if (providerAuth == null) { + throw new Error('No provider integration found'); + } + } + + let providerAuthHeader; + if (providerAuth != null) { + providerAuthHeader = { + 'Provider-Auth': Buffer.from(JSON.stringify(providerAuth)).toString('base64'), + }; + } + + const rsp = await this.connection.fetchGkDevApi(`v1/drafts/${draft.id}/archive`, { + method: 'POST', + body: + options?.archiveReason != null + ? JSON.stringify({ archiveReason: options.archiveReason }) + : undefined, + headers: providerAuthHeader, + }); + + if (!rsp.ok) { + await handleBadDraftResponse(`Unable to archive draft '${draft.id}'`, rsp, scope); + } + } catch (ex) { + debugger; + Logger.error(ex, scope); + + throw ex; + } + } + + @log({ args: { 1: opts => JSON.stringify({ ...opts, providerAuth: undefined }) } }) + async getDraft(id: string, options?: { providerAuth?: ProviderAuth }): Promise { + const scope = getLogScope(); + + type Result = { data: DraftResponse }; + + let headers; + if (options?.providerAuth) { + headers = { + 'Provider-Auth': Buffer.from(JSON.stringify(options.providerAuth)).toString('base64'), + }; + } + + const [rspResult, changesetsResult] = await Promise.allSettled([ + this.connection.fetchGkDevApi(`v1/drafts/${id}`, { method: 'GET', headers: headers }), + this.getChangesets(id), + ]); + + if (rspResult.status === 'rejected') { + Logger.error(rspResult.reason, scope, `Unable to open draft '${id}': ${rspResult.reason}`); + throw new Error(`Unable to open draft '${id}': ${rspResult.reason}`); + } + + if (changesetsResult.status === 'rejected') { + Logger.error( + changesetsResult.reason, + scope, + `Unable to open changeset for draft '${id}': ${changesetsResult.reason}`, + ); + throw new Error(`Unable to open changesets for draft '${id}': ${changesetsResult.reason}`); + } + + const rsp = getSettledValue(rspResult)!; + if (!rsp?.ok) { + await handleBadDraftResponse(`Unable to open draft '${id}'`, rsp, scope); + } + + const draft = ((await rsp.json()) as Result).data; + const changesets = getSettledValue(changesetsResult)!; + + const [subscriptionResult, membersResult] = await Promise.allSettled([ + this.container.subscription.getSubscription(), + this.container.organizations.getMembers(draft.organizationId), + ]); + + const account = getSettledValue(subscriptionResult)?.account; + const members = getSettledValue(membersResult); + + const newDraft = formatDraft(draft, { + account: account, + members: members, + }); + newDraft.changesets = changesets; + + return newDraft; + } + + @log() + async getDrafts(isArchived?: boolean): Promise { + return this.getDraftsCore(isArchived ? { isArchived: isArchived } : undefined); + } + + async getDraftsCore(options?: { + prEntityId?: string; + providerAuth?: ProviderAuth; + isArchived?: boolean; + }): Promise { + const scope = getLogScope(); + type Result = { data: DraftResponse[] }; + + const queryStrings = []; + let fromPrEntityId = false; + if (options?.prEntityId != null) { + if (options.providerAuth == null) { + throw new Error('No provider integration found'); + } + fromPrEntityId = true; + queryStrings.push(`prEntityId=${encodeURIComponent(options.prEntityId)}`); + } + + if (options?.isArchived) { + queryStrings.push('archived=true'); + } + + let headers; + if (options?.providerAuth) { + headers = { + 'Provider-Auth': Buffer.from(JSON.stringify(options.providerAuth)).toString('base64'), + }; + } + + const rsp = await this.connection.fetchGkDevApi( + '/v1/drafts', + { + method: 'GET', + headers: headers, + }, + { + query: queryStrings.length ? queryStrings.join('&') : undefined, + }, + ); + + if (!rsp.ok) { + await handleBadDraftResponse('Unable to open drafts', rsp, scope); + } + + const drafts = ((await rsp.json()) as Result).data; + + const [subscriptionResult, membersResult] = await Promise.allSettled([ + this.container.subscription.getSubscription(), + this.container.organizations.getMembers(), + ]); + + const account = getSettledValue(subscriptionResult)?.account; + const members = getSettledValue(membersResult); + + return drafts.map( + (d): Draft => + formatDraft(d, { + account: account, + members: members, + fromPrEntityId: fromPrEntityId, + }), + ); + } + + @log() + async getChangesets(id: string): Promise { + const scope = getLogScope(); + + type Result = { data: DraftChangesetResponse[] }; + + try { + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/changesets`, { method: 'GET' }); + if (!rsp.ok) { + await handleBadDraftResponse(`Unable to open changesets for draft '${id}'`, rsp, scope); + } + + const changeset = ((await rsp.json()) as Result).data; + + const changesets: DraftChangeset[] = []; + for (const c of changeset) { + const newChangeset = formatChangeset(c); + changesets.push(newChangeset); + } + + return changesets; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log() + async getPatch(id: string): Promise { + const patch = await this.getPatchCore(id); + + const details = await this.getPatchDetails(patch); + patch.contents = details.contents; + patch.files = details.files; + patch.repository = details.repository; + + return patch; + } + + private async getPatchCore(id: string): Promise { + const scope = getLogScope(); + type Result = { data: DraftPatchResponse }; + + // GET /v1/patches/:patchId + const rsp = await this.connection.fetchGkDevApi(`/v1/patches/${id}`, { method: 'GET' }); + + if (!rsp.ok) { + await handleBadDraftResponse(`Unable to open patch '${id}'`, rsp, scope); + } + + const data = ((await rsp.json()) as Result).data; + + const newPatch = formatPatch(data); + + return newPatch; + } + + async getPatchDetails(id: string): Promise; + async getPatchDetails(patch: DraftPatch): Promise; + @log({ + args: { 0: idOrPatch => (typeof idOrPatch === 'string' ? idOrPatch : idOrPatch.id) }, + }) + async getPatchDetails(idOrPatch: string | DraftPatch): Promise { + const patch = typeof idOrPatch === 'string' ? await this.getPatchCore(idOrPatch) : idOrPatch; + + const [contentsResult, repositoryResult] = await Promise.allSettled([ + this.getPatchContentsCore(patch.secureLink), + this.getRepositoryOrIdentity(patch.draftId, patch.gkRepositoryId, { + openIfNeeded: true, + skipRefValidation: true, + }), + ]); + + const contents = getSettledValue(contentsResult)!; + const repositoryOrIdentity = getSettledValue(repositoryResult)!; + + let repoPath = ''; + if (isRepository(repositoryOrIdentity)) { + repoPath = repositoryOrIdentity.path; + } + + const diffFiles = await this.container.git.getDiffFiles(repoPath, contents); + const files = diffFiles?.files.map(f => ({ ...f, gkRepositoryId: patch.gkRepositoryId })) ?? []; + + return { + id: patch.id, + contents: contents, + files: files, + repository: repositoryOrIdentity, + }; + } + + private async getPatchContentsCore( + secureLink: DraftPatchResponse['secureDownloadData'], + ): Promise { + const { url, method, headers } = secureLink; + + // Download patch from returned S3 url + const contentsRsp = await this.connection.fetch(url, { + method: method, + headers: { + Accept: 'text/plain', + ...headers, + }, + }); + + return contentsRsp.text(); + } + + @log() + async updateDraftVisibility(id: string, visibility: DraftVisibility): Promise { + const scope = getLogScope(); + + type Result = { data: Draft }; + + try { + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}`, { + method: 'PATCH', + body: JSON.stringify({ visibility: visibility }), + }); + + if (rsp?.ok === false) { + await handleBadDraftResponse(`Unable to update draft '${id}'`, rsp, scope); + } + + const draft = ((await rsp.json()) as Result).data; + + return draft; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log() + async getDraftUsers(id: string): Promise { + const scope = getLogScope(); + + type Result = { data: DraftUser[] }; + + try { + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/users`, { method: 'GET' }); + + if (rsp?.ok === false) { + await handleBadDraftResponse(`Unable to get users for draft '${id}'`, rsp, scope); + } + + const users: DraftUser[] = ((await rsp.json()) as Result).data; + + return users; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log({ args: { 1: false } }) + async addDraftUsers(id: string, pendingUsers: DraftPendingUser[]): Promise { + const scope = getLogScope(); + + type Result = { data: DraftUser[] }; + type Request = { id: string; users: DraftPendingUser[] }; + + try { + if (pendingUsers.length === 0) { + throw new Error('No changes found'); + } + + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/users`, { + method: 'POST', + body: JSON.stringify({ + id: id, + users: pendingUsers, + } as Request), + }); + + if (rsp?.ok === false) { + await handleBadDraftResponse(`Unable to add users for draft '${id}'`, rsp, scope); + } + + const users: DraftUser[] = ((await rsp.json()) as Result).data; + + return users; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log() + async removeDraftUser(id: string, userId: DraftUser['userId']): Promise { + const scope = getLogScope(); + try { + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/users/${userId}`, { method: 'DELETE' }); + + if (rsp?.ok === false) { + await handleBadDraftResponse(`Unable to update user ${userId} for draft '${id}'`, rsp, scope); + } + + return true; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log() + async getRepositoryOrIdentity( + draftId: Draft['id'], + repoId: GkRepositoryId, + options?: { openIfNeeded?: boolean; keepOpen?: boolean; prompt?: boolean; skipRefValidation?: boolean }, + ): Promise { + const identity = await this.getRepositoryIdentity(draftId, repoId); + return (await this.container.repositoryIdentity.getRepository(identity, options)) ?? identity; + } + + @log() + async getRepositoryIdentity(draftId: Draft['id'], repoId: GkRepositoryId): Promise { + type Result = { data: RepositoryIdentityResponse }; + + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${draftId}/git-repositories/${repoId}`, { + method: 'GET', + }); + const data = ((await rsp.json()) as Result).data; + + let name: string; + if ('name' in data && typeof data.name === 'string') { + name = data.name; + } else if (data.provider?.repoName != null) { + name = data.provider.repoName; + } else if (data.remote?.url != null && data.remote?.domain != null && data.remote?.path != null) { + const matcher = getRemoteProviderMatcher(this.container); + const provider = matcher(data.remote.url, data.remote.domain, data.remote.path); + name = provider?.repoName ?? data.remote.path; + } else { + name = + data.remote?.path ?? + `Unknown ${data.initialCommitSha ? ` (${shortenRevision(data.initialCommitSha)})` : ''}`; + } + + return { + id: data.id, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + name: name, + initialCommitSha: data.initialCommitSha, + remote: data.remote, + provider: data.provider, + }; + } + + async getProviderAuthFromRepoOrIntegrationId( + repoOrIntegrationId: Repository | IntegrationId, + ): Promise { + let integration; + if (isRepository(repoOrIntegrationId)) { + const remoteProvider = await repoOrIntegrationId.getBestRemoteWithIntegration(); + if (remoteProvider == null) return undefined; + + integration = await remoteProvider.getIntegration(); + } else { + const metadata = providersMetadata[repoOrIntegrationId]; + if (metadata == null) return undefined; + + integration = await this.container.integrations.get(repoOrIntegrationId, metadata.domain); + } + if (integration == null) return undefined; + + const session = await integration.getSession(); + if (session == null) return undefined; + + return { + provider: integration.authProvider.id, + token: session.accessToken, + }; + } + + async getProviderAuthForDraft(draft: Draft): Promise { + if (draft.changesets == null || draft.changesets.length === 0) { + return undefined; + } + + let patch: DraftPatch | undefined; + for (const changeset of draft.changesets) { + const changesetPatch = changeset.patches?.find(patch => patch.repository ?? patch.gkRepositoryId); + if (changesetPatch != null) { + patch = changesetPatch; + } + } + + if (patch == null) { + return undefined; + } + + let repo: Repository | undefined; + // avoid calling getRepositoryOrIdentity if possible + if (patch.repository != null) { + if (patch.repository instanceof Repository) { + repo = patch.repository; + } else { + repo = await this.container.repositoryIdentity.getRepository(patch.repository); + } + } + + if (repo == null) { + const repositoryOrIdentity = await this.getRepositoryOrIdentity(draft.id, patch.gkRepositoryId); + if (!(repositoryOrIdentity instanceof Repository)) { + return undefined; + } + + repo = repositoryOrIdentity; + } + + return this.getProviderAuthFromRepoOrIntegrationId(repo); + } + + async getCodeSuggestions( + pullRequest: PullRequest, + repository: Repository, + options?: { includeArchived?: boolean }, + ): Promise; + async getCodeSuggestions( + focusItem: FocusItem, + integrationId: IntegrationId, + options?: { includeArchived?: boolean }, + ): Promise; + @log({ args: { 0: i => i.id, 1: r => (isRepository(r) ? r.id : r) } }) + async getCodeSuggestions( + item: PullRequest | FocusItem, + repositoryOrIntegrationId: Repository | IntegrationId, + options?: { includeArchived?: boolean }, + ): Promise { + const entityIdentifier = getEntityIdentifierInput(item); + const prEntityId = EntityIdentifierUtils.encode(entityIdentifier); + const providerAuth = await this.getProviderAuthFromRepoOrIntegrationId(repositoryOrIntegrationId); + + // swallowing this error as we don't need to fail here + try { + const drafts = await this.getDraftsCore({ + prEntityId: prEntityId, + providerAuth: providerAuth, + isArchived: options?.includeArchived != null ? options.includeArchived : true, + }); + return drafts; + } catch (e) { + return []; + } + } + + @log({ args: { 0: prs => prs.map(pr => pr.id).join(',') } }) + async getCodeSuggestionCounts(pullRequests: PullRequest[]): Promise { + const scope = getLogScope(); + + type Result = { data: CodeSuggestionCountsResponse }; + + const prEntityIds = pullRequests.map(pr => { + return EntityIdentifierUtils.encode(getEntityIdentifierInput(pr)); + }); + + const body = JSON.stringify({ + prEntityIds: prEntityIds, + }); + + try { + const rsp = await this.connection.fetchGkDevApi( + 'v1/drafts/counts', + { + method: 'POST', + body: body, + }, + { + query: 'type=suggested_pr_change', + }, + ); + + if (!rsp.ok) { + await handleBadDraftResponse('Unable to open code suggestion counts', rsp, scope); + } + + return ((await rsp.json()) as Result).data.counts; + } catch (ex) { + debugger; + Logger.error(ex, scope); + + throw ex; + } + } + + generateWebUrl(draftId: string): string; + generateWebUrl(draft: Draft): string; + generateWebUrl(draftOrDraftId: Draft | string): string { + const id = typeof draftOrDraftId === 'string' ? draftOrDraftId : draftOrDraftId.id; + return this.container.generateWebGkDevUrl(`/drafts/${id}`); + } +} + +async function handleBadDraftResponse(message: string, rsp?: any, scope?: LogScope) { + let json: { error?: { message?: string } } | { error?: string } | undefined; + try { + json = (await rsp?.json()) as { error?: { message?: string } } | { error?: string } | undefined; + } catch {} + const rspErrorMessage = typeof json?.error === 'string' ? json.error : json?.error?.message ?? rsp?.statusText; + const errorMessage = rsp != null ? `${message}: (${rsp?.status}) ${rspErrorMessage}` : message; + Logger.error(undefined, scope, errorMessage); + throw new Error(errorMessage); +} + +function formatDraft( + draftResponse: DraftResponse, + options?: { + account?: SubscriptionAccount; + members?: OrganizationMember[]; + fromPrEntityId?: boolean; + }, +): Draft { + let author: Draft['author']; + let isMine = false; + + if (draftResponse.createdBy === options?.account?.id) { + isMine = true; + author = { + id: draftResponse.createdBy, + name: `${options.account.name} (you)`, + email: options.account.email, + avatarUri: getAvatarUri(options.account.email), + }; + } else { + let member; + if (options?.members?.length) { + member = options.members.find(m => m.id === draftResponse.createdBy); + } + + author = { + id: draftResponse.createdBy, + name: member?.name ?? 'Unknown', + email: member?.email, + avatarUri: getAvatarUri(member?.email), + }; + } + + let role = draftResponse.role; + if (!role) { + if (options?.fromPrEntityId === true) { + role = 'editor'; + } else { + role = 'viewer'; + } + } + + return { + draftType: 'cloud', + type: draftResponse.type, + id: draftResponse.id, + createdAt: new Date(draftResponse.createdAt), + updatedAt: new Date(draftResponse.updatedAt ?? draftResponse.createdAt), + author: author, + isMine: isMine, + organizationId: draftResponse.organizationId || undefined, + role: role, + isPublished: draftResponse.isPublished, + + title: draftResponse.title, + description: draftResponse.description, + + deepLinkUrl: draftResponse.deepLink, + visibility: draftResponse.visibility, + + isArchived: draftResponse.isArchived, + archivedBy: draftResponse.archivedBy, + archivedReason: draftResponse.archivedReason, + archivedAt: draftResponse.archivedAt != null ? new Date(draftResponse.archivedAt) : draftResponse.archivedAt, + + latestChangesetId: draftResponse.latestChangesetId, + }; +} + +function formatChangeset(changesetResponse: DraftChangesetResponse): DraftChangeset { + return { + id: changesetResponse.id, + createdAt: new Date(changesetResponse.createdAt), + updatedAt: new Date(changesetResponse.updatedAt ?? changesetResponse.createdAt), + draftId: changesetResponse.draftId, + parentChangesetId: changesetResponse.parentChangesetId, + userId: changesetResponse.userId, + + gitUserName: changesetResponse.gitUserName, + gitUserEmail: changesetResponse.gitUserEmail, + deepLinkUrl: changesetResponse.deepLink, + + patches: changesetResponse.patches.map((patch: DraftPatchResponse) => formatPatch(patch)), + }; +} + +function formatPatch( + patchResponse: DraftPatchResponse, + options?: { + commit?: GitCommit; + contents?: string; + files?: DraftPatchFileChange[]; + repository?: Repository | RepositoryIdentity; + }, +): DraftPatch { + return { + type: 'cloud', + id: patchResponse.id, + createdAt: new Date(patchResponse.createdAt), + updatedAt: new Date(patchResponse.updatedAt ?? patchResponse.createdAt), + draftId: patchResponse.draftId, + changesetId: patchResponse.changesetId, + userId: patchResponse.userId, + + baseBranchName: patchResponse.baseBranchName, + baseRef: patchResponse.baseCommitSha, + gkRepositoryId: patchResponse.gitRepositoryId, + secureLink: patchResponse.secureDownloadData, + + commit: options?.commit, + contents: options?.contents, + files: options?.files, + repository: options?.repository, + }; +} + +export function getDraftEntityIdentifier(draft: Draft, patch?: DraftPatch): EntityIdentifier | undefined { + if (draft.prEntityId != null) { + return EntityIdentifierUtils.decode(draft.prEntityId); + } + + if (patch?.prEntityId != null) { + return EntityIdentifierUtils.decode(patch.prEntityId); + } + + return undefined; +} diff --git a/src/plus/focus/enrichmentService.ts b/src/plus/focus/enrichmentService.ts new file mode 100644 index 0000000000000..31e551c4c5483 --- /dev/null +++ b/src/plus/focus/enrichmentService.ts @@ -0,0 +1,230 @@ +import type { CancellationToken, Disposable } from 'vscode'; +import type { Container } from '../../container'; +import { AuthenticationRequiredError, CancellationError } from '../../errors'; +import type { RemoteProvider } from '../../git/remotes/remoteProvider'; +import { log } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; +import type { ServerConnection } from '../gk/serverConnection'; +import { ensureAccount } from '../utils'; + +export interface EnrichableItem { + type: EnrichedItemResponse['entityType']; + id: string; + provider: EnrichedItemResponse['provider']; + url: string; + expiresAt?: string; +} + +export type EnrichedItem = { + id: string; + userId?: string; + type: EnrichedItemResponse['type']; + + provider: EnrichedItemResponse['provider']; + entityType: EnrichedItemResponse['entityType']; + entityId: string; + entityUrl: string; + + createdAt: string; + updatedAt: string; + expiresAt?: string; +}; + +type EnrichedItemRequest = { + provider: EnrichedItemResponse['provider']; + entityType: EnrichedItemResponse['entityType']; + entityId: string; + entityUrl: string; + expiresAt?: string; +}; + +type EnrichedItemResponse = { + id: string; + userId?: string; + type: 'pin' | 'snooze'; + + provider: 'azure' | 'bitbucket' | 'github' | 'gitlab' | 'jira' | 'trello' | 'gitkraken'; + entityType: 'issue' | 'pr'; + entityId: string; + entityUrl: string; + + createdAt: string; + updatedAt: string; + expiresAt?: string; +}; + +export class EnrichmentService implements Disposable { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose(): void {} + + private async delete(id: string, context: 'unpin' | 'unsnooze'): Promise { + const scope = getLogScope(); + + try { + const rsp = await this.connection.fetchGkDevApi(`v1/enrich-items/${id}`, { method: 'DELETE' }); + + if (!rsp.ok) throw new Error(`Unable to ${context} item '${id}': (${rsp.status}) ${rsp.statusText}`); + } catch (ex) { + Logger.error(ex, scope); + debugger; + throw ex; + } + } + + @log() + async get(type?: EnrichedItemResponse['type'], cancellation?: CancellationToken): Promise { + const scope = getLogScope(); + + try { + type Result = { data: EnrichedItemResponse[] }; + + const rsp = await this.connection.fetchGkDevApi('v1/enrich-items', { method: 'GET' }); + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + const result = (await rsp.json()) as Result; + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + return type == null ? result.data : result.data.filter(i => i.type === type); + } catch (ex) { + if (ex instanceof AuthenticationRequiredError) return []; + + Logger.error(ex, scope); + debugger; + throw ex; + } + } + + @log() + getPins(cancellation?: CancellationToken): Promise { + return this.get('pin', cancellation); + } + + @log() + getSnoozed(cancellation?: CancellationToken): Promise { + return this.get('snooze', cancellation); + } + + @log({ args: { 0: i => `${i.id} (${i.provider} ${i.type})` } }) + async pinItem(item: EnrichableItem): Promise { + const scope = getLogScope(); + + try { + if ( + !(await ensureAccount(this.container, 'Pinning is a Preview feature and requires an account.', { + source: 'launchpad', + detail: 'pin', + })) + ) { + throw new Error('Unable to pin item: account required'); + } + + type Result = { data: EnrichedItemResponse }; + + const rq: EnrichedItemRequest = { + provider: item.provider, + entityType: item.type, + entityId: item.id, + entityUrl: item.url, + }; + + const rsp = await this.connection.fetchGkDevApi('v1/enrich-items/pin', { + method: 'POST', + body: JSON.stringify(rq), + }); + + if (!rsp.ok) { + throw new Error( + `Unable to pin item '${rq.provider}|${rq.entityUrl}#${item.id}': (${rsp.status}) ${rsp.statusText}`, + ); + } + + const result = (await rsp.json()) as Result; + return result.data; + } catch (ex) { + Logger.error(ex, scope); + debugger; + throw ex; + } + } + + @log() + unpinItem(id: string): Promise { + return this.delete(id, 'unpin'); + } + + @log({ args: { 0: i => `${i.id} (${i.provider} ${i.type})` } }) + async snoozeItem(item: EnrichableItem): Promise { + const scope = getLogScope(); + + try { + if ( + !(await ensureAccount(this.container, 'Snoozing is a Preview feature and requires an acccount.', { + source: 'launchpad', + detail: 'snooze', + })) + ) { + throw new Error('Unable to snooze item: subscription required'); + } + + type Result = { data: EnrichedItemResponse }; + + const rq: EnrichedItemRequest = { + provider: item.provider, + entityType: item.type, + entityId: item.id, + entityUrl: item.url, + }; + if (item.expiresAt != null) { + rq.expiresAt = item.expiresAt; + } + + const rsp = await this.connection.fetchGkDevApi('v1/enrich-items/snooze', { + method: 'POST', + body: JSON.stringify(rq), + }); + + if (!rsp.ok) { + throw new Error( + `Unable to snooze item '${rq.provider}|${rq.entityUrl}#${item.id}': (${rsp.status}) ${rsp.statusText}`, + ); + } + + const result = (await rsp.json()) as Result; + return result.data; + } catch (ex) { + Logger.error(ex, scope); + debugger; + throw ex; + } + } + + @log() + unsnoozeItem(id: string): Promise { + return this.delete(id, 'unsnooze'); + } +} + +export function convertRemoteProviderToEnrichProvider(provider: RemoteProvider): EnrichedItemResponse['provider'] { + switch (provider.id) { + case 'azure-devops': + return 'azure'; + + case 'bitbucket': + case 'bitbucket-server': + return 'bitbucket'; + + case 'github': + return 'github'; + + case 'gitlab': + return 'gitlab'; + + default: + throw new Error(`Unknown remote provider '${provider.id}'`); + } +} diff --git a/src/plus/focus/focus.ts b/src/plus/focus/focus.ts new file mode 100644 index 0000000000000..74cf7a372c4fd --- /dev/null +++ b/src/plus/focus/focus.ts @@ -0,0 +1,1083 @@ +import type { QuickPick } from 'vscode'; +import { commands, Uri } from 'vscode'; +import { getAvatarUri } from '../../avatars'; +import type { + PartialStepState, + StepGenerator, + StepResultGenerator, + StepSelection, + StepState, +} from '../../commands/quickCommand'; +import { + canPickStepContinue, + createPickStep, + endSteps, + QuickCommand, + StepResultBreak, +} from '../../commands/quickCommand'; +import { + FeedbackQuickInputButton, + LaunchpadSettingsQuickInputButton, + MergeQuickInputButton, + OpenOnGitHubQuickInputButton, + OpenOnWebQuickInputButton, + OpenWorktreeInNewWindowQuickInputButton, + PinQuickInputButton, + RefreshQuickInputButton, + SnoozeQuickInputButton, + UnpinQuickInputButton, + UnsnoozeQuickInputButton, +} from '../../commands/quickCommand.buttons'; +import type { LaunchpadTelemetryContext, Source, Sources, TelemetryEvents } from '../../constants'; +import { previewBadge } from '../../constants'; +import type { Container } from '../../container'; +import type { QuickPickItemOfT } from '../../quickpicks/items/common'; +import { createQuickPickItemOfT, createQuickPickSeparator } from '../../quickpicks/items/common'; +import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive'; +import { getScopedCounter } from '../../system/counter'; +import { fromNow } from '../../system/date'; +import { interpolate, pluralize } from '../../system/string'; +import { openUrl } from '../../system/utils'; +import { isSupportedCloudIntegrationId } from '../integrations/authentication/models'; +import type { IntegrationId } from '../integrations/providers/models'; +import { + HostingIntegrationId, + ProviderBuildStatusState, + ProviderPullRequestReviewState, +} from '../integrations/providers/models'; +import type { + FocusAction, + FocusActionCategory, + FocusCategorizedResult, + FocusGroup, + FocusItem, + FocusTargetAction, +} from './focusProvider'; +import { + countFocusItemGroups, + focusGroupIconMap, + focusGroupLabelMap, + focusGroups, + getFocusItemIdHash, + groupAndSortFocusItems, + supportedFocusIntegrations, +} from './focusProvider'; + +const actionGroupMap = new Map([ + ['mergeable', ['Ready to Merge', 'Ready to merge']], + ['unassigned-reviewers', ['Unassigned Reviewers', 'You need to assign reviewers']], + ['failed-checks', ['Failed Checks', 'You need to resolve the failing checks']], + ['conflicts', ['Resolve Conflicts', 'You need to resolve merge conflicts']], + ['needs-my-review', ['Needs Your Review', `\${author} requested your review`]], + ['code-suggestions', ['Code Suggestions', 'Code suggestions have been made on this pull request']], + ['changes-requested', ['Changes Requested', 'Reviewers requested changes before this can be merged']], + ['reviewer-commented', ['Reviewers Commented', 'Reviewers have commented on this pull request']], + ['waiting-for-review', ['Waiting for Review', 'Waiting for reviewers to approve this pull request']], + ['draft', ['Draft', 'Continue working on your draft']], + ['other', ['Other', `Opened by \${author} \${createdDateRelative}`]], +]); + +export interface FocusItemQuickPickItem extends QuickPickItemOfT { + group: FocusGroup; +} + +interface Context { + result: FocusCategorizedResult; + + title: string; + collapsed: Map; + telemetryContext: LaunchpadTelemetryContext | undefined; +} + +interface GroupedFocusItem extends FocusItem { + group: FocusGroup; +} + +interface State { + item?: GroupedFocusItem; + action?: FocusAction | FocusTargetAction; + initialGroup?: FocusGroup; + selectTopItem?: boolean; +} + +export interface FocusCommandArgs { + readonly command: 'focus'; + confirm?: boolean; + source?: Sources; + state?: Partial; +} + +type FocusStepState = RequireSome, 'item'>; + +function assertsFocusStepState(state: StepState): asserts state is FocusStepState { + if (state.item != null) return; + + debugger; + throw new Error('Missing item'); +} + +const instanceCounter = getScopedCounter(); + +const defaultCollapsedGroups: FocusGroup[] = ['draft', 'other', 'snoozed']; + +export class FocusCommand extends QuickCommand { + private readonly source: Source; + private readonly telemetryContext: LaunchpadTelemetryContext | undefined; + + constructor(container: Container, args?: FocusCommandArgs) { + super(container, 'focus', 'focus', `GitLens Launchpad\u00a0\u00a0${previewBadge}`, { + description: 'focus on a pull request or issue', + }); + + if ( + args?.source === 'launchpad-indicator' && + container.storage.get('launchpad:indicator:hasInteracted') == null + ) { + void container.storage.store('launchpad:indicator:hasInteracted', new Date().toISOString()); + } + + this.source = { source: args?.source ?? 'commandPalette' }; + if (this.container.telemetry.enabled) { + this.telemetryContext = { + instance: instanceCounter.next(), + 'initialState.group': args?.state?.initialGroup, + 'initialState.selectTopItem': args?.state?.selectTopItem ?? false, + }; + + this.container.telemetry.sendEvent('launchpad/open', { ...this.telemetryContext }, this.source); + } + + const counter = 0; + + this.initialState = { + counter: counter, + confirm: args?.confirm, + ...args?.state, + }; + } + + private async ensureIntegrationConnected(id: IntegrationId) { + const integration = await this.container.integrations.get(id); + let connected = integration.maybeConnected ?? (await integration.isConnected()); + if (!connected) { + if (isSupportedCloudIntegrationId(integration.id)) { + await this.container.integrations.manageCloudIntegrations( + { integrationId: integration.id }, + { + source: 'launchpad', + detail: { + action: 'connect', + integration: integration.id, + }, + }, + ); + } + connected = await integration.connect(); + } + + return connected; + } + + protected async *steps(state: PartialStepState): StepGenerator { + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; + } + + let storedCollapsed = this.container.storage.get('launchpad:groups:collapsed') satisfies + | FocusGroup[] + | undefined; + if (storedCollapsed == null) { + storedCollapsed = defaultCollapsedGroups; + } + + const collapsed = new Map(storedCollapsed.map(g => [g, true])); + if (state.initialGroup != null) { + // set all to true except the initial group + for (const group of focusGroups) { + collapsed.set(group, group !== state.initialGroup); + } + } + + const context: Context = { + result: { items: [] }, + title: this.title, + collapsed: collapsed, + telemetryContext: this.telemetryContext, + }; + + let opened = false; + + while (this.canStepsContinue(state)) { + context.title = this.title; + + if (state.counter < 1 && !(await this.container.focus.hasConnectedIntegration())) { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + opened ? 'launchpad/steps/connect' : 'launchpad/opened', + { + ...context.telemetryContext!, + connected: false, + }, + this.source, + ); + } + + opened = true; + + const result = yield* this.confirmIntegrationConnectStep(state, context); + if (result !== StepResultBreak && !(await this.ensureIntegrationConnected(result))) { + let integration; + switch (result) { + case HostingIntegrationId.GitHub: + integration = 'GitHub'; + break; + default: + integration = `integration (${result})`; + break; + } + throw new Error(`Unable to connect to ${integration}`); + } + } + + await updateContextItems(this.container, context); + + if (state.counter < 2 || state.item == null) { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + opened ? 'launchpad/steps/main' : 'launchpad/opened', + { + ...context.telemetryContext!, + connected: true, + }, + this.source, + ); + } + + opened = true; + + const result = yield* this.pickFocusItemStep(state, context, { + picked: state.item?.id, + selectTopItem: state.selectTopItem, + }); + if (result === StepResultBreak) continue; + + state.item = result; + } + + assertsFocusStepState(state); + + if (this.confirm(state.confirm)) { + this.sendItemActionTelemetry('select', state.item, state.item.group, context); + await this.container.focus.ensureFocusItemCodeSuggestions(state.item); + + const result = yield* this.confirmStep(state, context); + if (result === StepResultBreak) continue; + + state.action = result; + } + + if (state.action) { + this.sendItemActionTelemetry(state.action, state.item, state.item.group, context); + } + + if (typeof state.action === 'string') { + switch (state.action) { + case 'merge': + void this.container.focus.merge(state.item); + break; + case 'open': + this.container.focus.open(state.item); + break; + case 'soft-open': + this.container.focus.open(state.item); + state.counter = 2; + continue; + case 'switch': + case 'show-overview': + void this.container.focus.switchTo(state.item); + break; + case 'open-worktree': + void this.container.focus.switchTo(state.item, { skipWorktreeConfirmations: true }); + break; + case 'switch-and-code-suggest': + case 'code-suggest': + void this.container.focus.switchTo(state.item, { startCodeSuggestion: true }); + break; + case 'open-changes': + void this.container.focus.openChanges(state.item); + break; + case 'open-in-graph': + void this.container.focus.openInGraph(state.item); + break; + } + } else { + switch (state.action?.action) { + case 'open-suggestion': { + this.container.focus.openCodeSuggestion(state.item, state.action.target); + break; + } + } + } + + endSteps(state); + } + + return state.counter < 0 ? StepResultBreak : undefined; + } + + private *pickFocusItemStep( + state: StepState, + context: Context, + { picked, selectTopItem }: { picked?: string; selectTopItem?: boolean }, + ): StepResultGenerator { + const getItems = (result: FocusCategorizedResult) => { + const items: (FocusItemQuickPickItem | DirectiveQuickPickItem)[] = []; + + if (result.items?.length) { + const uiGroups = groupAndSortFocusItems(result.items); + const topItem: FocusItem | undefined = + !selectTopItem || picked != null + ? undefined + : uiGroups.get('mergeable')?.[0] || + uiGroups.get('blocked')?.[0] || + uiGroups.get('follow-up')?.[0] || + uiGroups.get('needs-review')?.[0]; + for (const [ui, groupItems] of uiGroups) { + if (!groupItems.length) continue; + + items.push( + createQuickPickSeparator(groupItems.length ? groupItems.length.toString() : undefined), + createDirectiveQuickPickItem(Directive.Reload, false, { + label: `$(${ + context.collapsed.get(ui) ? 'chevron-down' : 'chevron-up' + })\u00a0\u00a0${focusGroupIconMap.get(ui)!}\u00a0\u00a0${focusGroupLabelMap + .get(ui) + ?.toUpperCase()}`, //'\u00a0', + //detail: groupMap.get(group)?.[0].toUpperCase(), + onDidSelect: () => { + const collapsed = !context.collapsed.get(ui); + context.collapsed.set(ui, collapsed); + if (state.initialGroup == null) { + void this.container.storage.store( + 'launchpad:groups:collapsed', + Array.from(context.collapsed.keys()).filter(g => context.collapsed.get(g)), + ); + } + + if (this.container.telemetry.enabled) { + updateTelemetryContext(context); + this.container.telemetry.sendEvent( + 'launchpad/groupToggled', + { + ...context.telemetryContext!, + group: ui, + collapsed: collapsed, + }, + this.source, + ); + } + }, + }), + ); + + if (context.collapsed.get(ui)) continue; + + items.push( + ...groupItems.map(i => { + const buttons = []; + + if (i.actionableCategory === 'mergeable') { + buttons.push(MergeQuickInputButton); + } + + buttons.push( + i.viewer.pinned ? UnpinQuickInputButton : PinQuickInputButton, + i.viewer.snoozed ? UnsnoozeQuickInputButton : SnoozeQuickInputButton, + ); + + if (!i.openRepository?.localBranch?.current) { + buttons.push(OpenWorktreeInNewWindowQuickInputButton); + } + + buttons.push(OpenOnGitHubQuickInputButton); + + return { + label: i.title.length > 60 ? `${i.title.substring(0, 60)}...` : i.title, + // description: `${i.repoAndOwner}#${i.id}, by @${i.author}`, + description: `\u00a0 ${i.repository.owner.login}/${i.repository.name}#${i.id} \u00a0 ${ + i.codeSuggestionsCount > 0 + ? ` $(gitlens-code-suggestion) ${i.codeSuggestionsCount}` + : '' + } \u00a0 ${i.isNew ? '(New since last view)' : ''}`, + detail: ` ${ + i.actionableCategory === 'other' + ? '' + : `${actionGroupMap.get(i.actionableCategory)![0]} \u2022 ` + }${fromNow(i.updatedDate)} by @${i.author!.username}`, + + buttons: buttons, + iconPath: i.author?.avatarUrl != null ? Uri.parse(i.author.avatarUrl) : undefined, + item: i, + picked: i.id === picked || i.id === topItem?.id, + group: ui, + }; + }), + ); + } + } + + return items; + }; + + function getItemsAndPlaceholder() { + if (context.result.error != null) { + return { + placeholder: `Unable to load items (${String(context.result.error)})`, + items: [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })], + }; + } + + if (!context.result.items.length) { + return { + placeholder: 'All done! Take a vacation', + items: [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })], + }; + } + + return { + placeholder: 'Choose an item to focus on', + items: getItems(context.result), + }; + } + + const updateItems = async (quickpick: QuickPick) => { + quickpick.busy = true; + + try { + await updateContextItems(this.container, context, { force: true }); + + const { items, placeholder } = getItemsAndPlaceholder(); + quickpick.placeholder = placeholder; + quickpick.items = items; + } finally { + quickpick.busy = false; + } + }; + + const { items, placeholder } = getItemsAndPlaceholder(); + + const step = createPickStep({ + title: context.title, + placeholder: placeholder, + matchOnDetail: true, + items: items, + buttons: [ + FeedbackQuickInputButton, + OpenOnWebQuickInputButton, + LaunchpadSettingsQuickInputButton, + RefreshQuickInputButton, + ], + // onDidChangeValue: async (quickpick, value) => {}, + onDidClickButton: async (quickpick, button) => { + switch (button) { + case LaunchpadSettingsQuickInputButton: + this.sendTitleActionTelemetry('settings', context); + void commands.executeCommand('workbench.action.openSettings', 'gitlens.launchpad'); + break; + + case FeedbackQuickInputButton: + this.sendTitleActionTelemetry('feedback', context); + void openUrl('https://github.com/gitkraken/vscode-gitlens/discussions/3286'); + break; + + case OpenOnWebQuickInputButton: + this.sendTitleActionTelemetry('open-on-gkdev', context); + void openUrl(this.container.focus.generateWebUrl()); + break; + case RefreshQuickInputButton: + this.sendTitleActionTelemetry('refresh', context); + await updateItems(quickpick); + break; + } + }, + + onDidClickItemButton: async (quickpick, button, { group, item }) => { + switch (button) { + case OpenOnGitHubQuickInputButton: + this.sendItemActionTelemetry('soft-open', item, group, context); + this.container.focus.open(item); + break; + + case SnoozeQuickInputButton: + this.sendItemActionTelemetry('snooze', item, group, context); + await this.container.focus.snooze(item); + break; + + case UnsnoozeQuickInputButton: + this.sendItemActionTelemetry('unsnooze', item, group, context); + await this.container.focus.unsnooze(item); + break; + + case PinQuickInputButton: + this.sendItemActionTelemetry('pin', item, group, context); + await this.container.focus.pin(item); + break; + + case UnpinQuickInputButton: + this.sendItemActionTelemetry('unpin', item, group, context); + await this.container.focus.unpin(item); + break; + + case MergeQuickInputButton: + this.sendItemActionTelemetry('merge', item, group, context); + await this.container.focus.merge(item); + break; + + case OpenWorktreeInNewWindowQuickInputButton: + this.sendItemActionTelemetry('open-worktree', item, group, context); + await this.container.focus.switchTo(item, { skipWorktreeConfirmations: true }); + break; + } + + await updateItems(quickpick); + }, + }); + + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) + ? { ...selection[0].item, group: selection[0].group } + : StepResultBreak; + } + + private *confirmStep( + state: FocusStepState, + context: Context, + ): StepResultGenerator { + const confirmations: ( + | QuickPickItemOfT + | QuickPickItemOfT + | DirectiveQuickPickItem + )[] = [ + createQuickPickSeparator(fromNow(state.item.updatedDate)), + createQuickPickItemOfT( + { + label: state.item.title, + description: `${state.item.repository.owner.login}/${state.item.repository.name}#${state.item.id}`, + detail: interpolate(actionGroupMap.get(state.item.actionableCategory)![1], { + author: state.item.author!.username, + createdDateRelative: fromNow(state.item.createdDate), + }), + iconPath: state.item.author?.avatarUrl != null ? Uri.parse(state.item.author.avatarUrl) : undefined, + buttons: [OpenOnGitHubQuickInputButton], + }, + 'soft-open', + ), + createDirectiveQuickPickItem(Directive.Noop, false, { label: '' }), + ...this.getFocusItemInformationRows(state.item), + createQuickPickSeparator('Actions'), + ]; + + for (const action of state.item.suggestedActions) { + switch (action) { + case 'merge': { + let from; + let into; + if ( + state.item.headRepository?.owner != null && + state.item.headRepository.owner !== state.item.repository.owner + ) { + from = + state.item.headRef != null + ? `${state.item.headRepository.owner.login}:${state.item.headRef.name}` + : 'these changes'; + into = + state.item.baseRef != null + ? ` into ${state.item.repository.owner.login}:${state.item.baseRef.name}` + : ''; + } else { + from = state.item.headRef?.name ?? 'these changes'; + into = state.item.baseRef?.name ? ` into ${state.item.baseRef.name}` : ''; + } + + confirmations.push( + createQuickPickItemOfT( + { + label: 'Merge...', + detail: `Will merge ${from}${into}`, + buttons: [OpenOnGitHubQuickInputButton], + }, + action, + ), + ); + break; + } + case 'open': + confirmations.push( + createQuickPickItemOfT( + { + label: `${this.getOpenActionLabel(state.item.actionableCategory)} on GitHub`, + buttons: [OpenOnGitHubQuickInputButton], + }, + action, + ), + ); + break; + case 'switch': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Switch to Branch or Worktree', + detail: 'Will checkout the branch, create or open a worktree', + }, + action, + ), + ); + break; + case 'open-worktree': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Open Worktree in New Window', + detail: 'Will create or open a worktree in a new window', + }, + action, + ), + ); + break; + case 'switch-and-code-suggest': + confirmations.push( + createQuickPickItemOfT( + { + label: `Switch & Suggest ${ + state.item.viewer.isAuthor ? 'Additional ' : '' + }Code Changes`, + detail: 'Will checkout and start suggesting code changes', + }, + action, + ), + ); + break; + case 'code-suggest': + confirmations.push( + createQuickPickItemOfT( + { + label: `Suggest ${state.item.viewer.isAuthor ? 'Additional ' : ''}Code Changes`, + detail: 'Will start suggesting code changes', + }, + action, + ), + ); + break; + case 'show-overview': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Open Details', + detail: 'Will open the pull request details in the Side Bar', + }, + action, + ), + ); + break; + case 'open-changes': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Open Changes', + detail: 'Will open the pull request changes for review', + }, + action, + ), + ); + break; + case 'open-in-graph': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Open in Commit Graph', + }, + action, + ), + ); + break; + } + } + + const step = this.createConfirmStep( + `Launchpad \u00a0\u2022\u00a0 Pull Request ${state.item.repository.owner.login}/${state.item.repository.name}#${state.item.id}`, + confirmations, + undefined, + { + placeholder: 'Choose an action to perform', + onDidClickItemButton: (_quickpick, button, item) => { + switch (button) { + case OpenOnGitHubQuickInputButton: + this.sendItemActionTelemetry('soft-open', state.item, state.item.group, context); + this.container.focus.open(state.item); + break; + case OpenOnWebQuickInputButton: + this.sendItemActionTelemetry( + 'open-suggestion-browser', + state.item, + state.item.group, + context, + ); + if (isFocusTargetActionQuickPickItem(item)) { + this.container.focus.openCodeSuggestionInBrowser(item.item.target); + } + break; + } + }, + }, + ); + + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; + } + + private *confirmIntegrationConnectStep( + state: StepState, + _context: Context, + ): StepResultGenerator { + const confirmations: (QuickPickItemOfT | DirectiveQuickPickItem)[] = []; + + for (const integration of supportedFocusIntegrations) { + switch (integration) { + case HostingIntegrationId.GitHub: + confirmations.push( + createQuickPickItemOfT( + { + label: 'Connect to GitHub...', + detail: 'Will connect to GitHub to provide access your pull requests and issues', + }, + integration, + ), + ); + break; + default: + break; + } + } + + const step = this.createConfirmStep( + `${this.title} \u00a0\u2022\u00a0 Connect an Integration`, + confirmations, + createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), + { placeholder: 'Launchpad requires a connected integration', ignoreFocusOut: false }, + ); + + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; + } + + private getFocusItemInformationRows( + item: FocusItem, + ): (QuickPickItemOfT | QuickPickItemOfT | DirectiveQuickPickItem)[] { + const information: ( + | QuickPickItemOfT + | QuickPickItemOfT + | DirectiveQuickPickItem + )[] = []; + switch (item.actionableCategory) { + case 'mergeable': + information.push( + createQuickPickSeparator('Status'), + this.getFocusItemStatusInformation(item), + ...this.getFocusItemReviewInformation(item), + ); + break; + case 'failed-checks': + case 'conflicts': + information.push(createQuickPickSeparator('Status'), this.getFocusItemStatusInformation(item)); + break; + case 'unassigned-reviewers': + case 'needs-my-review': + case 'changes-requested': + case 'reviewer-commented': + case 'waiting-for-review': + information.push(createQuickPickSeparator('Reviewers'), ...this.getFocusItemReviewInformation(item)); + break; + default: + break; + } + + if (item.codeSuggestions?.value != null && item.codeSuggestions.value.length > 0) { + if (information.length > 0) { + information.push(createDirectiveQuickPickItem(Directive.Noop, false, { label: '' })); + } + + information.push( + createQuickPickSeparator('Suggestions'), + ...this.getFocusItemCodeSuggestionInformation(item), + ); + } + + if (information.length > 0) { + information.push(createDirectiveQuickPickItem(Directive.Noop, false, { label: '' })); + } + + return information; + } + + private getFocusItemStatusInformation(item: FocusItem): QuickPickItemOfT { + let status: string | undefined; + const base = item.baseRef?.name != null ? `$(git-branch) ${item.baseRef.name}` : ''; + const ciStatus = item.headCommit?.buildStatuses?.[0].state; + if (ciStatus === ProviderBuildStatusState.Success) { + if (item.hasConflicts) { + status = `$(error) Conflicts with ${base}, but passed CI checks`; + } else { + status = `$(pass) No conflicts, and passed CI checks`; + } + } else if (ciStatus === ProviderBuildStatusState.Failed) { + if (item.hasConflicts) { + status = `$(error) Conflicts with ${base}, and failed CI checks`; + } else { + status = `$(error) No conflicts, but failed CI checks`; + } + } else if (item.hasConflicts) { + status = `$(error) Conflicts with ${base}`; + } else { + status = `$(pass) No conflicts`; + } + + return createQuickPickItemOfT({ label: status, buttons: [OpenOnGitHubQuickInputButton] }, 'soft-open'); + } + + private getFocusItemReviewInformation(item: FocusItem): QuickPickItemOfT[] { + if (item.reviews == null || item.reviews.length === 0) { + return [ + createQuickPickItemOfT( + { label: `$(info) No reviewers have been assigned`, buttons: [OpenOnGitHubQuickInputButton] }, + 'soft-open', + ), + ]; + } + + const reviewInfo: QuickPickItemOfT[] = []; + + for (const review of item.reviews) { + const isCurrentUser = review.reviewer.username === item.currentViewer.username; + let reviewLabel: string | undefined; + const iconPath = review.reviewer.avatarUrl != null ? Uri.parse(review.reviewer.avatarUrl) : undefined; + switch (review.state) { + case ProviderPullRequestReviewState.Approved: + reviewLabel = `${isCurrentUser ? 'You' : review.reviewer.username} approved these changes`; + break; + case ProviderPullRequestReviewState.ChangesRequested: + reviewLabel = `${isCurrentUser ? 'You' : review.reviewer.username} requested changes`; + break; + case ProviderPullRequestReviewState.Commented: + reviewLabel = `${isCurrentUser ? 'You' : review.reviewer.username} left a comment review`; + break; + case ProviderPullRequestReviewState.ReviewRequested: + reviewLabel = `${ + isCurrentUser ? `You haven't` : `${review.reviewer.username} hasn't` + } reviewed these changes yet`; + break; + } + + if (reviewLabel != null) { + reviewInfo.push( + createQuickPickItemOfT( + { label: reviewLabel, iconPath: iconPath, buttons: [OpenOnGitHubQuickInputButton] }, + 'soft-open', + ), + ); + } + } + + return reviewInfo; + } + + private getFocusItemCodeSuggestionInformation( + item: FocusItem, + ): (QuickPickItemOfT | DirectiveQuickPickItem)[] { + if (item.codeSuggestions?.value == null || item.codeSuggestions.value.length === 0) { + return []; + } + + const codeSuggestionInfo: (QuickPickItemOfT | DirectiveQuickPickItem)[] = [ + createDirectiveQuickPickItem(Directive.Noop, false, { + label: `$(gitlens-code-suggestion) ${pluralize('code suggestion', item.codeSuggestions.value.length)}`, + }), + ]; + + for (const suggestion of item.codeSuggestions.value) { + codeSuggestionInfo.push( + createQuickPickItemOfT( + { + label: ` ${suggestion.author.name} suggested a code change ${fromNow( + suggestion.createdAt, + )}: "${suggestion.title}"`, + iconPath: suggestion.author.avatarUri ?? getAvatarUri(suggestion.author.email), + buttons: [OpenOnWebQuickInputButton], + }, + { + action: 'open-suggestion', + target: suggestion.id, + }, + ), + ); + } + + return codeSuggestionInfo; + } + + private getOpenActionLabel(actionCategory: string) { + switch (actionCategory) { + case 'unassigned-reviewers': + return 'Assign Reviewers'; + case 'failed-checks': + return 'Resolve Failing Checks'; + case 'conflicts': + return 'Resolve Conflicts'; + case 'needs-my-review': + return 'Start Reviewing'; + case 'changes-requested': + case 'reviewer-commented': + return 'Respond to Reviewers'; + case 'waiting-for-review': + return 'Check In with Reviewers'; + case 'draft': + return 'View draft'; + default: + return 'Open'; + } + } + + private sendItemActionTelemetry( + actionOrTargetAction: + | FocusAction + | FocusTargetAction + | 'pin' + | 'unpin' + | 'snooze' + | 'unsnooze' + | 'open-suggestion-browser' + | 'select', + item: FocusItem, + group: FocusGroup, + context: Context, + ) { + if (!this.container.telemetry.enabled) return; + + let action: + | FocusAction + | 'pin' + | 'unpin' + | 'snooze' + | 'unsnooze' + | 'open-suggestion' + | 'open-suggestion-browser' + | 'select' + | undefined; + if (typeof actionOrTargetAction !== 'string' && 'action' in actionOrTargetAction) { + action = actionOrTargetAction.action; + } else { + action = actionOrTargetAction; + } + if (action == null) return; + + this.container.telemetry.sendEvent( + action === 'select' ? 'launchpad/steps/details' : 'launchpad/action', + { + ...context.telemetryContext!, + action: action, + 'item.id': getFocusItemIdHash(item), + 'item.type': item.type, + 'item.provider': item.provider.id, + 'item.actionableCategory': item.actionableCategory, + 'item.group': group, + 'item.assignees.count': item.assignees?.length ?? undefined, + 'item.createdDate': item.createdDate.getTime(), + 'item.updatedDate': item.updatedDate.getTime(), + 'item.isNew': item.isNew, + + 'item.comments.count': item.commentCount ?? undefined, + 'item.upvotes.count': item.upvoteCount ?? undefined, + + 'item.pr.codeSuggestionCount': item.codeSuggestionsCount, + 'item.pr.isDraft': item.isDraft, + 'item.pr.mergeableState': item.mergeableState, + 'item.pr.state': item.state, + + 'item.pr.changes.additions': item.additions ?? undefined, + 'item.pr.changes.deletions': item.deletions ?? undefined, + 'item.pr.changes.commits': item.commitCount ?? undefined, + 'item.pr.changes.files': item.fileCount ?? undefined, + + 'item.pr.failingCI': item.failingCI, + 'item.pr.hasConflicts': item.hasConflicts, + + 'item.pr.reviews.count': item.reviews?.length ?? undefined, + 'item.pr.reviews.decision': item.reviewDecision ?? undefined, + 'item.pr.reviews.changeRequestCount': item.changeRequestReviewCount ?? undefined, + + 'item.viewer.isAuthor': item.viewer.isAuthor, + 'item.viewer.isAssignee': item.viewer.isAssignee, + 'item.viewer.pinned': item.viewer.pinned, + 'item.viewer.snoozed': item.viewer.snoozed, + 'item.viewer.pr.canMerge': item.viewer.canMerge, + 'item.viewer.pr.isReviewer': item.viewer.isReviewer, + 'item.viewer.pr.shouldAssignReviewer': item.viewer.shouldAssignReviewer, + 'item.viewer.pr.shouldMerge': item.viewer.shouldMerge, + 'item.viewer.pr.shouldReview': item.viewer.shouldReview, + 'item.viewer.pr.waitingOnReviews': item.viewer.waitingOnReviews, + }, + this.source, + ); + } + + private sendTitleActionTelemetry(action: TelemetryEvents['launchpad/title/action']['action'], context: Context) { + if (!this.container.telemetry.enabled) return; + + this.container.telemetry.sendEvent( + 'launchpad/title/action', + { ...context.telemetryContext!, action: action }, + this.source, + ); + } +} + +async function updateContextItems(container: Container, context: Context, options?: { force?: boolean }) { + context.result = await container.focus.getCategorizedItems(options); + if (container.telemetry.enabled) { + updateTelemetryContext(context); + } +} + +function updateTelemetryContext(context: Context) { + if (context.telemetryContext == null) return; + + let updatedContext: NonNullable<(typeof context)['telemetryContext']>; + if (context.result.error != null) { + updatedContext = { + ...context.telemetryContext, + 'items.error': String(context.result.error), + }; + } else { + const grouped = countFocusItemGroups(context.result.items); + + updatedContext = { + ...context.telemetryContext, + 'items.count': context.result.items.length, + 'items.timings.prs': context.result.timings?.prs, + 'items.timings.codeSuggestionCounts': context.result.timings?.codeSuggestionCounts, + 'items.timings.enrichedItems': context.result.timings?.enrichedItems, + 'groups.count': grouped.size, + }; + + for (const [group, count] of grouped) { + updatedContext[`groups.${group}.count`] = count; + updatedContext[`groups.${group}.collapsed`] = context.collapsed.get(group); + } + } + + context.telemetryContext = updatedContext; +} + +function isFocusTargetActionQuickPickItem(item: any): item is QuickPickItemOfT { + return item?.item?.action != null && item?.item?.target != null; +} diff --git a/src/plus/focus/focusIndicator.ts b/src/plus/focus/focusIndicator.ts new file mode 100644 index 0000000000000..e957571d20612 --- /dev/null +++ b/src/plus/focus/focusIndicator.ts @@ -0,0 +1,618 @@ +import type { ConfigurationChangeEvent, StatusBarItem } from 'vscode'; +import { Disposable, MarkdownString, StatusBarAlignment, ThemeColor, window } from 'vscode'; +import type { OpenWalkthroughCommandArgs } from '../../commands/walkthroughs'; +import type { Colors } from '../../constants'; +import { Commands, previewBadge } from '../../constants'; +import type { Container } from '../../container'; +import { executeCommand, registerCommand } from '../../system/command'; +import { configuration } from '../../system/configuration'; +import { groupByMap } from '../../system/iterable'; +import { wait } from '../../system/promise'; +import { pluralize } from '../../system/string'; +import type { ConnectionStateChangeEvent } from '../integrations/integrationService'; +import { HostingIntegrationId } from '../integrations/providers/models'; +import type { FocusCommandArgs } from './focus'; +import type { FocusGroup, FocusItem, FocusProvider, FocusRefreshEvent } from './focusProvider'; +import { + focusGroupIconMap, + focusPriorityGroups, + groupAndSortFocusItems, + supportedFocusIntegrations, +} from './focusProvider'; + +type FocusIndicatorState = 'idle' | 'disconnected' | 'loading' | 'load' | 'failed'; + +export class FocusIndicator implements Disposable { + private readonly _disposable: Disposable; + private _categorizedItems: FocusItem[] | undefined; + /** Tracks if this is the first state after startup */ + private _firstStateAfterStartup: boolean = true; + private _lastDataUpdate: Date | undefined; + private _lastRefreshPaused: Date | undefined; + private _refreshTimer: ReturnType | undefined; + private _state?: FocusIndicatorState; + private _statusBarFocus!: StatusBarItem; + + constructor( + private readonly container: Container, + private readonly provider: FocusProvider, + ) { + this._disposable = Disposable.from( + window.onDidChangeWindowState(this.onWindowStateChanged, this), + provider.onDidRefresh(this.onFocusRefreshed, this), + configuration.onDidChange(this.onConfigurationChanged, this), + container.integrations.onDidChangeConnectionState(this.onConnectedIntegrationsChanged, this), + ...this.registerCommands(), + ); + + void this.onReady(); + } + + dispose() { + this.clearRefreshTimer(); + this._statusBarFocus?.dispose(); + this._disposable.dispose(); + } + + private get pollingEnabled() { + return ( + configuration.get('launchpad.indicator.polling.enabled') && + configuration.get('launchpad.indicator.polling.interval') > 0 + ); + } + + private get pollingInterval() { + return configuration.get('launchpad.indicator.polling.interval') * 1000 * 60; + } + + private async onConnectedIntegrationsChanged(e: ConnectionStateChangeEvent) { + if (supportedFocusIntegrations.includes(e.key as HostingIntegrationId)) { + await this.maybeLoadData(); + } + } + + private async onConfigurationChanged(e: ConfigurationChangeEvent) { + if (!configuration.changed(e, 'launchpad.indicator')) return; + + if ( + configuration.changed(e, 'launchpad.indicator.openInEditor') || + configuration.changed(e, 'launchpad.indicator.label') + ) { + this.updateStatusBarCommand(); + } + + let load = false; + + if (configuration.changed(e, 'launchpad.indicator.polling')) { + if (configuration.changed(e, 'launchpad.indicator.polling.enabled')) { + load = true; + } else if (configuration.changed(e, 'launchpad.indicator.polling.interval')) { + this.startRefreshTimer(); + } + } + + load ||= + configuration.changed(e, 'launchpad.indicator.useColors') || + configuration.changed(e, 'launchpad.indicator.icon') || + configuration.changed(e, 'launchpad.indicator.label') || + configuration.changed(e, 'launchpad.indicator.groups'); + + if (load) { + await this.maybeLoadData(); + } + } + + private async maybeLoadData() { + if (this.pollingEnabled) { + if (await this.provider.hasConnectedIntegration()) { + if (this._state === 'load' && this._categorizedItems != null) + this.updateStatusBarState('load', this._categorizedItems); + else { + this.updateStatusBarState('loading'); + } + } else { + this.updateStatusBarState('disconnected'); + } + } else { + this.updateStatusBarState('idle'); + } + } + + private onFocusRefreshed(e: FocusRefreshEvent) { + if (!this.pollingEnabled) { + this.updateStatusBarState('idle'); + + return; + } + + if (e.error != null) { + this.updateStatusBarState('failed'); + + return; + } + + this.updateStatusBarState('load', e.items); + } + + private async onReady(): Promise { + this._statusBarFocus = window.createStatusBarItem('gitlens.launchpad', StatusBarAlignment.Left, 10000 - 3); + this._statusBarFocus.name = 'GitLens Launchpad'; + + await this.maybeLoadData(); + this.updateStatusBarCommand(); + + this._statusBarFocus.show(); + } + + private onWindowStateChanged(e: { focused: boolean }) { + if (this._state === 'disconnected' || this._state === 'idle') return; + + if (!e.focused) { + this.clearRefreshTimer(); + this._lastRefreshPaused = new Date(); + + return; + } + + if (this._lastRefreshPaused == null) return; + if (this._state === 'loading') { + this.startRefreshTimer(); + + return; + } + + const now = Date.now(); + const timeSinceLastUpdate = this._lastDataUpdate != null ? now - this._lastDataUpdate.getTime() : undefined; + const timeSinceLastUnfocused = now - this._lastRefreshPaused.getTime(); + this._lastRefreshPaused = undefined; + + const refreshInterval = configuration.get('launchpad.indicator.polling.interval') * 1000 * 60; + + let timeToNextPoll = timeSinceLastUpdate != null ? refreshInterval - timeSinceLastUpdate : refreshInterval; + if (timeToNextPoll < 0) { + timeToNextPoll = 0; + } + + const diff = timeToNextPoll - timeSinceLastUnfocused; + this.startRefreshTimer(diff < 0 ? 0 : diff); + } + + private clearRefreshTimer() { + if (this._refreshTimer != null) { + clearInterval(this._refreshTimer); + this._refreshTimer = undefined; + } + } + + private startRefreshTimer(startDelay?: number) { + const starting = this._firstStateAfterStartup; + if (starting) { + this._firstStateAfterStartup = false; + } + + this.clearRefreshTimer(); + if (!this.pollingEnabled || this._state === 'disconnected') { + if (this._state !== 'idle' && this._state !== 'disconnected') { + this.updateStatusBarState('idle'); + } + return; + } + + const startRefreshInterval = () => { + this._refreshTimer = setInterval(() => { + void this.provider.getCategorizedItems({ force: true }); + }, this.pollingInterval); + }; + + if (startDelay != null) { + this._refreshTimer = setTimeout(() => { + startRefreshInterval(); + + // If we are loading at startup, wait to give vscode time to settle before querying + if (starting) { + // Using a wait here, instead using the `startDelay` to avoid case where the timer could be cancelled if the user focused a different windows before the timer fires (because we will cancel the timer) + void wait(5000).then(() => this.provider.getCategorizedItems({ force: true })); + } else { + void this.provider.getCategorizedItems({ force: true }); + } + }, startDelay); + } else { + startRefreshInterval(); + } + } + + private updateStatusBarState(state: FocusIndicatorState, categorizedItems?: FocusItem[]) { + if (state !== 'load' && state === this._state) return; + + this._state = state; + this._categorizedItems = categorizedItems; + + const tooltip = new MarkdownString('', true); + tooltip.supportHtml = true; + tooltip.isTrusted = true; + + tooltip.appendMarkdown( + `GitLens Launchpad ${previewBadge}\u00a0\u00a0\u00a0\u00a0—\u00a0\u00a0\u00a0\u00a0`, + ); + tooltip.appendMarkdown(`[$(question)](command:gitlens.launchpad.indicator.action?%22info%22 "What is this?")`); + tooltip.appendMarkdown('\u00a0'); + tooltip.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%22gitlens.launchpad%22 "Settings")`); + tooltip.appendMarkdown('\u00a0\u00a0|\u00a0\u00a0'); + tooltip.appendMarkdown(`[$(circle-slash) Hide](command:gitlens.launchpad.indicator.action?%22hide%22 "Hide")`); + + if ( + state === 'idle' || + state === 'disconnected' || + state === 'loading' || + (state === 'load' && !this.hasInteracted()) + ) { + tooltip.appendMarkdown('\n\n---\n\n'); + tooltip.appendMarkdown( + '[Launchpad](command:gitlens.launchpad.indicator.action?%info%22 "Learn about Launchpad") organizes your pull requests into actionable groups to help you focus and keep your team unblocked.', + ); + tooltip.appendMarkdown( + "\n\nIt's always accessible using the `GitLens: Open Launchpad` command from the Command Palette.", + ); + } + + switch (state) { + case 'idle': + this.clearRefreshTimer(); + this._statusBarFocus.text = '$(rocket)'; + this._statusBarFocus.tooltip = tooltip; + this._statusBarFocus.color = undefined; + break; + + case 'disconnected': + this.clearRefreshTimer(); + tooltip.appendMarkdown( + `\n\n---\n\n[Connect to GitHub](command:gitlens.launchpad.indicator.action?%22connectGitHub%22 "Connect to GitHub") to get started.`, + ); + + this._statusBarFocus.text = `$(rocket)$(gitlens-unplug) Launchpad`; + this._statusBarFocus.tooltip = tooltip; + this._statusBarFocus.color = undefined; + break; + + case 'loading': + this.startRefreshTimer(0); + tooltip.appendMarkdown('\n\n---\n\n$(loading~spin) Loading...'); + + this._statusBarFocus.text = '$(rocket)$(loading~spin)'; + this._statusBarFocus.tooltip = tooltip; + this._statusBarFocus.color = undefined; + break; + + case 'load': + this.updateStatusBarWithItems(tooltip, categorizedItems); + break; + + case 'failed': + this.clearRefreshTimer(); + tooltip.appendMarkdown('\n\n---\n\n$(alert) Unable to load items'); + + this._statusBarFocus.text = '$(rocket)$(alert)'; + this._statusBarFocus.tooltip = tooltip; + this._statusBarFocus.color = undefined; + break; + } + + // After the first state change, clear this + this._firstStateAfterStartup = false; + } + + private updateStatusBarCommand() { + const labelType = configuration.get('launchpad.indicator.label') ?? 'item'; + this._statusBarFocus.command = configuration.get('launchpad.indicator.openInEditor') + ? 'gitlens.showFocusPage' + : { + title: 'Open Launchpad', + command: Commands.ShowLaunchpad, + arguments: [ + { + source: 'launchpad-indicator', + state: { selectTopItem: labelType === 'item' }, + } satisfies Omit, + ], + }; + } + + private updateStatusBarWithItems(tooltip: MarkdownString, categorizedItems: FocusItem[] | undefined) { + this.sendTelemetryFirstLoadEvent(); + + this._lastDataUpdate = new Date(); + const useColors = configuration.get('launchpad.indicator.useColors'); + const groups: FocusGroup[] = configuration.get('launchpad.indicator.groups') ?? []; + const labelType = configuration.get('launchpad.indicator.label') ?? 'item'; + const iconType = configuration.get('launchpad.indicator.icon') ?? 'default'; + + let color: string | ThemeColor | undefined = undefined; + let priorityIcon: `$(${string})` | undefined; + let priorityItem: { item: FocusItem; groupLabel: string } | undefined; + + const groupedItems = groupAndSortFocusItems(categorizedItems); + const totalGroupedItems = Array.from(groupedItems.values()).reduce((total, group) => total + group.length, 0); + + const hasImportantGroupsWithItems = groups.some(group => groupedItems.get(group)?.length); + if (totalGroupedItems === 0) { + tooltip.appendMarkdown('\n\n---\n\n'); + tooltip.appendMarkdown('You are all caught up!'); + } else if (!hasImportantGroupsWithItems) { + tooltip.appendMarkdown('\n\n---\n\n'); + tooltip.appendMarkdown( + `No pull requests need your attention\\\n(${totalGroupedItems} other pull requests)`, + ); + } else { + for (const group of groups) { + const items = groupedItems.get(group); + if (!items?.length) continue; + + if (tooltip.value.length > 0) { + tooltip.appendMarkdown(`\n\n---\n\n`); + } + + const icon = focusGroupIconMap.get(group)!; + switch (group) { + case 'mergeable': { + priorityIcon ??= icon; + color = new ThemeColor('gitlens.launchpadIndicatorMergeableColor' satisfies Colors); + priorityItem ??= { item: items[0], groupLabel: 'can be merged' }; + tooltip.appendMarkdown( + `${icon}$(blank) [${ + labelType === 'item' && priorityItem != null + ? this.getPriorityItemLabel(priorityItem.item, items.length) + : pluralize('pull request', items.length) + } can be merged](command:gitlens.showLaunchpad?${encodeURIComponent( + JSON.stringify({ + source: 'launchpad-indicator', + state: { + initialGroup: 'mergeable', + selectTopItem: labelType === 'item', + }, + } satisfies Omit), + )} "Open Ready to Merge in Launchpad")`, + ); + break; + } + case 'blocked': { + const action = groupByMap(items, i => + i.actionableCategory === 'failed-checks' || + i.actionableCategory === 'conflicts' || + i.actionableCategory === 'unassigned-reviewers' + ? i.actionableCategory + : 'blocked', + ); + + const hasMultipleCategories = action.size > 1; + + let item: FocusItem | undefined; + let actionMessage = ''; + let summaryMessage = '('; + + let actionGroupItems = action.get('unassigned-reviewers'); + if (actionGroupItems?.length) { + actionMessage = `${actionGroupItems.length > 1 ? 'need' : 'needs'} reviewers`; + summaryMessage += `${actionGroupItems.length} ${actionMessage}`; + item ??= actionGroupItems[0]; + } + + actionGroupItems = action.get('failed-checks'); + if (actionGroupItems?.length) { + actionMessage = `failed CI checks`; + summaryMessage += `${hasMultipleCategories ? ', ' : ''}${ + actionGroupItems.length + } ${actionMessage}`; + item ??= actionGroupItems[0]; + } + + actionGroupItems = action.get('conflicts'); + if (actionGroupItems?.length) { + actionMessage = `${actionGroupItems.length > 1 ? 'have' : 'has'} conflicts`; + summaryMessage += `${hasMultipleCategories ? ', ' : ''}${ + actionGroupItems.length + } ${actionMessage}`; + item ??= actionGroupItems[0]; + } + + summaryMessage += ')'; + + priorityIcon ??= icon; + color ??= new ThemeColor('gitlens.launchpadIndicatorBlockedColor' satisfies Colors); + tooltip.appendMarkdown( + `${icon}$(blank) [${ + labelType === 'item' && item != null && priorityItem == null + ? this.getPriorityItemLabel(item, items.length) + : pluralize('pull request', items.length) + } ${ + hasMultipleCategories ? 'are blocked' : actionMessage + }](command:gitlens.showLaunchpad?${encodeURIComponent( + JSON.stringify({ + source: 'launchpad-indicator', + state: { initialGroup: 'blocked', selectTopItem: labelType === 'item' }, + } satisfies Omit), + )} "Open Blocked in Launchpad")`, + ); + if (hasMultipleCategories) { + tooltip.appendMarkdown(`\\\n$(blank)$(blank) ${summaryMessage}`); + } + + if (item != null) { + let label = 'is blocked'; + if (item.actionableCategory === 'unassigned-reviewers') { + label = 'needs reviewers'; + } else if (item.actionableCategory === 'failed-checks') { + label = 'failed CI checks'; + } else if (item.actionableCategory === 'conflicts') { + label = 'has conflicts'; + } + priorityItem ??= { item: item, groupLabel: label }; + } + break; + } + case 'follow-up': { + priorityIcon ??= icon; + color ??= new ThemeColor('gitlens.launchpadIndicatorAttentionColor' satisfies Colors); + tooltip.appendMarkdown( + `${icon}$(blank) [${ + labelType === 'item' && priorityItem == null && items.length + ? this.getPriorityItemLabel(items[0], items.length) + : pluralize('pull request', items.length) + } ${ + items.length > 1 ? 'require' : 'requires' + } follow-up](command:gitlens.showLaunchpad?${encodeURIComponent( + JSON.stringify({ + source: 'launchpad-indicator', + state: { + initialGroup: 'follow-up', + selectTopItem: labelType === 'item', + }, + } satisfies Omit), + )} "Open Follow-Up in Launchpad")`, + ); + priorityItem ??= { item: items[0], groupLabel: 'requires follow-up' }; + break; + } + case 'needs-review': { + priorityIcon ??= icon; + color ??= new ThemeColor('gitlens.launchpadIndicatorAttentionColor' satisfies Colors); + tooltip.appendMarkdown( + `${icon}$(blank) [${ + labelType === 'item' && priorityItem == null && items.length + ? this.getPriorityItemLabel(items[0], items.length) + : pluralize('pull request', items.length) + } ${ + items.length > 1 ? 'need' : 'needs' + } your review](command:gitlens.showLaunchpad?${encodeURIComponent( + JSON.stringify({ + source: 'launchpad-indicator', + state: { + initialGroup: 'needs-review', + selectTopItem: labelType === 'item', + }, + } satisfies Omit), + )} "Open Needs Your Review in Launchpad")`, + ); + priorityItem ??= { item: items[0], groupLabel: 'needs your review' }; + break; + } + } + } + } + + const iconSegment = iconType === 'group' && priorityIcon != null ? priorityIcon : '$(rocket)'; + + let labelSegment; + switch (labelType) { + case 'item': + labelSegment = + priorityItem != null + ? ` ${this.getPriorityItemLabel(priorityItem.item)} ${priorityItem.groupLabel}` + : ''; + break; + + case 'counts': + labelSegment = ''; + for (const group of groups) { + if (!focusPriorityGroups.includes(group)) continue; + + const count = groupedItems.get(group)?.length ?? 0; + const icon = focusGroupIconMap.get(group)!; + + labelSegment += + !labelSegment && iconSegment === icon ? `\u00a0${count}` : `\u00a0\u00a0${icon} ${count}`; + } + break; + + default: + labelSegment = ''; + break; + } + + this._statusBarFocus.text = `${iconSegment}${labelSegment}`; + this._statusBarFocus.tooltip = tooltip; + this._statusBarFocus.color = useColors ? color : undefined; + } + + private registerCommands(): Disposable[] { + return [ + registerCommand('gitlens.launchpad.indicator.action', async (action: string) => { + this.storeFirstInteractionIfNeeded(); + switch (action) { + case 'info': { + void executeCommand(Commands.OpenWalkthrough, { + step: 'launchpad', + source: 'launchpad-indicator', + detail: 'info', + }); + break; + } + case 'hide': { + const hide = { title: 'Hide Anyway' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const action = await window.showInformationMessage( + 'GitLens Launchpad helps you focus and keep your team unblocked.\n\nAre you sure you want hide the indicator?', + { + modal: true, + detail: '\nYou can always access Launchpad using the "GitLens: Open Launchpad" command, and can re-enable the indicator with the "GitLens: Toggle Launchpad Indicator" command.', + }, + hide, + cancel, + ); + if (action === hide) { + void configuration.updateEffective('launchpad.indicator.enabled', false); + } + break; + } + case 'connectGitHub': { + const github = await this.container.integrations?.get(HostingIntegrationId.GitHub); + if (github == null) break; + if (!(github.maybeConnected ?? (await github.isConnected()))) { + // TODO: Add back in once we switch GitHub to use the cloud + /* await this.container.integrations.manageCloudIntegrations( + { integrationId: HostingIntegrationId.GitHub }, + { + source: 'launchpad-indicator', + detail: { + action: 'connect', + integration: HostingIntegrationId.GitHub, + }, + }, + ); */ + void github.connect(); + } + break; + } + default: + break; + } + }), + ]; + } + + private getPriorityItemLabel(item: FocusItem, groupLength?: number) { + return `${item.repository != null ? `${item.repository.owner.login}/${item.repository.name}` : ''}#${item.id}${ + groupLength != null && groupLength > 1 + ? ` and ${pluralize('pull request', groupLength - 1, { infix: ' other ' })}` + : '' + }`; + } + + private sendTelemetryFirstLoadEvent() { + if (!this.container.telemetry.enabled) return; + + const hasLoaded = this.container.storage.get('launchpad:indicator:hasLoaded') ?? false; + if (!hasLoaded) { + void this.container.storage.store('launchpad:indicator:hasLoaded', true); + this.container.telemetry.sendEvent('launchpad/indicator/firstLoad'); + } + } + + private storeFirstInteractionIfNeeded() { + if (this.container.storage.get('launchpad:indicator:hasInteracted') != null) return; + void this.container.storage.store('launchpad:indicator:hasInteracted', new Date().toISOString()); + } + + private hasInteracted() { + return this.container.storage.get('launchpad:indicator:hasInteracted') != null; + } +} diff --git a/src/plus/focus/focusProvider.ts b/src/plus/focus/focusProvider.ts new file mode 100644 index 0000000000000..3f26ba1d4cff5 --- /dev/null +++ b/src/plus/focus/focusProvider.ts @@ -0,0 +1,967 @@ +import type { CancellationToken, ConfigurationChangeEvent } from 'vscode'; +import { Disposable, env, EventEmitter, Uri, window } from 'vscode'; +import { md5 } from '@env/crypto'; +import { Commands } from '../../constants'; +import type { Container } from '../../container'; +import { CancellationError } from '../../errors'; +import { openComparisonChanges } from '../../git/actions/commit'; +import type { Account } from '../../git/models/author'; +import type { GitBranch } from '../../git/models/branch'; +import { getLocalBranchByUpstream } from '../../git/models/branch'; +import type { SearchedPullRequest } from '../../git/models/pullRequest'; +import { getComparisonRefsForPullRequest } from '../../git/models/pullRequest'; +import type { GitRemote } from '../../git/models/remote'; +import type { Repository } from '../../git/models/repository'; +import type { CodeSuggestionCounts, Draft } from '../../gk/models/drafts'; +import { executeCommand, registerCommand } from '../../system/command'; +import { configuration } from '../../system/configuration'; +import { debug, log } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; +import type { TimedResult } from '../../system/promise'; +import { getSettledValue, timedWithSlowThreshold } from '../../system/promise'; +import { openUrl } from '../../system/utils'; +import type { UriTypes } from '../../uris/deepLinks/deepLink'; +import { DeepLinkActionType, DeepLinkType } from '../../uris/deepLinks/deepLink'; +import { showInspectView } from '../../webviews/commitDetails/actions'; +import type { ShowWipArgs } from '../../webviews/commitDetails/protocol'; +import type { IntegrationResult } from '../integrations/integration'; +import type { EnrichablePullRequest, ProviderActionablePullRequest } from '../integrations/providers/models'; +import { + getActionablePullRequests, + HostingIntegrationId, + toProviderPullRequestWithUniqueId, +} from '../integrations/providers/models'; +import type { EnrichableItem, EnrichedItem } from './enrichmentService'; + +export const focusActionCategories = [ + 'mergeable', + 'unassigned-reviewers', + 'failed-checks', + 'conflicts', + 'needs-my-review', + 'code-suggestions', + 'changes-requested', + 'reviewer-commented', + 'waiting-for-review', + 'draft', + 'other', +] as const; +export type FocusActionCategory = (typeof focusActionCategories)[number]; + +export const focusGroups = [ + 'current-branch', + 'pinned', + 'mergeable', + 'blocked', + 'follow-up', + // 'needs-attention', + 'needs-review', + 'waiting-for-review', + 'draft', + 'other', + 'snoozed', +] as const; +export type FocusGroup = (typeof focusGroups)[number]; + +export const focusPriorityGroups = [ + 'mergeable', + 'blocked', + 'follow-up', + 'needs-review', +] satisfies readonly FocusPriorityGroup[] as readonly FocusGroup[]; +export type FocusPriorityGroup = Extract; + +export const focusGroupIconMap = new Map([ + ['current-branch', '$(git-branch)'], + ['pinned', '$(pinned)'], + ['mergeable', '$(rocket)'], + ['blocked', '$(error)'], //bracket-error + ['follow-up', '$(report)'], + // ['needs-attention', '$(bell-dot)'], //comment-unresolved + ['needs-review', '$(comment-unresolved)'], // feedback + ['waiting-for-review', '$(gitlens-clock)'], + ['draft', '$(git-pull-request-draft)'], + ['other', '$(ellipsis)'], + ['snoozed', '$(bell-slash)'], +]); + +export const focusGroupLabelMap = new Map([ + ['current-branch', 'Current Branch'], + ['pinned', 'Pinned'], + ['mergeable', 'Ready to Merge'], + ['blocked', 'Blocked'], + ['follow-up', 'Requires Follow-up'], + // ['needs-attention', 'Needs Your Attention'], + ['needs-review', 'Needs Your Review'], + ['waiting-for-review', 'Waiting for Review'], + ['draft', 'Draft'], + ['other', 'Other'], + ['snoozed', 'Snoozed'], +]); + +export const focusCategoryToGroupMap = new Map([ + // ['pinned', 'pinned'], + ['mergeable', 'mergeable'], + ['conflicts', 'blocked'], + ['failed-checks', 'blocked'], + ['unassigned-reviewers', 'blocked'], + ['needs-my-review', 'needs-review'], + ['code-suggestions', 'follow-up'], + ['changes-requested', 'follow-up'], + ['reviewer-commented', 'follow-up'], + ['waiting-for-review', 'waiting-for-review'], + ['draft', 'draft'], + ['other', 'other'], + // ['snoozed', 'snoozed'], +]); + +export const sharedCategoryToFocusActionCategoryMap = new Map([ + ['readyToMerge', 'mergeable'], + ['unassignedReviewers', 'unassigned-reviewers'], + ['failingCI', 'failed-checks'], + ['conflicts', 'conflicts'], + ['needsMyReview', 'needs-my-review'], + ['changesRequested', 'changes-requested'], + ['reviewerCommented', 'reviewer-commented'], + ['waitingForReview', 'waiting-for-review'], + ['draft', 'draft'], + ['other', 'other'], +]); + +export type FocusAction = + | 'merge' + | 'open' + | 'soft-open' + | 'switch' + | 'switch-and-code-suggest' + | 'open-worktree' + | 'code-suggest' + | 'show-overview' + | 'open-changes' + | 'open-in-graph'; + +export type FocusTargetAction = { + action: 'open-suggestion'; + target: string; +}; + +const prActionsMap = new Map([ + ['mergeable', ['merge']], + ['unassigned-reviewers', ['open']], + ['failed-checks', ['open']], + ['conflicts', ['open']], + ['needs-my-review', ['open']], + ['code-suggestions', ['open']], + ['changes-requested', ['open']], + ['reviewer-commented', ['open']], + ['waiting-for-review', ['open']], + ['draft', ['open']], + ['other', []], +]); + +export function getSuggestedActions(category: FocusActionCategory, isCurrentBranch: boolean): FocusAction[] { + const actions = [...prActionsMap.get(category)!]; + if (isCurrentBranch) { + actions.push('show-overview', 'open-changes', 'code-suggest', 'open-in-graph'); + } else { + actions.push('switch', 'open-worktree', 'switch-and-code-suggest', 'open-in-graph'); + } + return actions; +} + +export type FocusPullRequest = EnrichablePullRequest & ProviderActionablePullRequest; + +export type FocusItem = FocusPullRequest & { + currentViewer: Account; + codeSuggestionsCount: number; + codeSuggestions?: TimedResult; + isNew: boolean; + actionableCategory: FocusActionCategory; + suggestedActions: FocusAction[]; + openRepository?: OpenRepository; +}; + +export type OpenRepository = { + repo: Repository; + remote: GitRemote; + localBranch?: GitBranch; +}; + +type CachedFocusPromise = { + expiresAt: number; + promise: Promise; +}; + +const cacheExpiration = 1000 * 60 * 30; // 30 minutes + +type PullRequestsWithSuggestionCounts = { + prs: IntegrationResult | undefined; + suggestionCounts: TimedResult | undefined; +}; + +export type FocusRefreshEvent = FocusCategorizedResult; + +export const supportedFocusIntegrations = [HostingIntegrationId.GitHub]; + +export type FocusCategorizedResult = + | { + items: FocusItem[]; + timings?: FocusCategorizedTimings; + error?: never; + } + | { + error: Error; + items?: never; + }; + +export interface FocusCategorizedTimings { + prs: number | undefined; + codeSuggestionCounts: number | undefined; + enrichedItems: number | undefined; +} + +export class FocusProvider implements Disposable { + private readonly _onDidChange = new EventEmitter(); + get onDidChange() { + return this._onDidChange.event; + } + + private readonly _onDidRefresh = new EventEmitter(); + get onDidRefresh() { + return this._onDidRefresh.event; + } + + private readonly _disposable: Disposable; + + constructor(private readonly container: Container) { + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this), + ...this.registerCommands(), + ); + } + + dispose() { + this._disposable.dispose(); + } + + // private _issues: CachedFocusPromise | undefined; + // private async getIssues(options?: { cancellation?: CancellationToken; force?: boolean }) { + // if (options?.force || this._issues == null || this._issues.expiresAt < Date.now()) { + // this._issues = { + // promise: this.container.integrations.getMyIssues([HostingIntegrationId.GitHub], options?.cancellation), + // expiresAt: Date.now() + cacheExpiration, + // }; + // } + + // return this._issues?.promise; + // } + + private _prs: CachedFocusPromise | undefined; + @debug({ args: { 0: o => `force=${o?.force}` } }) + private async getPullRequestsWithSuggestionCounts(options?: { cancellation?: CancellationToken; force?: boolean }) { + if (options?.force || this._prs == null || this._prs.expiresAt < Date.now()) { + this._prs = { + promise: this.fetchPullRequestsWithSuggestionCounts(options?.cancellation), + expiresAt: Date.now() + cacheExpiration, + }; + } + + return this._prs?.promise; + } + + @debug({ args: false }) + private async fetchPullRequestsWithSuggestionCounts(cancellation?: CancellationToken) { + const scope = getLogScope(); + + const [prsResult, subscriptionResult] = await Promise.allSettled([ + withDurationAndSlowEventOnTimeout( + this.container.integrations.getMyPullRequests([HostingIntegrationId.GitHub], cancellation), + 'getMyPullRequests', + this.container, + ), + this.container.subscription.getSubscription(true), + ]); + + if (prsResult.status === 'rejected') { + Logger.error(prsResult.reason, scope, 'Failed to get pull requests'); + throw prsResult.reason; + } + + const prs = getSettledValue(prsResult)?.value; + if (prs?.error != null) { + Logger.error(prs.error, scope, 'Failed to get pull requests'); + throw prs.error; + } + + const subscription = getSettledValue(subscriptionResult); + + let suggestionCounts; + if (prs?.value?.length && subscription?.account != null) { + try { + suggestionCounts = await withDurationAndSlowEventOnTimeout( + this.container.drafts.getCodeSuggestionCounts(prs.value.map(pr => pr.pullRequest)), + 'getCodeSuggestionCounts', + this.container, + ); + } catch (ex) { + Logger.error(ex, scope, 'Failed to get code suggestion counts'); + } + } + + return { prs: prs, suggestionCounts: suggestionCounts }; + } + + private _enrichedItems: CachedFocusPromise> | undefined; + @debug({ args: { 0: o => `force=${o?.force}` } }) + private async getEnrichedItems(options?: { cancellation?: CancellationToken; force?: boolean }) { + if (options?.force || this._enrichedItems == null || this._enrichedItems.expiresAt < Date.now()) { + this._enrichedItems = { + promise: withDurationAndSlowEventOnTimeout( + this.container.enrichments.get(undefined, options?.cancellation), + 'getEnrichedItems', + this.container, + ), + expiresAt: Date.now() + cacheExpiration, + }; + } + + return this._enrichedItems?.promise; + } + + private _codeSuggestions: Map>> | undefined; + @debug({ + args: { 0: i => `${i.id} (${i.provider.name} ${i.type})`, 1: o => `force=${o?.force}` }, + }) + private async getCodeSuggestions(item: FocusItem, options?: { force?: boolean }) { + if (item.codeSuggestionsCount < 1) return undefined; + + if (this._codeSuggestions == null || options?.force) { + this._codeSuggestions = new Map>>(); + } + + if ( + options?.force || + !this._codeSuggestions.has(item.uuid) || + this._codeSuggestions.get(item.uuid)!.expiresAt < Date.now() + ) { + this._codeSuggestions.set(item.uuid, { + promise: withDurationAndSlowEventOnTimeout( + this.container.drafts.getCodeSuggestions(item, HostingIntegrationId.GitHub, { + includeArchived: false, + }), + 'getCodeSuggestions', + this.container, + ), + expiresAt: Date.now() + cacheExpiration, + }); + } + + return this._codeSuggestions.get(item.uuid)!.promise; + } + + @log() + refresh() { + // this._issues = undefined; + this._prs = undefined; + this._enrichedItems = undefined; + this._codeSuggestions = undefined; + + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async pin(item: FocusItem) { + item.viewer.pinned = true; + this._onDidChange.fire(); + + await this.container.enrichments.pinItem(item.enrichable); + this._enrichedItems = undefined; + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async unpin(item: FocusItem) { + item.viewer.pinned = false; + this._onDidChange.fire(); + + if (item.viewer.enrichedItems == null) return; + const pinned = item.viewer.enrichedItems.find(e => e.type === 'pin'); + if (pinned == null) return; + await this.container.enrichments.unpinItem(pinned.id); + this._enrichedItems = undefined; + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async snooze(item: FocusItem) { + item.viewer.snoozed = true; + this._onDidChange.fire(); + + await this.container.enrichments.snoozeItem(item.enrichable); + this._enrichedItems = undefined; + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async unsnooze(item: FocusItem) { + item.viewer.snoozed = false; + this._onDidChange.fire(); + + if (item.viewer.enrichedItems == null) return; + const snoozed = item.viewer.enrichedItems.find(e => e.type === 'snooze'); + if (snoozed == null) return; + await this.container.enrichments.unsnoozeItem(snoozed.id); + this._enrichedItems = undefined; + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async merge(item: FocusItem): Promise { + if (item.graphQLId == null || item.headRef?.oid == null) return; + // TODO: Include other providers. + if (item.provider.id !== 'github') return; + const confirm = await window.showQuickPick(['Merge', 'Cancel'], { + placeHolder: `Are you sure you want to merge ${item.headRef?.name ?? 'this pull request'}${ + item.baseRef?.name ? ` into ${item.baseRef.name}` : '' + }? This cannot be undone.`, + }); + if (confirm !== 'Merge') return; + const integrations = await this.container.integrations.get(HostingIntegrationId.GitHub); + await integrations.mergePullRequest({ id: item.graphQLId, headRefSha: item.headRef.oid }); + this.refresh(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + open(item: FocusItem): void { + if (item.url == null) return; + void openUrl(item.url); + this._prs = undefined; + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + openCodeSuggestion(item: FocusItem, target: string) { + const draft = item.codeSuggestions?.value?.find(d => d.id === target); + if (draft == null) return; + this._codeSuggestions?.delete(item.uuid); + this._prs = undefined; + void executeCommand(Commands.OpenCloudPatch, { + type: 'code_suggestion', + draft: draft, + }); + } + + @log() + openCodeSuggestionInBrowser(target: string) { + void openUrl(this.container.drafts.generateWebUrl(target)); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async switchTo( + item: FocusItem, + options?: { skipWorktreeConfirmations?: boolean; startCodeSuggestion?: boolean }, + ): Promise { + if (item.openRepository?.localBranch?.current) { + void showInspectView({ + type: 'wip', + inReview: options?.startCodeSuggestion, + repository: item.openRepository.repo, + source: 'launchpad', + } satisfies ShowWipArgs); + return; + } + + const deepLinkUrl = this.getItemBranchDeepLink( + item, + options?.startCodeSuggestion + ? DeepLinkActionType.SwitchToAndSuggestPullRequest + : options?.skipWorktreeConfirmations + ? DeepLinkActionType.SwitchToPullRequestWorktree + : DeepLinkActionType.SwitchToPullRequest, + ); + if (deepLinkUrl == null) return; + + this._codeSuggestions?.delete(item.uuid); + await this.container.deepLinks.processDeepLinkUri(deepLinkUrl, false); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async openChanges(item: FocusItem) { + if (!item.openRepository?.localBranch?.current) return; + await this.switchTo(item); + if (item.refs != null) { + const refs = await getComparisonRefsForPullRequest( + this.container, + item.openRepository.repo.path, + item.refs, + ); + await openComparisonChanges( + this.container, + { + repoPath: refs.repoPath, + lhs: refs.base.ref, + rhs: refs.head.ref, + }, + { title: `Changes in Pull Request #${item.id}` }, + ); + } + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async openInGraph(item: FocusItem) { + const deepLinkUrl = this.getItemBranchDeepLink(item); + if (deepLinkUrl == null) return; + await this.container.deepLinks.processDeepLinkUri(deepLinkUrl, false); + } + + generateWebUrl(): string { + return this.container.generateWebGkDevUrl('/launchpad'); + } + + private getItemBranchDeepLink(item: FocusItem, action?: DeepLinkActionType): Uri | undefined { + if (item.type !== 'pullrequest' || item.headRef == null || item.repoIdentity?.remote?.url == null) + return undefined; + const schemeOverride = configuration.get('deepLinks.schemeOverride'); + const scheme = typeof schemeOverride === 'string' ? schemeOverride : env.uriScheme; + + const branchName = + action == null && item.openRepository?.localBranch?.current + ? item.openRepository.localBranch.name + : item.headRef.name; + + // TODO: Get the proper pull URL from the provider, rather than tacking .git at the end of the + // url from the head ref. + return Uri.parse( + `${scheme}://${this.container.context.extension.id}/${'link' satisfies UriTypes}/${ + DeepLinkType.Repository + }/-/${DeepLinkType.Branch}/${branchName}?url=${encodeURIComponent( + ensureRemoteUrl(item.repoIdentity.remote.url), + )}${action != null ? `&action=${action}` : ''}`, + ); + } + + private async getMatchingOpenRepository( + pr: EnrichablePullRequest, + matchingRemoteMap: Map, + ): Promise { + if (pr.repoIdentity.remote.url == null) return undefined; + + const match = matchingRemoteMap.get(pr.repoIdentity.remote.url); + if (match == null) return undefined; + + const [repo, remote] = match; + const remoteBranchName = `${remote.name}/${pr.refs?.head.branch ?? pr.headRef?.name}`; + const matchingLocalBranch = await getLocalBranchByUpstream(repo, remoteBranchName); + + return { repo: repo, remote: remote, localBranch: matchingLocalBranch }; + } + + private async getMatchingRemoteMap(actionableItems: FocusPullRequest[]) { + const uniqueRemoteUrls = new Set(); + for (const item of actionableItems) { + if (item.repoIdentity.remote.url != null) { + uniqueRemoteUrls.add(item.repoIdentity.remote.url); + } + } + + // Get the repo/remote pairs for the unique remote urls + const repoRemotes = new Map(); + + for (const repo of this.container.git.openRepositories) { + const remotes = await repo.getRemotes(); + for (const remote of remotes) { + if (uniqueRemoteUrls.has(remote.url)) { + repoRemotes.set(remote.url, [repo, remote]); + uniqueRemoteUrls.delete(remote.url); + + if (uniqueRemoteUrls.size === 0) return repoRemotes; + } else { + for (const url of uniqueRemoteUrls) { + if (remote.matches(url)) { + repoRemotes.set(url, [repo, remote]); + uniqueRemoteUrls.delete(url); + + if (uniqueRemoteUrls.size === 0) return repoRemotes; + + break; + } + } + } + } + } + + return repoRemotes; + } + + @log({ args: { 0: o => `force=${o?.force}`, 1: false } }) + async getCategorizedItems( + options?: { force?: boolean }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + const ignoredRepositories = new Set( + (configuration.get('launchpad.ignoredRepositories') ?? []).map(r => r.toLowerCase()), + ); + const staleThreshold = configuration.get('launchpad.staleThreshold'); + let staleDate: Date | undefined; + if (staleThreshold != null) { + staleDate = new Date(); + // Subtract the number of days from the current date + staleDate.setDate(staleDate.getDate() - staleThreshold); + } + + const enrichedItemsPromise = this.getEnrichedItems({ force: options?.force, cancellation: cancellation }); + + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; + } + + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + const [enrichedItemsResult, prsWithCountsResult] = await Promise.allSettled([ + enrichedItemsPromise, + this.getPullRequestsWithSuggestionCounts({ force: options?.force, cancellation: cancellation }), + ]); + + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + // TODO: Since this is all repos we probably should order by repos you are a contributor on (or even filter out one you aren't) + + let result: FocusCategorizedResult | undefined; + + try { + if (prsWithCountsResult.status === 'rejected') { + Logger.error(prsWithCountsResult.reason, scope, 'Failed to get pull requests with suggestion counts'); + result = { + error: + prsWithCountsResult.reason instanceof Error + ? prsWithCountsResult.reason + : new Error(String(prsWithCountsResult.reason)), + }; + return result; + } + + const enrichedItems = getSettledValue(enrichedItemsResult); + const prsWithSuggestionCounts = getSettledValue(prsWithCountsResult); + + const prs = prsWithSuggestionCounts?.prs; + if (prs?.value == null) { + result = { + items: [], + timings: { + prs: prsWithSuggestionCounts?.prs?.duration, + codeSuggestionCounts: prsWithSuggestionCounts?.suggestionCounts?.duration, + enrichedItems: enrichedItems?.duration, + }, + }; + return result; + } + + // Multiple enriched items can have the same entityId. Map by entityId to an array of enriched items. + const enrichedItemsByEntityId: { [id: string]: EnrichedItem[] } = {}; + + if (enrichedItems?.value != null) { + for (const enrichedItem of enrichedItems.value) { + if (enrichedItem.entityId in enrichedItemsByEntityId) { + enrichedItemsByEntityId[enrichedItem.entityId].push(enrichedItem); + } else { + enrichedItemsByEntityId[enrichedItem.entityId] = [enrichedItem]; + } + } + } + + const filteredPrs = !ignoredRepositories.size + ? prs.value + : prs.value.filter( + pr => + !ignoredRepositories.has( + `${pr.pullRequest.repository.owner.toLowerCase()}/${pr.pullRequest.repository.repo.toLowerCase()}`, + ), + ); + + const github = await this.container.integrations.get(HostingIntegrationId.GitHub); + const myAccount = await github.getCurrentAccount(); + + const inputPrs: EnrichablePullRequest[] = filteredPrs.map(pr => { + const providerPr = toProviderPullRequestWithUniqueId(pr.pullRequest); + + const enrichable = { + type: 'pr', + id: providerPr.uuid, + url: pr.pullRequest.url, + provider: 'github', + } satisfies EnrichableItem; + + const repoIdentity = { + remote: { + url: pr.pullRequest.refs?.head?.url, + domain: pr.pullRequest.provider.domain, + }, + name: pr.pullRequest.repository.repo, + provider: { + id: pr.pullRequest.provider.id, + domain: pr.pullRequest.provider.domain, + repoDomain: pr.pullRequest.repository.owner, + repoName: pr.pullRequest.repository.repo, + }, + }; + + return { + ...providerPr, + type: 'pullrequest', + uuid: providerPr.uuid, + provider: pr.pullRequest.provider, + enrichable: enrichable, + repoIdentity: repoIdentity, + refs: pr.pullRequest.refs, + }; + }) satisfies EnrichablePullRequest[]; + + // Note: The expected output of this is ActionablePullRequest[], but we are passing in EnrichablePullRequest, + // so we need to cast the output as FocusPullRequest[]. + const actionableItems = getActionablePullRequests( + inputPrs, + { id: myAccount!.username! }, + { enrichedItemsByUniqueId: enrichedItemsByEntityId }, + ) as FocusPullRequest[]; + + // Get the unique remote urls + const mappedRemotesPromise = await this.getMatchingRemoteMap(actionableItems); + + const { suggestionCounts } = prsWithSuggestionCounts!; + + // Map from shared category label to local actionable category, and get suggested actions + const categorized = (await Promise.all( + actionableItems.map(async item => { + const codeSuggestionsCount = suggestionCounts?.value?.[item.uuid]?.count ?? 0; + + let actionableCategory = sharedCategoryToFocusActionCategoryMap.get(item.suggestedActionCategory)!; + // category overrides + if (staleDate != null && item.updatedDate.getTime() < staleDate.getTime()) { + actionableCategory = 'other'; + } else if (codeSuggestionsCount > 0 && item.viewer.isAuthor) { + actionableCategory = 'code-suggestions'; + } + + const openRepository = await this.getMatchingOpenRepository(item, mappedRemotesPromise); + const suggestedActions = getSuggestedActions( + actionableCategory, + openRepository?.localBranch?.current ?? false, + ); + + return { + ...item, + currentViewer: myAccount!, + codeSuggestionsCount: codeSuggestionsCount, + isNew: this.isItemNewInGroup(item, actionableCategory), + actionableCategory: actionableCategory, + suggestedActions: suggestedActions, + openRepository: openRepository, + }; + }), + )) satisfies FocusItem[]; + + result = { + items: categorized, + timings: { + prs: prsWithSuggestionCounts?.prs?.duration, + codeSuggestionCounts: prsWithSuggestionCounts?.suggestionCounts?.duration, + enrichedItems: enrichedItems?.duration, + }, + }; + return result; + } finally { + this.updateGroupedIds(result?.items ?? []); + if (result != null) { + this._onDidRefresh.fire(result); + } else { + debugger; + } + } + } + + private _groupedIds: Set | undefined; + + private isItemNewInGroup(item: FocusPullRequest, actionableCategory: FocusActionCategory) { + return ( + this._groupedIds != null && + !this._groupedIds.has(`${item.uuid}:${focusCategoryToGroupMap.get(actionableCategory)}`) + ); + } + + private updateGroupedIds(items: FocusItem[]) { + const groupedIds = new Set(); + for (const item of items) { + const group = focusCategoryToGroupMap.get(item.actionableCategory)!; + const key = `${item.uuid}:${group}`; + if (!groupedIds.has(key)) { + groupedIds.add(key); + } + } + + this._groupedIds = groupedIds; + } + + async hasConnectedIntegration(): Promise { + for (const integrationId of supportedFocusIntegrations) { + const integration = await this.container.integrations.get(integrationId); + if (integration.maybeConnected ?? (await integration.isConnected())) { + return true; + } + } + + return false; + } + + @log({ + args: { 0: i => `${i.id} (${i.provider.name} ${i.type})`, 1: o => `force=${o?.force}` }, + }) + async ensureFocusItemCodeSuggestions( + item: FocusItem, + options?: { force?: boolean }, + ): Promise | undefined> { + item.codeSuggestions ??= await this.getCodeSuggestions(item, options); + return item.codeSuggestions; + } + + private registerCommands(): Disposable[] { + return [ + registerCommand(Commands.ToggleLaunchpadIndicator, () => { + const enabled = configuration.get('launchpad.indicator.enabled') ?? false; + void configuration.updateEffective('launchpad.indicator.enabled', !enabled); + }), + ]; + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + if (!configuration.changed(e, 'launchpad')) return; + + const cfg = configuration.get('launchpad'); + this.container.telemetry.sendEvent('launchpad/configurationChanged', { + 'config.launchpad.staleThreshold': cfg.staleThreshold, + 'config.launchpad.ignoredOrganizations': cfg.ignoredOrganizations?.length ?? 0, + 'config.launchpad.ignoredRepositories': cfg.ignoredRepositories?.length ?? 0, + 'config.launchpad.indicator.enabled': cfg.indicator.enabled, + 'config.launchpad.indicator.openInEditor': cfg.indicator.openInEditor, + 'config.launchpad.indicator.icon': cfg.indicator.icon, + 'config.launchpad.indicator.label': cfg.indicator.label, + 'config.launchpad.indicator.useColors': cfg.indicator.useColors, + 'config.launchpad.indicator.groups': cfg.indicator.groups.join(','), + 'config.launchpad.indicator.polling.enabled': cfg.indicator.polling.enabled, + 'config.launchpad.indicator.polling.interval': cfg.indicator.polling.interval, + }); + + if ( + configuration.changed(e, 'launchpad.ignoredOrganizations') || + configuration.changed(e, 'launchpad.ignoredRepositories') || + configuration.changed(e, 'launchpad.staleThreshold') + ) { + this.refresh(); + void this.getCategorizedItems({ force: true }); + } + } +} + +export function groupAndSortFocusItems(items?: FocusItem[]) { + if (items == null || items.length === 0) return new Map(); + const grouped = new Map(focusGroups.map(g => [g, []])); + + sortFocusItems(items); + + for (const item of items) { + if (item.viewer.snoozed) { + grouped.get('snoozed')!.push(item); + + continue; + } else if (item.viewer.pinned) { + grouped.get('pinned')!.push(item); + } + + if (item.openRepository?.localBranch?.current) { + grouped.get('current-branch')!.push(item); + } + + if (item.isDraft) { + grouped.get('draft')!.push(item); + } else { + const group = focusCategoryToGroupMap.get(item.actionableCategory)!; + grouped.get(group)!.push(item); + } + } + + // Re-sort pinned and draft groups by updated date + grouped.get('pinned')!.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime()); + grouped.get('draft')!.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime()); + return grouped; +} + +export function countFocusItemGroups(items?: FocusItem[]) { + if (items == null || items.length === 0) return new Map(); + const grouped = new Map(focusGroups.map(g => [g, 0])); + + function incrementGroup(group: FocusGroup) { + grouped.set(group, (grouped.get(group) ?? 0) + 1); + } + + for (const item of items) { + if (item.viewer.snoozed) { + incrementGroup('snoozed'); + continue; + } else if (item.viewer.pinned) { + incrementGroup('pinned'); + } + + if (item.openRepository?.localBranch?.current) { + incrementGroup('current-branch'); + } + + if (item.isDraft) { + incrementGroup('draft'); + } else { + incrementGroup(focusCategoryToGroupMap.get(item.actionableCategory)!); + } + } + + return grouped; +} + +export function sortFocusItems(items: FocusItem[]) { + return items.sort( + (a, b) => + (a.viewer.pinned ? -1 : 1) - (b.viewer.pinned ? -1 : 1) || + focusActionCategories.indexOf(a.actionableCategory) - focusActionCategories.indexOf(b.actionableCategory) || + b.updatedDate.getTime() - a.updatedDate.getTime(), + ); +} + +function ensureRemoteUrl(url: string) { + if (url.startsWith('https')) { + return url.endsWith('.git') ? url : `${url}.git`; + } + + return url; +} + +export function getFocusItemIdHash(item: FocusItem) { + return md5(item.uuid); +} + +const slowEventTimeout = 1000 * 30; // 30 seconds + +function withDurationAndSlowEventOnTimeout( + promise: Promise, + name: 'getMyPullRequests' | 'getCodeSuggestionCounts' | 'getCodeSuggestions' | 'getEnrichedItems', + container: Container, +): Promise> { + return timedWithSlowThreshold(promise, { + timeout: slowEventTimeout, + onSlow: (duration: number) => { + container.telemetry.sendEvent('launchpad/operation/slow', { + timeout: slowEventTimeout, + operation: name, + duration: duration, + }); + }, + }); +} diff --git a/src/plus/gk/authenticationConnection.ts b/src/plus/gk/account/authenticationConnection.ts similarity index 52% rename from src/plus/gk/authenticationConnection.ts rename to src/plus/gk/account/authenticationConnection.ts index b67e567516d76..d90bb5c768b8f 100644 --- a/src/plus/gk/authenticationConnection.ts +++ b/src/plus/gk/account/authenticationConnection.ts @@ -2,13 +2,14 @@ import type { CancellationToken, Disposable, StatusBarItem } from 'vscode'; import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode'; import { uuid } from '@env/crypto'; import type { Response } from '@env/fetch'; -import type { Container } from '../../container'; -import { debug } from '../../system/decorators/log'; -import type { DeferredEvent, DeferredEventExecutor } from '../../system/event'; -import { promisifyDeferred } from '../../system/event'; -import { Logger } from '../../system/logger'; -import { getLogScope } from '../../system/logger.scope'; -import type { ServerConnection } from './serverConnection'; +import type { Container } from '../../../container'; +import { debug } from '../../../system/decorators/log'; +import type { DeferredEvent, DeferredEventExecutor } from '../../../system/event'; +import { promisifyDeferred } from '../../../system/event'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import { openUrl } from '../../../system/utils'; +import type { ServerConnection } from '../serverConnection'; export const AuthenticationUriPathPrefix = 'did-authenticate'; @@ -38,13 +39,13 @@ export class AuthenticationConnection implements Disposable { return new Promise(resolve => setTimeout(resolve, 50)); } - @debug({ args: false }) + @debug({ args: false, exit: r => `returned ${r.id}` }) async getAccountInfo(token: string): Promise { const scope = getLogScope(); let rsp: Response; try { - rsp = await this.connection.fetchApi('user', undefined, token); + rsp = await this.connection.fetchApi('user', undefined, { token: token }); } catch (ex) { Logger.error(ex, scope); throw ex; @@ -60,7 +61,7 @@ export class AuthenticationConnection implements Disposable { } @debug() - async login(scopes: string[], scopeKey: string): Promise { + async login(scopes: string[], scopeKey: string, signUp: boolean = false): Promise { this.updateStatusBarItem(true); // Include a state parameter here to prevent CSRF attacks @@ -69,25 +70,24 @@ export class AuthenticationConnection implements Disposable { this._pendingStates.set(scopeKey, [...existingStates, gkstate]); const callbackUri = await env.asExternalUri( - Uri.parse( - `${env.uriScheme}://${this.container.context.extension.id}/${AuthenticationUriPathPrefix}?gkstate=${gkstate}`, - ), + Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${AuthenticationUriPathPrefix}`), ); - const uri = this.connection.getAccountsUri( - 'register', - `${scopes.includes('gitlens') ? 'referrer=gitlens&' : ''}pass-token=true&return-url=${encodeURIComponent( - callbackUri.toString(), - )}`, + const uri = this.container.getGkDevUri( + signUp ? 'register' : 'login', + `${scopes.includes('gitlens') ? 'source=gitlens&' : ''}state=${encodeURIComponent( + gkstate, + )}&redirect_uri=${encodeURIComponent(callbackUri.toString(true))}`, ); - void (await env.openExternal(uri)); + + void (await openUrl(uri.toString(true))); // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); if (deferredCodeExchange == null) { deferredCodeExchange = promisifyDeferred( this.container.uri.onDidReceiveAuthenticationUri, - this.getUriHandlerDeferredExecutor(scopeKey), + this.getUriHandlerDeferredExecutor(), ); this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); } @@ -99,17 +99,23 @@ export class AuthenticationConnection implements Disposable { this._cancellationSource = new CancellationTokenSource(); - void this.openCompletionInputFallback(this._cancellationSource.token); - - return Promise.race([ - deferredCodeExchange.promise, - new Promise( - (_, reject) => - // eslint-disable-next-line prefer-promise-reject-errors - this._cancellationSource?.token.onCancellationRequested(() => reject('Cancelled')), - ), - new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), - ]).finally(() => { + try { + const code = await Promise.race([ + deferredCodeExchange.promise, + new Promise(resolve => + this.openCompletionInputFallback(this._cancellationSource!.token, resolve), + ), + new Promise( + (_, reject) => + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + this._cancellationSource?.token.onCancellationRequested(() => reject('Cancelled')), + ), + new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), + ]); + + const token = await this.getTokenFromCodeAndState(scopeKey, code, gkstate); + return token; + } finally { this._cancellationSource?.cancel(); this._cancellationSource = undefined; @@ -117,86 +123,94 @@ export class AuthenticationConnection implements Disposable { deferredCodeExchange?.cancel(); this._deferredCodeExchanges.delete(scopeKey); this.updateStatusBarItem(false); - }); + } } - private async openCompletionInputFallback(cancellationToken: CancellationToken) { + private async openCompletionInputFallback(cancellationToken: CancellationToken, resolve: (token: string) => void) { const input = window.createInputBox(); input.ignoreFocusOut = true; const disposables: Disposable[] = []; + let code: string | undefined = undefined; try { if (cancellationToken.isCancellationRequested) return; - const uri = await new Promise(resolve => { + code = await new Promise(resolve => { disposables.push( cancellationToken.onCancellationRequested(() => input.hide()), input.onDidHide(() => resolve(undefined)), input.onDidChangeValue(e => { if (!e) { - input.validationMessage = undefined; + input.validationMessage = 'Please enter a valid code'; return; } - try { - const uri = Uri.parse(e.trim()); - if (uri.scheme && uri.scheme !== 'file') { - input.validationMessage = undefined; - return; - } - } catch {} - - input.validationMessage = 'Please enter a valid authorization URL'; + input.validationMessage = undefined; }), - input.onDidAccept(() => resolve(Uri.parse(input.value.trim()))), + input.onDidAccept(() => resolve(input.value)), ); input.title = 'GitKraken Sign In'; - input.placeholder = 'Please enter the provided authorization URL'; - input.prompt = 'If the auto-redirect fails, paste the authorization URL'; + input.placeholder = 'Please enter the provided authorization code'; + input.prompt = 'If the auto-redirect fails, paste the authorization code'; input.show(); }); - - if (uri != null) { - this.container.uri.handleUri(uri); - } } finally { input.dispose(); disposables.forEach(d => void d.dispose()); } + + if (code != null) { + resolve(code); + } } - private getUriHandlerDeferredExecutor(_scopeKey: string): DeferredEventExecutor { - return (uri: Uri, resolve, reject) => { - // TODO: We should really support a code to token exchange, but just return the token from the query string - // await this.exchangeCodeForToken(uri.query); - // As the backend still doesn't implement yet the code to token exchange, we just validate the state returned - const queryParams: URLSearchParams = new URLSearchParams(uri.query); + private async getTokenFromCodeAndState(scopeKey: string, code: string, state: string): Promise { + const existingStates = this._pendingStates.get(scopeKey); + if (!existingStates?.includes(state)) { + throw new Error('Getting token failed: Invalid state'); + } - const acceptedStates = this._pendingStates.get(_scopeKey); - const state = queryParams.get('gkstate'); + const rsp = await this.connection.fetchGkDevApi( + 'oauth/access_token', + { + method: 'POST', + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: 'gitkraken.gitlens', + code: code, + state: state, + }), + }, + { + unAuthenticated: true, + }, + ); - if (acceptedStates == null || !state || !acceptedStates.includes(state)) { - // A common scenario of this happening is if you: - // 1. Trigger a sign in with one set of scopes - // 2. Before finishing 1, you trigger a sign in with a different set of scopes - // In this scenario we should just return and wait for the next UriHandler event - // to run as we are probably still waiting on the user to hit 'Continue' - Logger.log('State not found in accepted state. Skipping this execution...'); - return; - } + if (!rsp.ok) { + throw new Error(`Getting token failed: (${rsp.status}) ${rsp.statusText}`); + } - const accessToken = queryParams.get('access-token'); - const code = queryParams.get('code'); - const token = accessToken ?? code; + const json: { access_token: string } = await rsp.json(); + if (json.access_token == null) { + throw new Error('Getting token failed: No access token returned'); + } + + return json.access_token; + } - if (token == null) { - reject('Token not returned'); - } else { - resolve(token); + private getUriHandlerDeferredExecutor(): DeferredEventExecutor { + return (uri: Uri, resolve, reject) => { + const queryParams: URLSearchParams = new URLSearchParams(uri.query); + const code = queryParams.get('code'); + if (code == null) { + reject('Code not returned'); + return; } + + resolve(code); }; } @@ -213,4 +227,28 @@ export class AuthenticationConnection implements Disposable { this._statusBarItem = undefined; } } + + async getExchangeToken(redirectPath?: string): Promise { + const redirectUrl = + redirectPath != null + ? await env.asExternalUri( + Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${redirectPath}`), + ) + : undefined; + + const rsp = await this.connection.fetchGkDevApi('v1/login/auth-exchange', { + method: 'POST', + body: JSON.stringify({ + source: 'gitlens', + redirectUrl: redirectUrl?.toString(), + }), + }); + + if (!rsp.ok) { + throw new Error(`Failed to get exchange token: (${rsp.status}) ${rsp.statusText}`); + } + + const json: { data: { exchangeToken: string } } = await rsp.json(); + return json.data.exchangeToken; + } } diff --git a/src/plus/gk/authenticationProvider.ts b/src/plus/gk/account/authenticationProvider.ts similarity index 90% rename from src/plus/gk/authenticationProvider.ts rename to src/plus/gk/account/authenticationProvider.ts index 9400415605b0d..a2247e08e44f4 100644 --- a/src/plus/gk/authenticationProvider.ts +++ b/src/plus/gk/account/authenticationProvider.ts @@ -5,12 +5,13 @@ import type { } from 'vscode'; import { authentication, Disposable, EventEmitter, window } from 'vscode'; import { uuid } from '@env/crypto'; -import type { Container, Environment } from '../../container'; -import { debug } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; +import type { Container, Environment } from '../../../container'; +import { CancellationError } from '../../../errors'; +import { debug } from '../../../system/decorators/log'; +import { Logger } from '../../../system/logger'; +import { getLogScope, setLogScopeExit } from '../../../system/logger.scope'; +import type { ServerConnection } from '../serverConnection'; import { AuthenticationConnection } from './authenticationConnection'; -import type { ServerConnection } from './serverConnection'; interface StoredSession { id: string; @@ -71,12 +72,18 @@ export class AccountAuthenticationProvider implements AuthenticationProvider, Di public async createSession(scopes: string[]): Promise { const scope = getLogScope(); + const signUp = scopes.includes('signUp'); + // 'signUp' is just a flag, not a valid scope, so remove it before continuing + if (signUp) { + scopes = scopes.filter(s => s !== 'signUp'); + } + // Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter) scopes = scopes.sort(); const scopesKey = getScopesKey(scopes); try { - const token = await this._authConnection.login(scopes, scopesKey); + const token = await this._authConnection.login(scopes, scopesKey, signUp); const session = await this.createSessionForToken(token, scopes); const sessions = await this._sessionsPromise; @@ -96,7 +103,11 @@ export class AccountAuthenticationProvider implements AuthenticationProvider, Di if (ex === 'Cancelled') throw ex; Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign in to GitKraken: ${ex}`); + void window.showErrorMessage( + `Unable to sign in to GitKraken: ${ + ex instanceof CancellationError ? 'request timed out' : ex + }. Please try again. If this issue persists, please contact support.`, + ); throw ex; } } @@ -286,6 +297,10 @@ export class AccountAuthenticationProvider implements AuthenticationProvider, Di Logger.error(ex, `Unable to store ${sessions.length} sessions`); } } + + async getExchangeToken(redirectPath?: string): Promise { + return this._authConnection.getExchangeToken(redirectPath); + } } function getScopesKey(scopes: readonly string[]): string; diff --git a/src/plus/gk/account/organization.ts b/src/plus/gk/account/organization.ts new file mode 100644 index 0000000000000..bf61599cf6062 --- /dev/null +++ b/src/plus/gk/account/organization.ts @@ -0,0 +1,60 @@ +export interface Organization { + readonly id: string; + readonly name: string; + readonly role: OrganizationRole; +} + +export type OrganizationRole = 'owner' | 'admin' | 'billing' | 'user'; + +export type OrganizationsResponse = Organization[]; + +export interface FullOrganization { + readonly id: string; + readonly name: string; + readonly domain: string; + readonly updatedToNewRoles: boolean; + readonly memberCount: number; + readonly members: OrganizationMember[]; + readonly connections: OrganizationConnection[]; + readonly type: OrganizationType; + readonly isOnChargebee: boolean; +} + +export enum OrganizationType { + Enterprise = 'ENTERPRISE', + Individual = 'INDIVIDUAL', + Pro = 'PRO', + Teams = 'TEAMS', +} + +export type OrganizationConnection = Record; + +export interface OrganizationMember { + readonly id: string; + readonly email: string; + readonly name: string; + readonly username: string; + readonly role: OrganizationRole; + readonly licenseConsumption: Record; +} + +export interface OrganizationSettings { + aiSettings: OrganizationSetting; + draftsSettings: OrganizationDraftsSettings; +} + +export interface OrganizationSetting { + readonly enabled: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface OrganizationDraftsSettings extends OrganizationSetting { + readonly bucket: + | { + readonly name: string; + readonly region: string; + readonly provider: string; + } + | undefined; +} diff --git a/src/plus/gk/account/organizationService.ts b/src/plus/gk/account/organizationService.ts new file mode 100644 index 0000000000000..f4004e8ca81c4 --- /dev/null +++ b/src/plus/gk/account/organizationService.ts @@ -0,0 +1,239 @@ +import { Disposable, window } from 'vscode'; +import type { Container } from '../../../container'; +import { setContext } from '../../../system/context'; +import { gate } from '../../../system/decorators/gate'; +import { once } from '../../../system/function'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import type { ServerConnection } from '../serverConnection'; +import type { + FullOrganization, + Organization, + OrganizationMember, + OrganizationSettings, + OrganizationsResponse, +} from './organization'; +import type { SubscriptionChangeEvent } from './subscriptionService'; + +const organizationsCacheExpiration = 24 * 60 * 60 * 1000; // 1 day + +export class OrganizationService implements Disposable { + private _disposable: Disposable; + private _organizations: Organization[] | null | undefined; + private _fullOrganizations: Map | undefined; + private _organizationSettings: Map | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) { + this._disposable = Disposable.from( + once(container.onReady)(async () => { + const orgId = await this.getActiveOrganizationId(); + void this.updateOrganizationPermissions(orgId); + }), + container.subscription.onDidChange(this.onSubscriptionChanged, this), + ); + } + + dispose(): void { + this._disposable.dispose(); + } + + @gate() + async getOrganizations(options?: { + force?: boolean; + accessToken?: string; + userId?: string; + }): Promise { + const scope = getLogScope(); + const userId = options?.userId ?? (await this.container.subscription.getSubscription(true))?.account?.id; + if (userId == null) { + this.updateOrganizations(undefined); + return this._organizations; + } + + if (this._organizations === undefined || options?.force) { + if (!options?.force) { + this.loadStoredOrganizations(userId); + if (this._organizations != null) return this._organizations; + } + + let rsp; + try { + rsp = await this.connection.fetchApi( + 'user/organizations-light', + { + method: 'GET', + }, + { token: options?.accessToken }, + ); + } catch (ex) { + debugger; + Logger.error(ex, scope); + + void window.showErrorMessage(`Unable to get organizations due to error: ${ex}`, 'OK'); + this.updateOrganizations(undefined); + return this._organizations; + } + + if (!rsp.ok) { + debugger; + Logger.error('', scope, `Unable to get organizations; status=(${rsp.status}): ${rsp.statusText}`); + + void window.showErrorMessage(`Unable to get organizations; Status: ${rsp.statusText}`, 'OK'); + + // Setting to null prevents hitting the API again until you reload + this.updateOrganizations(null); + return this._organizations; + } + + const organizationsResponse = (await rsp.json()) as OrganizationsResponse; + const organizations = organizationsResponse.map((o: any) => ({ + id: o.id, + name: o.name, + role: o.role, + })); + + await this.storeOrganizations(organizations, userId); + this.updateOrganizations(organizations); + } + + return this._organizations; + } + + @gate() + private loadStoredOrganizations(userId: string): void { + const storedOrganizations = this.container.storage.get(`gk:${userId}:organizations`); + if (storedOrganizations == null) return; + const { timestamp, data: organizations } = storedOrganizations; + if (timestamp == null || Date.now() - timestamp > organizationsCacheExpiration) { + return; + } + + this.updateOrganizations(organizations); + } + + private async storeOrganizations(organizations: Organization[], userId: string): Promise { + return this.container.storage.store(`gk:${userId}:organizations`, { + v: 1, + timestamp: Date.now(), + data: organizations, + }); + } + + private onSubscriptionChanged(e: SubscriptionChangeEvent): void { + if (e.current?.account?.id == null) { + this.updateOrganizations(undefined); + } + void this.updateOrganizationPermissions(e.current?.activeOrganization?.id); + } + + private updateOrganizations(organizations: Organization[] | null | undefined): void { + this._organizations = organizations; + void setContext('gitlens:gk:hasOrganizations', (organizations ?? []).length > 1); + } + + private async updateOrganizationPermissions(orgId: string | undefined): Promise { + const settings = orgId != null ? await this.getOrganizationSettings(orgId) : undefined; + + void setContext('gitlens:gk:organization:ai:enabled', settings?.aiSettings.enabled ?? true); + void setContext('gitlens:gk:organization:drafts:byob', settings?.draftsSettings.bucket != null); + void setContext('gitlens:gk:organization:drafts:enabled', settings?.draftsSettings.enabled ?? true); + } + + @gate() + private async getFullOrganization( + id: string, + options?: { force?: boolean }, + ): Promise { + if (!this._fullOrganizations?.has(id) || options?.force === true) { + const rsp = await this.connection.fetchApi(`organization/${id}`, { method: 'GET' }); + if (!rsp.ok) { + Logger.error( + '', + getLogScope(), + `Unable to get organization; status=(${rsp.status}): ${rsp.statusText}`, + ); + return undefined; + } + + const organization = (await rsp.json()) as FullOrganization; + if (this._fullOrganizations == null) { + this._fullOrganizations = new Map(); + } + organization.members.sort((a, b) => (a.name ?? a.username).localeCompare(b.name ?? b.username)); + this._fullOrganizations.set(id, organization); + } + return this._fullOrganizations.get(id); + } + + @gate() + async getMembers( + organizationId?: string | undefined, + options?: { force?: boolean }, + ): Promise { + if (organizationId == null) { + organizationId = await this.getActiveOrganizationId(); + if (organizationId == null) return []; + } + + const organization = await this.getFullOrganization(organizationId, options); + return organization?.members ?? []; + } + + async getMemberById(id: string, organizationId: string): Promise { + return (await this.getMembers(organizationId)).find(m => m.id === id); + } + + async getMembersByIds(ids: string[], organizationId: string): Promise { + return (await this.getMembers(organizationId)).filter(m => ids.includes(m.id)); + } + + private async getActiveOrganizationId(cached = true): Promise { + const subscription = await this.container.subscription.getSubscription(cached); + return subscription?.activeOrganization?.id; + } + + @gate() + async getOrganizationSettings( + orgId: string | undefined, + options?: { force?: boolean }, + ): Promise { + type OrganizationSettingsResponse = { + data: OrganizationSettings; + error: string | undefined; + }; + // TODO: maybe getActiveOrganizationId(false) when force is true + const id = orgId ?? (await this.getActiveOrganizationId()); + if (id == null) return undefined; + + if (!this._organizationSettings?.has(id) || options?.force === true) { + const rsp = await this.connection.fetchApi(`v1/organizations/settings`, { method: 'GET' }); + if (!rsp.ok) { + Logger.error( + '', + getLogScope(), + `Unable to get organization settings; status=(${rsp.status}): ${rsp.statusText}`, + ); + return undefined; + } + + const organizationResponse = (await rsp.json()) as OrganizationSettingsResponse; + if (organizationResponse.error != null) { + Logger.error( + '', + getLogScope(), + `Unable to get organization settings; status=(${rsp.status}): ${organizationResponse.error}`, + ); + return undefined; + } + + if (this._organizationSettings == null) { + this._organizationSettings = new Map(); + } + this._organizationSettings.set(id, organizationResponse.data); + } + return this._organizationSettings.get(id); + } +} diff --git a/src/subscription.ts b/src/plus/gk/account/subscription.ts similarity index 76% rename from src/subscription.ts rename to src/plus/gk/account/subscription.ts index c2719718b4974..9ed1169f688b9 100644 --- a/src/subscription.ts +++ b/src/plus/gk/account/subscription.ts @@ -1,5 +1,8 @@ // NOTE@eamodio This file is referenced in the webviews to we can't use anything vscode or other imports that aren't available in the webviews -import { getDateDifference } from './system/date'; +import { getDateDifference } from '../../../system/date'; +import type { Organization } from './organization'; + +export const SubscriptionUpdatedUriPathPrefix = 'did-update-subscription'; export const enum SubscriptionPlanId { Free = 'free', @@ -24,12 +27,16 @@ export interface Subscription { state: SubscriptionState; lastValidatedAt?: number; + + readonly activeOrganization?: Organization; } export interface SubscriptionPlan { readonly id: SubscriptionPlanId; readonly name: string; readonly bundle: boolean; + readonly trialReactivationCount: number; + readonly nextTrialOptInDate?: string | undefined; readonly cancelled: boolean; readonly startedOn: string; readonly expiresOn?: string | undefined; @@ -42,7 +49,6 @@ export interface SubscriptionAccount { readonly email: string | undefined; readonly verified: boolean; readonly createdOn: string; - readonly organizationIds: string[]; } export interface SubscriptionPreviewTrial { @@ -50,21 +56,48 @@ export interface SubscriptionPreviewTrial { readonly expiresOn: string; } +// NOTE: Pay attention to gitlens:plus:state in package.json when modifying this enum +// NOTE: This is reported in telemetry so we should NOT change the values export const enum SubscriptionState { /** Indicates a user who hasn't verified their email address yet */ VerificationRequired = -1, /** Indicates a Free user who hasn't yet started the preview trial */ Free = 0, /** Indicates a Free user who is in preview trial */ - FreeInPreviewTrial, + FreeInPreviewTrial = 1, /** Indicates a Free user who's preview has expired trial */ - FreePreviewTrialExpired, + FreePreviewTrialExpired = 2, /** Indicates a Free+ user with a completed trial */ - FreePlusInTrial, - /** Indicates a Free+ user who's trial has expired */ - FreePlusTrialExpired, + FreePlusInTrial = 3, + /** Indicates a Free+ user who's trial has expired and is not yet eligible for reactivation */ + FreePlusTrialExpired = 4, + /** Indicated a Free+ user who's trial has expired and is eligible for reactivation */ + FreePlusTrialReactivationEligible = 5, /** Indicates a Paid user */ - Paid, + Paid = 6, +} + +export function getSubscriptionStateString(state: SubscriptionState | undefined): string { + switch (state) { + case SubscriptionState.VerificationRequired: + return 'verification'; + case SubscriptionState.Free: + return 'free'; + case SubscriptionState.FreeInPreviewTrial: + return 'preview'; + case SubscriptionState.FreePreviewTrialExpired: + return 'preview-expired'; + case SubscriptionState.FreePlusInTrial: + return 'trial'; + case SubscriptionState.FreePlusTrialExpired: + return 'trial-expired'; + case SubscriptionState.FreePlusTrialReactivationEligible: + return 'trial-reactivation-eligible'; + case SubscriptionState.Paid: + return 'paid'; + default: + return 'unknown'; + } } export function computeSubscriptionState(subscription: Optional): SubscriptionState { @@ -81,8 +114,13 @@ export function computeSubscriptionState(subscription: Optional): boolean { - return isSubscriptionPaidPlan(subscription.plan.effective.id); + return isSubscriptionPaidPlan(subscription.plan.actual.id); } export function isSubscriptionPaidPlan(id: SubscriptionPlanId): id is PaidSubscriptionPlans { @@ -248,3 +275,7 @@ export function hasAccountFromSubscriptionState(state: SubscriptionState | undef state !== SubscriptionState.FreeInPreviewTrial ); } + +export function assertSubscriptionState( + subscription: Optional, +): asserts subscription is Subscription {} diff --git a/src/plus/gk/account/subscriptionService.ts b/src/plus/gk/account/subscriptionService.ts new file mode 100644 index 0000000000000..7c3e4b15fbaa2 --- /dev/null +++ b/src/plus/gk/account/subscriptionService.ts @@ -0,0 +1,1403 @@ +import type { + AuthenticationProviderAuthenticationSessionsChangeEvent, + AuthenticationSession, + CancellationToken, + Event, + MessageItem, + StatusBarItem, +} from 'vscode'; +import { + authentication, + CancellationTokenSource, + version as codeVersion, + Disposable, + env, + EventEmitter, + MarkdownString, + ProgressLocation, + StatusBarAlignment, + ThemeColor, + window, +} from 'vscode'; +import { getPlatform } from '@env/platform'; +import type { OpenWalkthroughCommandArgs } from '../../../commands/walkthroughs'; +import type { CoreColors, Source } from '../../../constants'; +import { Commands, urls } from '../../../constants'; +import type { Container } from '../../../container'; +import { AccountValidationError } from '../../../errors'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; +import { executeCommand, registerCommand } from '../../../system/command'; +import { configuration } from '../../../system/configuration'; +import { setContext } from '../../../system/context'; +import { createFromDateDelta, fromNow } from '../../../system/date'; +import { gate } from '../../../system/decorators/gate'; +import { debug, log } from '../../../system/decorators/log'; +import { take } from '../../../system/event'; +import type { Deferrable } from '../../../system/function'; +import { debounce, once } from '../../../system/function'; +import { Logger } from '../../../system/logger'; +import { getLogScope, setLogScopeExit } from '../../../system/logger.scope'; +import { flatten } from '../../../system/object'; +import { pauseOnCancelOrTimeout } from '../../../system/promise'; +import { pluralize } from '../../../system/string'; +import { openUrl } from '../../../system/utils'; +import { satisfies } from '../../../system/version'; +import type { GKCheckInResponse } from '../checkin'; +import { getSubscriptionFromCheckIn } from '../checkin'; +import type { ServerConnection } from '../serverConnection'; +import { ensurePlusFeaturesEnabled } from '../utils'; +import { authenticationProviderId, authenticationProviderScopes } from './authenticationProvider'; +import type { Organization } from './organization'; +import type { Subscription } from './subscription'; +import { + assertSubscriptionState, + computeSubscriptionState, + getSubscriptionPlan, + getSubscriptionPlanName, + getSubscriptionStateString, + getSubscriptionTimeRemaining, + getTimeRemaining, + isSubscriptionExpired, + isSubscriptionInProTrial, + isSubscriptionPaid, + isSubscriptionTrial, + SubscriptionPlanId, + SubscriptionState, + SubscriptionUpdatedUriPathPrefix, +} from './subscription'; + +export interface SubscriptionChangeEvent { + readonly current: Subscription; + readonly previous: Subscription; + readonly etag: number; +} + +export class SubscriptionService implements Disposable { + private _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + private _onDidCheckIn = new EventEmitter(); + get onDidCheckIn(): Event { + return this._onDidCheckIn.event; + } + + private _disposable: Disposable; + private _subscription!: Subscription; + private _getCheckInData: () => Promise; + private _statusBarSubscription: StatusBarItem | undefined; + private _validationTimer: ReturnType | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + previousVersion: string | undefined, + ) { + this._disposable = Disposable.from( + once(container.onReady)(this.onReady, this), + this.container.accountAuthentication.onDidChangeSessions( + e => setTimeout(() => this.onAuthenticationChanged(e), 0), + this, + ), + configuration.onDidChange(e => { + if (configuration.changed(e, 'plusFeatures')) { + this.updateContext(); + } + }), + container.uri.onDidReceiveSubscriptionUpdatedUri(this.onSubscriptionUpdatedUri, this), + ); + + const subscription = this.getStoredSubscription(); + this._getCheckInData = () => Promise.resolve(undefined); + // Resets the preview trial state on the upgrade to 14.0 + if (subscription != null) { + if (satisfies(previousVersion, '< 14.0')) { + subscription.previewTrial = undefined; + } + + if (subscription.account?.id != null) { + this._getCheckInData = () => this.loadStoredCheckInData(subscription.account!.id); + } + } + + this.changeSubscription(subscription, { silent: true }); + setTimeout(() => void this.ensureSession(false), 10000); + } + + dispose(): void { + this._statusBarSubscription?.dispose(); + + this._disposable.dispose(); + } + + private async onAuthenticationChanged(e: AuthenticationProviderAuthenticationSessionsChangeEvent) { + let session = this._session; + if (session == null && this._sessionPromise != null) { + session = await this._sessionPromise; + } + + if (session != null && e.removed?.some(s => s.id === session.id)) { + this._session = undefined; + this._sessionPromise = undefined; + void this.logout(undefined, undefined); + return; + } + + const updated = e.added?.[0] ?? e.changed?.[0]; + if (updated == null) return; + + if (updated.id === session?.id && updated.accessToken === session?.accessToken) { + return; + } + + this._session = session; + void this.validate({ force: true }); + } + + private _etag: number = 0; + get etag(): number { + return this._etag; + } + + private onReady() { + this._disposable = Disposable.from( + this._disposable, + this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + ...this.registerCommands(), + ); + this.updateContext(); + } + + private onRepositoriesChanged(_e: RepositoriesChangeEvent): void { + this.updateContext(); + } + + private registerCommands(): Disposable[] { + void this.container.viewCommands; + + return [ + registerCommand(Commands.PlusLogin, (src?: Source) => this.loginOrSignUp(false, src)), + registerCommand(Commands.PlusSignUp, (src?: Source) => this.loginOrSignUp(true, src)), + registerCommand(Commands.PlusLogout, (src?: Source) => this.logout(undefined, src)), + registerCommand(Commands.GKSwitchOrganization, () => this.switchOrganization()), + + registerCommand(Commands.PlusManage, (src?: Source) => this.manage(src)), + registerCommand(Commands.PlusShowPlans, (src?: Source) => this.showPlans(src)), + registerCommand(Commands.PlusStartPreviewTrial, (src?: Source) => this.startPreviewTrial(src)), + registerCommand(Commands.PlusReactivateProTrial, (src?: Source) => this.reactivateProTrial(src)), + registerCommand(Commands.PlusResendVerification, (src?: Source) => this.resendVerification(src)), + registerCommand(Commands.PlusUpgrade, (src?: Source) => this.upgrade(src)), + + registerCommand(Commands.PlusHide, (src?: Source) => this.setProFeaturesVisibility(false, src)), + registerCommand(Commands.PlusRestore, (src?: Source) => this.setProFeaturesVisibility(true, src)), + + registerCommand(Commands.PlusValidate, (src?: Source) => this.validate({ force: true }, src)), + ]; + } + + async getAuthenticationSession(createIfNeeded: boolean = false): Promise { + return this.ensureSession(createIfNeeded); + } + + async getSubscription(cached = false): Promise { + const promise = this.ensureSession(false); + if (!cached) { + void (await promise); + } + return this._subscription; + } + + @debug() + async learnAboutPro(source: Source, originalSource: Source | undefined): Promise { + if (originalSource != null) { + source.detail = { + ...(typeof source.detail === 'string' ? { action: source.detail } : source.detail), + ...flatten(originalSource, 'original'), + }; + } + + const subscription = await this.getSubscription(); + switch (subscription.state) { + case SubscriptionState.VerificationRequired: + case SubscriptionState.Free: + case SubscriptionState.FreeInPreviewTrial: + case SubscriptionState.FreePreviewTrialExpired: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-features', + }); + break; + case SubscriptionState.FreePlusInTrial: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-trial', + }); + break; + case SubscriptionState.FreePlusTrialExpired: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-upgrade', + }); + break; + case SubscriptionState.FreePlusTrialReactivationEligible: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-reactivate', + }); + break; + case SubscriptionState.Paid: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-paid', + }); + break; + } + } + + private async showPlanMessage(source: Source | undefined) { + if (!(await this.ensureSession(false))) return; + const { + account, + plan: { actual, effective }, + } = this._subscription; + + if (account?.verified === false) { + const days = getSubscriptionTimeRemaining(this._subscription, 'days') ?? 7; + + const verify: MessageItem = { title: 'Resend Email' }; + const learn: MessageItem = { title: 'See Pro Features' }; + const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `Welcome to your ${ + effective.name + } Trial.\n\nYou must first verify your email. Once verified, you will have full access to Pro features for ${ + days < 1 ? '<1 more day' : pluralize('day', days, { infix: ' more ' }) + }.`, + { + modal: true, + detail: 'Your trial also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.', + }, + verify, + learn, + confirm, + ); + + if (result === verify) { + void this.resendVerification(source); + } else if (result === learn) { + void this.learnAboutPro({ source: 'prompt', detail: { action: 'trial-started-verify-email' } }, source); + } + } else if (isSubscriptionPaid(this._subscription)) { + const learn: MessageItem = { title: 'See Pro Features' }; + const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `You are now on the ${actual.name} plan and have full access to Pro features.`, + { + modal: true, + detail: 'Your plan also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.', + }, + learn, + confirm, + ); + + if (result === learn) { + void this.learnAboutPro({ source: 'prompt', detail: { action: 'upgraded' } }, source); + } + } else if (isSubscriptionTrial(this._subscription)) { + const days = getSubscriptionTimeRemaining(this._subscription, 'days') ?? 0; + + const learn: MessageItem = { title: 'See Pro Features' }; + const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `Welcome to your ${effective.name} Trial.\n\nYou now have full access to Pro features for ${ + days < 1 ? '<1 more day' : pluralize('day', days, { infix: ' more ' }) + }.`, + { + modal: true, + detail: 'Your trial also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.', + }, + confirm, + learn, + ); + + if (result === learn) { + void this.learnAboutPro({ source: 'prompt', detail: { action: 'trial-started' } }, source); + } + } else { + const upgrade: MessageItem = { title: 'Upgrade to Pro' }; + const learn: MessageItem = { title: 'See Pro Features' }; + const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `You are now on the ${actual.name} plan.`, + { + modal: true, + detail: 'You only have access to Pro features on publicly-hosted repos. For full access to Pro features, please upgrade to a paid plan.\nA paid plan also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.', + }, + upgrade, + learn, + confirm, + ); + + if (result === upgrade) { + void this.upgrade(source); + } else if (result === learn) { + void this.learnAboutPro({ source: 'prompt', detail: { action: 'trial-ended' } }, source); + } + } + } + + @log() + async loginOrSignUp(signUp: boolean, source: Source | undefined): Promise { + if (!(await ensurePlusFeaturesEnabled())) return false; + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'subscription/action', + { action: signUp ? 'sign-up' : 'sign-in' }, + source, + ); + } + + // Abort any waiting authentication to ensure we can start a new flow + await this.container.accountAuthentication.abort(); + void this.showAccountView(); + + const session = await this.ensureSession(true, { signUp: signUp }); + const loggedIn = Boolean(session); + if (loggedIn) { + void this.showPlanMessage(source); + } + return loggedIn; + } + + @log() + async logout(reset: boolean = false, source: Source | undefined): Promise { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'sign-out' }, source); + } + + return this.logoutCore(reset); + } + + private async logoutCore(reset: boolean = false): Promise { + this._lastValidatedDate = undefined; + if (this._validationTimer != null) { + clearInterval(this._validationTimer); + this._validationTimer = undefined; + } + + await this.container.accountAuthentication.abort(); + + this._sessionPromise = undefined; + if (this._session != null) { + void this.container.accountAuthentication.removeSession(this._session.id); + this._session = undefined; + } else { + // Even if we don't have a session, make sure to remove any other matching sessions + void this.container.accountAuthentication.removeSessionsByScopes(authenticationProviderScopes); + } + + if (reset && this.container.debugging) { + this.changeSubscription(undefined); + + return; + } + + this.changeSubscription({ + ...this._subscription, + plan: { + actual: getSubscriptionPlan( + SubscriptionPlanId.Free, + false, + 0, + undefined, + this._subscription.plan?.actual?.startedOn != null + ? new Date(this._subscription.plan.actual.startedOn) + : undefined, + ), + effective: getSubscriptionPlan( + SubscriptionPlanId.Free, + false, + 0, + undefined, + this._subscription.plan?.effective?.startedOn != null + ? new Date(this._subscription.plan.actual.startedOn) + : undefined, + ), + }, + account: undefined, + activeOrganization: undefined, + }); + } + + @log() + async manage(source: Source | undefined): Promise { + const scope = getLogScope(); + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'manage' }, source); + } + + try { + const exchangeToken = await this.container.accountAuthentication.getExchangeToken(); + void env.openExternal(this.container.getGkDevExchangeUri(exchangeToken, 'account')); + } catch (ex) { + Logger.error(ex, scope); + void env.openExternal(this.container.getGkDevUri('account')); + } + } + + @gate(() => '') + @log() + async reactivateProTrial(source: Source | undefined): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + const scope = getLogScope(); + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'reactivate' }, source); + } + + const session = await this.ensureSession(false); + if (session == null) return; + + const rsp = await this.connection.fetchApi('user/reactivate-trial', { + method: 'POST', + body: JSON.stringify({ client: 'gitlens' }), + }); + + if (!rsp.ok) { + if (rsp.status === 409) { + void window.showErrorMessage( + 'You are not eligible to reactivate your Pro trial. If you feel that is an error, please contact support.', + 'OK', + ); + return; + } + + void window.showErrorMessage( + `Unable to reactivate trial: (${rsp.status}) ${rsp.statusText}. Please try again. If this issue persists, please contact support.`, + 'OK', + ); + return; + } + + // Trial was reactivated. Do a check-in to update, and show a message if successful. + try { + await this.checkInAndValidate(session, { force: true }); + if (isSubscriptionTrial(this._subscription)) { + const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); + + const confirm: MessageItem = { title: 'OK' }; + const learn: MessageItem = { title: "See What's New" }; + const result = await window.showInformationMessage( + `Your Pro trial has been reactivated! Experience all the new Pro features for another ${pluralize( + 'day', + remaining ?? 0, + )}.`, + { modal: true }, + confirm, + learn, + ); + + if (result === learn) { + void openUrl(urls.releaseNotes); + } + } + } catch (ex) { + Logger.error(ex, scope); + debugger; + } + } + + @gate(() => '') + @log() + async resendVerification(source: Source | undefined): Promise { + if (this._subscription.account?.verified) return true; + + const scope = getLogScope(); + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'resend-verification' }, source); + } + void this.showAccountView(true); + + const session = await this.ensureSession(false); + if (session == null) return false; + + try { + const rsp = await this.connection.fetchApi( + 'resend-email', + { + method: 'POST', + body: JSON.stringify({ id: session.account.id }), + }, + { token: session.accessToken }, + ); + + if (!rsp.ok) { + debugger; + Logger.error( + '', + scope, + `Unable to resend verification email; status=(${rsp.status}): ${rsp.statusText}`, + ); + + void window.showErrorMessage(`Unable to resend verification email; Status: ${rsp.statusText}`, 'OK'); + + return false; + } + + const confirm = { title: 'Recheck' }; + const cancel = { title: 'Cancel' }; + const result = await window.showInformationMessage( + "Once you have verified your email address, click 'Recheck'.", + confirm, + cancel, + ); + + if (result === confirm) { + await this.validate({ force: true }, source); + return true; + } + } catch (ex) { + Logger.error(ex, scope); + debugger; + + void window.showErrorMessage('Unable to resend verification email', 'OK'); + } + + return false; + } + + private setProFeaturesVisibility(visible: boolean, source: Source | undefined) { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'subscription/action', + { action: 'visibility', visible: visible }, + source, + ); + } + + void configuration.updateEffective('plusFeatures.enabled', false); + } + + @log() + async showAccountView(silent: boolean = false): Promise { + if (silent && !configuration.get('plusFeatures.enabled', undefined, true)) return; + + if (!this.container.accountView.visible) { + await executeCommand(Commands.ShowAccountView); + } + } + + private showPlans(source: Source | undefined): void { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'pricing' }, source); + } + + void openUrl(urls.pricing); + } + + @gate(() => '') + @log() + async startPreviewTrial(source: Source | undefined): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'start-preview-trial' }, source); + } + + let { plan, previewTrial } = this._subscription; + if (previewTrial != null) { + void this.showAccountView(); + + if (plan.effective.id === SubscriptionPlanId.Free) { + const signUp: MessageItem = { title: 'Start Pro Trial' }; + const signIn: MessageItem = { title: 'Sign In' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + 'Do you want to start your free 7-day Pro trial for full access to Pro features?', + { modal: true }, + signUp, + signIn, + cancel, + ); + + if (result === signUp || result === signIn) { + void this.loginOrSignUp(result === signUp, source); + } + } + + return; + } + + // Don't overwrite a trial that is already in progress + if (isSubscriptionInProTrial(this._subscription)) return; + + const startedOn = new Date(); + + let days: number; + let expiresOn = new Date(startedOn); + if (this.container.debugging) { + expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); + days = 0; + } else { + // Normalize the date to just before midnight on the same day + expiresOn.setHours(23, 59, 59, 999); + expiresOn = createFromDateDelta(expiresOn, { days: 3 }); + days = 3; + } + + previewTrial = { + startedOn: startedOn.toISOString(), + expiresOn: expiresOn.toISOString(), + }; + + this.changeSubscription({ + ...this._subscription, + plan: { + ...this._subscription.plan, + effective: getSubscriptionPlan(SubscriptionPlanId.Pro, false, 0, undefined, startedOn, expiresOn), + }, + previewTrial: previewTrial, + }); + + setTimeout(async () => { + const confirm: MessageItem = { title: 'Continue' }; + const learn: MessageItem = { title: 'See Pro Features' }; + const result = await window.showInformationMessage( + `You can now preview local Pro features for ${ + days < 1 ? '1 day' : pluralize('day', days) + }, or [start your free 7-day Pro trial](command:gitlens.plus.signUp "Start Pro Trial") for full access to Pro features.`, + confirm, + learn, + ); + + if (result === learn) { + void this.learnAboutPro({ source: 'notification', detail: { action: 'preview-started' } }, source); + } + }, 1); + } + + @log() + async upgrade(source: Source | undefined): Promise { + const scope = getLogScope(); + + if (!(await ensurePlusFeaturesEnabled())) return; + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'upgrade' }, source); + } + + if (this._subscription.account == null) { + this.showPlans(source); + } else { + const activeOrgId = this._subscription.activeOrganization?.id; + const query = `source=gitlens${activeOrgId != null ? `&org=${activeOrgId}` : ''}`; + try { + const token = await this.container.accountAuthentication.getExchangeToken( + SubscriptionUpdatedUriPathPrefix, + ); + const purchasePath = `purchase?${query}`; + void openUrl(this.container.getGkDevExchangeUri(token, purchasePath).toString(true)); + } catch (ex) { + Logger.error(ex, scope); + void env.openExternal(this.container.getGkDevUri('purchase', query)); + take( + window.onDidChangeWindowState, + 2, + )(e => { + if (e.focused && this._session != null) { + void this.checkInAndValidate(this._session, { force: true }); + } + }); + } + } + await this.showAccountView(); + } + + @gate(o => `${o?.force ?? false}`) + @log() + async validate(options?: { force?: boolean }, _source?: Source | undefined): Promise { + const scope = getLogScope(); + + const session = await this.ensureSession(false); + if (session == null) { + this.changeSubscription(this._subscription); + return; + } + + try { + await this.checkInAndValidate(session, options); + } catch (ex) { + Logger.error(ex, scope); + debugger; + } + } + + private _lastValidatedDate: Date | undefined; + + @debug({ args: { 0: s => s?.account?.label } }) + private async checkInAndValidate( + session: AuthenticationSession, + options?: { force?: boolean; showSlowProgress?: boolean; organizationId?: string }, + ): Promise { + const scope = getLogScope(); + + // Only check in if we haven't in the last 12 hours + if ( + !options?.force && + this._lastValidatedDate != null && + Date.now() - this._lastValidatedDate.getTime() < 12 * 60 * 60 * 1000 && + !isSubscriptionExpired(this._subscription) + ) { + setLogScopeExit(scope, ` (${fromNow(this._lastValidatedDate.getTime(), true)})...`, 'skipped'); + return; + } + + const validating = this.checkInAndValidateCore(session, options?.organizationId); + if (!options?.showSlowProgress) return validating; + + // Show progress if we are waiting too long + const result = await pauseOnCancelOrTimeout(validating, undefined, 3000); + if (result.paused) { + return window.withProgress( + { location: ProgressLocation.Notification, title: 'Validating your GitKraken account...' }, + () => result.value, + ); + } + + return result.value; + } + + @gate(s => s.account.id) + @debug({ args: { 0: s => s?.account?.label } }) + private async checkInAndValidateCore( + session: AuthenticationSession, + organizationId?: string, + ): Promise { + const scope = getLogScope(); + this._lastValidatedDate = undefined; + + try { + const checkInData = { + id: session.account.id, + platform: getPlatform(), + gitlensVersion: this.container.version, + machineId: env.machineId, + sessionId: env.sessionId, + vscodeEdition: env.appName, + vscodeHost: env.appHost, + vscodeVersion: codeVersion, + previewStartedOn: this._subscription.previewTrial?.startedOn, + previewExpiresOn: this._subscription.previewTrial?.expiresOn, + }; + + const rsp = await this.connection.fetchApi( + 'gitlens/checkin', + { + method: 'POST', + body: JSON.stringify(checkInData), + }, + { token: session.accessToken, organizationId: organizationId }, + ); + + if (!rsp.ok) { + this._getCheckInData = () => Promise.resolve(undefined); + throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); + } + + this._onDidCheckIn.fire(); + + const data: GKCheckInResponse = await rsp.json(); + this._getCheckInData = () => Promise.resolve(data); + this.storeCheckInData(data); + + await this.validateAndUpdateSubscriptions(data, session); + return data; + } catch (ex) { + this._getCheckInData = () => Promise.resolve(undefined); + + Logger.error(ex, scope); + debugger; + if (ex instanceof AccountValidationError) throw ex; + + throw new AccountValidationError('Unable to validate account', ex); + } finally { + this.startDailyValidationTimer(); + } + } + + private startDailyValidationTimer(): void { + if (this._validationTimer != null) { + clearInterval(this._validationTimer); + } + + // Check 4 times a day to ensure we validate at least once a day + this._validationTimer = setInterval( + () => { + if (this._lastValidatedDate == null || this._lastValidatedDate.getDate() !== new Date().getDate()) { + void this.ensureSession(false, { force: true }); + } + }, + 6 * 60 * 60 * 1000, + ); + } + + private storeCheckInData(data: GKCheckInResponse): void { + if (data.user?.id == null) return; + + void this.container.storage.store(`gk:${data.user.id}:checkin`, { + v: 1, + timestamp: Date.now(), + data: data, + }); + } + + private async loadStoredCheckInData(userId: string): Promise { + const scope = getLogScope(); + const storedCheckIn = this.container.storage.get(`gk:${userId}:checkin`); + // If more than a day old, ignore + if (storedCheckIn?.timestamp == null || Date.now() - storedCheckIn.timestamp > 24 * 60 * 60 * 1000) { + // Attempt a check-in to see if we can get a new one + const session = await this.getAuthenticationSession(false); + if (session == null) return undefined; + + try { + return await this.checkInAndValidate(session, { force: true }); + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + return storedCheckIn?.data; + } + + @debug() + private async validateAndUpdateSubscriptions(data: GKCheckInResponse, session: AuthenticationSession) { + const scope = getLogScope(); + let organizations: Organization[]; + try { + organizations = + (await this.container.organizations.getOrganizations({ + force: true, + accessToken: session.accessToken, + userId: session.account.id, + })) ?? []; + } catch (ex) { + Logger.error(ex, scope); + organizations = []; + } + let chosenOrganizationId: string | undefined = configuration.get('gitKraken.activeOrganizationId') ?? undefined; + if (chosenOrganizationId === '') { + chosenOrganizationId = undefined; + } else if (chosenOrganizationId != null && !organizations.some(o => o.id === chosenOrganizationId)) { + chosenOrganizationId = undefined; + void configuration.updateEffective('gitKraken.activeOrganizationId', undefined); + } + const subscription = getSubscriptionFromCheckIn(data, organizations, chosenOrganizationId); + this._lastValidatedDate = new Date(); + this.changeSubscription( + { + ...this._subscription, + ...subscription, + }, + { store: true }, + ); + } + + private _sessionPromise: Promise | undefined; + private _session: AuthenticationSession | null | undefined; + + @gate() + @debug() + private async ensureSession( + createIfNeeded: boolean, + options?: { force?: boolean; signUp?: boolean }, + ): Promise { + if (this._sessionPromise != null) { + void (await this._sessionPromise); + } + + if (!options?.force && this._session != null) return this._session; + if (this._session === null && !createIfNeeded) return undefined; + + if (this._sessionPromise === undefined) { + this._sessionPromise = this.getOrCreateSession(createIfNeeded, options?.signUp).then( + s => { + this._session = s; + this._sessionPromise = undefined; + return this._session; + }, + () => { + this._session = null; + this._sessionPromise = undefined; + return this._session; + }, + ); + } + + const session = await this._sessionPromise; + return session ?? undefined; + } + + @debug() + private async getOrCreateSession( + createIfNeeded: boolean, + signUp: boolean = false, + ): Promise { + const scope = getLogScope(); + + let session: AuthenticationSession | null | undefined; + + try { + session = await authentication.getSession( + authenticationProviderId, + signUp ? [...authenticationProviderScopes, 'signUp'] : authenticationProviderScopes, + { + createIfNone: createIfNeeded, + silent: !createIfNeeded, + }, + ); + } catch (ex) { + session = null; + + if (ex instanceof Error && ex.message.includes('User did not consent')) { + setLogScopeExit(scope, ' \u2022 User declined authentication'); + await this.logoutCore(); + return null; + } + + Logger.error(ex, scope); + } + + if (session == null) { + setLogScopeExit(scope, ' \u2022 No valid session was found'); + await this.logoutCore(); + return session ?? null; + } + + try { + await this.checkInAndValidate(session, { showSlowProgress: createIfNeeded, force: createIfNeeded }); + } catch (ex) { + Logger.error(ex, scope); + debugger; + + this.container.telemetry.sendEvent('account/validation/failed', { + 'account.id': session.account.id, + exception: String(ex), + code: ex.original?.code, + statusCode: ex.statusCode, + }); + + setLogScopeExit( + scope, + ` \u2022 Account validation failed (${ex.statusCode ?? ex.original?.code})`, + 'FAILED', + ); + + if (ex instanceof AccountValidationError) { + const name = session.account.label; + + // if ( + // (ex.statusCode != null && ex.statusCode < 500) || + // (ex.statusCode == null && (ex.original as any)?.code !== 'ENOTFOUND') + // ) { + if ( + (ex.original as any)?.code !== 'ENOTFOUND' && + ex.statusCode != null && + ex.statusCode < 500 && + ex.statusCode >= 400 + ) { + session = null; + await this.logoutCore(); + + if (createIfNeeded) { + const unauthorized = ex.statusCode === 401; + queueMicrotask(async () => { + const confirm: MessageItem = { title: 'Retry Sign In' }; + const result = await window.showErrorMessage( + `Unable to sign in to your (${name}) GitKraken account. Please try again. If this issue persists, please contact support.${ + unauthorized ? '' : ` Error=${ex.message}` + }`, + confirm, + ); + + if (result === confirm) { + void this.loginOrSignUp(false, { + source: 'subscription', + detail: { + error: 'validation-failed', + 'error.message': ex.message, + }, + }); + } + }); + } + } else { + session = session ?? null; + + // if ((ex.original as any)?.code !== 'ENOTFOUND') { + // void window.showErrorMessage( + // `Unable to sign in to your (${name}) GitKraken account right now. Please try again in a few minutes. If this issue persists, please contact support. Error=${ex.message}`, + // 'OK', + // ); + // } + } + } + } + + return session; + } + + @debug() + private changeSubscription( + subscription: Optional | undefined, + options?: { silent?: boolean; store?: boolean }, + ): void { + if (subscription == null) { + subscription = { + plan: { + actual: getSubscriptionPlan(SubscriptionPlanId.Free, false, 0, undefined), + effective: getSubscriptionPlan(SubscriptionPlanId.Free, false, 0, undefined), + }, + account: undefined, + state: SubscriptionState.Free, + }; + } + + // If the effective plan has expired, then replace it with the actual plan + if (isSubscriptionExpired(subscription)) { + subscription = { + ...subscription, + plan: { + ...subscription.plan, + effective: subscription.plan.actual, + }, + }; + } + + // If we don't have a paid plan (or a non-preview trial), check if the preview trial has expired, if not apply it + if ( + !isSubscriptionPaid(subscription) && + subscription.previewTrial != null && + (getTimeRemaining(subscription.previewTrial.expiresOn) ?? 0) > 0 + ) { + subscription = { + ...subscription, + plan: { + ...subscription.plan, + effective: getSubscriptionPlan( + SubscriptionPlanId.Pro, + false, + 0, + undefined, + new Date(subscription.previewTrial.startedOn), + new Date(subscription.previewTrial.expiresOn), + ), + }, + }; + } + + subscription.state = computeSubscriptionState(subscription); + assertSubscriptionState(subscription); + + const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor + // Check the previous and new subscriptions are exactly the same + const matches = previous != null && JSON.stringify(previous) === JSON.stringify(subscription); + + // If the previous and new subscriptions are exactly the same, kick out + if (matches) { + if (options?.store) { + void this.storeSubscription(subscription); + } + return; + } + + queueMicrotask(() => { + let data = flattenSubscription(subscription); + this.container.telemetry.setGlobalAttributes(data); + + data = { + ...data, + ...(!matches ? flattenSubscription(previous, 'previous') : {}), + }; + + this.container.telemetry.sendEvent(previous == null ? 'subscription' : 'subscription/changed', data); + }); + + void this.storeSubscription(subscription); + + this._subscription = subscription; + this._etag = Date.now(); + + if (!options?.silent) { + this.updateContext(); + + if (previous != null) { + this._onDidChange.fire({ current: subscription, previous: previous, etag: this._etag }); + } + } + } + + private getStoredSubscription(): Subscription | undefined { + const storedSubscription = this.container.storage.get('premium:subscription'); + + let lastValidatedAt: number | undefined; + let subscription: Subscription | undefined; + if (storedSubscription?.data != null) { + ({ lastValidatedAt, ...subscription } = storedSubscription.data); + this._lastValidatedDate = lastValidatedAt != null ? new Date(lastValidatedAt) : undefined; + } else { + subscription = undefined; + } + + if (subscription != null) { + // Migrate the plan names to the latest names + (subscription.plan.actual as Mutable).name = getSubscriptionPlanName( + subscription.plan.actual.id, + ); + (subscription.plan.effective as Mutable).name = getSubscriptionPlanName( + subscription.plan.effective.id, + ); + } + + return subscription; + } + + private async storeSubscription(subscription: Subscription): Promise { + return this.container.storage.store('premium:subscription', { + v: 1, + data: { ...subscription, lastValidatedAt: this._lastValidatedDate?.getTime() }, + }); + } + + private _cancellationSource: CancellationTokenSource | undefined; + private _updateAccessContextDebounced: Deferrable | undefined; + + private updateContext(): void { + this._updateAccessContextDebounced?.cancel(); + if (this._updateAccessContextDebounced == null) { + this._updateAccessContextDebounced = debounce(this.updateAccessContext.bind(this), 500); + } + + if (this._cancellationSource != null) { + this._cancellationSource.cancel(); + } + this._cancellationSource = new CancellationTokenSource(); + + void this._updateAccessContextDebounced(this._cancellationSource.token); + this.updateStatusBar(); + + const { + plan: { actual }, + state, + } = this._subscription; + + void setContext('gitlens:plus', actual.id != SubscriptionPlanId.Free ? actual.id : undefined); + void setContext('gitlens:plus:state', state); + } + + private async updateAccessContext(cancellation: CancellationToken): Promise { + let allowed: boolean | 'mixed' = false; + // For performance reasons, only check if we have any repositories + if (this.container.git.repositoryCount !== 0) { + ({ allowed } = await this.container.git.access()); + if (cancellation.isCancellationRequested) return; + } + + const plusFeatures = configuration.get('plusFeatures.enabled') ?? true; + + let disallowedRepos: string[] | undefined; + + if (!plusFeatures && allowed === 'mixed') { + disallowedRepos = []; + for (const repo of this.container.git.repositories) { + if (repo.closed) continue; + + const access = await this.container.git.access(undefined, repo.uri); + if (cancellation.isCancellationRequested) return; + + if (!access.allowed) { + disallowedRepos.push(repo.uri.toString()); + } + } + } + + void setContext('gitlens:plus:enabled', Boolean(allowed) || plusFeatures); + void setContext('gitlens:plus:required', allowed === false); + void setContext('gitlens:plus:disallowedRepos', disallowedRepos); + } + + private updateStatusBar(): void { + const { + account, + plan: { effective }, + state, + } = this._subscription; + + if (effective.id === SubscriptionPlanId.Free) { + this._statusBarSubscription?.dispose(); + this._statusBarSubscription = undefined; + return; + } + + const trial = isSubscriptionTrial(this._subscription); + if (!trial && account?.verified !== false) { + this._statusBarSubscription?.dispose(); + this._statusBarSubscription = undefined; + return; + } + + if (this._statusBarSubscription == null) { + this._statusBarSubscription = window.createStatusBarItem( + 'gitlens.plus.subscription', + StatusBarAlignment.Left, + 1, + ); + } + + this._statusBarSubscription.name = 'GitKraken Subscription'; + this._statusBarSubscription.command = Commands.ShowAccountView; + + if (account?.verified === false) { + this._statusBarSubscription.text = `$(warning) ${effective.name} (Unverified)`; + this._statusBarSubscription.backgroundColor = new ThemeColor( + 'statusBarItem.warningBackground' satisfies CoreColors, + ); + this._statusBarSubscription.tooltip = new MarkdownString( + trial + ? `**Please verify your email**\n\nYou must verify your email before you can start your **${effective.name}** trial.\n\nClick for details` + : `**Please verify your email**\n\nYou must verify your email before you can use Pro features on privately-hosted repos.\n\nClick for details`, + true, + ); + } else { + const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); + const isReactivatedTrial = + state === SubscriptionState.FreePlusInTrial && effective.trialReactivationCount > 0; + + this._statusBarSubscription.text = `${effective.name} (Trial)`; + this._statusBarSubscription.tooltip = new MarkdownString( + `${ + isReactivatedTrial + ? `[See what's new](${urls.releaseNotes}) with ${pluralize('day', remaining ?? 0, { + infix: ' more ', + })} in your **${effective.name}** trial.` + : `You have ${pluralize('day', remaining ?? 0)} remaining in your **${effective.name}** trial.` + } Once your trial ends, you'll need a paid plan for full access to [Pro features](command:gitlens.openWalkthrough?%7B%22step%22%3A%22pro-trial%22,%22source%22%3A%22prompt%22%7D).\n\nYour trial also includes access to our [DevEx platform](${ + urls.platform + }), unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.`, + true, + ); + } + + this._statusBarSubscription.show(); + } + + async switchOrganization(): Promise { + const scope = getLogScope(); + if (this._session == null) return; + + let organizations; + try { + organizations = await this.container.organizations.getOrganizations(); + } catch (ex) { + debugger; + Logger.error(ex, scope); + return; + } + + if (organizations == null || organizations.length === 0) return; + + // Show a quickpick to select the active organization + const picks: { label: string; org: Organization | null }[] = organizations.map(org => ({ + label: org.name, + org: org, + })); + + const pick = await window.showQuickPick(picks, { + title: 'Switch Organization', + placeHolder: 'Select the active organization for your GitKraken account', + }); + + const currentActiveOrganization = this._subscription?.activeOrganization; + if (pick?.org == null) { + return; + } + + if (currentActiveOrganization != null && pick.org.id === currentActiveOrganization.id) { + return; + } + + await this.checkInAndValidate(this._session, { force: true, organizationId: pick.org.id }); + const checkInData = await this._getCheckInData(); + if (checkInData == null) return; + + const organizationSubscription = getSubscriptionFromCheckIn(checkInData, organizations, pick.org.id); + + if (configuration.get('gitKraken.activeOrganizationId') !== pick.org.id) { + await configuration.updateEffective('gitKraken.activeOrganizationId', pick.org.id); + } + + this.changeSubscription( + { + ...this._subscription, + ...organizationSubscription, + }, + { store: true }, + ); + } + + async onSubscriptionUpdatedUri() { + if (this._session == null) return; + const oldSubscriptionState = this._subscription.state; + await this.checkInAndValidate(this._session, { force: true }); + if (oldSubscriptionState !== this._subscription.state) { + void this.showPlanMessage({ source: 'subscription' }); + } + } +} + +type FlattenedSubscription = { + 'subscription.state'?: SubscriptionState; + 'subscription.status'?: + | 'verification' + | 'free' + | 'preview' + | 'preview-expired' + | 'trial' + | 'trial-expired' + | 'trial-reactivation-eligible' + | 'paid' + | 'unknown'; +} & Partial< + Record<`account.${string}`, string | number | boolean | undefined> & + Record<`subscription.${string}`, string | number | boolean | undefined> & + Record<`subscription.previewTrial.${string}`, string | number | boolean | undefined> & + Record<`previous.account.${string}`, string | number | boolean | undefined> & + Record<`previous.subscription.${string}`, string | number | boolean | undefined> & + Record<`previous.subscription.previewTrial.${string}`, string | number | boolean | undefined> +>; + +function flattenSubscription( + subscription: Optional | undefined, + prefix?: string, +): FlattenedSubscription { + if (subscription == null) return {}; + + return { + ...flatten(subscription.account, `${prefix ? `${prefix}.` : ''}account`, { + joinArrays: true, + skipPaths: ['name', 'email'], + }), + ...flatten(subscription.plan, `${prefix ? `${prefix}.` : ''}subscription`, { + skipPaths: ['actual.name', 'effective.name'], + }), + ...flatten(subscription.previewTrial, `${prefix ? `${prefix}.` : ''}subscription.previewTrial`, { + skipPaths: ['actual.name', 'effective.name'], + }), + 'subscription.state': subscription.state, + 'subscription.stateString': getSubscriptionStateString(subscription.state), + }; +} diff --git a/src/plus/gk/checkin.ts b/src/plus/gk/checkin.ts new file mode 100644 index 0000000000000..ca749be3ef756 --- /dev/null +++ b/src/plus/gk/checkin.ts @@ -0,0 +1,251 @@ +import type { Organization } from './account/organization'; +import type { Subscription } from './account/subscription'; +import { getSubscriptionPlan, getSubscriptionPlanPriority, SubscriptionPlanId } from './account/subscription'; + +export interface GKCheckInResponse { + readonly user: GKUser; + readonly licenses: { + readonly paidLicenses: Record; + readonly effectiveLicenses: Record; + }; + readonly nextOptInDate?: string; +} + +export interface GKUser { + readonly id: string; + readonly name: string; + readonly email: string; + readonly status: 'activated' | 'pending'; + readonly createdDate: string; + readonly firstGitLensCheckIn?: string; +} + +export interface GKLicense { + readonly latestStatus: 'active' | 'canceled' | 'cancelled' | 'expired' | 'in_trial' | 'non_renewing' | 'trial'; + readonly latestStartDate: string; + readonly latestEndDate: string; + readonly organizationId: string | undefined; + readonly reactivationCount?: number; + readonly nextOptInDate?: string; +} + +export type GKLicenseType = + | 'gitlens-pro' + | 'gitlens-teams' + | 'gitlens-hosted-enterprise' + | 'gitlens-self-hosted-enterprise' + | 'gitlens-standalone-enterprise' + | 'bundle-pro' + | 'bundle-teams' + | 'bundle-hosted-enterprise' + | 'bundle-self-hosted-enterprise' + | 'bundle-standalone-enterprise' + | 'gitkraken_v1-pro' + | 'gitkraken_v1-teams' + | 'gitkraken_v1-hosted-enterprise' + | 'gitkraken_v1-self-hosted-enterprise' + | 'gitkraken_v1-standalone-enterprise'; + +export function getSubscriptionFromCheckIn( + data: GKCheckInResponse, + organizations: Organization[], + organizationId?: string, +): Omit { + const account: Subscription['account'] = { + id: data.user.id, + name: data.user.name, + email: data.user.email, + verified: data.user.status === 'activated', + createdOn: data.user.createdDate, + }; + + let effectiveLicenses = Object.entries(data.licenses.effectiveLicenses) as [GKLicenseType, GKLicense][]; + let paidLicenses = Object.entries(data.licenses.paidLicenses) as [GKLicenseType, GKLicense][]; + paidLicenses = paidLicenses.filter( + license => license[1].latestStatus !== 'expired' && license[1].latestStatus !== 'cancelled', + ); + if (paidLicenses.length > 1) { + paidLicenses.sort( + (a, b) => + getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + + licenseStatusPriority(b[1].latestStatus) - + (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + + licenseStatusPriority(a[1].latestStatus)), + ); + } + if (effectiveLicenses.length > 1) { + effectiveLicenses.sort( + (a, b) => + getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + + licenseStatusPriority(b[1].latestStatus) - + (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + + licenseStatusPriority(a[1].latestStatus)), + ); + } + + const effectiveLicensesByOrganizationId = new Map(); + const paidLicensesByOrganizationId = new Map(); + for (const licenseData of effectiveLicenses) { + const [, license] = licenseData; + if (license.organizationId == null) continue; + const existingLicense = effectiveLicensesByOrganizationId.get(license.organizationId); + if (existingLicense == null) { + effectiveLicensesByOrganizationId.set(license.organizationId, licenseData); + } + } + + for (const licenseData of paidLicenses) { + const [, license] = licenseData; + if (license.organizationId == null) continue; + const existingLicense = paidLicensesByOrganizationId.get(license.organizationId); + if (existingLicense == null) { + paidLicensesByOrganizationId.set(license.organizationId, licenseData); + } + } + + const organizationsWithNoLicense = organizations.filter( + organization => + !paidLicensesByOrganizationId.has(organization.id) && + !effectiveLicensesByOrganizationId.has(organization.id), + ); + + if (organizationId != null) { + paidLicenses = paidLicenses.filter( + ([, license]) => license.organizationId === organizationId || license.organizationId == null, + ); + effectiveLicenses = effectiveLicenses.filter( + ([, license]) => license.organizationId === organizationId || license.organizationId == null, + ); + } + + let actual: Subscription['plan']['actual'] | undefined; + const bestPaidLicense = paidLicenses.length > 0 ? paidLicenses[0] : undefined; + const bestEffectiveLicense = effectiveLicenses.length > 0 ? effectiveLicenses[0] : undefined; + const chosenPaidLicense = + organizationId != null ? paidLicensesByOrganizationId.get(organizationId) ?? bestPaidLicense : bestPaidLicense; + if (chosenPaidLicense != null) { + const [licenseType, license] = chosenPaidLicense; + actual = getSubscriptionPlan( + convertLicenseTypeToPlanId(licenseType), + isBundleLicenseType(licenseType), + license.reactivationCount ?? 0, + license.organizationId, + new Date(license.latestStartDate), + new Date(license.latestEndDate), + ); + } + + if (actual == null) { + actual = getSubscriptionPlan( + SubscriptionPlanId.FreePlus, + false, + 0, + undefined, + data.user.firstGitLensCheckIn != null + ? new Date(data.user.firstGitLensCheckIn) + : data.user.createdDate != null + ? new Date(data.user.createdDate) + : undefined, + undefined, + undefined, + data.nextOptInDate, + ); + } + + let effective: Subscription['plan']['effective'] | undefined; + const chosenEffectiveLicense = + organizationId != null + ? effectiveLicensesByOrganizationId.get(organizationId) ?? bestEffectiveLicense + : bestEffectiveLicense; + if (chosenEffectiveLicense != null) { + const [licenseType, license] = chosenEffectiveLicense; + effective = getSubscriptionPlan( + convertLicenseTypeToPlanId(licenseType), + isBundleLicenseType(licenseType), + license.reactivationCount ?? 0, + license.organizationId, + new Date(license.latestStartDate), + new Date(license.latestEndDate), + license.latestStatus === 'cancelled', + license.nextOptInDate ?? data.nextOptInDate, + ); + } + + if (effective == null || getSubscriptionPlanPriority(actual.id) >= getSubscriptionPlanPriority(effective.id)) { + effective = { ...actual }; + } + + let activeOrganization: Organization | undefined; + if (organizationId != null) { + activeOrganization = organizations.find(organization => organization.id === organizationId); + } else if (effective?.organizationId != null) { + activeOrganization = organizations.find(organization => organization.id === effective.organizationId); + } else if (organizationsWithNoLicense.length > 0) { + activeOrganization = organizationsWithNoLicense[0]; + } + + return { + plan: { + actual: actual, + effective: effective, + }, + account: account, + activeOrganization: activeOrganization, + }; +} + +function convertLicenseTypeToPlanId(licenseType: GKLicenseType): SubscriptionPlanId { + switch (licenseType) { + case 'gitlens-pro': + case 'bundle-pro': + case 'gitkraken_v1-pro': + return SubscriptionPlanId.Pro; + case 'gitlens-teams': + case 'bundle-teams': + case 'gitkraken_v1-teams': + return SubscriptionPlanId.Teams; + case 'gitlens-hosted-enterprise': + case 'gitlens-self-hosted-enterprise': + case 'gitlens-standalone-enterprise': + case 'bundle-hosted-enterprise': + case 'bundle-self-hosted-enterprise': + case 'bundle-standalone-enterprise': + case 'gitkraken_v1-hosted-enterprise': + case 'gitkraken_v1-self-hosted-enterprise': + case 'gitkraken_v1-standalone-enterprise': + return SubscriptionPlanId.Enterprise; + default: + return SubscriptionPlanId.FreePlus; + } +} + +function isBundleLicenseType(licenseType: GKLicenseType): boolean { + switch (licenseType) { + case 'bundle-pro': + case 'bundle-teams': + case 'bundle-hosted-enterprise': + case 'bundle-self-hosted-enterprise': + case 'bundle-standalone-enterprise': + return true; + default: + return false; + } +} + +function licenseStatusPriority(status: GKLicense['latestStatus']): number { + switch (status) { + case 'active': + return 100; + case 'expired': + case 'cancelled': + return -100; + case 'in_trial': + case 'trial': + return 1; + case 'canceled': + case 'non_renewing': + return 0; + default: + return -200; + } +} diff --git a/src/plus/gk/serverConnection.ts b/src/plus/gk/serverConnection.ts index cc0a692f95745..854e646e8135d 100644 --- a/src/plus/gk/serverConnection.ts +++ b/src/plus/gk/serverConnection.ts @@ -1,38 +1,31 @@ -import type { Disposable } from 'vscode'; -import { Uri } from 'vscode'; -import type { RequestInfo, RequestInit, Response } from '@env/fetch'; +import type { CancellationToken, Disposable } from 'vscode'; +import { version as codeVersion, env, Uri } from 'vscode'; +import type { HeadersInit, RequestInfo, RequestInit, Response } from '@env/fetch'; import { fetch as _fetch, getProxyAgent } from '@env/fetch'; +import { getPlatform } from '@env/platform'; import type { Container } from '../../container'; +import { AuthenticationRequiredError, CancellationError } from '../../errors'; import { memoize } from '../../system/decorators/memoize'; import { Logger } from '../../system/logger'; import { getLogScope } from '../../system/logger.scope'; +interface FetchOptions { + cancellation?: CancellationToken; + timeout?: number; +} + +interface GKFetchOptions extends FetchOptions { + token?: string; + unAuthenticated?: boolean; + query?: string; + organizationId?: string | false; +} + export class ServerConnection implements Disposable { constructor(private readonly container: Container) {} dispose() {} - @memoize() - private get accountsUri(): Uri { - if (this.container.env === 'staging') { - return Uri.parse('https://stagingapp.gitkraken.com'); - } - - if (this.container.env === 'dev') { - return Uri.parse('https://devapp.gitkraken.com'); - } - - return Uri.parse('https://app.gitkraken.com'); - } - - getAccountsUri(path?: string, query?: string) { - let uri = path != null ? Uri.joinPath(this.accountsUri, path) : this.accountsUri; - if (query != null) { - uri = uri.with({ query: query }); - } - return uri; - } - @memoize() private get baseApiUri(): Uri { if (this.container.env === 'staging') { @@ -68,79 +61,135 @@ export class ServerConnection implements Disposable { } @memoize() - get siteUri(): Uri { - const { env } = this.container; - if (env === 'staging') { - return Uri.parse('https://staging.gitkraken.com'); - } - - if (env === 'dev') { - return Uri.parse('https://dev.gitkraken.com'); - } - - return Uri.parse('https://gitkraken.com'); - } - - getSiteUri(path?: string, query?: string) { - let uri = path != null ? Uri.joinPath(this.siteUri, path) : this.siteUri; - if (query != null) { - uri = uri.with({ query: query }); - } - return uri; + get userAgent(): string { + // TODO@eamodio figure out standardized format/structure for our user agents + return `${this.container.debugging ? 'GitLens-Debug' : this.container.prerelease ? 'GitLens-Pre' : 'GitLens'}/${ + this.container.version + } (${env.appName}/${codeVersion}; ${getPlatform()})`; } @memoize() - get userAgent(): string { - // TODO@eamodio figure out standardized format/structure for our user agents - return 'Visual-Studio-Code-GitLens'; + get clientName(): string { + return this.container.debugging + ? 'gitlens-vsc-debug' + : this.container.prerelease + ? 'gitlens-vsc-pre' + : 'gitlens-vsc'; } - async fetch(url: RequestInfo, init?: RequestInit, token?: string): Promise { + async fetch(url: RequestInfo, init?: RequestInit, options?: FetchOptions): Promise { const scope = getLogScope(); + if (options?.cancellation?.isCancellationRequested) throw new CancellationError(); + + const aborter = new AbortController(); + + let timeout; + if (options?.cancellation != null) { + timeout = options.timeout; // Don't set a default timeout if we have a cancellation token + options.cancellation.onCancellationRequested(() => aborter.abort()); + } else { + timeout = options?.timeout ?? 60 * 1000; + } + + const timer = timeout != null ? setTimeout(() => aborter.abort(), timeout) : undefined; + try { - token ??= await this.getAccessToken(); - const options = { + const promise = _fetch(url, { agent: getProxyAgent(), ...init, headers: { - Authorization: `Bearer ${token}`, 'User-Agent': this.userAgent, - 'Content-Type': 'application/json', ...init?.headers, }, - }; - - // TODO@eamodio handle common response errors - - return await _fetch(url, options); + signal: aborter?.signal, + }); + void promise.finally(() => clearTimeout(timer)); + return await promise; } catch (ex) { Logger.error(ex, scope); + if (ex.name === 'AbortError') throw new CancellationError(ex); + throw ex; } } - async fetchApi(path: string, init?: RequestInit, token?: string): Promise { - return this.fetch(this.getApiUrl(path), init, token); + async fetchApi(path: string, init?: RequestInit, options?: GKFetchOptions): Promise { + return this.gkFetch(this.getApiUrl(path), init, options); + } + + async fetchApiGraphQL(path: string, request: GraphQLRequest, init?: RequestInit, options?: GKFetchOptions) { + return this.fetchApi( + path, + { + method: 'POST', + ...init, + body: JSON.stringify(request), + }, + options, + ); } - async fetchApiGraphQL(path: string, request: GraphQLRequest, init?: RequestInit) { - return this.fetchApi(path, { - method: 'POST', - ...init, - body: JSON.stringify(request), - }); + async fetchGkDevApi(path: string, init?: RequestInit, options?: GKFetchOptions): Promise { + return this.gkFetch(this.getGkDevApiUrl(path), init, options); } - async fetchGkDevApi(path: string, init?: RequestInit, token?: string): Promise { - return this.fetch(this.getGkDevApiUrl(path), init, token); + private async gkFetch(url: RequestInfo, init?: RequestInit, options?: GKFetchOptions): Promise { + const scope = getLogScope(); + + try { + let token; + ({ token, ...options } = options ?? {}); + if (!options?.unAuthenticated) { + token ??= await this.getAccessToken(); + } + + const headers: Record = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Client-Name': this.clientName, + 'Client-Version': this.container.version, + ...init?.headers, + }; + + // only check for cached subscription or we'll get into an infinite loop + let organizationId = options?.organizationId; + if (organizationId === undefined) { + organizationId = (await this.container.subscription.getSubscription(true)).activeOrganization?.id; + } + + if (organizationId) { + headers['gk-org-id'] = organizationId; + } + + if (options?.query != null) { + if (url instanceof URL) { + url.search = options.query; + } else if (typeof url === 'string') { + url = `${url}?${options.query}`; + } + } + // TODO@eamodio handle common response errors + + return this.fetch( + url, + { + ...init, + headers: headers as HeadersInit, + }, + options, + ); + } catch (ex) { + Logger.error(ex, scope); + throw ex; + } } private async getAccessToken() { const session = await this.container.subscription.getAuthenticationSession(); if (session != null) return session.accessToken; - throw new Error('Authentication required'); + throw new AuthenticationRequiredError(); } } diff --git a/src/plus/subscription/utils.ts b/src/plus/gk/utils.ts similarity index 100% rename from src/plus/subscription/utils.ts rename to src/plus/gk/utils.ts diff --git a/src/plus/integrationAuthentication.ts b/src/plus/integrationAuthentication.ts deleted file mode 100644 index 880031aeb5098..0000000000000 --- a/src/plus/integrationAuthentication.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { AuthenticationSession, Disposable } from 'vscode'; -import type { Container } from '../container'; -import { debug } from '../system/decorators/log'; - -interface StoredSession { - id: string; - accessToken: string; - account?: { - label?: string; - displayName?: string; - id: string; - }; - scopes: string[]; -} - -export interface IntegrationAuthenticationSessionDescriptor { - domain: string; - scopes: string[]; - [key: string]: unknown; -} - -export interface IntegrationAuthenticationProvider { - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string; - createSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise; -} - -export class IntegrationAuthenticationService implements Disposable { - private readonly providers = new Map(); - - constructor(private readonly container: Container) {} - - dispose() { - this.providers.clear(); - } - - registerProvider(providerId: string, provider: IntegrationAuthenticationProvider): Disposable { - if (this.providers.has(providerId)) throw new Error(`Provider with id ${providerId} already registered`); - - this.providers.set(providerId, provider); - return { - dispose: () => this.providers.delete(providerId), - }; - } - - hasProvider(providerId: string): boolean { - return this.providers.has(providerId); - } - - @debug() - async createSession( - providerId: string, - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const session = await provider?.createSession(descriptor); - if (session == null) return undefined; - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - await this.container.storage.storeSecret(key, JSON.stringify(session)); - - return session; - } - - @debug() - async getSession( - providerId: string, - descriptor?: IntegrationAuthenticationSessionDescriptor, - options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, - ): Promise { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - - if (options?.forceNewSession) { - await this.container.storage.deleteSecret(key); - } - - let storedSession: StoredSession | undefined; - try { - const sessionJSON = await this.container.storage.getSecret(key); - if (sessionJSON) { - storedSession = JSON.parse(sessionJSON); - } - } catch (ex) { - try { - await this.container.storage.deleteSecret(key); - } catch {} - - if (!options?.createIfNeeded) { - throw ex; - } - } - - if (options?.createIfNeeded && storedSession == null) { - return this.createSession(providerId, descriptor); - } - - return storedSession as AuthenticationSession | undefined; - } - - @debug() - async deleteSession(providerId: string, descriptor?: IntegrationAuthenticationSessionDescriptor) { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - await this.container.storage.deleteSecret(key); - } - - private getSecretKey(providerId: string, id: string): `gitlens.integration.auth:${string}` { - return `gitlens.integration.auth:${providerId}|${id}`; - } -} diff --git a/src/plus/integrations/authentication/azureDevOps.ts b/src/plus/integrations/authentication/azureDevOps.ts new file mode 100644 index 0000000000000..2873ae36cda76 --- /dev/null +++ b/src/plus/integrations/authentication/azureDevOps.ts @@ -0,0 +1,119 @@ +import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { base64 } from '../../../system/string'; +import { HostingIntegrationId } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import { LocalIntegrationAuthenticationProvider } from './integrationAuthentication'; + +export class AzureDevOpsAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + protected override get authProviderId(): HostingIntegrationId.AzureDevOps { + return HostingIntegrationId.AzureDevOps; + } + + override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + let azureOrganization: string | undefined = descriptor?.organization as string | undefined; + if (!azureOrganization) { + const orgInput = window.createInputBox(); + orgInput.ignoreFocusOut = true; + const orgInputDisposables: Disposable[] = []; + try { + azureOrganization = await new Promise(resolve => { + orgInputDisposables.push( + orgInput.onDidHide(() => resolve(undefined)), + orgInput.onDidChangeValue(() => (orgInput.validationMessage = undefined)), + orgInput.onDidAccept(() => { + const value = orgInput.value.trim(); + if (!value) { + orgInput.validationMessage = 'An organization is required'; + return; + } + + resolve(value); + }), + ); + + orgInput.title = `Azure DevOps Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + orgInput.placeholder = 'Organization'; + orgInput.prompt = 'Enter your Azure DevOps organization'; + orgInput.show(); + }); + } finally { + orgInput.dispose(); + orgInputDisposables.forEach(d => void d.dispose()); + } + } + + if (!azureOrganization) return undefined; + + const tokenInput = window.createInputBox(); + tokenInput.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Azure DevOps Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + tokenInput.onDidHide(() => resolve(undefined)), + tokenInput.onDidChangeValue(() => (tokenInput.validationMessage = undefined)), + tokenInput.onDidAccept(() => { + const value = tokenInput.value.trim(); + if (!value) { + tokenInput.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + tokenInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${ + descriptor?.domain ?? 'dev.azure.com' + }/${azureOrganization}/_usersSettings/tokens`, + ), + ); + } + }), + ); + + tokenInput.password = true; + tokenInput.title = `Azure DevOps Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + tokenInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + tokenInput.prompt = `Paste your [Azure DevOps Personal Access Token](https://${ + descriptor?.domain ?? 'dev.azure.com' + }/${azureOrganization}/_usersSettings/tokens "Get your Azure DevOps Access Token")`; + tokenInput.buttons = [infoButton]; + + tokenInput.show(); + }); + } finally { + tokenInput.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: base64(`:${token}`), + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + }; + } +} diff --git a/src/plus/integrations/authentication/bitbucket.ts b/src/plus/integrations/authentication/bitbucket.ts new file mode 100644 index 0000000000000..a0f54854aa3e0 --- /dev/null +++ b/src/plus/integrations/authentication/bitbucket.ts @@ -0,0 +1,131 @@ +import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { base64 } from '../../../system/string'; +import { HostingIntegrationId } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import { LocalIntegrationAuthenticationProvider } from './integrationAuthentication'; + +export class BitbucketAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + protected override get authProviderId(): HostingIntegrationId.Bitbucket { + return HostingIntegrationId.Bitbucket; + } + + override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + let bitbucketUsername: string | undefined = descriptor?.username as string | undefined; + if (!bitbucketUsername) { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Bitbucket Settings Page', + }; + + const usernameInput = window.createInputBox(); + usernameInput.ignoreFocusOut = true; + const usernameInputDisposables: Disposable[] = []; + try { + bitbucketUsername = await new Promise(resolve => { + usernameInputDisposables.push( + usernameInput.onDidHide(() => resolve(undefined)), + usernameInput.onDidChangeValue(() => (usernameInput.validationMessage = undefined)), + usernameInput.onDidAccept(() => { + const value = usernameInput.value.trim(); + if (!value) { + usernameInput.validationMessage = 'A Bitbucket username is required'; + return; + } + + resolve(value); + }), + usernameInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse(`https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/`), + ); + } + }), + ); + + usernameInput.title = `Bitbucket Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + usernameInput.placeholder = 'Username'; + usernameInput.prompt = `Enter your [Bitbucket Username](https://${ + descriptor?.domain ?? 'bitbucket.org' + }/account/settings/ "Get your Bitbucket App Password")`; + usernameInput.show(); + }); + } finally { + usernameInput.dispose(); + usernameInputDisposables.forEach(d => void d.dispose()); + } + } + + if (!bitbucketUsername) return undefined; + + const appPasswordInput = window.createInputBox(); + appPasswordInput.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let appPassword; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Bitbucket App Passwords Page', + }; + + appPassword = await new Promise(resolve => { + disposables.push( + appPasswordInput.onDidHide(() => resolve(undefined)), + appPasswordInput.onDidChangeValue(() => (appPasswordInput.validationMessage = undefined)), + appPasswordInput.onDidAccept(() => { + const value = appPasswordInput.value.trim(); + if (!value) { + appPasswordInput.validationMessage = 'An app password is required'; + return; + } + + resolve(value); + }), + appPasswordInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/app-passwords/`, + ), + ); + } + }), + ); + + appPasswordInput.password = true; + appPasswordInput.title = `Bitbucket Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + appPasswordInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + appPasswordInput.prompt = `Paste your [Bitbucket App Password](https://${ + descriptor?.domain ?? 'bitbucket.org' + }/account/settings/app-passwords/ "Get your Bitbucket App Password")`; + appPasswordInput.buttons = [infoButton]; + + appPasswordInput.show(); + }); + } finally { + appPasswordInput.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!appPassword) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: base64(`${bitbucketUsername}:${appPassword}`), + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + }; + } +} diff --git a/src/plus/integrations/authentication/cloudIntegrationService.ts b/src/plus/integrations/authentication/cloudIntegrationService.ts new file mode 100644 index 0000000000000..75530ddb08dc1 --- /dev/null +++ b/src/plus/integrations/authentication/cloudIntegrationService.ts @@ -0,0 +1,105 @@ +import { env, Uri } from 'vscode'; +import type { Container } from '../../../container'; +import { Logger } from '../../../system/logger'; +import type { ServerConnection } from '../../gk/serverConnection'; +import type { IntegrationId } from '../providers/models'; +import type { + CloudIntegrationAuthenticationSession, + CloudIntegrationAuthorization, + CloudIntegrationConnection, +} from './models'; +import { CloudIntegrationAuthenticationUriPathPrefix, toCloudIntegrationType } from './models'; + +export class CloudIntegrationService { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + async getConnections(): Promise { + const providersRsp = await this.connection.fetchGkDevApi( + 'v1/provider-tokens', + { method: 'GET' }, + { organizationId: false }, + ); + if (!providersRsp.ok) { + const error = (await providersRsp.json())?.error; + if (error != null) { + Logger.error(`Failed to get connected providers from cloud: ${error.message}`); + } + return undefined; + } + + return (await providersRsp.json())?.data as Promise; + } + + async getConnectionSession( + id: IntegrationId, + refreshToken?: string, + ): Promise { + const refresh = Boolean(refreshToken); + const cloudIntegrationType = toCloudIntegrationType[id]; + if (cloudIntegrationType == null) { + Logger.error(`Unsupported cloud integration type: ${id}`); + return undefined; + } + const reqInitOptions = refreshToken + ? { + method: 'POST', + body: JSON.stringify({ + access_token: refreshToken, + }), + } + : { method: 'GET' }; + + const tokenRsp = await this.connection.fetchGkDevApi( + `v1/provider-tokens/${cloudIntegrationType}${refresh ? '/refresh' : ''}`, + reqInitOptions, + { organizationId: false }, + ); + if (!tokenRsp.ok) { + const error = (await tokenRsp.json())?.error; + if (error != null) { + Logger.error(`Failed to ${refresh ? 'refresh' : 'get'} ${id} token from cloud: ${error.message}`); + } + return undefined; + } + + return (await tokenRsp.json())?.data as Promise; + } + + async authorize(id: IntegrationId): Promise { + const cloudIntegrationType = toCloudIntegrationType[id]; + if (cloudIntegrationType == null) { + Logger.error(`Unsupported cloud integration type: ${id}`); + return undefined; + } + + // attach the callback to the url + const callbackUri = await env.asExternalUri( + Uri.parse( + `${env.uriScheme}://${this.container.context.extension.id}/${CloudIntegrationAuthenticationUriPathPrefix}?provider=${id}`, + ), + ); + + const authorizeRsp = await this.connection.fetchGkDevApi( + `v1/provider-tokens/${cloudIntegrationType}/authorize`, + { + method: 'GET', + }, + { + query: `source=gitlens&targetURL=${encodeURIComponent(callbackUri.toString(true))}`, + organizationId: false, + }, + ); + if (!authorizeRsp.ok) { + const error = (await authorizeRsp.json())?.error; + if (error != null) { + Logger.error(`Failed to authorize with ${id}: ${error.message}`); + } + return undefined; + } + + return (await authorizeRsp.json())?.data as Promise; + } +} diff --git a/src/plus/integrations/authentication/github.ts b/src/plus/integrations/authentication/github.ts new file mode 100644 index 0000000000000..5402e35cea656 --- /dev/null +++ b/src/plus/integrations/authentication/github.ts @@ -0,0 +1,105 @@ +import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; +import { authentication, env, ThemeIcon, Uri, window } from 'vscode'; +import { wrapForForcedInsecureSSL } from '@env/fetch'; +import { HostingIntegrationId, SelfHostedIntegrationId } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import { + CloudIntegrationAuthenticationProvider, + LocalIntegrationAuthenticationProvider, +} from './integrationAuthentication'; + +export class GitHubAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override get authProviderId(): HostingIntegrationId.GitHub { + return HostingIntegrationId.GitHub; + } + + override async getBuiltInExistingSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + if (descriptor == null) return undefined; + + return wrapForForcedInsecureSSL( + this.container.integrations.ignoreSSLErrors({ id: this.authProviderId, domain: descriptor?.domain }), + () => + authentication.getSession(this.authProviderId, descriptor.scopes, { + silent: true, + }), + ); + } + + protected override getCompletionInputTitle(): string { + return 'Connect to GitHub'; + } +} + +export class GitHubEnterpriseAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + protected override get authProviderId(): SelfHostedIntegrationId.GitHubEnterprise { + return SelfHostedIntegrationId.GitHubEnterprise; + } + + override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the GitHub Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(() => (input.validationMessage = undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse(`https://${descriptor?.domain ?? 'github.com'}/settings/tokens`), + ); + } + }), + ); + + input.password = true; + input.title = `GitHub Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; + input.placeholder = `Requires a classic token with ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + input.prompt = `Paste your [GitHub Personal Access Token](https://${ + descriptor?.domain ?? 'github.com' + }/settings/tokens "Get your GitHub Access Token")`; + + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: token, + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + }; + } +} diff --git a/src/plus/integrations/authentication/gitlab.ts b/src/plus/integrations/authentication/gitlab.ts new file mode 100644 index 0000000000000..899219159d863 --- /dev/null +++ b/src/plus/integrations/authentication/gitlab.ts @@ -0,0 +1,84 @@ +import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { Container } from '../../../container'; +import type { HostingIntegrationId, SelfHostedIntegrationId } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import { LocalIntegrationAuthenticationProvider } from './integrationAuthentication'; + +type GitLabId = HostingIntegrationId.GitLab | SelfHostedIntegrationId.GitLabSelfHosted; + +export class GitLabAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + constructor( + container: Container, + protected readonly authProviderId: GitLabId, + ) { + super(container); + } + + override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the GitLab Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(() => (input.validationMessage = undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${descriptor?.domain ?? 'gitlab.com'}/-/profile/personal_access_tokens`, + ), + ); + } + }), + ); + + input.password = true; + input.title = `GitLab Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; + input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + input.prompt = `Paste your [GitLab Personal Access Token](https://${ + descriptor?.domain ?? 'gitlab.com' + }/-/profile/personal_access_tokens "Get your GitLab Access Token")`; + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: token, + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + }; + } +} diff --git a/src/plus/integrations/authentication/integrationAuthentication.ts b/src/plus/integrations/authentication/integrationAuthentication.ts new file mode 100644 index 0000000000000..8b5a08cbfa84c --- /dev/null +++ b/src/plus/integrations/authentication/integrationAuthentication.ts @@ -0,0 +1,463 @@ +import type { AuthenticationSession, CancellationToken, Disposable, Uri } from 'vscode'; +import { authentication, CancellationTokenSource, window } from 'vscode'; +import { wrapForForcedInsecureSSL } from '@env/fetch'; +import type { SecretKeys } from '../../../constants'; +import type { Container } from '../../../container'; +import { debug, log } from '../../../system/decorators/log'; +import type { DeferredEventExecutor } from '../../../system/event'; +import { promisifyDeferred } from '../../../system/event'; +import { openUrl } from '../../../system/utils'; +import type { IntegrationId } from '../providers/models'; +import { + HostingIntegrationId, + IssueIntegrationId, + SelfHostedIntegrationId, + supportedIntegrationIds, +} from '../providers/models'; +import type { ProviderAuthenticationSession } from './models'; + +interface StoredSession { + id: string; + accessToken: string; + account?: { + label?: string; + displayName?: string; + id: string; + }; + scopes: string[]; + expiresAt?: string; +} + +export interface IntegrationAuthenticationProviderDescriptor { + id: IntegrationId; + scopes: string[]; +} + +export interface IntegrationAuthenticationSessionDescriptor { + domain: string; + scopes: string[]; + [key: string]: unknown; +} + +export interface IntegrationAuthenticationProvider { + deleteSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise; + getSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, + ): Promise; +} + +abstract class IntegrationAuthenticationProviderBase + implements IntegrationAuthenticationProvider +{ + constructor(protected readonly container: Container) {} + + protected abstract get authProviderId(): ID; + + protected abstract createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { authorizeIfNeeded?: boolean }, + ): Promise; + + protected abstract deleteAllSecrets(sessionId: string): Promise; + + protected abstract storeSession(sessionId: string, session: AuthenticationSession): Promise; + + protected abstract restoreSession(options: { + sessionId: string; + ignoreErrors: boolean; + }): Promise; + + protected async deleteSecret(key: SecretKeys) { + await this.container.storage.deleteSecret(key); + } + + protected async writeSecret(key: SecretKeys, session: AuthenticationSession | StoredSession) { + await this.container.storage.storeSecret(key, JSON.stringify(session)); + } + + protected async readSecret(key: SecretKeys, ignoreErrors: boolean): Promise { + let storedSession: StoredSession | undefined; + try { + const sessionJSON = await this.container.storage.getSecret(key); + if (sessionJSON) { + storedSession = JSON.parse(sessionJSON); + } + } catch (ex) { + try { + await this.deleteSecret(key); + } catch {} + + if (ignoreErrors) { + throw ex; + } + } + return storedSession; + } + + protected getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { + return descriptor?.domain ?? ''; + } + + protected getLocalSecretKey(id: string): `gitlens.integration.auth:${IntegrationId}|${string}` { + return `gitlens.integration.auth:${this.authProviderId}|${id}`; + } + + @debug() + async deleteSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise { + const sessionId = this.getSessionId(descriptor); + await this.deleteAllSecrets(sessionId); + } + + @debug() + async getSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, + ): Promise { + const sessionId = this.getSessionId(descriptor); + + if (options?.forceNewSession) { + await this.deleteAllSecrets(sessionId); + } + + const storedSession = await this.restoreSession({ + sessionId: sessionId, + ignoreErrors: !options?.createIfNeeded, + }); + if ( + (options?.createIfNeeded && storedSession == null) || + (storedSession?.expiresAt != null && new Date(storedSession.expiresAt).getTime() < Date.now()) + ) { + const session = await this.createSession(descriptor); + if (session != null) { + await this.storeSession(sessionId, session); + } + return session; + } + + return storedSession as ProviderAuthenticationSession | undefined; + } +} + +export abstract class LocalIntegrationAuthenticationProvider< + ID extends IntegrationId = IntegrationId, +> extends IntegrationAuthenticationProviderBase { + protected async deleteAllSecrets(sessionId: string) { + await this.deleteSecret(this.getLocalSecretKey(sessionId)); + } + + protected async storeSession(sessionId: string, session: AuthenticationSession) { + await this.writeSecret(this.getLocalSecretKey(sessionId), session); + } + + protected override async restoreSession({ + sessionId, + ignoreErrors, + }: { + sessionId: string; + ignoreErrors: boolean; + }): Promise { + const key = this.getLocalSecretKey(sessionId); + return this.readSecret(key, ignoreErrors); + } +} + +export abstract class CloudIntegrationAuthenticationProvider< + ID extends IntegrationId = IntegrationId, +> extends IntegrationAuthenticationProviderBase { + protected async getBuiltInExistingSession( + _?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + private getCloudSecretKey(id: string): `gitlens.integration.auth.cloud:${IntegrationId}|${string}` { + return `gitlens.integration.auth.cloud:${this.authProviderId}|${id}`; + } + + public async deleteCloudSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise { + const key = this.getCloudSecretKey(this.getSessionId(descriptor)); + await this.deleteSecret(key); + } + + protected async deleteAllSecrets(sessionId: string) { + await Promise.all([ + this.deleteSecret(this.getLocalSecretKey(sessionId)), + this.deleteSecret(this.getCloudSecretKey(sessionId)), + ]); + } + + protected override async storeSession(sessionId: string, session: AuthenticationSession) { + await this.writeSecret(this.getCloudSecretKey(sessionId), session); + } + + /** + * This method gets the session from the storage and returns it. + * Howewer, if a cloud session is stored with a local key, it will be renamed and saved in the storage with the cloud key. + */ + protected override async restoreSession({ + sessionId, + ignoreErrors, + }: { + sessionId: string; + ignoreErrors: boolean; + }): Promise { + // At first we try to restore the cloud session + let session = await this.readSecret(this.getCloudSecretKey(sessionId), ignoreErrors); + if (session != null) return session; + + // If no cloud session, we check whether we have a token with the local key + session = await this.readSecret(this.getLocalSecretKey(sessionId), ignoreErrors); + if (session != null) { + // Check the `expiresAt` field + // If it has an expiresAt property and the key is the old type, then it's a cloud session, + // so delete it from the local key and + // store with the "cloud" type key, and then use that one + // + if (session.expiresAt != null) { + await Promise.all([ + this.deleteSecret(this.getLocalSecretKey(sessionId)), + this.writeSecret(this.getCloudSecretKey(sessionId), session), + ]); + return session; + } + // Otherwise, it is rather unexpected, so I'm deleting it and returning undefined, + // This lets us drop local GitHub sessions that were incorrectly saved during testing the intermitent version of this change. + return undefined; + // However, I'm also thingking about using it rather than deleting: + // return session; + } + + return undefined; + } + + public override async getSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, + ): Promise { + const session = await super.getSession(descriptor, options); + if (session != null) return session; + + // by default getBuiltInExistingSession returns undefined + // but specific providers can override it to return a session (e.g. GitHub) + return this.getBuiltInExistingSession(descriptor); + } + + protected override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { authorizeIfNeeded?: boolean }, + ): Promise { + const loggedIn = await this.container.subscription.getAuthenticationSession(false); + if (!loggedIn) return undefined; + + const cloudIntegrations = await this.container.cloudIntegrations; + if (cloudIntegrations == null) return undefined; + + let session = await cloudIntegrations.getConnectionSession(this.authProviderId); + + // Make an exception for GitHub because they always return 0 + if (session?.expiresIn === 0 && this.authProviderId === HostingIntegrationId.GitHub) { + // It never expires so don't refresh it frequently: + session.expiresIn = 31536000; // 1 year + } + + if (session != null && session.expiresIn < 60) { + session = await cloudIntegrations.getConnectionSession(this.authProviderId, session.accessToken); + } + + if (!session && options?.authorizeIfNeeded) { + const authorizeUrl = (await cloudIntegrations.authorize(this.authProviderId))?.url; + + if (!authorizeUrl) return undefined; + + void (await openUrl(authorizeUrl)); + + const cancellation = new CancellationTokenSource(); + const deferredCallback = promisifyDeferred( + this.container.uri.onDidReceiveCloudIntegrationAuthenticationUri, + this.getUriHandlerDeferredExecutor(), + ); + + try { + await Promise.race([ + deferredCallback.promise, + this.openCompletionInput(cancellation.token), + new Promise((_, reject) => + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + cancellation.token.onCancellationRequested(() => reject('Cancelled')), + ), + new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), + ]); + session = await cloudIntegrations.getConnectionSession(this.authProviderId); + } catch { + session = undefined; + } finally { + cancellation.cancel(); + cancellation.dispose(); + deferredCallback.cancel(); + } + } + + if (!session) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: session.accessToken, + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + expiresAt: new Date(session.expiresIn * 1000 + Date.now()), + }; + } + + private async openCompletionInput(cancellationToken: CancellationToken) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + if (cancellationToken.isCancellationRequested) return; + + await new Promise(resolve => { + disposables.push( + cancellationToken.onCancellationRequested(() => input.hide()), + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => resolve(undefined)), + ); + + input.title = this.getCompletionInputTitle(); + input.placeholder = 'Please enter the provided authorization code'; + input.prompt = ''; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + } + + protected abstract getCompletionInputTitle(): string; + + private getUriHandlerDeferredExecutor(): DeferredEventExecutor { + return (uri: Uri, resolve, reject) => { + const queryParams: URLSearchParams = new URLSearchParams(uri.query); + const provider = queryParams.get('provider'); + if (provider !== this.authProviderId) { + reject('Invalid provider'); + return; + } + + resolve(uri.toString(true)); + }; + } +} + +class BuiltInAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + constructor( + container: Container, + protected readonly authProviderId: IntegrationId, + ) { + super(container); + } + + protected override createSession(): Promise { + throw new Error('Method `createSession` should never be used in BuiltInAuthenticationProvider'); + } + + @debug() + override async getSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, + ): Promise { + if (descriptor == null) return undefined; + const { createIfNeeded, forceNewSession } = options ?? {}; + return wrapForForcedInsecureSSL( + this.container.integrations.ignoreSSLErrors({ id: this.authProviderId, domain: descriptor?.domain }), + () => + authentication.getSession(this.authProviderId, descriptor.scopes, { + createIfNone: forceNewSession ? undefined : createIfNeeded, + silent: !createIfNeeded && !forceNewSession ? true : undefined, + forceNewSession: forceNewSession ? true : undefined, + }), + ); + } +} + +export class IntegrationAuthenticationService implements Disposable { + private readonly providers = new Map(); + + constructor(private readonly container: Container) {} + + dispose() { + this.providers.clear(); + } + + async get(providerId: IntegrationId): Promise { + return this.ensureProvider(providerId); + } + + @log() + async reset() { + // TODO: This really isn't ideal, since it will only work for "cloud" providers as we won't have any more specific descriptors + await Promise.allSettled( + supportedIntegrationIds.map(async providerId => (await this.ensureProvider(providerId)).deleteSession()), + ); + } + + supports(providerId: string): boolean { + switch (providerId) { + case HostingIntegrationId.AzureDevOps: + case HostingIntegrationId.Bitbucket: + case SelfHostedIntegrationId.GitHubEnterprise: + case HostingIntegrationId.GitLab: + case SelfHostedIntegrationId.GitLabSelfHosted: + case IssueIntegrationId.Jira: + return true; + default: + return false; + } + } + + private async ensureProvider(providerId: IntegrationId): Promise { + let provider = this.providers.get(providerId); + if (provider == null) { + switch (providerId) { + case HostingIntegrationId.AzureDevOps: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './azureDevOps') + ).AzureDevOpsAuthenticationProvider(this.container); + break; + case HostingIntegrationId.Bitbucket: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './bitbucket') + ).BitbucketAuthenticationProvider(this.container); + break; + case SelfHostedIntegrationId.GitHubEnterprise: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './github') + ).GitHubEnterpriseAuthenticationProvider(this.container); + break; + case HostingIntegrationId.GitLab: + case SelfHostedIntegrationId.GitLabSelfHosted: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './gitlab') + ).GitLabAuthenticationProvider(this.container, providerId); + break; + case IssueIntegrationId.Jira: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './jira') + ).JiraAuthenticationProvider(this.container); + break; + default: + provider = new BuiltInAuthenticationProvider(this.container, providerId); + } + this.providers.set(providerId, provider); + } + + return provider; + } +} diff --git a/src/plus/integrations/authentication/jira.ts b/src/plus/integrations/authentication/jira.ts new file mode 100644 index 0000000000000..436f9a3264f75 --- /dev/null +++ b/src/plus/integrations/authentication/jira.ts @@ -0,0 +1,12 @@ +import { IssueIntegrationId } from '../providers/models'; +import { CloudIntegrationAuthenticationProvider } from './integrationAuthentication'; + +export class JiraAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override get authProviderId(): IssueIntegrationId.Jira { + return IssueIntegrationId.Jira; + } + + protected override getCompletionInputTitle(): string { + return 'Connect to Jira'; + } +} diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts new file mode 100644 index 0000000000000..9a46c0a990383 --- /dev/null +++ b/src/plus/integrations/authentication/models.ts @@ -0,0 +1,58 @@ +import type { AuthenticationSession } from 'vscode'; +import type { IntegrationId } from '../providers/models'; +import { HostingIntegrationId, IssueIntegrationId, SelfHostedIntegrationId } from '../providers/models'; + +export interface ProviderAuthenticationSession extends AuthenticationSession { + readonly expiresAt?: Date; +} + +export interface CloudIntegrationAuthenticationSession { + type: CloudIntegrationAuthType; + accessToken: string; + domain: string; + expiresIn: number; + scopes: string; +} + +export interface CloudIntegrationAuthorization { + url: string; +} + +export interface CloudIntegrationConnection { + type: CloudIntegrationAuthType; + provider: CloudIntegrationType; + domain: string; +} + +export type CloudIntegrationType = 'jira' | 'trello' | 'gitlab' | 'github' | 'bitbucket' | 'azure'; + +export type CloudIntegrationAuthType = 'oauth' | 'pat'; + +export const CloudIntegrationAuthenticationUriPathPrefix = 'did-authenticate-cloud-integration'; + +export const supportedCloudIntegrationIds = [IssueIntegrationId.Jira]; +export type SupportedCloudIntegrationIds = (typeof supportedCloudIntegrationIds)[number]; + +export function isSupportedCloudIntegrationId(id: string): id is SupportedCloudIntegrationIds { + return supportedCloudIntegrationIds.includes(id as SupportedCloudIntegrationIds); +} + +export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationId } = { + jira: IssueIntegrationId.Jira, + trello: IssueIntegrationId.Trello, + gitlab: HostingIntegrationId.GitLab, + github: HostingIntegrationId.GitHub, + bitbucket: HostingIntegrationId.Bitbucket, + azure: HostingIntegrationId.AzureDevOps, +}; + +export const toCloudIntegrationType: { [key in IntegrationId]: CloudIntegrationType | undefined } = { + [IssueIntegrationId.Jira]: 'jira', + [IssueIntegrationId.Trello]: 'trello', + [HostingIntegrationId.GitLab]: 'gitlab', + [HostingIntegrationId.GitHub]: 'github', + [HostingIntegrationId.Bitbucket]: 'bitbucket', + [HostingIntegrationId.AzureDevOps]: 'azure', + [SelfHostedIntegrationId.GitHubEnterprise]: undefined, + [SelfHostedIntegrationId.GitLabSelfHosted]: undefined, +}; diff --git a/src/plus/integrations/integration.ts b/src/plus/integrations/integration.ts new file mode 100644 index 0000000000000..eb441de58ecd4 --- /dev/null +++ b/src/plus/integrations/integration.ts @@ -0,0 +1,1237 @@ +import type { CancellationToken, Event, MessageItem } from 'vscode'; +import { EventEmitter, window } from 'vscode'; +import type { DynamicAutolinkReference } from '../../annotations/autolinks'; +import type { AutolinkReference } from '../../config'; +import type { Container } from '../../container'; +import { AuthenticationError, CancellationError, ProviderRequestClientError } from '../../errors'; +import type { PagedResult } from '../../git/gitProvider'; +import type { Account } from '../../git/models/author'; +import type { DefaultBranch } from '../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; +import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; +import { configuration } from '../../system/configuration'; +import { gate } from '../../system/decorators/gate'; +import { debug, log } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import type { LogScope } from '../../system/logger.scope'; +import { getLogScope } from '../../system/logger.scope'; +import type { + IntegrationAuthenticationProviderDescriptor, + IntegrationAuthenticationService, + IntegrationAuthenticationSessionDescriptor, +} from './authentication/integrationAuthentication'; +import { CloudIntegrationAuthenticationProvider } from './authentication/integrationAuthentication'; +import type { ProviderAuthenticationSession } from './authentication/models'; +import type { + GetIssuesOptions, + GetPullRequestsOptions, + IntegrationId, + IssueIntegrationId, + PagedProjectInput, + PagedRepoInput, + ProviderAccount, + ProviderIssue, + ProviderPullRequest, + ProviderRepoInput, + ProviderReposInput, + SelfHostedIntegrationId, +} from './providers/models'; +import { HostingIntegrationId, IssueFilter, PagingMode, PullRequestFilter } from './providers/models'; +import type { ProvidersApi } from './providers/providersApi'; + +export type IntegrationResult = + | { value: T; duration?: number; error?: never } + | { error: Error; duration?: number; value?: never } + | undefined; + +export type SupportedIntegrationIds = IntegrationId; +export type SupportedHostingIntegrationIds = HostingIntegrationId; +export type SupportedIssueIntegrationIds = IssueIntegrationId; +export type SupportedSelfHostedIntegrationIds = SelfHostedIntegrationId; + +export type Integration = IssueIntegration | HostingIntegration; +export type IntegrationKey = + | `${SupportedHostingIntegrationIds}` + | `${SupportedIssueIntegrationIds}` + | `${SupportedSelfHostedIntegrationIds}:${string}`; +export type IntegrationKeyById = T extends SupportedIssueIntegrationIds + ? `${SupportedIssueIntegrationIds}` + : T extends SupportedHostingIntegrationIds + ? `${SupportedHostingIntegrationIds}` + : `${SupportedSelfHostedIntegrationIds}:${string}`; +export type IntegrationType = 'issues' | 'hosting'; + +export type ResourceDescriptor = { key: string } & Record; + +export function isHostingIntegration(integration: Integration): integration is HostingIntegration { + return integration.type === 'hosting'; +} +export function isIssueIntegration(integration: Integration): integration is IssueIntegration { + return integration.type === 'issues'; +} + +export abstract class IntegrationBase< + ID extends SupportedIntegrationIds = SupportedIntegrationIds, + T extends ResourceDescriptor = ResourceDescriptor, +> { + abstract readonly type: IntegrationType; + + private readonly _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + constructor( + protected readonly container: Container, + protected readonly authenticationService: IntegrationAuthenticationService, + protected readonly getProvidersApi: () => Promise, + ) {} + + abstract get authProvider(): IntegrationAuthenticationProviderDescriptor; + abstract get id(): ID; + protected abstract get key(): IntegrationKeyById; + abstract get name(): string; + abstract get domain(): string; + + get authProviderDescriptor(): IntegrationAuthenticationSessionDescriptor { + return { domain: this.domain, scopes: this.authProvider.scopes }; + } + + get icon(): string { + return this.id; + } + + autolinks(): + | (AutolinkReference | DynamicAutolinkReference)[] + | Promise<(AutolinkReference | DynamicAutolinkReference)[]> { + return []; + } + + private get connectedKey(): `connected:${Integration['key']}` { + return `connected:${this.key}`; + } + + get maybeConnected(): boolean | undefined { + return this._session === undefined ? undefined : this._session !== null; + } + + get connectionExpired(): boolean | undefined { + if (this._session?.expiresAt == null) return undefined; + return new Date(this._session.expiresAt) < new Date(); + } + + protected _session: ProviderAuthenticationSession | null | undefined; + getSession() { + if (this._session === undefined) { + return this.ensureSession(false); + } + return this._session ?? undefined; + } + + @log() + async connect(): Promise { + try { + const session = await this.ensureSession(true); + return Boolean(session); + } catch (ex) { + return false; + } + } + + protected providerOnConnect?(): void | Promise; + + @gate() + @log() + async disconnect(options?: { + silent?: boolean; + currentSessionOnly?: boolean; + cloudSessionOnly?: boolean; + }): Promise { + if (options?.currentSessionOnly && this._session === null) return; + + const connected = this._session != null; + + let signOut = !options?.currentSessionOnly; + + if (connected && !options?.currentSessionOnly && !options?.silent) { + const disable = { title: 'Disable' }; + const disableAndSignOut = { title: 'Disable & Sign Out' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + + let result: MessageItem | undefined; + if (this.authenticationService.supports(this.authProvider.id)) { + result = await window.showWarningMessage( + `Are you sure you want to disable the rich integration with ${this.name}?\n\nNote: signing out clears the saved authentication.`, + { modal: true }, + disable, + disableAndSignOut, + cancel, + ); + } else { + result = await window.showWarningMessage( + `Are you sure you want to disable the rich integration with ${this.name}?`, + { modal: true }, + disable, + cancel, + ); + } + + if (result == null || result === cancel) return; + + signOut = result === disableAndSignOut; + } + + if (signOut) { + const authProvider = await this.authenticationService.get(this.authProvider.id); + if (options?.cloudSessionOnly && authProvider instanceof CloudIntegrationAuthenticationProvider) { + void authProvider.deleteCloudSession(this.authProviderDescriptor); + } else { + void authProvider.deleteSession(this.authProviderDescriptor); + } + } + + this.resetRequestExceptionCount(); + this._session = null; + + if (connected && options?.cloudSessionOnly) { + const authProvider = await this.authenticationService.get(this.authProvider.id); + this._session = await authProvider.getSession(this.authProviderDescriptor, { + createIfNeeded: false, + forceNewSession: false, + }); + } + + if (this._session != null) { + return; + } + + if (connected) { + // Don't store the disconnected flag if this only for this current VS Code session (will be re-connected on next restart) + if (!options?.currentSessionOnly) { + void this.container.storage.storeWorkspace(this.connectedKey, false); + } + + this._onDidChange.fire(); + if (!options?.currentSessionOnly) { + this.container.integrations.disconnected(this, this.key); + } + } + + await this.providerOnDisconnect?.(); + } + + protected providerOnDisconnect?(): void | Promise; + + @log() + async reauthenticate(): Promise { + if (this._session === undefined) return; + + this._session = undefined; + void (await this.ensureSession(true, true)); + } + + refresh() { + void this.ensureSession(false); + } + + private requestExceptionCount = 0; + + resetRequestExceptionCount(): void { + this.requestExceptionCount = 0; + } + + protected handleProviderException(ex: Error, scope: LogScope | undefined, defaultValue: T): T { + if (ex instanceof CancellationError) return defaultValue; + + Logger.error(ex, scope); + + if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { + this.trackRequestException(); + } + return defaultValue; + } + + @debug() + trackRequestException(): void { + this.requestExceptionCount++; + + if (this.requestExceptionCount >= 5 && this._session !== null) { + void showIntegrationDisconnectedTooManyFailedRequestsWarningMessage(this.name); + void this.disconnect({ currentSessionOnly: true }); + } + } + + @gate() + @debug({ exit: true }) + async isConnected(): Promise { + return (await this.getSession()) != null; + } + + @gate() + private async ensureSession( + createIfNeeded: boolean, + forceNewSession: boolean = false, + ): Promise { + if (this._session != null) return this._session; + if (!configuration.get('integrations.enabled')) return undefined; + + if (createIfNeeded) { + await this.container.storage.deleteWorkspace(this.connectedKey); + } else if (this.container.storage.getWorkspace(this.connectedKey) === false) { + return undefined; + } + + let session: ProviderAuthenticationSession | undefined | null; + try { + const authProvider = await this.authenticationService.get(this.authProvider.id); + session = await authProvider.getSession(this.authProviderDescriptor, { + createIfNeeded: createIfNeeded, + forceNewSession: forceNewSession, + }); + } catch (ex) { + await this.container.storage.deleteWorkspace(this.connectedKey); + + if (ex instanceof Error && ex.message.includes('User did not consent')) { + return undefined; + } + + session = null; + } + + if (session === undefined && !createIfNeeded) { + await this.container.storage.deleteWorkspace(this.connectedKey); + } + + this._session = session ?? null; + this.resetRequestExceptionCount(); + + if (session != null) { + await this.container.storage.storeWorkspace(this.connectedKey, true); + + queueMicrotask(() => { + this._onDidChange.fire(); + this.container.integrations.connected(this, this.key); + void this.providerOnConnect?.(); + }); + } + + return session ?? undefined; + } + + getIgnoreSSLErrors(): boolean | 'force' { + return this.container.integrations.ignoreSSLErrors(this); + } + + async searchMyIssues( + resource?: ResourceDescriptor, + cancellation?: CancellationToken, + ): Promise; + async searchMyIssues( + resources?: ResourceDescriptor[], + cancellation?: CancellationToken, + ): Promise; + @debug() + async searchMyIssues( + resources?: ResourceDescriptor | ResourceDescriptor[], + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const issues = await this.searchProviderMyIssues( + this._session!, + resources != null ? (Array.isArray(resources) ? resources : [resources]) : undefined, + cancellation, + ); + this.resetRequestExceptionCount(); + return issues; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract searchProviderMyIssues( + session: ProviderAuthenticationSession, + resources?: ResourceDescriptor[], + cancellation?: CancellationToken, + ): Promise; + + @debug() + async getIssueOrPullRequest( + resource: T, + id: string, + options?: { expiryOverride?: boolean | number }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const issueOrPR = this.container.cache.getIssueOrPullRequest( + id, + resource, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderIssueOrPullRequest(this._session!, resource, id); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + options, + ); + return issueOrPR; + } + + protected abstract getProviderIssueOrPullRequest( + session: ProviderAuthenticationSession, + resource: T, + id: string, + ): Promise; + + async getCurrentAccount(options?: { + avatarSize?: number; + expiryOverride?: boolean | number; + }): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const { expiryOverride, ...opts } = options ?? {}; + + const currentAccount = this.container.cache.getCurrentAccount( + this, + () => ({ + value: (async () => { + try { + const account = await this.getProviderCurrentAccount?.(this._session!, opts); + this.resetRequestExceptionCount(); + return account; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + { expiryOverride: expiryOverride }, + ); + return currentAccount; + } + + protected getProviderCurrentAccount?( + session: ProviderAuthenticationSession, + options?: { avatarSize?: number }, + ): Promise; + + @debug() + async getPullRequest(resource: T, id: string): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const pr = this.container.cache.getPullRequest(id, resource, this, () => ({ + value: (async () => { + try { + const result = await this.getProviderPullRequest?.(this._session!, resource, id); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + })); + return pr; + } + + protected getProviderPullRequest?( + session: ProviderAuthenticationSession, + resource: T, + id: string, + ): Promise; +} + +export abstract class IssueIntegration< + ID extends SupportedIntegrationIds = SupportedIntegrationIds, + T extends ResourceDescriptor = ResourceDescriptor, +> extends IntegrationBase { + readonly type: IntegrationType = 'issues'; + + @gate() + @debug() + async getAccountForResource(resource: T): Promise { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const account = await this.getProviderAccountForResource(this._session!, resource); + this.resetRequestExceptionCount(); + return account; + } catch (ex) { + return this.handleProviderException(ex, undefined, undefined); + } + } + + protected abstract getProviderAccountForResource( + session: ProviderAuthenticationSession, + resource: T, + ): Promise; + + @gate() + @debug() + async getResourcesForUser(): Promise { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const resources = await this.getProviderResourcesForUser(this._session!); + this.resetRequestExceptionCount(); + return resources; + } catch (ex) { + return this.handleProviderException(ex, undefined, undefined); + } + } + + protected abstract getProviderResourcesForUser(session: ProviderAuthenticationSession): Promise; + + @debug() + async getProjectsForResources(resources: T[]): Promise { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const projects = await this.getProviderProjectsForResources(this._session!, resources); + this.resetRequestExceptionCount(); + return projects; + } catch (ex) { + return this.handleProviderException(ex, undefined, undefined); + } + } + + async getProjectsForUser(): Promise { + const resources = await this.getResourcesForUser(); + if (resources == null) return undefined; + + return this.getProjectsForResources(resources); + } + + protected abstract getProviderProjectsForResources( + session: ProviderAuthenticationSession, + resources: T[], + ): Promise; + + @debug() + async getIssuesForProject( + project: T, + options?: { user?: string; filters?: IssueFilter[] }, + ): Promise { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const issues = await this.getProviderIssuesForProject(this._session!, project, options); + this.resetRequestExceptionCount(); + return issues; + } catch (ex) { + return this.handleProviderException(ex, undefined, undefined); + } + } + + protected abstract getProviderIssuesForProject( + session: ProviderAuthenticationSession, + project: T, + options?: { user?: string; filters?: IssueFilter[] }, + ): Promise; +} + +export abstract class HostingIntegration< + ID extends SupportedIntegrationIds = SupportedIntegrationIds, + T extends ResourceDescriptor = ResourceDescriptor, +> extends IntegrationBase { + readonly type: IntegrationType = 'hosting'; + + @gate() + @debug() + async getAccountForEmail( + repo: T, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const author = await this.getProviderAccountForEmail(this._session!, repo, email, options); + this.resetRequestExceptionCount(); + return author; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract getProviderAccountForEmail( + session: ProviderAuthenticationSession, + repo: T, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise; + + @gate() + @debug() + async getAccountForCommit( + repo: T, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const author = await this.getProviderAccountForCommit(this._session!, repo, ref, options); + this.resetRequestExceptionCount(); + return author; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract getProviderAccountForCommit( + session: ProviderAuthenticationSession, + repo: T, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise; + + @debug() + async getDefaultBranch(repo: T): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const defaultBranch = this.container.cache.getRepositoryDefaultBranch(repo, this, () => ({ + value: (async () => { + try { + const result = await this.getProviderDefaultBranch(this._session!, repo); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + })); + return defaultBranch; + } + + protected abstract getProviderDefaultBranch( + { accessToken }: ProviderAuthenticationSession, + repo: T, + ): Promise; + + @debug() + async getRepositoryMetadata( + repo: T, + options?: { cancellation?: CancellationToken; expiryOverride?: boolean | number }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const metadata = this.container.cache.getRepositoryMetadata( + repo, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderRepositoryMetadata( + this._session!, + repo, + options?.cancellation, + ); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + { expiryOverride: options?.expiryOverride }, + ); + return metadata; + } + + protected abstract getProviderRepositoryMetadata( + session: ProviderAuthenticationSession, + repo: T, + cancellation?: CancellationToken, + ): Promise; + + async mergePullRequest( + pr: PullRequest | { id: string; headRefSha: string }, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return false; + + try { + const result = await this.mergeProviderPullRequest(this._session!, pr, options); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, false); + } + } + + protected abstract mergeProviderPullRequest( + session: ProviderAuthenticationSession, + pr: PullRequest | { id: string; headRefSha: string }, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise; + + @debug() + async getPullRequestForBranch( + repo: T, + branch: string, + options?: { + avatarSize?: number; + expiryOverride?: boolean | number; + include?: PullRequestState[]; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const { expiryOverride, ...opts } = options ?? {}; + + const pr = this.container.cache.getPullRequestForBranch( + branch, + repo, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderPullRequestForBranch(this._session!, repo, branch, opts); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + { expiryOverride: expiryOverride }, + ); + return pr; + } + + protected abstract getProviderPullRequestForBranch( + session: ProviderAuthenticationSession, + repo: T, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise; + + @debug() + async getPullRequestForCommit( + repo: T, + ref: string, + options?: { expiryOverride?: boolean | number }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const pr = this.container.cache.getPullRequestForSha( + ref, + repo, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderPullRequestForCommit(this._session!, repo, ref); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + options, + ); + return pr; + } + + protected abstract getProviderPullRequestForCommit( + session: ProviderAuthenticationSession, + repo: T, + ref: string, + ): Promise; + + async getMyIssuesForRepos( + reposOrRepoIds: ProviderReposInput, + options?: { + filters?: IssueFilter[]; + cursor?: string; + customUrl?: string; + }, + ): Promise | undefined> { + const providerId = this.authProvider.id; + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const api = await this.getProvidersApi(); + if ( + providerId !== HostingIntegrationId.GitLab && + (api.isRepoIdsInput(reposOrRepoIds) || + (providerId === HostingIntegrationId.AzureDevOps && + !reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null))) + ) { + Logger.warn(`Unsupported input for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + let getIssuesOptions: GetIssuesOptions | undefined; + if (providerId === HostingIntegrationId.AzureDevOps) { + const organizations = new Set(); + const projects = new Set(); + for (const repo of reposOrRepoIds as ProviderRepoInput[]) { + organizations.add(repo.namespace); + projects.add(repo.project!); + } + + if (organizations.size > 1) { + Logger.warn(`Multiple organizations not supported for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } else if (organizations.size === 0) { + Logger.warn(`No organizations found for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const organization: string = organizations.values().next().value; + + if (options?.filters != null) { + if (!api.providerSupportsIssueFilters(providerId, options.filters)) { + Logger.warn(`Unsupported filters for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + let userAccount: ProviderAccount | undefined; + try { + userAccount = await api.getCurrentUserForInstance(providerId, organization); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const userFilterProperty = userAccount.name; + + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + getIssuesOptions = { + authorLogin: options.filters.includes(IssueFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(IssueFilter.Assignee) ? [userFilterProperty] : undefined, + mentionLogin: options.filters.includes(IssueFilter.Mention) ? userFilterProperty : undefined, + }; + } + + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedProjectInput[] = cursorInfo.cursors ?? []; + let projectInputs: PagedProjectInput[] = Array.from(projects.values()).map(project => ({ + namespace: organization, + project: project, + cursor: undefined, + })); + if (cursors.length > 0) { + projectInputs = cursors; + } + + try { + const cursor: { cursors: PagedProjectInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderIssue[] = []; + await Promise.all( + projectInputs.map(async projectInput => { + const results = await api.getIssuesForAzureProject( + projectInput.namespace, + projectInput.project, + { + ...getIssuesOptions, + cursor: projectInput.cursor, + }, + ); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ + namespace: projectInput.namespace, + project: projectInput.project, + cursor: results.paging.cursor, + }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + if (options?.filters != null) { + let userAccount: ProviderAccount | undefined; + try { + userAccount = await api.getCurrentUser(providerId); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const userFilterProperty = userAccount.username; + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + getIssuesOptions = { + authorLogin: options.filters.includes(IssueFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(IssueFilter.Assignee) ? [userFilterProperty] : undefined, + mentionLogin: options.filters.includes(IssueFilter.Mention) ? userFilterProperty : undefined, + }; + } + + if (api.getProviderIssuesPagingMode(providerId) === PagingMode.Repo && !api.isRepoIdsInput(reposOrRepoIds)) { + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedRepoInput[] = cursorInfo.cursors ?? []; + let repoInputs: PagedRepoInput[] = reposOrRepoIds.map(repo => ({ repo: repo, cursor: undefined })); + if (cursors.length > 0) { + repoInputs = cursors; + } + + try { + const cursor: { cursors: PagedRepoInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderIssue[] = []; + await Promise.all( + repoInputs.map(async repoInput => { + const results = await api.getIssuesForRepo(providerId, repoInput.repo, { + ...getIssuesOptions, + cursor: repoInput.cursor, + baseUrl: options?.customUrl, + }); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ repo: repoInput.repo, cursor: results.paging.cursor }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + + try { + return await api.getIssuesForRepos(providerId, reposOrRepoIds, { + ...getIssuesOptions, + cursor: options?.cursor, + baseUrl: options?.customUrl, + }); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + + async getMyPullRequestsForRepos( + reposOrRepoIds: ProviderReposInput, + options?: { + filters?: PullRequestFilter[]; + cursor?: string; + customUrl?: string; + }, + ): Promise | undefined> { + const providerId = this.authProvider.id; + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const api = await this.getProvidersApi(); + if ( + providerId !== HostingIntegrationId.GitLab && + (api.isRepoIdsInput(reposOrRepoIds) || + (providerId === HostingIntegrationId.AzureDevOps && + !reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null))) + ) { + Logger.warn(`Unsupported input for provider ${providerId}`); + return undefined; + } + + let getPullRequestsOptions: GetPullRequestsOptions | undefined; + if (options?.filters != null) { + if (!api.providerSupportsPullRequestFilters(providerId, options.filters)) { + Logger.warn(`Unsupported filters for provider ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + let userAccount: ProviderAccount | undefined; + if (providerId === HostingIntegrationId.AzureDevOps) { + const organizations = new Set(); + for (const repo of reposOrRepoIds as ProviderRepoInput[]) { + organizations.add(repo.namespace); + } + + if (organizations.size > 1) { + Logger.warn( + `Multiple organizations not supported for provider ${providerId}`, + 'getPullRequestsForRepos', + ); + return undefined; + } else if (organizations.size === 0) { + Logger.warn(`No organizations found for provider ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + const organization: string = organizations.values().next().value; + try { + userAccount = await api.getCurrentUserForInstance(providerId, organization); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } else { + try { + userAccount = await api.getCurrentUser(providerId); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + let userFilterProperty: string | null; + switch (providerId) { + case HostingIntegrationId.Bitbucket: + case HostingIntegrationId.AzureDevOps: + userFilterProperty = userAccount.id; + break; + default: + userFilterProperty = userAccount.username; + break; + } + + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + getPullRequestsOptions = { + authorLogin: options.filters.includes(PullRequestFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(PullRequestFilter.Assignee) ? [userFilterProperty] : undefined, + reviewRequestedLogin: options.filters.includes(PullRequestFilter.ReviewRequested) + ? userFilterProperty + : undefined, + mentionLogin: options.filters.includes(PullRequestFilter.Mention) ? userFilterProperty : undefined, + }; + } + + if ( + api.getProviderPullRequestsPagingMode(providerId) === PagingMode.Repo && + !api.isRepoIdsInput(reposOrRepoIds) + ) { + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedRepoInput[] = cursorInfo.cursors ?? []; + let repoInputs: PagedRepoInput[] = reposOrRepoIds.map(repo => ({ repo: repo, cursor: undefined })); + if (cursors.length > 0) { + repoInputs = cursors; + } + + try { + const cursor: { cursors: PagedRepoInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderPullRequest[] = []; + await Promise.all( + repoInputs.map(async repoInput => { + const results = await api.getPullRequestsForRepo(providerId, repoInput.repo, { + ...getPullRequestsOptions, + cursor: repoInput.cursor, + baseUrl: options?.customUrl, + }); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ repo: repoInput.repo, cursor: results.paging.cursor }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + try { + return api.getPullRequestsForRepos(providerId, reposOrRepoIds, { + ...getPullRequestsOptions, + cursor: options?.cursor, + baseUrl: options?.customUrl, + }); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + async searchMyPullRequests( + repo?: T, + cancellation?: CancellationToken, + ): Promise>; + async searchMyPullRequests( + repos?: T[], + cancellation?: CancellationToken, + ): Promise>; + @debug() + async searchMyPullRequests( + repos?: T | T[], + cancellation?: CancellationToken, + ): Promise> { + const scope = getLogScope(); + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const start = Date.now(); + try { + const pullRequests = await this.searchProviderMyPullRequests( + this._session!, + repos != null ? (Array.isArray(repos) ? repos : [repos]) : undefined, + cancellation, + ); + return { value: pullRequests, duration: Date.now() - start }; + } catch (ex) { + Logger.error(ex, scope); + return { error: ex, duration: Date.now() - start }; + } + } + + protected abstract searchProviderMyPullRequests( + session: ProviderAuthenticationSession, + repos?: T[], + cancellation?: CancellationToken, + ): Promise; + + async searchPullRequests( + searchQuery: string, + repo?: T, + cancellation?: CancellationToken, + ): Promise; + async searchPullRequests( + searchQuery: string, + repos?: T[], + cancellation?: CancellationToken, + ): Promise; + @debug() + async searchPullRequests( + searchQuery: string, + repos?: T | T[], + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const prs = await this.searchProviderPullRequests?.( + this._session!, + searchQuery, + repos != null ? (Array.isArray(repos) ? repos : [repos]) : undefined, + cancellation, + ); + this.resetRequestExceptionCount(); + return prs; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected searchProviderPullRequests?( + session: ProviderAuthenticationSession, + searchQuery: string, + repos?: T[], + cancellation?: CancellationToken, + ): Promise; +} diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts new file mode 100644 index 0000000000000..35a60efb701c8 --- /dev/null +++ b/src/plus/integrations/integrationService.ts @@ -0,0 +1,603 @@ +import type { AuthenticationSessionsChangeEvent, CancellationToken, Event } from 'vscode'; +import { authentication, Disposable, env, EventEmitter, window } from 'vscode'; +import { isWeb } from '@env/platform'; +import type { Source } from '../../constants'; +import type { Container } from '../../container'; +import type { SearchedIssue } from '../../git/models/issue'; +import type { SearchedPullRequest } from '../../git/models/pullRequest'; +import type { GitRemote } from '../../git/models/remote'; +import type { RemoteProvider, RemoteProviderId } from '../../git/remotes/remoteProvider'; +import { configuration } from '../../system/configuration'; +import { debug, log } from '../../system/decorators/log'; +import { take } from '../../system/event'; +import { filterMap, flatten } from '../../system/iterable'; +import type { SubscriptionChangeEvent } from '../gk/account/subscriptionService'; +import type { IntegrationAuthenticationService } from './authentication/integrationAuthentication'; +import type { SupportedCloudIntegrationIds } from './authentication/models'; +import { isSupportedCloudIntegrationId, supportedCloudIntegrationIds, toIntegrationId } from './authentication/models'; +import type { + HostingIntegration, + Integration, + IntegrationBase, + IntegrationKey, + IntegrationResult, + IntegrationType, + IssueIntegration, + ResourceDescriptor, + SupportedHostingIntegrationIds, + SupportedIntegrationIds, + SupportedIssueIntegrationIds, + SupportedSelfHostedIntegrationIds, +} from './integration'; +import type { IntegrationId } from './providers/models'; +import { + HostingIntegrationId, + isSelfHostedIntegrationId, + IssueIntegrationId, + SelfHostedIntegrationId, +} from './providers/models'; +import type { ProvidersApi } from './providers/providersApi'; + +export interface ConnectionStateChangeEvent { + key: string; + reason: 'connected' | 'disconnected'; +} + +export class IntegrationService implements Disposable { + private readonly _onDidChangeConnectionState = new EventEmitter(); + get onDidChangeConnectionState(): Event { + return this._onDidChangeConnectionState.event; + } + + private readonly _connectedCache = new Set(); + private readonly _disposable: Disposable; + private _integrations = new Map(); + + constructor( + private readonly container: Container, + private readonly authenticationService: IntegrationAuthenticationService, + ) { + this._disposable = Disposable.from( + configuration.onDidChange(e => { + if (configuration.changed(e, 'remotes')) { + this._ignoreSSLErrors.clear(); + } + }), + authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), + container.subscription.onDidCheckIn(this.onUserCheckedIn, this), + container.subscription.onDidChange(this.onDidChangeSubscription, this), + ); + } + + dispose() { + this._disposable?.dispose(); + } + + private async syncCloudIntegrations(_options?: { force?: boolean }) { + let connectedProviders = new Set(); + + const session = await this.container.subscription.getAuthenticationSession(); + if (session != null) { + const cloudIntegrations = await this.container.cloudIntegrations; + const connections = (await cloudIntegrations?.getConnections()) ?? []; + connectedProviders = new Set(connections.map(p => toIntegrationId[p.provider])); + } + + for (const cloudIntegrationId of supportedCloudIntegrationIds) { + const integration = await this.get(cloudIntegrationId); + const isConnected = integration.maybeConnected ?? (await integration.isConnected()); + if (connectedProviders.has(cloudIntegrationId)) { + if (isConnected) continue; + + await integration.connect(); + } else { + if (!isConnected) continue; + + await integration.disconnect({ silent: true, cloudSessionOnly: true }); + } + } + } + + private onUserCheckedIn() { + void this.syncCloudIntegrations(); + } + + private onDidChangeSubscription(e: SubscriptionChangeEvent) { + if (e.current?.account == null) { + void this.syncCloudIntegrations(); + } + } + + async manageCloudIntegrations( + connect: { integrationId: SupportedCloudIntegrationIds; skipIfConnected?: boolean } | undefined, + source: Source | undefined, + ) { + const integrationId = connect?.integrationId; + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'cloudIntegrations/settingsOpened', + { 'integration.id': integrationId }, + source, + ); + } + + const account = (await this.container.subscription.getSubscription()).account; + if (account == null) { + if (!(await this.container.subscription.loginOrSignUp(true, source))) return; + } + + if (integrationId && connect.skipIfConnected) { + await this.syncCloudIntegrations(); + const integration = await this.container.integrations.get(integrationId); + const connected = integration.maybeConnected ?? (await integration.isConnected()); + if (connected) return; + } + + let query = 'source=gitlens'; + if (integrationId != null) { + query += `&connect=${integrationId}`; + } + + await env.openExternal(this.container.getGkDevUri('settings/integrations', query)); + take( + window.onDidChangeWindowState, + 2, + )(e => { + if (e.focused) { + void this.syncCloudIntegrations(); + } + }); + } + + private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { + for (const integration of this._integrations.values()) { + if (e.provider.id === integration.authProvider.id) { + integration.refresh(); + } + } + } + + connected(integration: IntegrationBase, key: string): void { + // Only fire events if the key is being connected for the first time + if (this._connectedCache.has(key)) return; + + this._connectedCache.add(key); + if (this.container.telemetry.enabled) { + if (integration.type === 'hosting') { + if (isSupportedCloudIntegrationId(integration.id)) { + this.container.telemetry.sendEvent('cloudIntegrations/hosting/connected', { + 'hostingProvider.provider': integration.id, + 'hostingProvider.key': key, + }); + } else { + this.container.telemetry.sendEvent('remoteProviders/connected', { + 'hostingProvider.provider': integration.id, + 'hostingProvider.key': key, + + // Deprecated + 'remoteProviders.key': key, + }); + } + } else { + this.container.telemetry.sendEvent('cloudIntegrations/issue/connected', { + 'issueProvider.provider': integration.id, + 'issueProvider.key': key, + }); + } + } + + setTimeout(() => this._onDidChangeConnectionState.fire({ key: key, reason: 'connected' }), 250); + } + + disconnected(integration: IntegrationBase, key: string): void { + // Probably shouldn't bother to fire the event if we don't already think we are connected, but better to be safe + // if (!_connectedCache.has(key)) return; + this._connectedCache.delete(key); + if (this.container.telemetry.enabled) { + if (integration.type === 'hosting') { + if (isSupportedCloudIntegrationId(integration.id)) { + this.container.telemetry.sendEvent('cloudIntegrations/hosting/disconnected', { + 'hostingProvider.provider': integration.id, + 'hostingProvider.key': key, + }); + } else { + this.container.telemetry.sendEvent('remoteProviders/disconnected', { + 'hostingProvider.provider': integration.id, + 'hostingProvider.key': key, + + // Deprecated + 'remoteProviders.key': key, + }); + } + } else { + this.container.telemetry.sendEvent('cloudIntegrations/issue/disconnected', { + 'issueProvider.provider': integration.id, + 'issueProvider.key': key, + }); + } + } + + setTimeout(() => this._onDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }), 250); + } + + isConnected(key?: string): boolean { + return key == null ? this._connectedCache.size !== 0 : this._connectedCache.has(key); + } + + get(id: SupportedHostingIntegrationIds): Promise; + get(id: SupportedIssueIntegrationIds): Promise; + get(id: SupportedSelfHostedIntegrationIds, domain: string): Promise; + get(id: SupportedIntegrationIds, domain?: string): Promise; + async get( + id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, + domain?: string, + ): Promise { + let integration = this.getCached(id, domain); + if (integration == null) { + switch (id) { + case HostingIntegrationId.GitHub: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/github') + ).GitHubIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); + break; + case SelfHostedIntegrationId.GitHubEnterprise: + if (domain == null) throw new Error(`Domain is required for '${id}' integration`); + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/github') + ).GitHubEnterpriseIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + domain, + ); + break; + case HostingIntegrationId.GitLab: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/gitlab') + ).GitLabIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); + break; + case SelfHostedIntegrationId.GitLabSelfHosted: + if (domain == null) throw new Error(`Domain is required for '${id}' integration`); + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/gitlab') + ).GitLabSelfHostedIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + domain, + ); + break; + case HostingIntegrationId.Bitbucket: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/bitbucket') + ).BitbucketIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); + break; + case HostingIntegrationId.AzureDevOps: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/azureDevOps') + ).AzureDevOpsIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + ); + break; + case IssueIntegrationId.Jira: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/jira') + ).JiraIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); + break; + default: + throw new Error(`Integration with '${id}' is not supported`); + } + this._integrations.set(this.getCacheKey(id, domain), integration); + } + + return integration; + } + + private _providersApi: Promise | undefined; + private async getProvidersApi() { + if (this._providersApi == null) { + const container = this.container; + const authenticationService = this.authenticationService; + async function load() { + return new ( + await import(/* webpackChunkName: "integrations-api" */ './providers/providersApi') + ).ProvidersApi(container, authenticationService); + } + + this._providersApi = load(); + } + + return this._providersApi; + } + + getByRemote(remote: GitRemote): Promise { + if (remote?.provider == null) return Promise.resolve(undefined); + + return this.getByRemoteCore(remote as GitRemote, this.get); + } + + getByRemoteCached(remote: GitRemote): HostingIntegration | undefined { + if (remote?.provider == null) return undefined; + + return this.getByRemoteCore(remote as GitRemote, this.getCached); + } + + private getByRemoteCore( + remote: GitRemote, + getOrGetCached: F, + ): F extends typeof this.get ? Promise : HostingIntegration | undefined { + type RT = F extends typeof this.get ? Promise : HostingIntegration | undefined; + + const get = getOrGetCached.bind(this); + + switch (remote.provider.id) { + case 'azure-devops': + return get(HostingIntegrationId.AzureDevOps) as RT; + case 'bitbucket': + return get(HostingIntegrationId.Bitbucket) as RT; + case 'github': + if (remote.provider.custom && remote.provider.domain != null) { + return get(SelfHostedIntegrationId.GitHubEnterprise, remote.provider.domain) as RT; + } + return get(HostingIntegrationId.GitHub) as RT; + case 'gitlab': + if (remote.provider.custom && remote.provider.domain != null) { + return get(SelfHostedIntegrationId.GitLabSelfHosted, remote.provider.domain) as RT; + } + return get(HostingIntegrationId.GitLab) as RT; + case 'bitbucket-server': + default: + return (getOrGetCached === this.get ? Promise.resolve(undefined) : undefined) as RT; + } + } + + getConnected(type: 'issues'): IssueIntegration[]; + getConnected(type: 'hosting'): HostingIntegration[]; + getConnected(type: IntegrationType): Integration[] { + return [...this._integrations.values()].filter(p => p.maybeConnected && p.type === type); + } + + @log({ + args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : ''), 1: false }, + }) + async getMyIssues( + integrationIds?: HostingIntegrationId[], + cancellation?: CancellationToken, + ): Promise { + const integrations: Map = new Map(); + for (const integrationId of integrationIds?.length ? integrationIds : Object.values(HostingIntegrationId)) { + const integration = await this.get(integrationId); + if (integration == null) continue; + + integrations.set(integration, undefined); + } + if (integrations.size === 0) return undefined; + + return this.getMyIssuesCore(integrations, cancellation); + } + + private async getMyIssuesCore( + integrations: Map, + cancellation?: CancellationToken, + ): Promise { + const promises: Promise[] = []; + for (const [integration, repos] of integrations) { + if (integration == null) continue; + + promises.push(integration.searchMyIssues(repos, cancellation)); + } + + const results = await Promise.allSettled(promises); + return [...flatten(filterMap(results, r => (r.status === 'fulfilled' ? r.value : undefined)))]; + } + + async getMyIssuesForRemotes(remote: GitRemote): Promise; + async getMyIssuesForRemotes(remotes: GitRemote[]): Promise; + @debug({ + args: { 0: (r: GitRemote | GitRemote[]) => (Array.isArray(r) ? r.map(rp => rp.name) : r.name) }, + }) + async getMyIssuesForRemotes(remoteOrRemotes: GitRemote | GitRemote[]): Promise { + if (!Array.isArray(remoteOrRemotes)) { + remoteOrRemotes = [remoteOrRemotes]; + } + + if (!remoteOrRemotes.length) return undefined; + if (remoteOrRemotes.length === 1) { + const [remote] = remoteOrRemotes; + if (remote?.provider == null) return undefined; + + const integration = await this.getByRemote(remote); + return integration?.searchMyIssues(remote.provider.repoDesc); + } + + const integrations = new Map(); + + for (const remote of remoteOrRemotes) { + if (remote?.provider == null) continue; + + const integration = await remote.getIntegration(); + if (integration == null) continue; + + let repos = integrations.get(integration); + if (repos == null) { + repos = []; + integrations.set(integration, repos); + } + repos.push(remote.provider.repoDesc); + } + + return this.getMyIssuesCore(integrations); + } + + @log({ + args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : ''), 1: false }, + }) + async getMyPullRequests( + integrationIds?: HostingIntegrationId[], + cancellation?: CancellationToken, + ): Promise> { + const integrations: Map = new Map(); + for (const integrationId of integrationIds?.length ? integrationIds : Object.values(HostingIntegrationId)) { + const integration = await this.get(integrationId); + if (integration == null) continue; + + integrations.set(integration, undefined); + } + if (integrations.size === 0) return undefined; + + return this.getMyPullRequestsCore(integrations, cancellation); + } + + private async getMyPullRequestsCore( + integrations: Map, + cancellation?: CancellationToken, + ): Promise> { + const start = Date.now(); + + const promises: Promise>[] = []; + for (const [integration, repos] of integrations) { + if (integration == null) continue; + + promises.push(integration.searchMyPullRequests(repos, cancellation)); + } + + const results = await Promise.allSettled(promises); + + const errors = [ + ...filterMap(results, r => + r.status === 'fulfilled' && r.value?.error != null ? r.value.error : undefined, + ), + ]; + if (errors.length) { + return { + error: errors.length === 1 ? errors[0] : new AggregateError(errors), + duration: Date.now() - start, + }; + } + + return { + value: [ + ...flatten( + filterMap(results, r => + r.status === 'fulfilled' && r.value != null && r.value?.error == null + ? r.value.value + : undefined, + ), + ), + ], + duration: Date.now() - start, + }; + } + + async getMyPullRequestsForRemotes(remote: GitRemote): Promise>; + async getMyPullRequestsForRemotes( + remotes: GitRemote[], + ): Promise>; + @debug({ + args: { 0: (r: GitRemote | GitRemote[]) => (Array.isArray(r) ? r.map(rp => rp.name) : r.name) }, + }) + async getMyPullRequestsForRemotes( + remoteOrRemotes: GitRemote | GitRemote[], + ): Promise> { + if (!Array.isArray(remoteOrRemotes)) { + remoteOrRemotes = [remoteOrRemotes]; + } + + if (!remoteOrRemotes.length) return undefined; + if (remoteOrRemotes.length === 1) { + const [remote] = remoteOrRemotes; + if (remote?.provider == null) return undefined; + + const provider = await this.getByRemote(remote); + return provider?.searchMyPullRequests(remote.provider.repoDesc); + } + + const integrations = new Map(); + + for (const remote of remoteOrRemotes) { + if (remote?.provider == null) continue; + + const integration = await remote.getIntegration(); + if (integration == null) continue; + + let repos = integrations.get(integration); + if (repos == null) { + repos = []; + integrations.set(integration, repos); + } + repos.push(remote.provider.repoDesc); + } + + return this.getMyPullRequestsCore(integrations); + } + + isMaybeConnected(remote: GitRemote): boolean { + if (remote.provider?.id != null && this.supports(remote.provider.id)) { + return this.getByRemoteCached(remote)?.maybeConnected ?? false; + } + return false; + } + + @log() + async reset(): Promise { + for (const integration of this._integrations.values()) { + await integration.disconnect({ silent: true }); + } + + await this.authenticationService.reset(); + } + + supports(remoteId: RemoteProviderId): boolean { + switch (remoteId) { + case 'azure-devops': + case 'bitbucket': + case 'github': + case 'gitlab': + return true; + case 'bitbucket-server': + default: + return false; + } + } + + private _ignoreSSLErrors = new Map(); + ignoreSSLErrors( + integration: HostingIntegration | { id: SupportedIntegrationIds; domain?: string }, + ): boolean | 'force' { + if (isWeb) return false; + + let ignoreSSLErrors = this._ignoreSSLErrors.get(integration.id); + if (ignoreSSLErrors === undefined) { + const cfg = configuration + .get('remotes') + ?.find(remote => remote.type.toLowerCase() === integration.id && remote.domain === integration.domain); + ignoreSSLErrors = cfg?.ignoreSSLErrors ?? false; + this._ignoreSSLErrors.set(integration.id, ignoreSSLErrors); + } + + return ignoreSSLErrors; + } + + private getCached(id: SupportedHostingIntegrationIds): HostingIntegration | undefined; + private getCached(id: SupportedIssueIntegrationIds): IssueIntegration | undefined; + private getCached(id: SupportedSelfHostedIntegrationIds, domain: string): HostingIntegration | undefined; + private getCached( + id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, + domain?: string, + ): Integration | undefined; + private getCached( + id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, + domain?: string, + ): Integration | undefined { + return this._integrations.get(this.getCacheKey(id, domain)); + } + + private getCacheKey( + id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, + domain?: string, + ): IntegrationKey { + return isSelfHostedIntegrationId(id) ? (`${id}:${domain}` as const) : id; + } +} diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts new file mode 100644 index 0000000000000..c7909727e74f9 --- /dev/null +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -0,0 +1,150 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { PagedResult } from '../../../git/gitProvider'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { Logger } from '../../../system/logger'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { ResourceDescriptor } from '../integration'; +import { HostingIntegration } from '../integration'; +import type { ProviderRepository } from './models'; +import { HostingIntegrationId, providersMetadata } from './models'; + +const metadata = providersMetadata[HostingIntegrationId.AzureDevOps]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +interface AzureRepositoryDescriptor extends ResourceDescriptor { + owner: string; + name: string; +} + +export class AzureDevOpsIntegration extends HostingIntegration< + HostingIntegrationId.AzureDevOps, + AzureRepositoryDescriptor +> { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id = HostingIntegrationId.AzureDevOps; + protected readonly key = this.id; + readonly name: string = 'Azure DevOps'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://dev.azure.com'; + } + + async getReposForAzureProject( + namespace: string, + project: string, + options?: { cursor?: string }, + ): Promise | undefined> { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + return await ( + await this.getProvidersApi() + ).getReposForAzureProject(namespace, project, { cursor: options?.cursor }); + } catch (ex) { + Logger.error(ex, 'getReposForAzureProject'); + return undefined; + } + } + + protected override async mergeProviderPullRequest( + _session: AuthenticationSession, + _pr: PullRequest | { id: string; headRefSha: string }, + _options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + return Promise.resolve(false); + } + + protected override async getProviderAccountForCommit( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _ref: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderAccountForEmail( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _email: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderDefaultBranch( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderIssueOrPullRequest( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _id: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForBranch( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _branch: string, + _options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForCommit( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _ref: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderRepositoryMetadata( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _cancellation?: CancellationToken, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyPullRequests( + _session: AuthenticationSession, + _repos?: AzureRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyIssues( + _session: AuthenticationSession, + _repos?: AzureRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts new file mode 100644 index 0000000000000..e6a3c094563d2 --- /dev/null +++ b/src/plus/integrations/providers/bitbucket.ts @@ -0,0 +1,129 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { ResourceDescriptor } from '../integration'; +import { HostingIntegration } from '../integration'; +import { HostingIntegrationId, providersMetadata } from './models'; + +const metadata = providersMetadata[HostingIntegrationId.Bitbucket]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +interface BitbucketRepositoryDescriptor extends ResourceDescriptor { + owner: string; + name: string; +} + +export class BitbucketIntegration extends HostingIntegration< + HostingIntegrationId.Bitbucket, + BitbucketRepositoryDescriptor +> { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id = HostingIntegrationId.Bitbucket; + protected readonly key = this.id; + readonly name: string = 'Bitbucket'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://api.bitbucket.org/2.0'; + } + + protected override async mergeProviderPullRequest( + _session: AuthenticationSession, + _pr: PullRequest | { id: string; headRefSha: string }, + _options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + return Promise.resolve(false); + } + + protected override async getProviderAccountForCommit( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _ref: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderAccountForEmail( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _email: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderDefaultBranch( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderIssueOrPullRequest( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _id: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForBranch( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _branch: string, + _options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForCommit( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _ref: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderRepositoryMetadata( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _cancellation?: CancellationToken, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyPullRequests( + _session: AuthenticationSession, + _repos?: BitbucketRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyIssues( + _session: AuthenticationSession, + _repos?: BitbucketRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/plus/integrations/providers/github.ts b/src/plus/integrations/providers/github.ts new file mode 100644 index 0000000000000..cc6d98d300f79 --- /dev/null +++ b/src/plus/integrations/providers/github.ts @@ -0,0 +1,308 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import { authentication } from 'vscode'; +import type { Container } from '../../../container'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { PullRequestMergeMethod, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; +import { PullRequest } from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { log } from '../../../system/decorators/log'; +import { ensurePaidPlan } from '../../utils'; +import type { + IntegrationAuthenticationProviderDescriptor, + IntegrationAuthenticationService, +} from '../authentication/integrationAuthentication'; +import type { SupportedIntegrationIds } from '../integration'; +import { HostingIntegration } from '../integration'; +import { HostingIntegrationId, providersMetadata, SelfHostedIntegrationId } from './models'; +import type { ProvidersApi } from './providersApi'; + +const metadata = providersMetadata[HostingIntegrationId.GitHub]; +const authProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: metadata.id, + scopes: metadata.scopes, +}); + +const enterpriseMetadata = providersMetadata[SelfHostedIntegrationId.GitHubEnterprise]; +const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: enterpriseMetadata.id, + scopes: enterpriseMetadata.scopes, +}); + +export type GitHubRepositoryDescriptor = { + key: string; + owner: string; + name: string; +}; + +abstract class GitHubIntegrationBase extends HostingIntegration< + ID, + GitHubRepositoryDescriptor +> { + protected abstract get apiBaseUrl(): string; + + protected override async getProviderAccountForCommit( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.github)?.getAccountForCommit(this, accessToken, repo.owner, repo.name, ref, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderAccountForEmail( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.github)?.getAccountForEmail(this, accessToken, repo.owner, repo.name, email, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderDefaultBranch( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ): Promise { + return (await this.container.github)?.getDefaultBranch(this, accessToken, repo.owner, repo.name, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderIssueOrPullRequest( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + id: string, + ): Promise { + return (await this.container.github)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + Number(id), + { + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequest( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + id: string, + ): Promise { + return (await this.container.github)?.getPullRequest( + this, + accessToken, + repo.owner, + repo.name, + parseInt(id, 10), + { + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForBranch( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + const { include, ...opts } = options ?? {}; + + const toGitHubPullRequestState = (await import(/* webpackChunkName: "integrations" */ './github/models')) + .toGitHubPullRequestState; + return (await this.container.github)?.getPullRequestForBranch( + this, + accessToken, + repo.owner, + repo.name, + branch, + { + ...opts, + include: include?.map(s => toGitHubPullRequestState(s)), + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForCommit( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ref: string, + ): Promise { + return (await this.container.github)?.getPullRequestForCommit(this, accessToken, repo.owner, repo.name, ref, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderRepositoryMetadata( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + cancellation?: CancellationToken, + ): Promise { + return (await this.container.github)?.getRepositoryMetadata( + this, + accessToken, + repo.owner, + repo.name, + { + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override async searchProviderMyPullRequests( + { accessToken }: AuthenticationSession, + repos?: GitHubRepositoryDescriptor[], + cancellation?: CancellationToken, + ): Promise { + return (await this.container.github)?.searchMyPullRequests( + this, + accessToken, + { + repos: repos?.map(r => `${r.owner}/${r.name}`), + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override async searchProviderMyIssues( + { accessToken }: AuthenticationSession, + repos?: GitHubRepositoryDescriptor[], + cancellation?: CancellationToken, + ): Promise { + return (await this.container.github)?.searchMyIssues( + this, + accessToken, + { + repos: repos?.map(r => `${r.owner}/${r.name}`), + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override async searchProviderPullRequests( + { accessToken }: AuthenticationSession, + searchQuery: string, + repos?: GitHubRepositoryDescriptor[], + cancellation?: CancellationToken, + ): Promise { + return (await this.container.github)?.searchPullRequests( + this, + accessToken, + { + search: searchQuery, + repos: repos?.map(r => `${r.owner}/${r.name}`), + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override async mergeProviderPullRequest( + { accessToken }: AuthenticationSession, + pr: PullRequest | { id: string; headRefSha: string }, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + const id = pr instanceof PullRequest ? pr.nodeId : pr.id; + const headRefSha = pr instanceof PullRequest ? pr.refs?.head?.sha : pr.headRefSha; + if (id == null || headRefSha == null) return false; + return ( + (await this.container.github)?.mergePullRequest(this, accessToken, id, headRefSha, { + mergeMethod: options?.mergeMethod, + }) ?? false + ); + } + + protected override async getProviderCurrentAccount( + { accessToken }: AuthenticationSession, + options?: { avatarSize?: number }, + ): Promise { + return (await this.container.github)?.getCurrentAccount(this, accessToken, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } +} + +export class GitHubIntegration extends GitHubIntegrationBase { + readonly authProvider = authProvider; + readonly id = HostingIntegrationId.GitHub; + protected readonly key = this.id; + readonly name: string = 'GitHub'; + get domain(): string { + return metadata.domain; + } + + protected override get apiBaseUrl(): string { + return 'https://api.github.com'; + } + + // TODO: This is a special case for GitHub because we use VSCode's GitHub session, and it can be disconnected + // outside of the extension. Remove this once we use our own GitHub auth provider. + override async refresh() { + const session = await authentication.getSession(this.authProvider.id, this.authProvider.scopes); + if (session == null && this.maybeConnected) { + void this.disconnect(); + } else { + super.refresh(); + } + } +} + +export class GitHubEnterpriseIntegration extends GitHubIntegrationBase { + readonly authProvider = enterpriseAuthProvider; + readonly id = SelfHostedIntegrationId.GitHubEnterprise; + protected readonly key = `${this.id}:${this.domain}` as const; + readonly name = 'GitHub Enterprise'; + get domain(): string { + return this._domain; + } + + protected override get apiBaseUrl(): string { + return `https://${this._domain}/api/v3`; + } + + constructor( + container: Container, + authenticationService: IntegrationAuthenticationService, + getProvidersApi: () => Promise, + private readonly _domain: string, + ) { + super(container, authenticationService, getProvidersApi); + } + + @log() + override async connect(): Promise { + if ( + !(await ensurePaidPlan(this.container, `Rich integration with ${this.name} is a Pro feature.`, { + source: 'integrations', + detail: { action: 'connect', integration: this.id }, + })) + ) { + return false; + } + + return super.connect(); + } +} diff --git a/src/plus/github/github.ts b/src/plus/integrations/providers/github/github.ts similarity index 76% rename from src/plus/github/github.ts rename to src/plus/integrations/providers/github/github.ts index 3184c35511513..856de15352c31 100644 --- a/src/plus/github/github.ts +++ b/src/plus/integrations/providers/github/github.ts @@ -7,8 +7,7 @@ import type { CancellationToken, Disposable, Event } from 'vscode'; import { EventEmitter, Uri, window } from 'vscode'; import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; import { isWeb } from '@env/platform'; -import type { CoreConfiguration } from '../../constants'; -import type { Container } from '../../container'; +import type { Container } from '../../../../container'; import { AuthenticationError, AuthenticationErrorReason, @@ -16,31 +15,32 @@ import { ProviderRequestClientError, ProviderRequestNotFoundError, ProviderRequestRateLimitError, -} from '../../errors'; -import type { PagedResult, RepositoryVisibility } from '../../git/gitProvider'; -import type { Account } from '../../git/models/author'; -import type { DefaultBranch } from '../../git/models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../../git/models/issue'; -import type { PullRequest, SearchedPullRequest } from '../../git/models/pullRequest'; -import { isSha } from '../../git/models/reference'; -import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; -import type { GitUser } from '../../git/models/user'; -import { getGitHubNoReplyAddressParts } from '../../git/remotes/github'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +} from '../../../../errors'; +import type { PagedResult, RepositoryVisibility } from '../../../../git/gitProvider'; +import type { Account } from '../../../../git/models/author'; +import type { DefaultBranch } from '../../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../../git/models/issue'; +import type { PullRequest, SearchedPullRequest } from '../../../../git/models/pullRequest'; +import { PullRequestMergeMethod } from '../../../../git/models/pullRequest'; +import { isSha } from '../../../../git/models/reference'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata'; +import type { GitUser } from '../../../../git/models/user'; +import { getGitHubNoReplyAddressParts } from '../../../../git/remotes/github'; import { showIntegrationRequestFailed500WarningMessage, showIntegrationRequestTimedOutWarningMessage, -} from '../../messages'; -import { uniqueBy } from '../../system/array'; -import { configuration } from '../../system/configuration'; -import { debug } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import type { LogScope } from '../../system/logger.scope'; -import { getLogScope } from '../../system/logger.scope'; -import { maybeStopWatch } from '../../system/stopwatch'; -import { base64 } from '../../system/string'; -import type { Version } from '../../system/version'; -import { fromString, satisfies } from '../../system/version'; +} from '../../../../messages'; +import { configuration } from '../../../../system/configuration'; +import { debug } from '../../../../system/decorators/log'; +import { uniqueBy } from '../../../../system/iterable'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope } from '../../../../system/logger.scope'; +import { maybeStopWatch } from '../../../../system/stopwatch'; +import { base64 } from '../../../../system/string'; +import type { Version } from '../../../../system/version'; +import { fromString, satisfies } from '../../../../system/version'; import type { GitHubBlame, GitHubBlameRange, @@ -48,80 +48,95 @@ import type { GitHubCommit, GitHubCommitRef, GitHubContributor, - GitHubDetailedPullRequest, - GitHubIssueDetailed, + GitHubIssue, GitHubIssueOrPullRequest, GitHubPagedResult, GitHubPageInfo, GitHubPullRequest, + GitHubPullRequestLite, GitHubPullRequestState, GitHubTag, } from './models'; import { - fromGitHubIssueDetailed, + fromGitHubIssue, + fromGitHubIssueOrPullRequestState, fromGitHubPullRequest, - fromGitHubPullRequestDetailed, - fromGitHubPullRequestState, + fromGitHubPullRequestLite, } from './models'; const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); const emptyBlameResult: GitHubBlame = Object.freeze({ ranges: [] }); -const prNodeProperties = ` -assignees(first: 10) { - nodes { - login - avatarUrl - url - } -} +const gqlIssueOrPullRequestFragment = ` +closed +closedAt +createdAt +id +number +state +title +updatedAt +url +`; +const gqlPullRequestLiteFragment = ` +${gqlIssueOrPullRequestFragment} author { login - avatarUrl + avatarUrl(size: $avatarSize) url } baseRefName baseRefOid -baseRepository { +headRefName +headRefOid +headRepository { name owner { login } url } -checksUrl -isDraft isCrossRepository -isReadByViewer -headRefName -headRefOid -headRepository { +mergedAt +permalink +repository { + isFork name owner { login } url + viewerPermission } -permalink -number -title -state +`; +const gqlPullRequestFragment = ` +${gqlPullRequestLiteFragment} additions +assignees(first: 10) { + nodes { + login + avatarUrl(size: $avatarSize) + url + } +} +checksUrl deletions -updatedAt -closedAt +isDraft mergeable -mergedAt mergedBy { login } -repository { - isFork - owner { - login +reviewDecision +latestReviews(first: 10) { + nodes { + author { + login + avatarUrl(size: $avatarSize) + url + } + state } } -reviewDecision reviewRequests(first: 10) { nodes { asCodeOwner @@ -129,55 +144,52 @@ reviewRequests(first: 10) { requestedReviewer { ... on User { login - avatarUrl + avatarUrl(size: $avatarSize) url } } } } +statusCheckRollup { + state +} totalCommentsCount +viewerCanUpdate `; -const issueNodeProperties = ` -... on Issue { - assignees(first: 100) { - nodes { - login - url - avatarUrl - } - } - author { +const gqIssueFragment = ` +${gqlIssueOrPullRequestFragment} +assignees(first: 100) { + nodes { login - avatarUrl url + avatarUrl(size: $avatarSize) } - comments { - totalCount - } - number - title +} +author { + login + avatarUrl url - createdAt - closedAt - closed - updatedAt - labels(first: 20) { - nodes { - color - name - } - } - reactions(content: THUMBS_UP) { - totalCount - } - repository { +} +comments { + totalCount +} +labels(first: 20) { + nodes { + color name - owner { - login - } } } +reactions(content: THUMBS_UP) { + totalCount +} +repository { + name + owner { + login + } + viewerPermission +} `; export class GitHubApi implements Disposable { @@ -191,7 +203,7 @@ export class GitHubApi implements Disposable { constructor(_container: Container) { this._disposable = configuration.onDidChangeAny(e => { if ( - configuration.changedAny(e, ['http.proxy', 'http.proxyStrictSSL']) || + configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) || configuration.changed(e, ['outputLevel', 'proxy']) ) { this.resetCaches(); @@ -219,9 +231,67 @@ export class GitHubApi implements Disposable { return this._proxyAgent; } + async getCurrentAccount( + provider: Provider, + token: string, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + interface QueryResult { + viewer: { + name: string | null; + email: string | null; + login: string | null; + avatarUrl: string | null; + }; + } + + try { + const query = `query getCurrentAccount($avatarSize: Int) { + viewer { + name + email + login + avatarUrl(size: $avatarSize) + } +}`; + + const rsp = await this.graphql(provider, token, query, { ...options }, scope); + if (rsp?.viewer == null) return undefined; + + return { + provider: provider, + name: rsp.viewer.name ?? undefined, + email: rsp.viewer.email ?? undefined, + // If we are GitHub Enterprise, we may need to convert the avatar URL since it might require authentication + avatarUrl: + !rsp.viewer.avatarUrl || isGitHubDotCom(options) + ? rsp.viewer.avatarUrl ?? undefined + : rsp.viewer.email && options?.baseUrl != null + ? await this.createEnterpriseAvatarUrl( + provider, + token, + options.baseUrl, + rsp.viewer.email, + options.avatarSize, + ) + : undefined, + username: rsp.viewer.login ?? undefined, + }; + } catch (ex) { + if (ex instanceof ProviderRequestNotFoundError) return undefined; + + throw this.handleException(ex, provider, scope); + } + } + @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -242,6 +312,9 @@ export class GitHubApi implements Disposable { name: string | null; email: string | null; avatarUrl: string; + user: { + login: string | null; + } | null; }; } | null @@ -265,6 +338,9 @@ export class GitHubApi implements Disposable { name email avatarUrl(size: $avatarSize) + user { + login + } } } } @@ -296,14 +372,15 @@ export class GitHubApi implements Disposable { !author.avatarUrl || isGitHubDotCom(options) ? author.avatarUrl ?? undefined : author.email && options?.baseUrl != null - ? await this.createEnterpriseAvatarUrl( - provider, - token, - options.baseUrl, - author.email, - options.avatarSize, - ) - : undefined, + ? await this.createEnterpriseAvatarUrl( + provider, + token, + options.baseUrl, + author.email, + options.avatarSize, + ) + : undefined, + username: author.user?.login ?? undefined, }; } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -314,7 +391,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForEmail( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -334,6 +411,7 @@ export class GitHubApi implements Disposable { name: string | null; email: string | null; avatarUrl: string; + login: string | null; }[] | null | undefined; @@ -353,6 +431,7 @@ export class GitHubApi implements Disposable { name email avatarUrl(size: $avatarSize) + login } } } @@ -383,14 +462,15 @@ export class GitHubApi implements Disposable { !author.avatarUrl || isGitHubDotCom(options) ? author.avatarUrl ?? undefined : author.email && options?.baseUrl != null - ? await this.createEnterpriseAvatarUrl( - provider, - token, - options.baseUrl, - author.email, - options.avatarSize, - ) - : undefined, + ? await this.createEnterpriseAvatarUrl( + provider, + token, + options.baseUrl, + author.email, + options.avatarSize, + ) + : undefined, + username: author.login ?? undefined, }; } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -401,7 +481,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getDefaultBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -460,7 +540,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getIssueOrPullRequest( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -485,20 +565,10 @@ export class GitHubApi implements Disposable { issueOrPullRequest(number: $number) { __typename ... on Issue { - createdAt - closed - closedAt - title - url - state + ${gqlIssueOrPullRequestFragment} } ... on PullRequest { - createdAt - closed - closedAt - title - url - state + ${gqlIssueOrPullRequestFragment} } } } @@ -522,14 +592,16 @@ export class GitHubApi implements Disposable { return { provider: provider, - type: issue.type, - id: String(number), - date: new Date(issue.createdAt), + type: issue.__typename === 'PullRequest' ? 'pullrequest' : 'issue', + id: String(issue.number), + nodeId: issue.id, + createdDate: new Date(issue.createdAt), + updatedDate: new Date(issue.updatedAt), title: issue.title, closed: issue.closed, closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), url: issue.url, - state: fromGitHubPullRequestState(issue.state), + state: fromGitHubIssueOrPullRequestState(issue.state), }; } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -538,9 +610,69 @@ export class GitHubApi implements Disposable { } } + @debug({ args: { 0: p => p.name, 1: '' } }) + async getPullRequest( + provider: Provider, + token: string, + owner: string, + repo: string, + number: number, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + interface QueryResult { + repository: + | { + pullRequest: GitHubPullRequestLite | null | undefined; + } + | null + | undefined; + } + + try { + const query = `query getPullRequest( + $owner: String! + $repo: String! + $number: Int! + $avatarSize: Int +) { + repository(name: $repo, owner: $owner) { + pullRequest(number: $number) { + ${gqlPullRequestFragment} + } + } +}`; + + const rsp = await this.graphql( + provider, + token, + query, + { + ...options, + owner: owner, + repo: repo, + number: number, + }, + scope, + ); + + if (rsp?.repository?.pullRequest == null) return undefined; + + return fromGitHubPullRequestLite(rsp.repository.pullRequest, provider); + } catch (ex) { + if (ex instanceof ProviderRequestNotFoundError) return undefined; + + throw this.handleException(ex, provider, scope); + } + } + @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -559,7 +691,7 @@ export class GitHubApi implements Disposable { ref: | { associatedPullRequests?: { - nodes?: GitHubPullRequest[]; + nodes?: GitHubPullRequestLite[]; }; } | null @@ -582,24 +714,7 @@ export class GitHubApi implements Disposable { ref(qualifiedName: $branch) { associatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) { nodes { - author { - login - avatarUrl(size: $avatarSize) - url - } - permalink - number - title - state - updatedAt - closedAt - mergedAt - repository { - isFork - owner { - login - } - } + ${gqlPullRequestLiteFragment} } } } @@ -636,7 +751,7 @@ export class GitHubApi implements Disposable { ); } - return fromGitHubPullRequest(prs[0], provider); + return fromGitHubPullRequestLite(prs[0], provider); } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -646,7 +761,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -664,7 +779,7 @@ export class GitHubApi implements Disposable { | { object?: { associatedPullRequests?: { - nodes?: GitHubPullRequest[]; + nodes?: GitHubPullRequestLite[]; }; }; } @@ -684,24 +799,7 @@ export class GitHubApi implements Disposable { ... on Commit { associatedPullRequests(first: 2, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { - author { - login - avatarUrl(size: $avatarSize) - url - } - permalink - number - title - state - updatedAt - closedAt - mergedAt - repository { - isFork - owner { - login - } - } + ${gqlPullRequestLiteFragment} } } } @@ -738,7 +836,7 @@ export class GitHubApi implements Disposable { ); } - return fromGitHubPullRequest(prs[0], provider); + return fromGitHubPullRequestLite(prs[0], provider); } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -748,7 +846,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getRepositoryMetadata( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -1079,7 +1177,14 @@ export class GitHubApi implements Disposable { } @debug({ args: { 0: '' } }) - async getCommitBranches(token: string, owner: string, repo: string, ref: string, date: Date): Promise { + async getCommitBranches( + token: string, + owner: string, + repo: string, + refs: string[], + mode: 'contains' | 'pointsAt', + date?: Date, + ): Promise { const scope = getLogScope(); interface QueryResult { @@ -1097,6 +1202,8 @@ export class GitHubApi implements Disposable { }; } + const limit = mode === 'contains' ? 10 : 1; + try { const query = `query getCommitBranches( $owner: String! @@ -1110,7 +1217,7 @@ export class GitHubApi implements Disposable { name target { ... on Commit { - history(first: 3, since: $since until: $until) { + history(first: ${limit}, since: $since until: $until) { nodes { oid } } } @@ -1126,8 +1233,8 @@ export class GitHubApi implements Disposable { { owner: owner, repo: repo, - since: date.toISOString(), - until: date.toISOString(), + since: date?.toISOString(), + until: date?.toISOString(), }, scope, ); @@ -1139,7 +1246,7 @@ export class GitHubApi implements Disposable { for (const branch of nodes) { for (const commit of branch.target.history.nodes) { - if (commit.oid === ref) { + if (refs.includes(commit.oid)) { branches.push(branch.name); break; } @@ -1214,8 +1321,9 @@ export class GitHubApi implements Disposable { owner: string, repo: string, branch: string, - ref: string, - date: Date, + refs: string[], + mode: 'contains' | 'pointsAt', + date?: Date, ): Promise { const scope = getLogScope(); @@ -1230,6 +1338,9 @@ export class GitHubApi implements Disposable { }; }; } + + const limit = mode === 'contains' ? 100 : 1; + try { const query = `query getCommitOnBranch( $owner: String! @@ -1242,7 +1353,7 @@ export class GitHubApi implements Disposable { ref(qualifiedName: $ref) { target { ... on Commit { - history(first: 3, since: $since until: $until) { + history(first: ${limit}, since: $since until: $until) { nodes { oid } } } @@ -1258,8 +1369,8 @@ export class GitHubApi implements Disposable { owner: owner, repo: repo, ref: `refs/heads/${branch}`, - since: date.toISOString(), - until: date.toISOString(), + since: date?.toISOString(), + until: date?.toISOString(), }, scope, ); @@ -1270,7 +1381,7 @@ export class GitHubApi implements Disposable { const branches = []; for (const commit of nodes) { - if (commit.oid === ref) { + if (refs.includes(commit.oid)) { branches.push(branch); break; } @@ -1591,6 +1702,82 @@ export class GitHubApi implements Disposable { } } + @debug({ args: { 0: '' } }) + async getCommitTags(token: string, owner: string, repo: string, ref: string, date: Date): Promise { + const scope = getLogScope(); + + interface QueryResult { + repository: { + refs: { + nodes: { + name: string; + target: { + history: { + nodes: { oid: string }[]; + }; + }; + }[]; + }; + }; + } + + try { + const query = `query getCommitTags( + $owner: String! + $repo: String! + $since: GitTimestamp! + $until: GitTimestamp! +) { + repository(owner: $owner, name: $repo) { + refs(first: 20, refPrefix: "refs/tags/") { + nodes { + name + target { + ... on Commit { + history(first: 3, since: $since until: $until) { + nodes { oid } + } + } + } + } + } + } +}`; + const rsp = await this.graphql( + undefined, + token, + query, + { + owner: owner, + repo: repo, + since: date.toISOString(), + until: date.toISOString(), + }, + scope, + ); + + const nodes = rsp?.repository?.refs?.nodes; + if (nodes == null) return []; + + const tags = []; + + for (const tag of nodes) { + for (const commit of tag.target.history.nodes) { + if (commit.oid === ref) { + tags.push(tag.name); + break; + } + } + } + + return tags; + } catch (ex) { + if (ex instanceof ProviderRequestNotFoundError) return []; + + throw this.handleException(ex, undefined, scope); + } + } + @debug({ args: { 0: '' } }) async getNextCommitRefs( token: string, @@ -2189,9 +2376,9 @@ export class GitHubApi implements Disposable { private _enterpriseVersions = new Map(); - @debug({ args: { 0: '' } }) + @debug({ args: { 0: p => p?.name, 1: '' } }) private async getEnterpriseVersion( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, options?: { baseUrl?: string }, ): Promise { @@ -2203,7 +2390,7 @@ export class GitHubApi implements Disposable { try { const rsp = await this.request(provider, token, 'GET /meta', options, scope); - const v = (rsp?.data as any)?.installed_version as string | null | undefined; + const v = (rsp?.data as unknown as { installed_version: string | null | undefined })?.installed_version; version = v ? fromString(v) : null; } catch (ex) { debugger; @@ -2215,7 +2402,7 @@ export class GitHubApi implements Disposable { } private async graphql( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, query: string, variables: RequestParameters, @@ -2275,7 +2462,7 @@ export class GitHubApi implements Disposable { } private async request( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, route: keyof Endpoints | R, options: @@ -2300,7 +2487,7 @@ export class GitHubApi implements Disposable { return await wrapForForcedInsecureSSL( provider?.getIgnoreSSLErrors() ?? false, () => - this.getDefaults(token, request)(route, options) as unknown as Promise< + this.getDefaults(token, request)(route as R, options) as unknown as Promise< R extends keyof Endpoints ? Endpoints[R]['response'] : OctokitResponse >, ); @@ -2377,12 +2564,12 @@ export class GitHubApi implements Disposable { } private handleRequestError( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, ex: RequestError | (Error & { name: 'AbortError' }), scope: LogScope | undefined, ): void { - if (ex.name === 'AbortError') throw new CancellationError(); + if (ex.name === 'AbortError') throw new CancellationError(ex); switch (ex.status) { case 404: // Not found @@ -2413,7 +2600,7 @@ export class GitHubApi implements Disposable { provider?.trackRequestException(); void showIntegrationRequestFailed500WarningMessage( `${provider?.name ?? 'GitHub'} failed to respond and might be experiencing issues.${ - !provider?.custom + provider == null || provider.id === 'github' ? ' Please visit the [GitHub status page](https://githubstatus.com) for more information.' : '' }`, @@ -2442,7 +2629,7 @@ export class GitHubApi implements Disposable { } } - private handleException(ex: Error, provider: RichRemoteProvider | undefined, scope: LogScope | undefined): Error { + private handleException(ex: Error, provider: Provider | undefined, scope: LogScope | undefined): Error { Logger.error(ex, scope); // debugger; @@ -2452,7 +2639,7 @@ export class GitHubApi implements Disposable { return ex; } - private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: RichRemoteProvider | undefined) { + private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: Provider | undefined) { if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) { const confirm = 'Reauthenticate'; const result = await window.showErrorMessage( @@ -2473,7 +2660,7 @@ export class GitHubApi implements Disposable { } private async createEnterpriseAvatarUrl( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, baseUrl: string, email: string, @@ -2501,7 +2688,7 @@ export class GitHubApi implements Disposable { } const rsp = await wrapForForcedInsecureSSL(provider?.getIgnoreSSLErrors() ?? false, () => - fetch(url!, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }), + fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }), ); if (rsp.ok) { @@ -2517,61 +2704,67 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async searchMyPullRequests( - provider: RichRemoteProvider, + provider: Provider, token: string, - options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string }, + options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string; avatarSize?: number }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); + if (configuration.get('launchpad.experimental.queryUseInvolvesFilter') ?? false) { + return this.searchMyInvolvedPullRequests(provider, token, options, cancellation); + } + + const limit = Math.min(100, configuration.get('launchpad.experimental.queryLimit') ?? 100); + interface SearchResult { - related: { - nodes: GitHubDetailedPullRequest[]; - }; authored: { - nodes: GitHubDetailedPullRequest[]; + nodes: GitHubPullRequest[]; }; assigned: { - nodes: GitHubDetailedPullRequest[]; + nodes: GitHubPullRequest[]; }; reviewRequested: { - nodes: GitHubDetailedPullRequest[]; + nodes: GitHubPullRequest[]; }; mentioned: { - nodes: GitHubDetailedPullRequest[]; + nodes: GitHubPullRequest[]; }; } + try { - const query = `query searchPullRequests( + const query = `query searchMyPullRequests( $authored: String! $assigned: String! $reviewRequested: String! $mentioned: String! + $avatarSize: Int ) { - authored: search(first: 100, query: $authored, type: ISSUE) { + authored: search(first: ${limit}, query: $authored, type: ISSUE) { nodes { ...on PullRequest { - ${prNodeProperties} + ${gqlPullRequestFragment} } } } - assigned: search(first: 100, query: $assigned, type: ISSUE) { + assigned: search(first: ${limit}, query: $assigned, type: ISSUE) { nodes { ...on PullRequest { - ${prNodeProperties} + ${gqlPullRequestFragment} } } } - reviewRequested: search(first: 100, query: $reviewRequested, type: ISSUE) { + reviewRequested: search(first: ${limit}, query: $reviewRequested, type: ISSUE) { nodes { ...on PullRequest { - ${prNodeProperties} + ${gqlPullRequestFragment} } } } - mentioned: search(first: 100, query: $mentioned, type: ISSUE) { + mentioned: search(first: ${limit}, query: $mentioned, type: ISSUE) { nodes { ...on PullRequest { - ${prNodeProperties} + ${gqlPullRequestFragment} } } } @@ -2589,7 +2782,7 @@ export class GitHubApi implements Disposable { } const baseFilters = 'is:pr is:open archived:false'; - const resp = await this.graphql( + const rsp = await this.graphql( provider, token, query, @@ -2599,24 +2792,26 @@ export class GitHubApi implements Disposable { reviewRequested: `${search} ${baseFilters} review-requested:@me`.trim(), mentioned: `${search} ${baseFilters} mentions:@me`.trim(), baseUrl: options?.baseUrl, + avatarSize: options?.avatarSize, }, scope, + cancellation, ); - if (resp === undefined) return []; + if (rsp == null) return []; - function toQueryResult(pr: GitHubDetailedPullRequest, reason?: string): SearchedPullRequest { + function toQueryResult(pr: GitHubPullRequest, reason?: string): SearchedPullRequest { return { - pullRequest: fromGitHubPullRequestDetailed(pr, provider), + pullRequest: fromGitHubPullRequest(pr, provider), reasons: reason ? [reason] : [], }; } const results: SearchedPullRequest[] = uniqueWithReasons( [ - ...resp.assigned.nodes.map(pr => toQueryResult(pr, 'assigned')), - ...resp.reviewRequested.nodes.map(pr => toQueryResult(pr, 'review-requested')), - ...resp.mentioned.nodes.map(pr => toQueryResult(pr, 'mentioned')), - ...resp.authored.nodes.map(pr => toQueryResult(pr, 'authored')), + ...rsp.assigned.nodes.map(pr => toQueryResult(pr, 'assigned')), + ...rsp.reviewRequested.nodes.map(pr => toQueryResult(pr, 'review-requested')), + ...rsp.mentioned.nodes.map(pr => toQueryResult(pr, 'mentioned')), + ...rsp.authored.nodes.map(pr => toQueryResult(pr, 'authored')), ], r => r.pullRequest.url, ); @@ -2626,46 +2821,157 @@ export class GitHubApi implements Disposable { } } - @debug({ args: { 0: '' } }) + @debug({ args: { 0: p => p.name, 1: '' } }) + private async searchMyInvolvedPullRequests( + provider: Provider, + token: string, + options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string; avatarSize?: number }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + const limit = Math.min(100, configuration.get('launchpad.experimental.queryLimit') ?? 100); + + try { + interface SearchResult { + search: { + issueCount: number; + nodes: GitHubPullRequest[]; + }; + viewer: { + login: string; + }; + } + + const query = `query searchMyPullRequests( + $search: String! + $avatarSize: Int +) { + search(first: ${limit}, query: $search, type: ISSUE) { + issueCount + nodes { + ...on PullRequest { + ${gqlPullRequestFragment} + } + } + } + viewer { + login + } +}`; + + let search = options?.search?.trim() ?? ''; + + if (options?.user) { + search += ` user:${options.user}`; + } + + if (options?.repos?.length) { + search += ` repo:${options.repos.join(' repo:')}`; + } + + // Hack for now, ultimately this should be passed in + const ignoredRepos = configuration.get('launchpad.ignoredRepositories') ?? []; + if (ignoredRepos.length) { + search += ` -repo:${ignoredRepos.join(' -repo:')}`; + } + + // Hack for now, ultimately this should be passed in + const ignoredOrgs = configuration.get('launchpad.ignoredOrganizations') ?? []; + if (ignoredOrgs.length) { + search += ` -org:${ignoredOrgs.join(' -org:')}`; + } + + const rsp = await this.graphql( + provider, + token, + query, + { + search: `is:open is:pr involves:@me archived:false ${search}`.trim(), + baseUrl: options?.baseUrl, + avatarSize: options?.avatarSize, + }, + scope, + cancellation, + ); + if (rsp == null) return []; + + const viewer = rsp.viewer.login; + + function toQueryResult(pr: GitHubPullRequest): SearchedPullRequest { + const reasons = []; + if (pr.author.login === viewer) { + reasons.push('authored'); + } + if (pr.assignees.nodes.some(a => a.login === viewer)) { + reasons.push('assigned'); + } + if (pr.reviewRequests.nodes.some(r => r.requestedReviewer?.login === viewer)) { + reasons.push('review-requested'); + } + if (reasons.length === 0) { + reasons.push('mentioned'); + } + + return { + pullRequest: fromGitHubPullRequest(pr, provider), + reasons: reasons, + }; + } + + const results: SearchedPullRequest[] = rsp.search.nodes.map(pr => toQueryResult(pr)); + return results; + } catch (ex) { + throw this.handleException(ex, provider, scope); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) async searchMyIssues( - provider: RichRemoteProvider, + provider: Provider, token: string, - options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string }, + options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string; avatarSize?: number }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); + interface SearchResult { - related: { - nodes: GitHubIssueDetailed[]; - }; authored: { - nodes: GitHubIssueDetailed[]; + nodes: GitHubIssue[]; }; assigned: { - nodes: GitHubIssueDetailed[]; + nodes: GitHubIssue[]; }; mentioned: { - nodes: GitHubIssueDetailed[]; + nodes: GitHubIssue[]; }; } - const query = `query searchIssues( + const query = `query searchMyIssues( $authored: String! $assigned: String! $mentioned: String! + $avatarSize: Int ) { authored: search(first: 100, query: $authored, type: ISSUE) { nodes { - ${issueNodeProperties} + ... on Issue { + ${gqIssueFragment} + } } } assigned: search(first: 100, query: $assigned, type: ISSUE) { nodes { - ${issueNodeProperties} + ... on Issue { + ${gqIssueFragment} + } } } mentioned: search(first: 100, query: $mentioned, type: ISSUE) { nodes { - ${issueNodeProperties} + ... on Issue { + ${gqIssueFragment} + } } } }`; @@ -2683,7 +2989,7 @@ export class GitHubApi implements Disposable { const baseFilters = 'type:issue is:open archived:false'; try { - const resp = await this.graphql( + const rsp = await this.graphql( provider, token, query, @@ -2692,24 +2998,26 @@ export class GitHubApi implements Disposable { assigned: `${search} ${baseFilters} assignee:@me`.trim(), mentioned: `${search} ${baseFilters} mentions:@me`.trim(), baseUrl: options?.baseUrl, + avatarSize: options?.avatarSize, }, scope, + cancellation, ); - function toQueryResult(issue: GitHubIssueDetailed, reason?: string): SearchedIssue { + function toQueryResult(issue: GitHubIssue, reason?: string): SearchedIssue { return { - issue: fromGitHubIssueDetailed(issue, provider), + issue: fromGitHubIssue(issue, provider), reasons: reason ? [reason] : [], }; } - if (resp === undefined) return []; + if (rsp == null) return []; const results: SearchedIssue[] = uniqueWithReasons( [ - ...resp.assigned.nodes.map(pr => toQueryResult(pr, 'assigned')), - ...resp.mentioned.nodes.map(pr => toQueryResult(pr, 'mentioned')), - ...resp.authored.nodes.map(pr => toQueryResult(pr, 'authored')), + ...rsp.assigned.nodes.map(pr => toQueryResult(pr, 'assigned')), + ...rsp.mentioned.nodes.map(pr => toQueryResult(pr, 'mentioned')), + ...rsp.authored.nodes.map(pr => toQueryResult(pr, 'authored')), ], r => r.issue.url, ); @@ -2718,6 +3026,130 @@ export class GitHubApi implements Disposable { throw this.handleException(ex, provider, scope); } } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async searchPullRequests( + provider: Provider, + token: string, + options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string; avatarSize?: number }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + interface SearchResult { + nodes: GitHubPullRequest[]; + } + + try { + const query = `query searchPullRequests( + $searchQuery: String! + $avatarSize: Int +) { + search(first: 100, query: $searchQuery, type: ISSUE) { + nodes { + ...on PullRequest { + ${gqlPullRequestFragment} + } + } + } +}`; + + let search = options?.search?.trim() ?? ''; + + if (options?.user) { + search += ` user:${options.user}`; + } + + if (options?.repos != null && options.repos.length > 0) { + const repo = ' repo:'; + search += `${repo}${options.repos.join(repo)}`; + } + + const rsp = await this.graphql( + provider, + token, + query, + { + searchQuery: `is:pr is:open archived:false ${search.trim()}`, + baseUrl: options?.baseUrl, + avatarSize: options?.avatarSize, + }, + scope, + cancellation, + ); + if (rsp == null) return []; + + const results = rsp.nodes.map(pr => fromGitHubPullRequest(pr, provider)); + return results; + } catch (ex) { + throw this.handleException(ex, provider, scope); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async mergePullRequest( + provider: Provider, + token: string, + nodeId: string, + expectedSourceSha: string, + options?: { mergeMethod?: PullRequestMergeMethod; baseUrl?: string }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + interface QueryResult { + pullRequest: GitHubPullRequestLite | null | undefined; + } + + let githubMergeStrategy; + switch (options?.mergeMethod) { + case PullRequestMergeMethod.Merge: { + githubMergeStrategy = 'MERGE'; + break; + } + + case PullRequestMergeMethod.Rebase: { + githubMergeStrategy = 'REBASE'; + break; + } + + case PullRequestMergeMethod.Squash: { + githubMergeStrategy = 'SQUASH'; + break; + } + } + + try { + const query = `mutation mergePullRequest( + $id: ID! + $expectedSourceSha: GitObjectID! + $mergeMethod: PullRequestMergeMethod +) { + mergePullRequest(input: { pullRequestId: $id, expectedHeadOid: $expectedSourceSha, mergeMethod: $mergeMethod }) { + pullRequest { + id + } + } +}`; + + const rsp = await this.graphql( + provider, + token, + query, + { + id: nodeId, + expectedSourceSha: expectedSourceSha, + mergeMethod: githubMergeStrategy, + baseUrl: options?.baseUrl, + }, + scope, + cancellation, + ); + + return rsp?.pullRequest?.id === nodeId; + } catch (ex) { + throw this.handleException(ex, provider, scope); + } + } } function isGitHubDotCom(options?: { baseUrl?: string }) { @@ -2725,10 +3157,12 @@ function isGitHubDotCom(options?: { baseUrl?: string }) { } function uniqueWithReasons(items: T[], lookup: (item: T) => unknown): T[] { - return uniqueBy(items, lookup, (original, current) => { - if (current.reasons.length !== 0) { - original.reasons.push(...current.reasons); - } - return original; - }); + return [ + ...uniqueBy(items, lookup, (original, current) => { + if (current.reasons.length !== 0) { + original.reasons.push(...current.reasons); + } + return original; + }), + ]; } diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/integrations/providers/github/githubGitProvider.ts similarity index 93% rename from src/plus/github/githubGitProvider.ts rename to src/plus/integrations/providers/github/githubGitProvider.ts index 940c8192c36fb..ecbc8c2587d42 100644 --- a/src/plus/github/githubGitProvider.ts +++ b/src/plus/integrations/providers/github/githubGitProvider.ts @@ -11,41 +11,42 @@ import type { } from 'vscode'; import { authentication, EventEmitter, FileType, Uri, window, workspace } from 'vscode'; import { encodeUtf8Hex } from '@env/hex'; -import { CharCode, Schemes } from '../../constants'; -import type { Container } from '../../container'; -import { emojify } from '../../emojis'; +import { CharCode, Schemes } from '../../../../constants'; +import type { Container } from '../../../../container'; +import { emojify } from '../../../../emojis'; import { AuthenticationError, AuthenticationErrorReason, ExtensionNotFoundError, OpenVirtualRepositoryError, OpenVirtualRepositoryErrorReason, -} from '../../errors'; -import { Features } from '../../features'; -import { GitSearchError } from '../../git/errors'; +} from '../../../../errors'; +import { Features } from '../../../../features'; +import { GitSearchError } from '../../../../git/errors'; import type { GitCaches, GitProvider, NextComparisonUrisResult, PagedResult, + PagingOptions, PreviousComparisonUrisResult, PreviousLineComparisonUrisResult, RepositoryCloseEvent, RepositoryOpenEvent, RepositoryVisibility, ScmRepository, -} from '../../git/gitProvider'; -import { GitUri } from '../../git/gitUri'; -import type { GitBlame, GitBlameAuthor, GitBlameLine, GitBlameLines } from '../../git/models/blame'; -import type { BranchSortOptions } from '../../git/models/branch'; -import { getBranchId, getBranchNameWithoutRemote, GitBranch, sortBranches } from '../../git/models/branch'; -import type { GitCommitLine } from '../../git/models/commit'; -import { getChangedFilesCount, GitCommit, GitCommitIdentity } from '../../git/models/commit'; -import { deletedOrMissing, uncommitted } from '../../git/models/constants'; -import { GitContributor } from '../../git/models/contributor'; -import type { GitDiffFile, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat } from '../../git/models/diff'; -import type { GitFile } from '../../git/models/file'; -import { GitFileChange, GitFileIndexStatus } from '../../git/models/file'; +} from '../../../../git/gitProvider'; +import { GitUri } from '../../../../git/gitUri'; +import type { GitBlame, GitBlameAuthor, GitBlameLine, GitBlameLines } from '../../../../git/models/blame'; +import type { BranchSortOptions } from '../../../../git/models/branch'; +import { getBranchId, getBranchNameWithoutRemote, GitBranch, sortBranches } from '../../../../git/models/branch'; +import type { GitCommitLine } from '../../../../git/models/commit'; +import { getChangedFilesCount, GitCommit, GitCommitIdentity } from '../../../../git/models/commit'; +import { deletedOrMissing, uncommitted } from '../../../../git/models/constants'; +import { GitContributor } from '../../../../git/models/contributor'; +import type { GitDiffFile, GitDiffFilter, GitDiffLine, GitDiffShortStat } from '../../../../git/models/diff'; +import type { GitFile } from '../../../../git/models/file'; +import { GitFileChange, GitFileIndexStatus } from '../../../../git/models/file'; import type { GitGraph, GitGraphRow, @@ -55,49 +56,54 @@ import type { GitGraphRowsStats, GitGraphRowStats, GitGraphRowTag, -} from '../../git/models/graph'; -import type { GitLog } from '../../git/models/log'; -import type { GitMergeStatus } from '../../git/models/merge'; -import type { GitRebaseStatus } from '../../git/models/rebase'; -import type { GitBranchReference, GitReference } from '../../git/models/reference'; -import { createReference, isRevisionRange, isSha, isShaLike, isUncommitted } from '../../git/models/reference'; -import type { GitReflog } from '../../git/models/reflog'; -import { getRemoteIconUri, getVisibilityCacheKey, GitRemote } from '../../git/models/remote'; -import type { RepositoryChangeEvent } from '../../git/models/repository'; -import { Repository } from '../../git/models/repository'; -import type { GitStash } from '../../git/models/stash'; -import type { GitStatusFile } from '../../git/models/status'; -import { GitStatus } from '../../git/models/status'; -import type { TagSortOptions } from '../../git/models/tag'; -import { getTagId, GitTag, sortTags } from '../../git/models/tag'; -import type { GitTreeEntry } from '../../git/models/tree'; -import type { GitUser } from '../../git/models/user'; -import { isUserMatch } from '../../git/models/user'; -import { getRemoteProviderMatcher, loadRemoteProviders } from '../../git/remotes/remoteProviders'; -import type { GitSearch, GitSearchResultData, GitSearchResults, SearchQuery } from '../../git/search'; -import { getSearchQueryComparisonKey, parseSearchQuery } from '../../git/search'; -import { configuration } from '../../system/configuration'; -import { setContext } from '../../system/context'; -import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; -import { filterMap, first, last, some } from '../../system/iterable'; -import { Logger } from '../../system/logger'; -import type { LogScope } from '../../system/logger.scope'; -import { getLogScope } from '../../system/logger.scope'; -import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../system/path'; -import { asSettled, getSettledValue } from '../../system/promise'; -import { serializeWebviewItemContext } from '../../system/webview'; -import type { CachedBlame, CachedLog } from '../../trackers/gitDocumentTracker'; -import { GitDocumentState } from '../../trackers/gitDocumentTracker'; -import type { TrackedDocument } from '../../trackers/trackedDocument'; -import type { GitHubAuthorityMetadata, Metadata, RemoteHubApi } from '../remotehub'; -import { getRemoteHubApi, HeadType } from '../remotehub'; +} from '../../../../git/models/graph'; +import type { GitLog } from '../../../../git/models/log'; +import type { GitMergeStatus } from '../../../../git/models/merge'; +import type { GitRebaseStatus } from '../../../../git/models/rebase'; +import type { GitBranchReference, GitReference } from '../../../../git/models/reference'; +import { createReference, isRevisionRange, isSha, isShaLike, isUncommitted } from '../../../../git/models/reference'; +import type { GitReflog } from '../../../../git/models/reflog'; +import { getRemoteIconUri, getVisibilityCacheKey, GitRemote } from '../../../../git/models/remote'; +import type { RepositoryChangeEvent } from '../../../../git/models/repository'; +import { Repository } from '../../../../git/models/repository'; +import type { GitStash } from '../../../../git/models/stash'; +import type { GitStatusFile } from '../../../../git/models/status'; +import { GitStatus } from '../../../../git/models/status'; +import type { TagSortOptions } from '../../../../git/models/tag'; +import { getTagId, GitTag, sortTags } from '../../../../git/models/tag'; +import type { GitTreeEntry } from '../../../../git/models/tree'; +import type { GitUser } from '../../../../git/models/user'; +import { isUserMatch } from '../../../../git/models/user'; +import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../../git/remotes/remoteProviders'; +import type { + GitSearch, + GitSearchResultData, + GitSearchResults, + SearchOperators, + SearchQuery, +} from '../../../../git/search'; +import { getSearchQueryComparisonKey, parseSearchQuery } from '../../../../git/search'; +import { configuration } from '../../../../system/configuration'; +import { setContext } from '../../../../system/context'; +import { gate } from '../../../../system/decorators/gate'; +import { debug, log } from '../../../../system/decorators/log'; +import { filterMap, first, last, map, some } from '../../../../system/iterable'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope } from '../../../../system/logger.scope'; +import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../../../system/path'; +import { asSettled, getSettledValue } from '../../../../system/promise'; +import { serializeWebviewItemContext } from '../../../../system/webview'; +import type { CachedBlame, CachedLog, TrackedGitDocument } from '../../../../trackers/trackedDocument'; +import { GitDocumentState } from '../../../../trackers/trackedDocument'; +import type { GitHubAuthorityMetadata, Metadata, RemoteHubApi } from '../../../remotehub'; +import { getRemoteHubApi, HeadType } from '../../../remotehub'; import type { GraphBranchContextValue, GraphItemContext, GraphItemRefContext, GraphTagContextValue, -} from '../webviews/graph/protocol'; +} from '../../../webviews/graph/protocol'; import type { GitHubApi } from './github'; import { fromCommitFileStatus } from './models'; @@ -109,7 +115,8 @@ const emptyPromise: Promise = Promi const githubAuthenticationScopes = ['repo', 'read:user', 'user:email']; // Since negative lookbehind isn't supported in all browsers, this leaves out the negative lookbehind condition `(? {} @gate() @@ -520,6 +531,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { async getAheadBehindCommitCount( _repoPath: string, _refs: string[], + _options?: { authors?: GitUser[] | undefined }, ): Promise<{ ahead: number; behind: number } | undefined> { return undefined; } @@ -537,7 +549,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { key += `:${uri.sha}`; } - const doc = await this.container.tracker.getOrAdd(uri); + const doc = await this.container.documentTracker.getOrAdd(uri); if (doc.state != null) { const cachedBlame = doc.state.getBlame(key); if (cachedBlame != null) { @@ -568,7 +580,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { private async getBlameCore( uri: GitUri, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { @@ -674,7 +686,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { }; document.state.setBlame(key, value); - document.setBlameFailure(); + document.setBlameFailure(ex); return emptyPromise as Promise; } @@ -876,8 +888,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { async getBranches( repoPath: string | undefined, options?: { - cursor?: string; filter?: (b: GitBranch) => boolean; + paging?: PagingOptions; sort?: boolean | BranchSortOptions; }, ): Promise> { @@ -885,7 +897,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { const scope = getLogScope(); - let branchesPromise = options?.cursor ? undefined : this._branchesCache.get(repoPath); + let branchesPromise = options?.paging?.cursor ? undefined : this._branchesCache.get(repoPath); if (branchesPromise == null) { async function load(this: GitHubGitProvider): Promise> { try { @@ -896,7 +908,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { const branches: GitBranch[] = []; - let cursor = options?.cursor; + let cursor = options?.paging?.cursor; const loadAll = cursor == null; while (true) { @@ -955,7 +967,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { } branchesPromise = load.call(this); - if (options?.cursor == null) { + if (options?.paging?.cursor == null) { this._branchesCache.set(repoPath, branchesPromise); } } @@ -1047,8 +1059,11 @@ export class GitHubGitProvider implements GitProvider, Disposable { @log() async getCommitBranches( repoPath: string, - ref: string, - options?: { branch?: string; commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + refs: string[], + branch?: string | undefined, + options?: + | { all?: boolean; commitDate?: Date; mode?: 'contains' | 'pointsAt' } + | { commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, ): Promise { if (repoPath == null || options?.commitDate == null) return []; @@ -1059,13 +1074,14 @@ export class GitHubGitProvider implements GitProvider, Disposable { let branches; - if (options?.branch) { + if (branch) { branches = await github.getCommitOnBranch( session.accessToken, metadata.repo.owner, metadata.repo.name, - options?.branch, - ref, + branch, + refs, + options?.mode ?? 'contains', options?.commitDate, ); } else { @@ -1073,7 +1089,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { session.accessToken, metadata.repo.owner, metadata.repo.name, - ref, + refs, + options?.mode ?? 'contains', options?.commitDate, ); } @@ -1235,7 +1252,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { } const ids = new Set(); - const remote = getSettledValue(remotesResult)![0]!; + const remote = getSettledValue(remotesResult)![0]; const remoteMap = remote != null ? new Map([[remote.name, remote]]) : new Map(); const tagTips = new Map(); @@ -1406,6 +1423,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { avatarUrl: avatarUrl, context: serializeWebviewItemContext(context), current: true, + hostingServiceType: remote.provider?.gkProviderId, }, ]; @@ -1457,6 +1475,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { url: remote.url, avatarUrl: avatarUrl, context: serializeWebviewItemContext(context), + hostingServiceType: remote.provider?.gkProviderId, }); } } @@ -1591,10 +1610,39 @@ export class GitHubGitProvider implements GitProvider, Disposable { }; } + @log() + async getCommitTags( + repoPath: string, + ref: string, + options?: { commitDate?: Date; mode?: 'contains' | 'pointsAt' }, + ): Promise { + if (repoPath == null || options?.commitDate == null) return []; + + const scope = getLogScope(); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + const tags = await github.getCommitTags( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + ref, + options?.commitDate, + ); + + return tags; + } catch (ex) { + Logger.error(ex, scope); + debugger; + return []; + } + } + @log() async getContributors( repoPath: string, - _options?: { all?: boolean; ref?: string; stats?: boolean }, + _options?: { all?: boolean; merges?: boolean | 'first-parent'; ref?: string; stats?: boolean }, ): Promise { if (repoPath == null) return []; @@ -1700,7 +1748,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { _editorLine: number, // 0-based, Git is 1-based _ref1: string | undefined, _ref2?: string, - ): Promise { + ): Promise { return undefined; } @@ -1709,7 +1757,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { _repoPath: string, _ref1?: string, _ref2?: string, - _options?: { filters?: GitDiffFilter[]; similarityThreshold?: number }, + _options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ): Promise { return undefined; } @@ -1736,7 +1784,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { authors?: GitUser[]; cursor?: string; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -1841,7 +1889,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { authors?: GitUser[]; cursor?: string; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -1859,7 +1907,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { options?: { authors?: GitUser[]; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; }, @@ -2007,7 +2055,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { key += `:cursor=${options.cursor}`; } - const doc = await this.container.tracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, options.ref)); + const doc = await this.container.documentTracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, options.ref)); if (!options.force && options.range == null) { if (doc.state != null) { const cachedLog = doc.state.getLog(key); @@ -2094,7 +2142,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { private async getLogForFileCore( repoPath: string | undefined, path: string, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, options?: { @@ -2378,7 +2426,6 @@ export class GitHubGitProvider implements GitProvider, Disposable { uri: Uri, ref: string | undefined, skip: number = 0, - _firstParent: boolean = false, ): Promise { if (ref === deletedOrMissing) return undefined; @@ -2535,6 +2582,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { return [ new GitRemote( + this.container, repoPath, 'origin', 'https', @@ -2588,7 +2636,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { [], { ahead: 0, behind: 0 }, revision.type === HeadType.Branch || revision.type === HeadType.RemoteBranch - ? `origin/${revision.name}` + ? { name: `origin/${revision.name}`, missing: false } : undefined, ); } @@ -2596,13 +2644,17 @@ export class GitHubGitProvider implements GitProvider, Disposable { @log({ args: { 1: false } }) async getTags( repoPath: string | undefined, - options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + options?: { + filter?: (t: GitTag) => boolean; + paging?: PagingOptions; + sort?: boolean | TagSortOptions; + }, ): Promise> { if (repoPath == null) return emptyPagedResult; const scope = getLogScope(); - let tagsPromise = options?.cursor ? undefined : this._tagsCache.get(repoPath); + let tagsPromise = options?.paging?.cursor ? undefined : this._tagsCache.get(repoPath); if (tagsPromise == null) { async function load(this: GitHubGitProvider): Promise> { try { @@ -2610,7 +2662,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { const tags: GitTag[] = []; - let cursor = options?.cursor; + let cursor = options?.paging?.cursor; const loadAll = cursor == null; let authoredDate; @@ -2656,7 +2708,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { } tagsPromise = load.call(this); - if (options?.cursor == null) { + if (options?.paging?.cursor == null) { this._tagsCache.set(repoPath, tagsPromise); } } @@ -2694,8 +2746,9 @@ export class GitHubGitProvider implements GitProvider, Disposable { if (stats == null) return undefined; return { + ref: ref, + oid: '', path: this.getRelativePath(uri, repoPath), - commitSha: ref, size: stats.size, type: (stats.type & FileType.Directory) === FileType.Directory ? 'tree' : 'blob', }; @@ -2726,8 +2779,9 @@ export class GitHubGitProvider implements GitProvider, Disposable { // const stats = await workspace.fs.stat(uri); result.push({ + ref: ref, + oid: '', path: this.getRelativePath(path, uri), - commitSha: ref, size: 0, // stats?.size, type: (type & FileType.Directory) === FileType.Directory ? 'tree' : 'blob', }); @@ -2737,11 +2791,6 @@ export class GitHubGitProvider implements GitProvider, Disposable { return []; } - async getUniqueRepositoryId(_repoPath: string): Promise { - // TODO@ramint implement this if there is a way. - return undefined; - } - @log() async hasBranchOrTag( repoPath: string | undefined, @@ -2858,8 +2907,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { const operations = parseSearchQuery(search); const values = operations.get('commit:'); - if (values != null) { - const commit = await this.getCommit(repoPath, values[0]); + if (values?.size) { + const commit = await this.getCommit(repoPath, first(values)!); if (commit == null) return undefined; return { @@ -2890,8 +2939,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { options?.ordering === 'date' ? 'committer-date' : options?.ordering === 'author-date' - ? 'author-date' - : undefined, + ? 'author-date' + : undefined, }); if (result == null) return undefined; @@ -3028,8 +3077,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { const values = operations.get('commit:'); if (values != null) { - const commitsResults = await Promise.allSettled[]>( - values.map(v => this.getCommit(repoPath, v.replace(doubleQuoteRegex, ''))), + const commitsResults = await Promise.allSettled( + map(values, v => this.getCommit(repoPath, v.replace(doubleQuoteRegex, ''))), ); let i = 0; @@ -3082,8 +3131,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { options?.ordering === 'date' ? 'committer-date' : options?.ordering === 'author-date' - ? 'author-date' - : undefined, + ? 'author-date' + : undefined, }); if (result == null || options?.cancellation?.isCancellationRequested) { @@ -3412,7 +3461,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { private async getQueryArgsFromSearchQuery( search: SearchQuery, - operations: Map, + operations: Map>, repoPath: string, ) { const query = []; @@ -3420,12 +3469,12 @@ export class GitHubGitProvider implements GitProvider, Disposable { for (const [op, values] of operations.entries()) { switch (op) { case 'message:': - query.push(...values.map(m => m.replace(/ /g, '+'))); + query.push(...map(values, m => m.replace(/ /g, '+'))); break; case 'author:': { let currentUser: GitUser | undefined; - if (values.includes('@me')) { + if (values.has('@me')) { currentUser = await this.getCurrentUser(repoPath); } diff --git a/src/plus/github/models.ts b/src/plus/integrations/providers/github/models.ts similarity index 54% rename from src/plus/github/models.ts rename to src/plus/integrations/providers/github/models.ts index 6ad2707592a03..7ebb8ce4e8d65 100644 --- a/src/plus/github/models.ts +++ b/src/plus/integrations/providers/github/models.ts @@ -1,16 +1,28 @@ import type { Endpoints } from '@octokit/types'; -import { GitFileIndexStatus } from '../../git/models/file'; -import type { IssueLabel, IssueMember, IssueOrPullRequestType } from '../../git/models/issue'; -import { Issue } from '../../git/models/issue'; -import type { PullRequestState } from '../../git/models/pullRequest'; -import { PullRequest, PullRequestMergeableState, PullRequestReviewDecision } from '../../git/models/pullRequest'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +import { GitFileIndexStatus } from '../../../../git/models/file'; +import type { IssueLabel } from '../../../../git/models/issue'; +import { Issue, RepositoryAccessLevel } from '../../../../git/models/issue'; +import type { PullRequestState } from '../../../../git/models/pullRequest'; +import { + PullRequest, + PullRequestMergeableState, + PullRequestReviewDecision, + PullRequestReviewState, + PullRequestStatusCheckRollupState, +} from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; export interface GitHubBlame { ranges: GitHubBlameRange[]; viewer?: string; } +export interface GitHubMember { + login: string; + avatarUrl: string; + url: string; +} + export interface GitHubBlameRange { startingLine: number; endingLine: number; @@ -45,14 +57,17 @@ export interface GitHubCommitRef { export type GitHubContributor = Endpoints['GET /repos/{owner}/{repo}/contributors']['response']['data'][0]; export interface GitHubIssueOrPullRequest { - type: IssueOrPullRequestType; - number: number; - createdAt: string; + __typename: 'Issue' | 'PullRequest'; + closed: boolean; closedAt: string | null; + createdAt: string; + id: string; + number: number; + state: GitHubIssueOrPullRequestState; title: string; + updatedAt: string; url: string; - state: GitHubPullRequestState; } export interface GitHubPagedResult { @@ -67,103 +82,95 @@ export interface GitHubPageInfo { hasPreviousPage: boolean; } +export type GitHubIssueState = 'OPEN' | 'CLOSED'; export type GitHubPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED'; -export interface GitHubPullRequest { - author: { - login: string; - avatarUrl: string; +export type GitHubIssueOrPullRequestState = GitHubIssueState | GitHubPullRequestState; + +export interface GitHubPullRequestLite extends Omit { + author: GitHubMember; + + baseRefName: string; + baseRefOid: string; + + headRefName: string; + headRefOid: string; + headRepository: { + name: string; + owner: { + login: string; + }; url: string; }; - permalink: string; - number: number; - title: string; - state: GitHubPullRequestState; - updatedAt: string; - closedAt: string | null; + + isCrossRepository: boolean; mergedAt: string | null; + permalink: string; + repository: { isFork: boolean; + name: string; owner: { login: string; }; + url: string; + viewerPermission: GitHubViewerPermission; }; } -export interface GitHubIssueDetailed extends GitHubIssueOrPullRequest { - date: Date; - updatedAt: Date; - author: { - login: string; - avatarUrl: string; - url: string; +export interface GitHubIssue extends Omit { + author: GitHubMember; + assignees: { nodes: GitHubMember[] }; + comments?: { + totalCount: number; + }; + labels?: { nodes: IssueLabel[] }; + reactions?: { + totalCount: number; }; - assignees: { nodes: IssueMember[] }; repository: { name: string; owner: { login: string; }; - }; - labels?: { nodes: IssueLabel[] }; - reactions?: { - totalCount: number; - }; - comments?: { - totalCount: number; + viewerPermission: GitHubViewerPermission; }; } export type GitHubPullRequestReviewDecision = 'CHANGES_REQUESTED' | 'APPROVED' | 'REVIEW_REQUIRED'; export type GitHubPullRequestMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; +export type GitHubPullRequestStatusCheckRollupState = 'SUCCESS' | 'FAILURE' | 'PENDING' | 'EXPECTED' | 'ERROR'; +export type GitHubPullRequestReviewState = 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING'; -export interface GitHubDetailedPullRequest extends GitHubPullRequest { - baseRefName: string; - baseRefOid: string; - baseRepository: { - name: string; - owner: { - login: string; - }; - url: string; - }; - headRefName: string; - headRefOid: string; - headRepository: { - name: string; - owner: { - login: string; - }; - url: string; +export interface GitHubPullRequest extends GitHubPullRequestLite { + additions: number; + assignees: { + nodes: GitHubMember[]; }; - reviewDecision: GitHubPullRequestReviewDecision; - isReadByViewer: boolean; - isDraft: boolean; - isCrossRepository: boolean; checksUrl: string; - totalCommentsCount: number; - mergeable: GitHubPullRequestMergeableState; - additions: number; deletions: number; - reviewRequests: { + isDraft: boolean; + mergeable: GitHubPullRequestMergeableState; + reviewDecision: GitHubPullRequestReviewDecision; + latestReviews: { nodes: { - asCodeOwner: boolean; - requestedReviewer: { - login: string; - avatarUrl: string; - url: string; - }; + author: GitHubMember; + state: GitHubPullRequestReviewState; }[]; }; - assignees: { + reviewRequests: { nodes: { - login: string; - avatarUrl: string; - url: string; + asCodeOwner: boolean; + requestedReviewer: GitHubMember | null; }[]; }; + statusCheckRollup: { + state: 'SUCCESS' | 'FAILURE' | 'PENDING' | 'EXPECTED' | 'ERROR'; + } | null; + totalCommentsCount: number; + viewerCanUpdate: boolean; } -export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: RichRemoteProvider): PullRequest { +export function fromGitHubPullRequestLite(pr: GitHubPullRequestLite, provider: Provider): PullRequest { return new PullRequest( provider, { @@ -172,16 +179,44 @@ export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: RichRemot url: pr.author.url, }, String(pr.number), + pr.id, pr.title, pr.permalink, - fromGitHubPullRequestState(pr.state), + { + owner: pr.repository.owner.login, + repo: pr.repository.name, + accessLevel: fromGitHubViewerPermissionToAccessLevel(pr.repository.viewerPermission), + }, + fromGitHubIssueOrPullRequestState(pr.state), + new Date(pr.createdAt), new Date(pr.updatedAt), pr.closedAt == null ? undefined : new Date(pr.closedAt), pr.mergedAt == null ? undefined : new Date(pr.mergedAt), + undefined, + undefined, + { + head: { + exists: pr.headRepository != null, + owner: pr.headRepository?.owner.login, + repo: pr.headRepository?.name, + sha: pr.headRefOid, + branch: pr.headRefName, + url: pr.headRepository?.url, + }, + base: { + exists: pr.repository != null, + owner: pr.repository?.owner.login, + repo: pr.repository?.name, + sha: pr.baseRefOid, + branch: pr.baseRefName, + url: pr.repository?.url, + }, + isCrossRepository: pr.isCrossRepository, + }, ); } -export function fromGitHubPullRequestState(state: GitHubPullRequestState): PullRequestState { +export function fromGitHubIssueOrPullRequestState(state: GitHubPullRequestState): PullRequestState { return state === 'MERGED' ? 'merged' : state === 'CLOSED' ? 'closed' : 'opened'; } @@ -202,6 +237,21 @@ export function fromGitHubPullRequestReviewDecision( } } +export function fromGitHubPullRequestReviewState(state: GitHubPullRequestReviewState): PullRequestReviewState { + switch (state) { + case 'APPROVED': + return PullRequestReviewState.Approved; + case 'CHANGES_REQUESTED': + return PullRequestReviewState.ChangesRequested; + case 'COMMENTED': + return PullRequestReviewState.Commented; + case 'DISMISSED': + return PullRequestReviewState.Dismissed; + case 'PENDING': + return PullRequestReviewState.Pending; + } +} + export function toGitHubPullRequestReviewDecision( reviewDecision: PullRequestReviewDecision, ): GitHubPullRequestReviewDecision { @@ -241,10 +291,24 @@ export function toGitHubPullRequestMergeableState( } } -export function fromGitHubPullRequestDetailed( - pr: GitHubDetailedPullRequest, - provider: RichRemoteProvider, -): PullRequest { +export function fromGitHubPullRequestStatusCheckRollupState( + state: GitHubPullRequestStatusCheckRollupState | null | undefined, +): PullRequestStatusCheckRollupState | undefined { + switch (state) { + case 'SUCCESS': + case 'EXPECTED': + return PullRequestStatusCheckRollupState.Success; + case 'FAILURE': + case 'ERROR': + return PullRequestStatusCheckRollupState.Failed; + case 'PENDING': + return PullRequestStatusCheckRollupState.Pending; + default: + return undefined; + } +} + +export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider): PullRequest { return new PullRequest( provider, { @@ -253,29 +317,37 @@ export function fromGitHubPullRequestDetailed( url: pr.author.url, }, String(pr.number), + pr.id, pr.title, pr.permalink, - fromGitHubPullRequestState(pr.state), + { + owner: pr.repository.owner.login, + repo: pr.repository.name, + accessLevel: fromGitHubViewerPermissionToAccessLevel(pr.repository.viewerPermission), + }, + fromGitHubIssueOrPullRequestState(pr.state), + new Date(pr.createdAt), new Date(pr.updatedAt), pr.closedAt == null ? undefined : new Date(pr.closedAt), pr.mergedAt == null ? undefined : new Date(pr.mergedAt), fromGitHubPullRequestMergeableState(pr.mergeable), + pr.viewerCanUpdate, { head: { exists: pr.headRepository != null, owner: pr.headRepository?.owner.login, - repo: pr.baseRepository?.name, + repo: pr.headRepository?.name, sha: pr.headRefOid, branch: pr.headRefName, url: pr.headRepository?.url, }, base: { - exists: pr.baseRepository != null, - owner: pr.baseRepository?.owner.login, - repo: pr.baseRepository?.name, + exists: pr.repository != null, + owner: pr.repository?.owner.login, + repo: pr.repository?.name, sha: pr.baseRefOid, branch: pr.baseRefName, - url: pr.baseRepository?.url, + url: pr.repository?.url, }, isCrossRepository: pr.isCrossRepository, }, @@ -283,24 +355,41 @@ export function fromGitHubPullRequestDetailed( pr.additions, pr.deletions, pr.totalCommentsCount, + 0, //pr.reactions.totalCount, fromGitHubPullRequestReviewDecision(pr.reviewDecision), - pr.reviewRequests.nodes.map(r => ({ - isCodeOwner: r.asCodeOwner, + pr.reviewRequests.nodes + .map(r => + r.requestedReviewer != null + ? { + isCodeOwner: r.asCodeOwner, + reviewer: { + name: r.requestedReviewer.login, + avatarUrl: r.requestedReviewer.avatarUrl, + url: r.requestedReviewer.url, + }, + state: PullRequestReviewState.ReviewRequested, + } + : undefined, + ) + .filter((r?: T): r is T => Boolean(r)), + pr.latestReviews.nodes.map(r => ({ reviewer: { - name: r.requestedReviewer.login, - avatarUrl: r.requestedReviewer.avatarUrl, - url: r.requestedReviewer.url, + name: r.author.login, + avatarUrl: r.author.avatarUrl, + url: r.author.url, }, + state: fromGitHubPullRequestReviewState(r.state), })), pr.assignees.nodes.map(r => ({ name: r.login, avatarUrl: r.avatarUrl, url: r.url, })), + fromGitHubPullRequestStatusCheckRollupState(pr.statusCheckRollup?.state), ); } -export function fromGitHubIssueDetailed(value: GitHubIssueDetailed, provider: RichRemoteProvider): Issue { +export function fromGitHubIssue(value: GitHubIssue, provider: Provider): Issue { return new Issue( { id: provider.id, @@ -309,12 +398,13 @@ export function fromGitHubIssueDetailed(value: GitHubIssueDetailed, provider: Ri icon: provider.icon, }, String(value.number), + value.id, value.title, value.url, new Date(value.createdAt), - value.closed, - fromGitHubPullRequestState(value.state), new Date(value.updatedAt), + value.closed, + fromGitHubIssueOrPullRequestState(value.state), { name: value.author.login, avatarUrl: value.author.avatarUrl, @@ -323,9 +413,10 @@ export function fromGitHubIssueDetailed(value: GitHubIssueDetailed, provider: Ri { owner: value.repository.owner.login, repo: value.repository.name, + accessLevel: fromGitHubViewerPermissionToAccessLevel(value.repository.viewerPermission), }, value.assignees.nodes.map(assignee => ({ - name: assignee.name, + name: assignee.login, avatarUrl: assignee.avatarUrl, url: assignee.url, })), @@ -341,6 +432,33 @@ export function fromGitHubIssueDetailed(value: GitHubIssueDetailed, provider: Ri ); } +type GitHubViewerPermission = + | 'ADMIN' // Can read, clone, and push to this repository. Can also manage issues, pull requests, and repository settings, including adding collaborators + | 'MAINTAIN' // Can read, clone, and push to this repository. They can also manage issues, pull requests, and some repository settings + | 'WRITE' // Can read, clone, and push to this repository. Can also manage issues and pull requests + | 'TRIAGE' // Can read and clone this repository. Can also manage issues and pull requests + | 'READ' // Can read and clone this repository. Can also open and comment on issues and pull requests + | 'NONE'; + +function fromGitHubViewerPermissionToAccessLevel( + permission: GitHubViewerPermission | null | undefined, +): RepositoryAccessLevel { + switch (permission) { + case 'ADMIN': + return RepositoryAccessLevel.Admin; + case 'MAINTAIN': + return RepositoryAccessLevel.Maintain; + case 'WRITE': + return RepositoryAccessLevel.Write; + case 'TRIAGE': + return RepositoryAccessLevel.Triage; + case 'READ': + return RepositoryAccessLevel.Read; + default: + return RepositoryAccessLevel.None; + } +} + export interface GitHubTag { name: string; target: { diff --git a/src/plus/integrations/providers/gitlab.ts b/src/plus/integrations/providers/gitlab.ts new file mode 100644 index 0000000000000..301373a328282 --- /dev/null +++ b/src/plus/integrations/providers/gitlab.ts @@ -0,0 +1,229 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { Container } from '../../../container'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { log } from '../../../system/decorators/log'; +import { ensurePaidPlan } from '../../utils'; +import type { + IntegrationAuthenticationProviderDescriptor, + IntegrationAuthenticationService, +} from '../authentication/integrationAuthentication'; +import type { SupportedIntegrationIds } from '../integration'; +import { HostingIntegration } from '../integration'; +import { HostingIntegrationId, providersMetadata, SelfHostedIntegrationId } from './models'; +import type { ProvidersApi } from './providersApi'; + +const metadata = providersMetadata[HostingIntegrationId.GitLab]; +const authProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: metadata.id, + scopes: metadata.scopes, +}); + +const enterpriseMetadata = providersMetadata[SelfHostedIntegrationId.GitLabSelfHosted]; +const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: enterpriseMetadata.id, + scopes: enterpriseMetadata.scopes, +}); + +export type GitLabRepositoryDescriptor = { + key: string; + owner: string; + name: string; +}; + +abstract class GitLabIntegrationBase extends HostingIntegration< + ID, + GitLabRepositoryDescriptor +> { + protected abstract get apiBaseUrl(): string; + + protected override async getProviderAccountForCommit( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.gitlab)?.getAccountForCommit(this, accessToken, repo.owner, repo.name, ref, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderAccountForEmail( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.gitlab)?.getAccountForEmail(this, accessToken, repo.owner, repo.name, email, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderDefaultBranch( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ): Promise { + return (await this.container.gitlab)?.getDefaultBranch(this, accessToken, repo.owner, repo.name, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderIssueOrPullRequest( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + id: string, + ): Promise { + return (await this.container.gitlab)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + Number(id), + { + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForBranch( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + const { include, ...opts } = options ?? {}; + + const toGitLabMergeRequestState = (await import(/* webpackChunkName: "integrations" */ './gitlab/models')) + .toGitLabMergeRequestState; + return (await this.container.gitlab)?.getPullRequestForBranch( + this, + accessToken, + repo.owner, + repo.name, + branch, + { + ...opts, + include: include?.map(s => toGitLabMergeRequestState(s)), + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForCommit( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ref: string, + ): Promise { + return (await this.container.gitlab)?.getPullRequestForCommit(this, accessToken, repo.owner, repo.name, ref, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderRepositoryMetadata( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + cancellation?: CancellationToken, + ): Promise { + return (await this.container.gitlab)?.getRepositoryMetadata( + this, + accessToken, + repo.owner, + repo.name, + { + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override searchProviderMyPullRequests( + _session: AuthenticationSession, + _repo?: GitLabRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } + + protected override searchProviderMyIssues( + _session: AuthenticationSession, + _repos?: GitLabRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } + + protected override async mergeProviderPullRequest( + _session: AuthenticationSession, + _pr: PullRequest | { id: string; headRefSha: string }, + _options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + return Promise.resolve(false); + } +} + +export class GitLabIntegration extends GitLabIntegrationBase { + readonly authProvider = authProvider; + readonly id = HostingIntegrationId.GitLab; + protected readonly key = this.id; + readonly name: string = 'GitLab'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://gitlab.com/api'; + } +} + +export class GitLabSelfHostedIntegration extends GitLabIntegrationBase { + readonly authProvider = enterpriseAuthProvider; + readonly id = SelfHostedIntegrationId.GitLabSelfHosted; + protected readonly key = `${this.id}:${this.domain}` as const; + readonly name = 'GitLab Self-Hosted'; + get domain(): string { + return this._domain; + } + protected override get apiBaseUrl(): string { + return `https://${this._domain}/api`; + } + + constructor( + container: Container, + authenticationService: IntegrationAuthenticationService, + getProvidersApi: () => Promise, + private readonly _domain: string, + ) { + super(container, authenticationService, getProvidersApi); + } + + @log() + override async connect(): Promise { + if ( + !(await ensurePaidPlan(this.container, `Rich integration with ${this.name} is a Pro feature.`, { + source: 'integrations', + detail: { action: 'connect', integration: this.id }, + })) + ) { + return false; + } + + return super.connect(); + } +} diff --git a/src/plus/gitlab/gitlab.ts b/src/plus/integrations/providers/gitlab/gitlab.ts similarity index 91% rename from src/plus/gitlab/gitlab.ts rename to src/plus/integrations/providers/gitlab/gitlab.ts index 0c14952ab330c..0712bd9c40587 100644 --- a/src/plus/gitlab/gitlab.ts +++ b/src/plus/integrations/providers/gitlab/gitlab.ts @@ -4,8 +4,7 @@ import { Uri, window } from 'vscode'; import type { RequestInit, Response } from '@env/fetch'; import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; import { isWeb } from '@env/platform'; -import type { CoreConfiguration } from '../../constants'; -import type { Container } from '../../container'; +import type { Container } from '../../../../container'; import { AuthenticationError, AuthenticationErrorReason, @@ -14,24 +13,24 @@ import { ProviderRequestClientError, ProviderRequestNotFoundError, ProviderRequestRateLimitError, -} from '../../errors'; -import type { Account } from '../../git/models/author'; -import type { DefaultBranch } from '../../git/models/defaultBranch'; -import type { IssueOrPullRequest } from '../../git/models/issue'; -import { PullRequest } from '../../git/models/pullRequest'; -import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +} from '../../../../errors'; +import type { Account } from '../../../../git/models/author'; +import type { DefaultBranch } from '../../../../git/models/defaultBranch'; +import type { IssueOrPullRequest } from '../../../../git/models/issue'; +import { PullRequest } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata'; import { showIntegrationRequestFailed500WarningMessage, showIntegrationRequestTimedOutWarningMessage, -} from '../../messages'; -import { configuration } from '../../system/configuration'; -import { debug } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import type { LogScope } from '../../system/logger.scope'; -import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; -import { maybeStopWatch } from '../../system/stopwatch'; -import { equalsIgnoreCase } from '../../system/string'; +} from '../../../../messages'; +import { configuration } from '../../../../system/configuration'; +import { debug } from '../../../../system/decorators/log'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope, setLogScopeExit } from '../../../../system/logger.scope'; +import { maybeStopWatch } from '../../../../system/stopwatch'; +import { equalsIgnoreCase } from '../../../../system/string'; import type { GitLabCommit, GitLabIssue, @@ -50,7 +49,7 @@ export class GitLabApi implements Disposable { constructor(_container: Container) { this._disposable = configuration.onDidChangeAny(e => { if ( - configuration.changedAny(e, ['http.proxy', 'http.proxyStrictSSL']) || + configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) || configuration.changed(e, ['proxy', 'remotes']) ) { this.resetCaches(); @@ -68,7 +67,7 @@ export class GitLabApi implements Disposable { } private _proxyAgents = new Map(); - private getProxyAgent(provider: RichRemoteProvider): HttpsProxyAgent | undefined { + private getProxyAgent(provider: Provider): HttpsProxyAgent | undefined { if (isWeb) return undefined; let proxyAgent = this._proxyAgents.get(provider.id); @@ -83,7 +82,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -140,6 +139,7 @@ export class GitLabApi implements Disposable { name: user.name || undefined, email: commit.author_email || undefined, avatarUrl: user.avatarUrl || undefined, + username: user.username || undefined, }; } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -150,7 +150,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForEmail( - provider: RichRemoteProvider, + provider: Provider, token: string, _owner: string, _repo: string, @@ -171,6 +171,7 @@ export class GitLabApi implements Disposable { name: user.name || undefined, email: user.publicEmail || undefined, avatarUrl: user.avatarUrl || undefined, + username: user.username || undefined, }; } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -181,7 +182,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getDefaultBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -241,7 +242,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getIssueOrPullRequest( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -320,7 +321,9 @@ export class GitLabApi implements Disposable { provider: provider, type: 'issue', id: issue.iid, - date: new Date(issue.createdAt), + nodeId: undefined, + createdDate: new Date(issue.createdAt), + updatedDate: new Date(issue.updatedAt), title: issue.title, closed: issue.state === 'closed', closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), @@ -335,7 +338,9 @@ export class GitLabApi implements Disposable { provider: provider, type: 'pullrequest', id: mergeRequest.iid, - date: new Date(mergeRequest.createdAt), + nodeId: undefined, + createdDate: new Date(mergeRequest.createdAt), + updatedDate: new Date(mergeRequest.updatedAt), title: mergeRequest.title, closed: mergeRequest.state === 'closed', // TODO@eamodio this isn't right, but GitLab doesn't seem to provide a closedAt on merge requests in GraphQL @@ -355,7 +360,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -487,9 +492,12 @@ export class GitLabApi implements Disposable { url: pr.author?.webUrl ?? '', }, String(pr.iid), + undefined, pr.title, pr.webUrl, + { owner: owner, repo: repo }, fromGitLabMergeRequestState(pr.state), + new Date(pr.createdAt), new Date(pr.updatedAt), // TODO@eamodio this isn't right, but GitLab doesn't seem to provide a closedAt on merge requests in GraphQL pr.state !== 'closed' ? undefined : new Date(pr.updatedAt), @@ -504,7 +512,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -543,7 +551,7 @@ export class GitLabApi implements Disposable { ); } - return fromGitLabMergeRequestREST(mrs[0], provider); + return fromGitLabMergeRequestREST(mrs[0], provider, { owner: owner, repo: repo }); } catch (ex) { if (ex instanceof ProviderRequestNotFoundError) return undefined; @@ -553,7 +561,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getRepositoryMetadata( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -603,7 +611,7 @@ export class GitLabApi implements Disposable { } private async findUser( - provider: RichRemoteProvider, + provider: Provider, token: string, search: string, options?: { @@ -688,7 +696,7 @@ $search: String! } private getProjectId( - provider: RichRemoteProvider, + provider: Provider, token: string, group: string, repo: string, @@ -707,7 +715,7 @@ $search: String! } private async getProjectIdCore( - provider: RichRemoteProvider, + provider: Provider, token: string, group: string, repo: string, @@ -759,7 +767,7 @@ $search: String! } private async graphql( - provider: RichRemoteProvider, + provider: Provider, token: string, baseUrl: string | undefined, query: string, @@ -817,7 +825,7 @@ $search: String! } private async request( - provider: RichRemoteProvider, + provider: Provider, token: string, baseUrl: string | undefined, route: string, @@ -871,12 +879,12 @@ $search: String! } private handleRequestError( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, ex: ProviderFetchError | (Error & { name: 'AbortError' }), scope: LogScope | undefined, ): void { - if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(); + if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(ex); switch (ex.status) { case 404: // Not found @@ -907,7 +915,7 @@ $search: String! provider?.trackRequestException(); void showIntegrationRequestFailed500WarningMessage( `${provider?.name ?? 'GitLab'} failed to respond and might be experiencing issues.${ - !provider?.custom + provider == null || provider.id === 'gitlab' ? ' Please visit the [GitLab status page](https://status.gitlab.com) for more information.' : '' }`, @@ -936,7 +944,7 @@ $search: String! } } - private handleException(ex: Error, provider: RichRemoteProvider, scope: LogScope | undefined): Error { + private handleException(ex: Error, provider: Provider, scope: LogScope | undefined): Error { Logger.error(ex, scope); // debugger; @@ -946,7 +954,7 @@ $search: String! return ex; } - private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: RichRemoteProvider) { + private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: Provider) { if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) { const confirm = 'Reauthenticate'; const result = await window.showErrorMessage( diff --git a/src/plus/gitlab/models.ts b/src/plus/integrations/providers/gitlab/models.ts similarity index 85% rename from src/plus/gitlab/models.ts rename to src/plus/integrations/providers/gitlab/models.ts index f319ac1d67cd9..fc64da10f2306 100644 --- a/src/plus/gitlab/models.ts +++ b/src/plus/integrations/providers/gitlab/models.ts @@ -1,6 +1,6 @@ -import type { PullRequestState } from '../../git/models/pullRequest'; -import { PullRequest } from '../../git/models/pullRequest'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +import type { PullRequestState } from '../../../../git/models/pullRequest'; +import { PullRequest } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; export interface GitLabUser { id: number; @@ -89,7 +89,11 @@ export interface GitLabMergeRequestREST { web_url: string; } -export function fromGitLabMergeRequestREST(pr: GitLabMergeRequestREST, provider: RichRemoteProvider): PullRequest { +export function fromGitLabMergeRequestREST( + pr: GitLabMergeRequestREST, + provider: Provider, + repo: { owner: string; repo: string }, +): PullRequest { return new PullRequest( provider, { @@ -98,9 +102,12 @@ export function fromGitLabMergeRequestREST(pr: GitLabMergeRequestREST, provider: url: pr.author?.web_url ?? '', }, String(pr.iid), + undefined, pr.title, pr.web_url, + repo, fromGitLabMergeRequestState(pr.state), + new Date(pr.created_at), new Date(pr.updated_at), pr.closed_at == null ? undefined : new Date(pr.closed_at), pr.merged_at == null ? undefined : new Date(pr.merged_at), diff --git a/src/plus/integrations/providers/jira.ts b/src/plus/integrations/providers/jira.ts new file mode 100644 index 0000000000000..55d062e8ef6f1 --- /dev/null +++ b/src/plus/integrations/providers/jira.ts @@ -0,0 +1,307 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { DynamicAutolinkReference } from '../../../annotations/autolinks'; +import type { AutolinkReference } from '../../../config'; +import type { Account } from '../../../git/models/author'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import { filterMap, flatten } from '../../../system/iterable'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { ResourceDescriptor } from '../integration'; +import { IssueIntegration } from '../integration'; +import { IssueFilter, IssueIntegrationId, providersMetadata, toAccount, toSearchedIssue } from './models'; + +const metadata = providersMetadata[IssueIntegrationId.Jira]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +export interface JiraBaseDescriptor extends ResourceDescriptor { + id: string; + name: string; +} +export interface JiraOrganizationDescriptor extends JiraBaseDescriptor { + url: string; + avatarUrl: string; +} + +export interface JiraProjectDescriptor extends JiraBaseDescriptor { + resourceId: string; +} + +export class JiraIntegration extends IssueIntegration { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id = IssueIntegrationId.Jira; + protected readonly key = this.id; + readonly name: string = 'Jira'; + + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://api.atlassian.com'; + } + + private _autolinks: Map | undefined; + override async autolinks(): Promise<(AutolinkReference | DynamicAutolinkReference)[]> { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return []; + if (this._session == null || this._organizations == null || this._projects == null) return []; + + this._autolinks ||= new Map(); + + const cachedAutolinks = this._autolinks.get(this._session.accessToken); + if (cachedAutolinks != null) { + return cachedAutolinks; + } + + const autolinks: (AutolinkReference | DynamicAutolinkReference)[] = []; + const organizations = this._organizations.get(this._session.accessToken); + if (organizations != null) { + for (const organization of organizations) { + const projects = this._projects.get(`${this._session.accessToken}:${organization.id}`); + if (projects != null) { + for (const project of projects) { + const prefix = `${project.key}-`; + autolinks.push({ + type: 'issue', + url: `${organization.url}/browse/${prefix}`, + prefix: prefix, + title: `Open Issue ${prefix} on ${organization.name}`, + description: `${organization.name} Issue ${prefix}`, + descriptor: { ...organization }, + }); + } + } + } + } + + this._autolinks.set(this._session.accessToken, autolinks); + + return autolinks; + } + + protected override async getProviderAccountForResource( + { accessToken }: AuthenticationSession, + resource: JiraOrganizationDescriptor, + ): Promise { + const api = await this.getProvidersApi(); + const user = await api.getCurrentUserForResource(this.id, resource.id, { + accessToken: accessToken, + }); + + if (user == null) return undefined; + return toAccount(user, this); + } + + private _organizations: Map | undefined; + protected override async getProviderResourcesForUser( + { accessToken }: AuthenticationSession, + force: boolean = false, + ): Promise { + this._organizations ||= new Map(); + + const cachedResources = this._organizations.get(accessToken); + + if (cachedResources == null || force) { + const api = await this.getProvidersApi(); + const resources = await api.getJiraResourcesForCurrentUser({ accessToken: accessToken }); + this._organizations.set( + accessToken, + resources != null ? resources.map(r => ({ ...r, key: r.id })) : undefined, + ); + } + + return this._organizations.get(accessToken); + } + + private _projects: Map | undefined; + protected override async getProviderProjectsForResources( + { accessToken }: AuthenticationSession, + resources: JiraOrganizationDescriptor[], + force: boolean = false, + ): Promise { + this._projects ||= new Map(); + + let resourcesWithoutProjects = []; + if (force) { + resourcesWithoutProjects = resources; + } else { + for (const resource of resources) { + const resourceKey = `${accessToken}:${resource.id}`; + const cachedProjects = this._projects.get(resourceKey); + if (cachedProjects == null) { + resourcesWithoutProjects.push(resource); + } + } + } + + if (resourcesWithoutProjects.length > 0) { + const api = await this.getProvidersApi(); + const jiraProjectBaseDescriptors = await api.getJiraProjectsForResources( + resourcesWithoutProjects.map(r => r.id), + { accessToken: accessToken }, + ); + + for (const resource of resourcesWithoutProjects) { + const projects = jiraProjectBaseDescriptors?.filter(p => p.resourceId === resource.id); + if (projects != null) { + this._projects.set( + `${accessToken}:${resource.id}`, + projects.map(p => ({ ...p })), + ); + } + } + } + + return resources.reduce((projects, resource) => { + const resourceProjects = this._projects!.get(`${accessToken}:${resource.id}`); + if (resourceProjects != null) { + projects.push(...resourceProjects); + } + return projects; + }, []); + } + + protected override async getProviderIssuesForProject( + { accessToken }: AuthenticationSession, + project: JiraProjectDescriptor, + options?: { user: string; filters: IssueFilter[] }, + ): Promise { + let results; + + const api = await this.getProvidersApi(); + + const getSearchedUserIssuesForFilter = async ( + user: string, + filter: IssueFilter, + ): Promise => { + const results = await api.getIssuesForProject(this.id, project.name, project.resourceId, { + authorLogin: filter === IssueFilter.Author ? user : undefined, + assigneeLogins: filter === IssueFilter.Assignee ? [user] : undefined, + mentionLogin: filter === IssueFilter.Mention ? user : undefined, + accessToken: accessToken, + }); + + return results + ?.map(issue => toSearchedIssue(issue, this, filter)) + .filter((result): result is SearchedIssue => result !== undefined); + }; + + if (options?.user != null && options.filters.length > 0) { + const resultsPromise = Promise.allSettled( + options.filters.map(filter => getSearchedUserIssuesForFilter(options.user, filter)), + ); + + results = [ + ...flatten( + filterMap(await resultsPromise, r => + r.status === 'fulfilled' && r.value != null ? r.value : undefined, + ), + ), + ]; + + const resultsById = new Map(); + for (const result of results) { + if (resultsById.has(result.issue.id)) { + const existing = resultsById.get(result.issue.id)!; + existing.reasons = [...existing.reasons, ...result.reasons]; + } else { + resultsById.set(result.issue.id, result); + } + } + + return [...resultsById.values()]; + } + + results = await api.getIssuesForProject(this.id, project.name, project.resourceId, { + accessToken: accessToken, + }); + return results + ?.map(issue => toSearchedIssue(issue, this)) + .filter((result): result is SearchedIssue => result !== undefined); + } + + protected override async searchProviderMyIssues( + session: AuthenticationSession, + resources?: JiraOrganizationDescriptor[], + _cancellation?: CancellationToken, + ): Promise { + const myResources = resources ?? (await this.getProviderResourcesForUser(session)); + if (!myResources) return undefined; + + const api = await this.getProvidersApi(); + + const results: SearchedIssue[] = []; + for (const resource of myResources) { + const userLogin = (await this.getProviderAccountForResource(session, resource))?.username; + const resourceIssues = await api.getIssuesForResourceForCurrentUser(this.id, resource.id, { + accessToken: session.accessToken, + }); + const formattedIssues = resourceIssues + ?.map(issue => toSearchedIssue(issue, this, undefined, userLogin)) + .filter((result): result is SearchedIssue => result != null); + if (formattedIssues != null) { + results.push(...formattedIssues); + } + } + + return results; + } + + protected override async getProviderIssueOrPullRequest( + session: AuthenticationSession, + resource: JiraOrganizationDescriptor, + id: string, + ): Promise { + const api = await this.getProvidersApi(); + const userLogin = (await this.getProviderAccountForResource(session, resource))?.username; + const issue = await api.getIssue(this.id, resource.id, id, { accessToken: session.accessToken }); + return issue != null ? toSearchedIssue(issue, this, undefined, userLogin)?.issue : undefined; + } + + protected override async providerOnConnect(): Promise { + this._autolinks = undefined; + if (this._session == null) return; + + const storedOrganizations = this.container.storage.get(`jira:${this._session.accessToken}:organizations`); + const storedProjects = this.container.storage.get(`jira:${this._session.accessToken}:projects`); + let organizations = storedOrganizations?.data?.map(o => ({ ...o })); + let projects = storedProjects?.data?.map(p => ({ ...p })); + + if (storedOrganizations == null) { + organizations = await this.getProviderResourcesForUser(this._session, true); + await this.container.storage.store(`jira:${this._session.accessToken}:organizations`, { + v: 1, + timestamp: Date.now(), + data: organizations, + }); + } + + this._organizations ||= new Map(); + this._organizations.set(this._session.accessToken, organizations); + + if (storedProjects == null && organizations?.length) { + projects = await this.getProviderProjectsForResources(this._session, organizations); + await this.container.storage.store(`jira:${this._session.accessToken}:projects`, { + v: 1, + timestamp: Date.now(), + data: projects, + }); + } + + this._projects ||= new Map(); + for (const project of projects ?? []) { + const projectKey = `${this._session.accessToken}:${project.resourceId}`; + const projects = this._projects.get(projectKey); + if (projects == null) { + this._projects.set(projectKey, [project]); + } else { + projects.push(project); + } + } + } + + protected override providerOnDisconnect(): void { + this._organizations = undefined; + this._projects = undefined; + this._autolinks = undefined; + } +} diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts new file mode 100644 index 0000000000000..dc451fa0259af --- /dev/null +++ b/src/plus/integrations/providers/models.ts @@ -0,0 +1,883 @@ +import type { + Account, + ActionablePullRequest, + AzureDevOps, + AzureOrganization, + AzureProject, + Bitbucket, + EnterpriseOptions, + GetRepoInput, + GitHub, + GitLab, + GitPullRequest, + GitRepository, + Issue, + Jira, + JiraProject, + JiraResource, + PullRequestWithUniqueID, + Trello, +} from '@gitkraken/provider-apis'; +import { + EntityIdentifierUtils, + GitBuildStatusState, + GitProviderUtils, + GitPullRequestMergeableState, + GitPullRequestReviewState, + GitPullRequestState, +} from '@gitkraken/provider-apis'; +import type { Account as UserAccount } from '../../../git/models/author'; +import type { IssueMember, SearchedIssue } from '../../../git/models/issue'; +import { RepositoryAccessLevel } from '../../../git/models/issue'; +import type { + PullRequestMember, + PullRequestRefs, + PullRequestReviewer, + PullRequestState, +} from '../../../git/models/pullRequest'; +import { + PullRequest, + PullRequestMergeableState, + PullRequestReviewDecision, + PullRequestReviewState, + PullRequestStatusCheckRollupState, +} from '../../../git/models/pullRequest'; +import type { ProviderReference } from '../../../git/models/remoteProvider'; +import type { RepositoryIdentityDescriptor } from '../../../gk/models/repositoryIdentities'; +import type { EnrichableItem } from '../../focus/enrichmentService'; +import type { Integration } from '../integration'; +import { getEntityIdentifierInput } from './utils'; + +export type ProviderAccount = Account; +export type ProviderReposInput = (string | number)[] | GetRepoInput[]; +export type ProviderRepoInput = GetRepoInput; +export type ProviderPullRequest = GitPullRequest; +export type ProviderRepository = GitRepository; +export type ProviderIssue = Issue; +export type ProviderEnterpriseOptions = EnterpriseOptions; +export type ProviderJiraProject = JiraProject; +export type ProviderJiraResource = JiraResource; +export type ProviderAzureProject = AzureProject; +export type ProviderAzureResource = AzureOrganization; +export const ProviderPullRequestReviewState = GitPullRequestReviewState; +export const ProviderBuildStatusState = GitBuildStatusState; + +export type IntegrationId = HostingIntegrationId | IssueIntegrationId | SelfHostedIntegrationId; + +export enum HostingIntegrationId { + GitHub = 'github', + GitLab = 'gitlab', + Bitbucket = 'bitbucket', + AzureDevOps = 'azureDevOps', +} + +export enum IssueIntegrationId { + Jira = 'jira', + Trello = 'trello', +} + +export enum SelfHostedIntegrationId { + GitHubEnterprise = 'github-enterprise', + GitLabSelfHosted = 'gitlab-self-hosted', +} + +const selfHostedIntegrationIds: SelfHostedIntegrationId[] = [ + SelfHostedIntegrationId.GitHubEnterprise, + SelfHostedIntegrationId.GitLabSelfHosted, +] as const; + +export const supportedIntegrationIds: IntegrationId[] = [ + HostingIntegrationId.GitHub, + HostingIntegrationId.GitLab, + HostingIntegrationId.Bitbucket, + HostingIntegrationId.AzureDevOps, + IssueIntegrationId.Jira, + IssueIntegrationId.Trello, + ...selfHostedIntegrationIds, +] as const; + +export function isSelfHostedIntegrationId(id: IntegrationId): id is SelfHostedIntegrationId { + return selfHostedIntegrationIds.includes(id as SelfHostedIntegrationId); +} + +export enum PullRequestFilter { + Author = 'author', + Assignee = 'assignee', + ReviewRequested = 'review-requested', + Mention = 'mention', +} + +export enum IssueFilter { + Author = 'author', + Assignee = 'assignee', + Mention = 'mention', +} + +export enum PagingMode { + Project = 'project', + Repo = 'repo', + Repos = 'repos', +} + +export interface PagingInput { + cursor?: string | null; + page?: number; +} + +export interface PagedRepoInput { + repo: GetRepoInput; + cursor?: string; +} + +export interface PagedProjectInput { + namespace: string; + project: string; + cursor?: string; +} + +export interface GetPullRequestsOptions { + authorLogin?: string; + assigneeLogins?: string[]; + reviewRequestedLogin?: string; + mentionLogin?: string; + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} + baseUrl?: string; +} + +export interface GetPullRequestsForUserOptions { + includeFromArchivedRepos?: boolean; + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} + baseUrl?: string; +} + +export interface GetPullRequestsForUserInput extends GetPullRequestsForUserOptions { + userId: string; +} + +export interface GetPullRequestsAssociatedWithUserInput extends GetPullRequestsForUserOptions { + username: string; +} + +export interface GetPullRequestsForRepoInput extends GetPullRequestsOptions { + repo: GetRepoInput; +} + +export interface GetPullRequestsForReposInput extends GetPullRequestsOptions { + repos: GetRepoInput[]; +} + +export interface GetPullRequestsForRepoIdsInput extends GetPullRequestsOptions { + repoIds: (string | number)[]; +} + +export interface GetIssuesOptions { + authorLogin?: string; + assigneeLogins?: string[]; + mentionLogin?: string; + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} + baseUrl?: string; +} + +export interface GetIssuesForRepoInput extends GetIssuesOptions { + repo: GetRepoInput; +} + +export interface GetIssuesForReposInput extends GetIssuesOptions { + repos: GetRepoInput[]; +} + +export interface GetIssuesForRepoIdsInput extends GetIssuesOptions { + repoIds: (string | number)[]; +} + +export interface GetIssuesForProjectInput extends GetIssuesOptions { + project: string; + resourceId: string; +} + +export interface GetIssuesForAzureProjectInput extends GetIssuesOptions { + namespace: string; + project: string; +} + +export interface GetReposOptions { + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} +} + +export interface GetReposForAzureProjectInput { + namespace: string; + project: string; +} + +export interface PageInfo { + hasNextPage: boolean; + endCursor?: string | null; + nextPage?: number | null; +} + +export type GetPullRequestsForReposFn = ( + input: (GetPullRequestsForReposInput | GetPullRequestsForRepoIdsInput) & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderPullRequest[]; pageInfo?: PageInfo }>; + +export type GetPullRequestsForRepoFn = ( + input: GetPullRequestsForRepoInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderPullRequest[]; pageInfo?: PageInfo }>; + +export type GetPullRequestsForUserFn = ( + input: GetPullRequestsForUserInput | GetPullRequestsAssociatedWithUserInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderPullRequest[]; pageInfo?: PageInfo }>; + +export type GetPullRequestsForAzureProjectsFn = ( + input: { projects: { namespace: string; project: string }[]; authorLogin?: string; assigneeLogins?: string[] }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderPullRequest[] }>; + +export type GetIssueFn = ( + input: { resourceId: string; number: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue }>; + +export type GetIssuesForReposFn = ( + input: (GetIssuesForReposInput | GetIssuesForRepoIdsInput) & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>; + +export type GetIssuesForRepoFn = ( + input: GetIssuesForRepoInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>; + +export type GetIssuesForAzureProjectFn = ( + input: GetIssuesForAzureProjectInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>; + +export type GetReposForAzureProjectFn = ( + input: GetReposForAzureProjectInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderRepository[]; pageInfo?: PageInfo }>; + +export type GetCurrentUserFn = ( + input: Record, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderAccount }>; +export type GetCurrentUserForInstanceFn = ( + input: { namespace: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderAccount }>; +export type GetCurrentUserForResourceFn = ( + input: { resourceId: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderAccount }>; + +export type GetJiraResourcesForCurrentUserFn = (options?: EnterpriseOptions) => Promise<{ data: JiraResource[] }>; +export type GetJiraProjectsForResourcesFn = ( + input: { resourceIds: string[] }, + options?: EnterpriseOptions, +) => Promise<{ data: JiraProject[] }>; +export type GetAzureResourcesForUserFn = ( + input: { userId: string }, + options?: EnterpriseOptions, +) => Promise<{ data: AzureOrganization[] }>; +export type GetAzureProjectsForResourceFn = ( + input: { namespace: string; cursor?: string }, + options?: EnterpriseOptions, +) => Promise<{ data: AzureProject[]; pageInfo?: PageInfo }>; +export type GetIssuesForProjectFn = ( + input: GetIssuesForProjectInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[] }>; +export type GetIssuesForResourceForCurrentUserFn = ( + input: { resourceId: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[] }>; + +export interface ProviderInfo extends ProviderMetadata { + provider: GitHub | GitLab | Bitbucket | Jira | Trello | AzureDevOps; + getPullRequestsForReposFn?: GetPullRequestsForReposFn; + getPullRequestsForRepoFn?: GetPullRequestsForRepoFn; + getPullRequestsForUserFn?: GetPullRequestsForUserFn; + getPullRequestsForAzureProjectsFn?: GetPullRequestsForAzureProjectsFn; + getIssueFn?: GetIssueFn; + getIssuesForReposFn?: GetIssuesForReposFn; + getIssuesForRepoFn?: GetIssuesForRepoFn; + getIssuesForAzureProjectFn?: GetIssuesForAzureProjectFn; + getCurrentUserFn?: GetCurrentUserFn; + getCurrentUserForInstanceFn?: GetCurrentUserForInstanceFn; + getCurrentUserForResourceFn?: GetCurrentUserForResourceFn; + getJiraResourcesForCurrentUserFn?: GetJiraResourcesForCurrentUserFn; + getAzureResourcesForUserFn?: GetAzureResourcesForUserFn; + getJiraProjectsForResourcesFn?: GetJiraProjectsForResourcesFn; + getAzureProjectsForResourceFn?: GetAzureProjectsForResourceFn; + getIssuesForProjectFn?: GetIssuesForProjectFn; + getReposForAzureProjectFn?: GetReposForAzureProjectFn; + getIssuesForResourceForCurrentUserFn?: GetIssuesForResourceForCurrentUserFn; +} + +export interface ProviderMetadata { + domain: string; + id: IntegrationId; + issuesPagingMode?: PagingMode; + pullRequestsPagingMode?: PagingMode; + scopes: string[]; + supportedPullRequestFilters?: PullRequestFilter[]; + supportedIssueFilters?: IssueFilter[]; +} + +export type Providers = Record; +export type ProvidersMetadata = Record; + +export const providersMetadata: ProvidersMetadata = { + [HostingIntegrationId.GitHub]: { + domain: 'github.com', + id: HostingIntegrationId.GitHub, + issuesPagingMode: PagingMode.Repos, + pullRequestsPagingMode: PagingMode.Repos, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + PullRequestFilter.Mention, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['repo', 'read:user', 'user:email'], + }, + [SelfHostedIntegrationId.GitHubEnterprise]: { + domain: '', + id: SelfHostedIntegrationId.GitHubEnterprise, + issuesPagingMode: PagingMode.Repos, + pullRequestsPagingMode: PagingMode.Repos, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + PullRequestFilter.Mention, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['repo', 'read:user', 'user:email'], + }, + [HostingIntegrationId.GitLab]: { + domain: 'gitlab.com', + id: HostingIntegrationId.GitLab, + issuesPagingMode: PagingMode.Repo, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], + scopes: ['read_api', 'read_user', 'read_repository'], + }, + [SelfHostedIntegrationId.GitLabSelfHosted]: { + domain: '', + id: SelfHostedIntegrationId.GitLabSelfHosted, + issuesPagingMode: PagingMode.Repo, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], + scopes: ['read_api', 'read_user', 'read_repository'], + }, + [HostingIntegrationId.Bitbucket]: { + domain: 'bitbucket.org', + id: HostingIntegrationId.Bitbucket, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'id' property on account for PR filters + supportedPullRequestFilters: [PullRequestFilter.Author], + scopes: ['account:read', 'repository:read', 'pullrequest:read', 'issue:read'], + }, + [HostingIntegrationId.AzureDevOps]: { + domain: 'dev.azure.com', + id: HostingIntegrationId.AzureDevOps, + issuesPagingMode: PagingMode.Project, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'id' property on account for PR filters + supportedPullRequestFilters: [PullRequestFilter.Author, PullRequestFilter.Assignee], + // Use 'name' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['vso.code', 'vso.identity', 'vso.project', 'vso.profile', 'vso.work'], + }, + [IssueIntegrationId.Jira]: { + domain: 'atlassian.net', + id: IssueIntegrationId.Jira, + scopes: [ + 'read:status:jira', + 'read:application-role:jira', + 'write:attachment:jira', + 'read:comment:jira', + 'read:project-category:jira', + 'read:project:jira', + 'read:issue.vote:jira', + 'read:field-configuration:jira', + 'write:issue:jira', + 'read:issue-security-level:jira', + 'write:issue.property:jira', + 'read:issue.changelog:jira', + 'read:avatar:jira', + 'read:issue-meta:jira', + 'read:permission:jira', + 'offline_access', + 'read:issue:jira', + 'read:me', + 'read:audit-log:jira', + 'read:project.component:jira', + 'read:group:jira', + 'read:project-role:jira', + 'write:comment:jira', + 'read:label:jira', + 'write:comment.property:jira', + 'read:issue-details:jira', + 'read:issue-type-hierarchy:jira', + 'read:issue.transition:jira', + 'read:user:jira', + 'read:field:jira', + 'read:issue-type:jira', + 'read:project.property:jira', + 'read:comment.property:jira', + 'read:project-version:jira', + ], + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + }, + [IssueIntegrationId.Trello]: { + domain: 'trello.com', + id: IssueIntegrationId.Trello, + scopes: [], + }, +}; + +export function getReasonsForUserIssue(issue: ProviderIssue, userLogin: string): string[] { + const reasons: string[] = []; + let isAuthor = false; + let isAssignee = false; + if (issue.author?.username === userLogin || issue.author?.name === userLogin) { + reasons.push('authored'); + isAuthor = true; + } + if (issue.assignees?.some(assignee => assignee.username === userLogin || assignee.name === userLogin)) { + reasons.push('assigned'); + isAssignee = true; + } + + // TODO: Impossible to denote all issues we are mentioned on given their properties. for now just + // assume we are mentioned on any of our issues we are not the author or assignee on + if (!isAuthor && !isAssignee) { + reasons.push('mentioned'); + } + + return reasons; +} + +export function toSearchedIssue( + issue: ProviderIssue, + provider: ProviderReference, + filterUsed?: IssueFilter, + userLogin?: string, +): SearchedIssue | undefined { + // TODO: Add some protections/baselines rather than killing the transformation here + if (issue.updatedDate == null || issue.author == null || issue.url == null) return undefined; + + return { + reasons: + filterUsed != null + ? [issueFilterToReason(filterUsed)] + : userLogin != null + ? getReasonsForUserIssue(issue, userLogin) + : [], + issue: { + type: 'issue', + provider: provider, + id: issue.number, + nodeId: issue.graphQLId ?? issue.id, + title: issue.title, + url: issue.url, + createdDate: issue.createdDate, + updatedDate: issue.updatedDate, + closedDate: issue.closedDate ?? undefined, + closed: issue.closedDate != null, + state: issue.closedDate != null ? 'closed' : 'opened', + author: { + name: issue.author.name ?? '', + avatarUrl: issue.author.avatarUrl ?? undefined, + url: issue.author.url ?? undefined, + }, + assignees: + issue.assignees?.map(assignee => ({ + name: assignee.name ?? '', + avatarUrl: assignee.avatarUrl ?? undefined, + url: assignee.url ?? undefined, + })) ?? [], + repository: + issue.repository?.owner?.login != null + ? { + owner: issue.repository.owner.login, + repo: issue.repository.name, + } + : undefined, + labels: issue.labels.map(label => ({ color: label.color ?? undefined, name: label.name })), + commentsCount: issue.commentCount ?? undefined, + thumbsUpCount: issue.upvoteCount ?? undefined, + }, + }; +} + +export function issueFilterToReason(filter: IssueFilter): 'authored' | 'assigned' | 'mentioned' { + switch (filter) { + case IssueFilter.Author: + return 'authored'; + case IssueFilter.Assignee: + return 'assigned'; + case IssueFilter.Mention: + return 'mentioned'; + } +} + +export function toAccount(account: ProviderAccount, provider: ProviderReference): UserAccount { + return { + provider: provider, + name: account.name ?? undefined, + email: account.email ?? undefined, + avatarUrl: account.avatarUrl ?? undefined, + username: account.username ?? undefined, + }; +} + +export const toProviderBuildStatusState = { + [PullRequestStatusCheckRollupState.Success]: GitBuildStatusState.Success, + [PullRequestStatusCheckRollupState.Failed]: GitBuildStatusState.Failed, + [PullRequestStatusCheckRollupState.Pending]: GitBuildStatusState.Pending, +}; + +export const fromProviderBuildStatusState = { + [GitBuildStatusState.Success]: PullRequestStatusCheckRollupState.Success, + [GitBuildStatusState.Failed]: PullRequestStatusCheckRollupState.Failed, + [GitBuildStatusState.Pending]: PullRequestStatusCheckRollupState.Pending, + [GitBuildStatusState.ActionRequired]: PullRequestStatusCheckRollupState.Failed, + // TODO: The rest of these are defaulted because we don't have a matching state for them + [GitBuildStatusState.Error]: undefined, + [GitBuildStatusState.Cancelled]: undefined, + [GitBuildStatusState.OptionalActionRequired]: undefined, + [GitBuildStatusState.Skipped]: undefined, + [GitBuildStatusState.Running]: undefined, + [GitBuildStatusState.Warning]: undefined, +}; + +export const toProviderPullRequestReviewState = { + [PullRequestReviewState.Approved]: GitPullRequestReviewState.Approved, + [PullRequestReviewState.ChangesRequested]: GitPullRequestReviewState.ChangesRequested, + [PullRequestReviewState.Commented]: GitPullRequestReviewState.Commented, + [PullRequestReviewState.ReviewRequested]: GitPullRequestReviewState.ReviewRequested, + [PullRequestReviewState.Dismissed]: null, + [PullRequestReviewState.Pending]: null, +}; + +export const fromProviderPullRequestReviewState = { + [GitPullRequestReviewState.Approved]: PullRequestReviewState.Approved, + [GitPullRequestReviewState.ChangesRequested]: PullRequestReviewState.ChangesRequested, + [GitPullRequestReviewState.Commented]: PullRequestReviewState.Commented, + [GitPullRequestReviewState.ReviewRequested]: PullRequestReviewState.ReviewRequested, +}; + +export const toProviderPullRequestMergeableState = { + [PullRequestMergeableState.Mergeable]: GitPullRequestMergeableState.Mergeable, + [PullRequestMergeableState.Conflicting]: GitPullRequestMergeableState.Conflicts, + [PullRequestMergeableState.Unknown]: GitPullRequestMergeableState.Unknown, +}; + +export const fromProviderPullRequestMergeableState = { + [GitPullRequestMergeableState.Mergeable]: PullRequestMergeableState.Mergeable, + [GitPullRequestMergeableState.Conflicts]: PullRequestMergeableState.Conflicting, + [GitPullRequestMergeableState.Unknown]: PullRequestMergeableState.Unknown, + [GitPullRequestMergeableState.Behind]: PullRequestMergeableState.Unknown, + [GitPullRequestMergeableState.Blocked]: PullRequestMergeableState.Unknown, + [GitPullRequestMergeableState.UnknownAndBlocked]: PullRequestMergeableState.Unknown, + [GitPullRequestMergeableState.Unstable]: PullRequestMergeableState.Unknown, +}; + +export function toProviderReviews(reviewers: PullRequestReviewer[]): ProviderPullRequest['reviews'] { + return reviewers + .filter(r => r.state !== PullRequestReviewState.Dismissed && r.state !== PullRequestReviewState.Pending) + .map(reviewer => ({ + reviewer: toProviderAccount(reviewer.reviewer), + state: toProviderPullRequestReviewState[reviewer.state] ?? GitPullRequestReviewState.ReviewRequested, + })); +} + +export function toReviewRequests(reviews: ProviderPullRequest['reviews']): PullRequestReviewer[] | undefined { + return reviews == null + ? undefined + : reviews + ?.filter(r => r.state === GitPullRequestReviewState.ReviewRequested) + .map(r => ({ + isCodeOwner: false, // TODO: Find this value, and implement in the shared lib if needed + reviewer: fromProviderAccount(r.reviewer), + state: PullRequestReviewState.ReviewRequested, + })); +} + +export function toCompletedReviews(reviews: ProviderPullRequest['reviews']): PullRequestReviewer[] | undefined { + return reviews == null + ? undefined + : reviews + ?.filter(r => r.state !== GitPullRequestReviewState.ReviewRequested) + .map(r => ({ + isCodeOwner: false, // TODO: Find this value, and implement in the shared lib if needed + reviewer: fromProviderAccount(r.reviewer), + state: fromProviderPullRequestReviewState[r.state], + })); +} + +export function toProviderReviewDecision( + reviewDecision?: PullRequestReviewDecision, + reviewers?: PullRequestReviewer[], +): GitPullRequestReviewState | null { + switch (reviewDecision) { + case PullRequestReviewDecision.Approved: + return GitPullRequestReviewState.Approved; + case PullRequestReviewDecision.ChangesRequested: + return GitPullRequestReviewState.ChangesRequested; + case PullRequestReviewDecision.ReviewRequired: + return GitPullRequestReviewState.ReviewRequested; + default: { + if (reviewers?.some(r => r.state === PullRequestReviewState.ReviewRequested)) { + return GitPullRequestReviewState.ReviewRequested; + } else if (reviewers?.some(r => r.state === PullRequestReviewState.Commented)) { + return GitPullRequestReviewState.Commented; + } + return null; + } + } +} + +export const fromPullRequestReviewDecision = { + [GitPullRequestReviewState.Approved]: PullRequestReviewDecision.Approved, + [GitPullRequestReviewState.ChangesRequested]: PullRequestReviewDecision.ChangesRequested, + [GitPullRequestReviewState.Commented]: undefined, + [GitPullRequestReviewState.ReviewRequested]: PullRequestReviewDecision.ReviewRequired, +}; + +export function toProviderPullRequestState(state: PullRequestState): GitPullRequestState { + return state === 'opened' + ? GitPullRequestState.Open + : state === 'closed' + ? GitPullRequestState.Closed + : GitPullRequestState.Merged; +} + +export function fromProviderPullRequestState(state: GitPullRequestState): PullRequestState { + return state === GitPullRequestState.Open ? 'opened' : state === GitPullRequestState.Closed ? 'closed' : 'merged'; +} + +export function toProviderPullRequest(pr: PullRequest): ProviderPullRequest { + const prReviews = [...(pr.reviewRequests ?? []), ...(pr.latestReviews ?? [])]; + return { + id: pr.id, + graphQLId: pr.nodeId, + number: Number.parseInt(pr.id, 10), + title: pr.title, + url: pr.url, + state: toProviderPullRequestState(pr.state), + isDraft: pr.isDraft ?? false, + createdDate: pr.createdDate, + updatedDate: pr.updatedDate, + closedDate: pr.closedDate ?? null, + mergedDate: pr.mergedDate ?? null, + commentCount: pr.commentsCount ?? null, + upvoteCount: pr.thumbsUpCount ?? null, + commitCount: null, + fileCount: null, + additions: pr.additions ?? null, + deletions: pr.deletions ?? null, + author: toProviderAccount(pr.author), + assignees: pr.assignees?.map(toProviderAccount) ?? null, + baseRef: + pr.refs?.base == null + ? null + : { + name: pr.refs.base.branch, + oid: pr.refs.base.sha, + }, + headRef: + pr.refs?.head == null + ? null + : { + name: pr.refs.head.branch, + oid: pr.refs.head.sha, + }, + reviews: toProviderReviews(prReviews), + reviewDecision: toProviderReviewDecision(pr.reviewDecision, prReviews), + repository: + pr.repository != null + ? { + id: pr.repository.repo, + name: pr.repository.repo, + owner: { + login: pr.repository.owner, + }, + remoteInfo: null, // TODO: Add the urls to our model + } + : { + id: '', + name: '', + owner: { + login: '', + }, + remoteInfo: null, + }, + headRepository: + pr.refs?.head != null + ? { + id: pr.refs.head.repo, + name: pr.refs.head.repo, + owner: { + login: pr.refs.head.owner, + }, + remoteInfo: null, + } + : null, + headCommit: + pr.statusCheckRollupState != null + ? { + buildStatuses: [ + { + completedAt: null, + description: '', + name: '', + state: toProviderBuildStatusState[pr.statusCheckRollupState], + startedAt: null, + stage: null, + url: '', + }, + ], + } + : null, + permissions: { + canMerge: + pr.viewerCanUpdate === true && + pr.repository.accessLevel != null && + pr.repository.accessLevel >= RepositoryAccessLevel.Write, + canMergeAndBypassProtections: + pr.viewerCanUpdate === true && + pr.repository.accessLevel != null && + pr.repository.accessLevel >= RepositoryAccessLevel.Admin, + }, + mergeableState: pr.mergeableState + ? toProviderPullRequestMergeableState[pr.mergeableState] + : GitPullRequestMergeableState.Unknown, + }; +} + +export function fromProviderPullRequest(pr: ProviderPullRequest, integration: Integration): PullRequest { + return new PullRequest( + integration, + fromProviderAccount(pr.author), + pr.id, + pr.graphQLId, + pr.title, + pr.url ?? '', + { + owner: pr.repository.owner.login, + repo: pr.repository.name, + }, + fromProviderPullRequestState(pr.state), + pr.createdDate, + pr.updatedDate, + pr.closedDate ?? undefined, + pr.mergedDate ?? undefined, + pr.mergeableState ? fromProviderPullRequestMergeableState[pr.mergeableState] : undefined, + pr.permissions?.canMerge || pr.permissions?.canMergeAndBypassProtections ? true : undefined, + { + base: { + branch: pr.baseRef?.name ?? '', + sha: pr.baseRef?.oid ?? '', + repo: pr.repository.name, + owner: pr.repository.owner.login, + exists: pr.baseRef != null, + url: pr.repository.remoteInfo?.cloneUrlHTTPS + ? pr.repository.remoteInfo.cloneUrlHTTPS.replace(/\.git$/, '') + : '', + }, + head: { + branch: pr.headRef?.name ?? '', + sha: pr.headRef?.oid ?? '', + repo: pr.headRepository?.name ?? '', + owner: pr.headRepository?.owner.login ?? '', + exists: pr.headRef != null, + url: pr.headRepository?.remoteInfo?.cloneUrlHTTPS + ? pr.headRepository.remoteInfo.cloneUrlHTTPS.replace(/\.git$/, '') + : '', + }, + isCrossRepository: pr.headRepository?.id !== pr.repository.id, + }, + pr.isDraft, + pr.additions ?? undefined, + pr.deletions ?? undefined, + pr.commentCount ?? undefined, + pr.upvoteCount ?? undefined, + pr.reviewDecision ? fromPullRequestReviewDecision[pr.reviewDecision] : undefined, + toReviewRequests(pr.reviews), + toCompletedReviews(pr.reviews), + pr.assignees?.map(fromProviderAccount) ?? undefined, + pr.headCommit?.buildStatuses?.[0]?.state + ? fromProviderBuildStatusState[pr.headCommit.buildStatuses[0].state] + : undefined, + ); +} + +export function toProviderPullRequestWithUniqueId(pr: PullRequest): PullRequestWithUniqueID { + return { + ...toProviderPullRequest(pr), + uuid: EntityIdentifierUtils.encode(getEntityIdentifierInput(pr)), + }; +} + +export function toProviderAccount(account: PullRequestMember | IssueMember): ProviderAccount { + return { + avatarUrl: account.avatarUrl ?? null, + name: account.name ?? null, + url: account.url ?? null, + // TODO: Implement these in our own model + email: '', + username: account.name ?? null, + id: account.name ?? null, + }; +} + +export function fromProviderAccount(account: ProviderAccount | null): PullRequestMember | IssueMember { + return { + name: account?.name ?? 'unknown', + avatarUrl: account?.avatarUrl ?? undefined, + url: account?.url ?? '', + }; +} + +export type ProviderActionablePullRequest = ActionablePullRequest; + +export type EnrichablePullRequest = ProviderPullRequest & { + uuid: string; + type: 'pullrequest'; + provider: ProviderReference; + enrichable: EnrichableItem; + repoIdentity: RequireSomeWithProps< + RequireSome, 'remote' | 'provider'>, + 'provider', + 'id' | 'domain' | 'repoDomain' | 'repoName' + >; + refs?: PullRequestRefs; +}; + +export const getActionablePullRequests = GitProviderUtils.getActionablePullRequests; diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts new file mode 100644 index 0000000000000..eae63d769af4e --- /dev/null +++ b/src/plus/integrations/providers/providersApi.ts @@ -0,0 +1,762 @@ +import ProviderApis from '@gitkraken/provider-apis'; +import type { Container } from '../../../container'; +import { + AuthenticationError, + AuthenticationErrorReason, + ProviderRequestClientError, + ProviderRequestRateLimitError, +} from '../../../errors'; +import type { PagedResult } from '../../../git/gitProvider'; +import { base64 } from '../../../system/string'; +import type { IntegrationAuthenticationService } from '../authentication/integrationAuthentication'; +import type { + GetAzureProjectsForResourceFn, + GetAzureResourcesForUserFn, + GetCurrentUserFn, + GetCurrentUserForInstanceFn, + GetIssuesForAzureProjectFn, + GetIssuesForRepoFn, + GetIssuesForReposFn, + GetIssuesOptions, + GetPullRequestsForAzureProjectsFn, + GetPullRequestsForRepoFn, + GetPullRequestsForReposFn, + GetPullRequestsForUserFn, + GetPullRequestsForUserOptions, + GetPullRequestsOptions, + GetReposForAzureProjectFn, + GetReposOptions, + IntegrationId, + IssueFilter, + PageInfo, + PagingMode, + ProviderAccount, + ProviderAzureProject, + ProviderAzureResource, + ProviderInfo, + ProviderIssue, + ProviderJiraProject, + ProviderJiraResource, + ProviderPullRequest, + ProviderRepoInput, + ProviderReposInput, + ProviderRepository, + Providers, + PullRequestFilter, +} from './models'; +import { HostingIntegrationId, IssueIntegrationId, providersMetadata, SelfHostedIntegrationId } from './models'; + +export class ProvidersApi { + private readonly providers: Providers; + + constructor( + private readonly container: Container, + private readonly authenticationService: IntegrationAuthenticationService, + ) { + const providerApis = ProviderApis(); + this.providers = { + [HostingIntegrationId.GitHub]: { + ...providersMetadata[HostingIntegrationId.GitHub], + provider: providerApis.github, + getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( + providerApis.github, + ) as GetPullRequestsForReposFn, + getPullRequestsForUserFn: providerApis.github.getPullRequestsAssociatedWithUser.bind( + providerApis.github, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( + providerApis.github, + ) as GetIssuesForReposFn, + }, + [SelfHostedIntegrationId.GitHubEnterprise]: { + ...providersMetadata[SelfHostedIntegrationId.GitHubEnterprise], + provider: providerApis.github, + getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( + providerApis.github, + ) as GetPullRequestsForReposFn, + getPullRequestsForUserFn: providerApis.github.getPullRequestsAssociatedWithUser.bind( + providerApis.github, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( + providerApis.github, + ) as GetIssuesForReposFn, + }, + [HostingIntegrationId.GitLab]: { + ...providersMetadata[HostingIntegrationId.GitLab], + provider: providerApis.gitlab, + getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( + providerApis.gitlab, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind( + providerApis.gitlab, + ) as GetPullRequestsForRepoFn, + getPullRequestsForUserFn: providerApis.gitlab.getPullRequestsAssociatedWithUser.bind( + providerApis.gitlab, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( + providerApis.gitlab, + ) as GetIssuesForReposFn, + getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( + providerApis.gitlab, + ) as GetIssuesForRepoFn, + }, + [SelfHostedIntegrationId.GitLabSelfHosted]: { + ...providersMetadata[SelfHostedIntegrationId.GitLabSelfHosted], + provider: providerApis.gitlab, + getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( + providerApis.gitlab, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind( + providerApis.gitlab, + ) as GetPullRequestsForRepoFn, + getPullRequestsForUserFn: providerApis.gitlab.getPullRequestsAssociatedWithUser.bind( + providerApis.gitlab, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( + providerApis.gitlab, + ) as GetIssuesForReposFn, + getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( + providerApis.gitlab, + ) as GetIssuesForRepoFn, + }, + [HostingIntegrationId.Bitbucket]: { + ...providersMetadata[HostingIntegrationId.Bitbucket], + provider: providerApis.bitbucket, + getCurrentUserFn: providerApis.bitbucket.getCurrentUser.bind( + providerApis.bitbucket, + ) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.bitbucket.getPullRequestsForRepos.bind( + providerApis.bitbucket, + ) as GetPullRequestsForReposFn, + getPullRequestsForUserFn: providerApis.bitbucket.getPullRequestsForUser.bind( + providerApis.bitbucket, + ) as GetPullRequestsForUserFn, + getPullRequestsForRepoFn: providerApis.bitbucket.getPullRequestsForRepo.bind( + providerApis.bitbucket, + ) as GetPullRequestsForRepoFn, + }, + [HostingIntegrationId.AzureDevOps]: { + ...providersMetadata[HostingIntegrationId.AzureDevOps], + provider: providerApis.azureDevOps, + getCurrentUserFn: providerApis.azureDevOps.getCurrentUser.bind( + providerApis.azureDevOps, + ) as GetCurrentUserFn, + getCurrentUserForInstanceFn: providerApis.azureDevOps.getCurrentUserForInstance.bind( + providerApis.azureDevOps, + ) as GetCurrentUserForInstanceFn, + getAzureResourcesForUserFn: providerApis.azureDevOps.getOrgsForUser.bind( + providerApis.azureDevOps, + ) as GetAzureResourcesForUserFn, + getAzureProjectsForResourceFn: providerApis.azureDevOps.getAzureProjects.bind( + providerApis.azureDevOps, + ) as GetAzureProjectsForResourceFn, + getPullRequestsForReposFn: providerApis.azureDevOps.getPullRequestsForRepos.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.azureDevOps.getPullRequestsForRepo.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForRepoFn, + getPullRequestsForAzureProjectsFn: providerApis.azureDevOps.getPullRequestsForProjects.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForAzureProjectsFn, + getIssuesForAzureProjectFn: providerApis.azureDevOps.getIssuesForAzureProject.bind( + providerApis.azureDevOps, + ) as GetIssuesForAzureProjectFn, + getReposForAzureProjectFn: providerApis.azureDevOps.getReposForAzureProject.bind( + providerApis.azureDevOps, + ) as GetReposForAzureProjectFn, + }, + [IssueIntegrationId.Jira]: { + ...providersMetadata[IssueIntegrationId.Jira], + provider: providerApis.jira, + getCurrentUserForResourceFn: providerApis.jira.getCurrentUserForResource.bind(providerApis.jira), + getJiraResourcesForCurrentUserFn: providerApis.jira.getJiraResourcesForCurrentUser.bind( + providerApis.jira, + ), + getJiraProjectsForResourcesFn: providerApis.jira.getJiraProjectsForResources.bind(providerApis.jira), + getIssueFn: providerApis.jira.getIssue.bind(providerApis.jira), + getIssuesForProjectFn: providerApis.jira.getIssuesForProject.bind(providerApis.jira), + getIssuesForResourceForCurrentUserFn: providerApis.jira.getIssuesForResourceForCurrentUser.bind( + providerApis.jira, + ), + }, + [IssueIntegrationId.Trello]: { + ...providersMetadata[IssueIntegrationId.Trello], + provider: providerApis.trello, + }, + }; + } + + getScopesForProvider(providerId: IntegrationId): string[] | undefined { + return this.providers[providerId]?.scopes; + } + + getProviderDomain(providerId: IntegrationId): string | undefined { + return this.providers[providerId]?.domain; + } + + getProviderPullRequestsPagingMode(providerId: IntegrationId): PagingMode | undefined { + return this.providers[providerId]?.pullRequestsPagingMode; + } + + getProviderIssuesPagingMode(providerId: IntegrationId): PagingMode | undefined { + return this.providers[providerId]?.issuesPagingMode; + } + + providerSupportsPullRequestFilters(providerId: IntegrationId, filters: PullRequestFilter[]): boolean { + return ( + this.providers[providerId]?.supportedPullRequestFilters != null && + filters.every(filter => this.providers[providerId]?.supportedPullRequestFilters?.includes(filter)) + ); + } + + providerSupportsIssueFilters(providerId: IntegrationId, filters: IssueFilter[]): boolean { + return ( + this.providers[providerId]?.supportedIssueFilters != null && + filters.every(filter => this.providers[providerId]?.supportedIssueFilters?.includes(filter)) + ); + } + + isRepoIdsInput(input: any): input is (string | number)[] { + return ( + input != null && + Array.isArray(input) && + input.every((id: any) => typeof id === 'string' || typeof id === 'number') + ); + } + + private async getProviderToken( + provider: ProviderInfo, + options?: { createSessionIfNeeded?: boolean }, + ): Promise { + const providerDescriptor = + provider.domain == null || provider.scopes == null + ? undefined + : { domain: provider.domain, scopes: provider.scopes }; + try { + const authProvider = await this.authenticationService.get(provider.id); + return ( + await authProvider.getSession(providerDescriptor, { + createIfNeeded: options?.createSessionIfNeeded, + }) + )?.accessToken; + } catch { + return undefined; + } + } + + private getAzurePATForOAuthToken(oauthToken: string) { + return base64(`PAT:${oauthToken}`); + } + + private async ensureProviderTokenAndFunction( + providerId: IntegrationId, + providerFn: keyof ProviderInfo, + accessToken?: string, + ): Promise<{ provider: ProviderInfo; token: string }> { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + const token = accessToken ?? (await this.getProviderToken(provider)); + if (token == null) { + throw new Error(`Not connected to provider ${providerId}`); + } + + if (provider[providerFn] == null) { + throw new Error(`Provider with id ${providerId} does not support function: ${providerFn}`); + } + + return { provider: provider, token: token }; + } + + private handleProviderError(providerId: IntegrationId, token: string, error: any): T { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + switch (providerId) { + case IssueIntegrationId.Jira: { + if (error?.response?.status != null) { + if (error.response.status === 401) { + throw new AuthenticationError(providerId, AuthenticationErrorReason.Forbidden, error); + } else if (error.response.status === 429) { + let resetAt: number | undefined; + + const reset = error.response.headers?.['x-ratelimit-reset']; + if (reset != null) { + resetAt = parseInt(reset, 10); + if (Number.isNaN(resetAt)) { + resetAt = undefined; + } + } + + throw new ProviderRequestRateLimitError(error, token, resetAt); + } else if (error.response.status >= 400 && error.response.status < 500) { + throw new ProviderRequestClientError(error); + } + } + throw error; + } + default: { + throw error; + } + } + } + + async getPagedResult( + provider: ProviderInfo, + args: any, + providerFn: + | (( + input: any, + options?: { token?: string; isPAT?: boolean }, + ) => Promise<{ data: NonNullable[]; pageInfo?: PageInfo }>) + | undefined, + token: string, + cursor: string = '{}', + usePAT: boolean = false, + ): Promise> { + let cursorInfo; + try { + cursorInfo = JSON.parse(cursor); + } catch { + cursorInfo = {}; + } + const cursorValue = cursorInfo.value; + const cursorType = cursorInfo.type; + let cursorOrPage = {}; + if (cursorType === 'page') { + cursorOrPage = { page: cursorValue }; + } else if (cursorType === 'cursor') { + cursorOrPage = { cursor: cursorValue }; + } + + const input = { + ...args, + ...cursorOrPage, + }; + + const result = await providerFn?.(input, { token: token, isPAT: usePAT }); + if (result == null) { + return { values: [] }; + } + + const hasMore = result.pageInfo?.hasNextPage ?? false; + + let nextCursor = '{}'; + if (result.pageInfo?.endCursor != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); + } else if (result.pageInfo?.nextPage != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); + } + + return { + values: result.data, + paging: { + cursor: nextCursor, + more: hasMore, + }, + }; + } + + async getCurrentUser( + providerId: IntegrationId, + options?: { accessToken?: string; isPAT?: boolean }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getCurrentUserFn', + options?.accessToken, + ); + + try { + return (await provider.getCurrentUserFn?.({}, { token: token, isPAT: options?.isPAT }))?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getCurrentUserForInstance( + providerId: IntegrationId, + namespace: string, + options?: { accessToken?: string; isPAT?: boolean }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getCurrentUserForInstanceFn', + options?.accessToken, + ); + + return ( + await provider.getCurrentUserForInstanceFn?.( + { namespace: namespace }, + { token: token, isPAT: options?.isPAT }, + ) + )?.data; + } + + async getCurrentUserForResource( + providerId: IntegrationId, + resourceId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getCurrentUserForResourceFn', + options?.accessToken, + ); + + try { + return (await provider.getCurrentUserForResourceFn?.({ resourceId: resourceId }, { token: token }))?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getJiraResourcesForCurrentUser(options?: { + accessToken?: string; + }): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + IssueIntegrationId.Jira, + 'getJiraResourcesForCurrentUserFn', + options?.accessToken, + ); + + try { + return (await provider.getJiraResourcesForCurrentUserFn?.({ token: token }))?.data; + } catch (e) { + return this.handleProviderError(IssueIntegrationId.Jira, token, e); + } + } + + async getAzureResourcesForUser( + userId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getAzureResourcesForUserFn', + options?.accessToken, + ); + + try { + return (await provider.getAzureResourcesForUserFn?.({ userId: userId }, { token: token }))?.data; + } catch (e) { + return this.handleProviderError( + HostingIntegrationId.AzureDevOps, + token, + e, + ); + } + } + + async getJiraProjectsForResources( + resourceIds: string[], + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + IssueIntegrationId.Jira, + 'getJiraProjectsForResourcesFn', + options?.accessToken, + ); + + try { + return (await provider.getJiraProjectsForResourcesFn?.({ resourceIds: resourceIds }, { token: token })) + ?.data; + } catch (e) { + return this.handleProviderError(IssueIntegrationId.Jira, token, e); + } + } + + async getAzureProjectsForResource( + namespace: string, + options?: { accessToken?: string; cursor?: string; isPAT?: boolean }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getAzureProjectsForResourceFn', + options?.accessToken, + ); + + // Azure only supports PAT for this call + const azureToken = options?.isPAT ? token : this.getAzurePATForOAuthToken(token); + + try { + return this.getPagedResult( + provider, + { namespace: namespace, ...options }, + provider.getAzureProjectsForResourceFn, + azureToken, + options?.cursor, + true, + ); + } catch (e) { + return this.handleProviderError>( + HostingIntegrationId.AzureDevOps, + token, + e, + ); + } + } + + async getReposForAzureProject( + namespace: string, + project: string, + options?: GetReposOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getReposForAzureProjectFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { namespace: namespace, project: project, ...options }, + provider.getReposForAzureProjectFn, + token, + options?.cursor, + ); + } + + async getPullRequestsForRepos( + providerId: IntegrationId, + reposOrIds: ProviderReposInput, + options?: GetPullRequestsOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getPullRequestsForReposFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { + ...(this.isRepoIdsInput(reposOrIds) ? { repoIds: reposOrIds } : { repos: reposOrIds }), + ...options, + }, + provider.getPullRequestsForReposFn, + token, + options?.cursor, + ); + } + + async getPullRequestsForRepo( + providerId: IntegrationId, + repo: ProviderRepoInput, + options?: GetPullRequestsOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getPullRequestsForRepoFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { repo: repo, ...options }, + provider.getPullRequestsForRepoFn, + token, + options?.cursor, + ); + } + + async getPullRequestsForUser( + providerId: HostingIntegrationId.Bitbucket, + userId: string, + options?: { accessToken?: string } & GetPullRequestsForUserOptions, + ): Promise>; + async getPullRequestsForUser( + providerId: Exclude, + username: string, + options?: { accessToken?: string } & GetPullRequestsForUserOptions, + ): Promise>; + async getPullRequestsForUser( + providerId: IntegrationId, + usernameOrId: string, + options?: { accessToken?: string } & GetPullRequestsForUserOptions, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getPullRequestsForUserFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { + ...(providerId === HostingIntegrationId.Bitbucket + ? { userId: usernameOrId } + : { username: usernameOrId }), + ...options, + }, + provider.getPullRequestsForUserFn, + token, + options?.cursor, + ); + } + + async getPullRequestsForAzureProjects( + projects: { namespace: string; project: string }[], + options?: { accessToken?: string; authorLogin?: string; assigneeLogins?: string[]; isPAT?: boolean }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getPullRequestsForAzureProjectsFn', + options?.accessToken, + ); + + // Azure only supports PAT for this call + const azureToken = options?.isPAT ? token : this.getAzurePATForOAuthToken(token); + + try { + return ( + await provider.getPullRequestsForAzureProjectsFn?.( + { projects: projects, ...options }, + { token: azureToken, isPAT: true }, + ) + )?.data; + } catch (e) { + return this.handleProviderError(HostingIntegrationId.AzureDevOps, token, e); + } + } + + async getIssuesForRepos( + providerId: IntegrationId, + reposOrIds: ProviderReposInput, + options?: GetIssuesOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForReposFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { + ...(this.isRepoIdsInput(reposOrIds) ? { repoIds: reposOrIds } : { repos: reposOrIds }), + ...options, + }, + provider.getIssuesForReposFn, + token, + options?.cursor, + ); + } + + async getIssuesForRepo( + providerId: IntegrationId, + repo: ProviderRepoInput, + options?: GetIssuesOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForRepoFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { repo: repo, ...options }, + provider.getIssuesForRepoFn, + token, + options?.cursor, + ); + } + + async getIssuesForAzureProject( + namespace: string, + project: string, + options?: GetIssuesOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getIssuesForAzureProjectFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { namespace: namespace, project: project, ...options }, + provider.getIssuesForAzureProjectFn, + token, + options?.cursor, + ); + } + + async getIssuesForProject( + providerId: IntegrationId, + project: string, + resourceId: string, + options?: GetIssuesOptions & { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForProjectFn', + options?.accessToken, + ); + + try { + const result = await provider.getIssuesForProjectFn?.( + { project: project, resourceId: resourceId, ...options }, + { token: token }, + ); + + return result?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getIssuesForResourceForCurrentUser( + providerId: IntegrationId, + resourceId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForResourceForCurrentUserFn', + options?.accessToken, + ); + + try { + const result = await provider.getIssuesForResourceForCurrentUserFn?.( + { resourceId: resourceId }, + { token: token }, + ); + + return result?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getIssue( + providerId: IntegrationId, + resourceId: string, + issueId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssueFn', + options?.accessToken, + ); + + try { + const result = await provider.getIssueFn?.({ resourceId: resourceId, number: issueId }, { token: token }); + + return result?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } +} diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts new file mode 100644 index 0000000000000..75349ce539f64 --- /dev/null +++ b/src/plus/integrations/providers/utils.ts @@ -0,0 +1,48 @@ +import type { AnyEntityIdentifierInput, EntityIdentifier } from '@gitkraken/provider-apis'; +import { EntityIdentifierProviderType, EntityType, EntityVersion } from '@gitkraken/provider-apis'; +import type { IssueOrPullRequest } from '../../../git/models/issue'; +import { equalsIgnoreCase } from '../../../system/string'; +import type { FocusItem } from '../../focus/focusProvider'; +import type { IntegrationId } from './models'; +import { HostingIntegrationId, SelfHostedIntegrationId } from './models'; + +function isGitHubDotCom(domain: string): boolean { + return equalsIgnoreCase(domain, 'github.com'); +} + +function isFocusItem(item: IssueOrPullRequest | FocusItem): item is FocusItem { + return (item as FocusItem).uuid !== undefined; +} + +export function getEntityIdentifierInput(entity: IssueOrPullRequest | FocusItem): AnyEntityIdentifierInput { + let entityType = EntityType.Issue; + if (entity.type === 'pullrequest') { + entityType = EntityType.PullRequest; + } + + let provider = EntityIdentifierProviderType.Github; + let domain = undefined; + if (!isGitHubDotCom(entity.provider.domain)) { + provider = EntityIdentifierProviderType.GithubEnterprise; + domain = entity.provider.domain; + } + + return { + provider: provider, + entityType: entityType, + version: EntityVersion.One, + domain: domain, + entityId: isFocusItem(entity) ? entity.graphQLId! : entity.nodeId!, + }; +} + +export function getProviderIdFromEntityIdentifier(entityIdentifier: EntityIdentifier): IntegrationId | undefined { + switch (entityIdentifier.provider) { + case EntityIdentifierProviderType.Github: + return HostingIntegrationId.GitHub; + case EntityIdentifierProviderType.GithubEnterprise: + return SelfHostedIntegrationId.GitHubEnterprise; + default: + return undefined; + } +} diff --git a/src/plus/repos/repositoryIdentityService.ts b/src/plus/repos/repositoryIdentityService.ts new file mode 100644 index 0000000000000..d220b2b6e225b --- /dev/null +++ b/src/plus/repos/repositoryIdentityService.ts @@ -0,0 +1,170 @@ +import type { Disposable } from 'vscode'; +import { Uri, window } from 'vscode'; +import type { Container } from '../../container'; +import { RemoteResourceType } from '../../git/models/remoteResource'; +import type { Repository } from '../../git/models/repository'; +import { parseGitRemoteUrl } from '../../git/parsers/remoteParser'; +import type { RepositoryIdentityDescriptor } from '../../gk/models/repositoryIdentities'; +import { missingRepositoryId } from '../../gk/models/repositoryIdentities'; +import { log } from '../../system/decorators/log'; +import type { ServerConnection } from '../gk/serverConnection'; + +export class RepositoryIdentityService implements Disposable { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose(): void {} + + @log() + getRepository( + identity: RepositoryIdentityDescriptor, + options?: { openIfNeeded?: boolean; keepOpen?: boolean; prompt?: boolean; skipRefValidation?: boolean }, + ): Promise { + return this.locateRepository(identity, options); + } + + @log() + private async locateRepository( + identity: RepositoryIdentityDescriptor, + options?: { openIfNeeded?: boolean; keepOpen?: boolean; prompt?: boolean; skipRefValidation?: boolean }, + ): Promise { + const hasInitialCommitSha = + identity.initialCommitSha != null && identity.initialCommitSha !== missingRepositoryId; + const hasRemoteUrl = identity?.remote?.url != null; + const hasProviderInfo = + identity.provider?.id != null && identity.provider.repoDomain != null && identity.provider.repoName != null; + + if (!hasInitialCommitSha && !hasRemoteUrl && !hasProviderInfo) { + return undefined; + } + + const matches = + hasRemoteUrl || hasProviderInfo + ? await this.container.repositoryPathMapping.getLocalRepoPaths({ + remoteUrl: identity.remote?.url, + repoInfo: + identity.provider != null + ? { + provider: identity.provider.id, + owner: identity.provider.repoDomain, + repoName: identity.provider.repoName, + } + : undefined, + }) + : []; + + let foundRepo: Repository | undefined; + if (matches.length) { + for (const match of matches) { + const repo = this.container.git.getRepository(Uri.file(match)); + if (repo != null) { + foundRepo = repo; + break; + } + } + + if (foundRepo == null && options?.openIfNeeded) { + foundRepo = await this.container.git.getOrOpenRepository(Uri.file(matches[0]), { + closeOnOpen: !options?.keepOpen, + }); + } + } else { + const [, remoteDomain, remotePath] = + identity.remote?.url != null ? parseGitRemoteUrl(identity.remote.url) : []; + + // Try to match a repo using the remote URL first, since that saves us some steps. + // As a fallback, try to match using the repo id. + for (const repo of this.container.git.repositories) { + if (remoteDomain != null && remotePath != null) { + const matchingRemotes = await repo.getRemotes({ + filter: r => r.matches(remoteDomain, remotePath), + }); + if (matchingRemotes.length > 0) { + foundRepo = repo; + break; + } + } + + if (!options?.skipRefValidation && hasInitialCommitSha) { + // Repo ID can be any valid SHA in the repo, though standard practice is to use the + // first commit SHA. + if (await this.container.git.validateReference(repo.uri, identity.initialCommitSha)) { + foundRepo = repo; + break; + } + } + } + } + + if (foundRepo == null && options?.prompt) { + const locate = { title: 'Locate Repository' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const decision = await window.showInformationMessage( + `Unable to locate a repository for ${identity.name}`, + { modal: true }, + locate, + cancel, + ); + + if (decision !== locate) return undefined; + + const repoLocatedUri = ( + await window.showOpenDialog({ + title: `Choose a location for ${identity.name}`, + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + }) + )?.[0]; + + if (repoLocatedUri == null) return undefined; + + const locatedRepo = await this.container.git.getOrOpenRepository(repoLocatedUri, { + closeOnOpen: !options?.keepOpen, + detectNested: false, + }); + + if (locatedRepo == null) return undefined; + if ( + identity.initialCommitSha == null || + (await this.container.git.validateReference(locatedRepo.uri, identity.initialCommitSha)) + ) { + foundRepo = locatedRepo; + await this.addFoundRepositoryToMap(foundRepo, identity); + } + } + + return foundRepo; + } + + private async addFoundRepositoryToMap(repo: Repository, identity?: RepositoryIdentityDescriptor) { + const repoPath = repo.uri.fsPath; + + const remotes = await repo.getRemotes(); + for (const remote of remotes) { + const remoteUrl = remote.provider?.url({ type: RemoteResourceType.Repo }); + if (remoteUrl != null) { + await this.container.repositoryPathMapping.writeLocalRepoPath({ remoteUrl: remoteUrl }, repoPath); + } + } + + if ( + identity?.provider?.id != null && + identity?.provider?.repoDomain != null && + identity?.provider?.repoName != null + ) { + await this.container.repositoryPathMapping.writeLocalRepoPath( + { + repoInfo: { + provider: identity.provider.id, + owner: identity.provider.repoDomain, + repoName: identity.provider.repoName, + }, + }, + repoPath, + ); + } + } +} diff --git a/src/plus/subscription/subscriptionService.ts b/src/plus/subscription/subscriptionService.ts deleted file mode 100644 index 0f9359d2f5def..0000000000000 --- a/src/plus/subscription/subscriptionService.ts +++ /dev/null @@ -1,1168 +0,0 @@ -import type { - AuthenticationProviderAuthenticationSessionsChangeEvent, - AuthenticationSession, - CancellationToken, - Event, - MessageItem, - StatusBarItem, -} from 'vscode'; -import { - authentication, - CancellationTokenSource, - version as codeVersion, - Disposable, - env, - EventEmitter, - MarkdownString, - ProgressLocation, - StatusBarAlignment, - ThemeColor, - window, -} from 'vscode'; -import { getPlatform } from '@env/platform'; -import type { CoreColors } from '../../constants'; -import { Commands } from '../../constants'; -import type { Container } from '../../container'; -import { AccountValidationError } from '../../errors'; -import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; -import type { Subscription } from '../../subscription'; -import { - computeSubscriptionState, - getSubscriptionPlan, - getSubscriptionPlanName, - getSubscriptionPlanPriority, - getSubscriptionTimeRemaining, - getTimeRemaining, - isSubscriptionExpired, - isSubscriptionInProTrial, - isSubscriptionPaid, - isSubscriptionTrial, - SubscriptionPlanId, - SubscriptionState, -} from '../../subscription'; -import { executeCommand, registerCommand } from '../../system/command'; -import { configuration } from '../../system/configuration'; -import { setContext } from '../../system/context'; -import { createFromDateDelta } from '../../system/date'; -import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; -import type { Deferrable } from '../../system/function'; -import { debounce, once } from '../../system/function'; -import { Logger } from '../../system/logger'; -import { getLogScope } from '../../system/logger.scope'; -import { flatten } from '../../system/object'; -import { pluralize } from '../../system/string'; -import { openWalkthrough } from '../../system/utils'; -import { satisfies } from '../../system/version'; -import { authenticationProviderId, authenticationProviderScopes } from '../gk/authenticationProvider'; -import type { ServerConnection } from '../gk/serverConnection'; -import { ensurePlusFeaturesEnabled } from './utils'; - -export interface SubscriptionChangeEvent { - readonly current: Subscription; - readonly previous: Subscription; - readonly etag: number; -} - -export class SubscriptionService implements Disposable { - private _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - - private _disposable: Disposable; - private _subscription!: Subscription; - private _statusBarSubscription: StatusBarItem | undefined; - private _validationTimer: ReturnType | undefined; - - constructor( - private readonly container: Container, - private readonly connection: ServerConnection, - previousVersion: string | undefined, - ) { - this._disposable = Disposable.from( - once(container.onReady)(this.onReady, this), - this.container.accountAuthentication.onDidChangeSessions( - e => setTimeout(() => this.onAuthenticationChanged(e), 0), - this, - ), - configuration.onDidChange(e => { - if (configuration.changed(e, 'plusFeatures')) { - this.updateContext(); - } - }), - ); - - const subscription = this.getStoredSubscription(); - // Resets the preview trial state on the upgrade to 14.0 - if (subscription != null && satisfies(previousVersion, '< 14.0')) { - subscription.previewTrial = undefined; - } - - this.changeSubscription(subscription, { silent: true }); - setTimeout(() => void this.ensureSession(false), 10000); - } - - dispose(): void { - this._statusBarSubscription?.dispose(); - - this._disposable.dispose(); - } - - private async onAuthenticationChanged(e: AuthenticationProviderAuthenticationSessionsChangeEvent) { - let session = this._session; - if (session == null && this._sessionPromise != null) { - session = await this._sessionPromise; - } - - if (session != null && e.removed?.some(s => s.id === session!.id)) { - this._session = undefined; - this._sessionPromise = undefined; - void this.logout(); - return; - } - - const updated = e.added?.[0] ?? e.changed?.[0]; - if (updated == null) return; - - if (updated.id === session?.id && updated.accessToken === session?.accessToken) { - return; - } - - this._session = session; - void this.validate({ force: true }); - } - - private _etag: number = 0; - get etag(): number { - return this._etag; - } - - private onReady() { - this._disposable = Disposable.from( - this._disposable, - this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), - ...this.registerCommands(), - ); - this.updateContext(); - } - - private onRepositoriesChanged(_e: RepositoriesChangeEvent): void { - this.updateContext(); - } - - private registerCommands(): Disposable[] { - void this.container.viewCommands; - - return [ - registerCommand(Commands.PlusLoginOrSignUp, () => this.loginOrSignUp()), - registerCommand(Commands.PlusLogout, () => this.logout()), - - registerCommand(Commands.PlusStartPreviewTrial, () => this.startPreviewTrial()), - registerCommand(Commands.PlusManage, () => this.manage()), - registerCommand(Commands.PlusPurchase, () => this.purchase()), - - registerCommand(Commands.PlusResendVerification, () => this.resendVerification()), - registerCommand(Commands.PlusValidate, () => this.validate({ force: true })), - - registerCommand(Commands.PlusShowPlans, () => this.showPlans()), - - registerCommand(Commands.PlusHide, () => configuration.updateEffective('plusFeatures.enabled', false)), - registerCommand(Commands.PlusRestore, () => configuration.updateEffective('plusFeatures.enabled', true)), - - registerCommand('gitlens.plus.reset', () => this.logout(true)), - ]; - } - - async getAuthenticationSession(createIfNeeded: boolean = false): Promise { - return this.ensureSession(createIfNeeded); - } - - async getSubscription(cached = false): Promise { - const promise = this.ensureSession(false); - if (!cached) { - void (await promise); - } - return this._subscription; - } - - @debug() - async learnAboutPreviewOrTrial() { - const subscription = await this.getSubscription(); - if (subscription.state === SubscriptionState.FreeInPreviewTrial) { - void openWalkthrough( - this.container.context.extension.id, - 'gitlens.welcome', - 'gitlens.welcome.preview', - false, - ); - } else if (subscription.state === SubscriptionState.FreePlusInTrial) { - void openWalkthrough( - this.container.context.extension.id, - 'gitlens.welcome', - 'gitlens.welcome.trial', - false, - ); - } - } - - @log() - async loginOrSignUp(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return false; - - // Abort any waiting authentication to ensure we can start a new flow - await this.container.accountAuthentication.abort(); - void this.showAccountView(); - - const session = await this.ensureSession(true); - const loggedIn = Boolean(session); - if (loggedIn) { - const { - account, - plan: { actual, effective }, - } = this._subscription; - - if (account?.verified === false) { - const confirm: MessageItem = { title: 'Resend Verification', isCloseAffordance: true }; - const cancel: MessageItem = { title: 'Cancel' }; - const result = await window.showInformationMessage( - `You must verify your email before you can access ${effective.name}.`, - confirm, - cancel, - ); - - if (result === confirm) { - void this.resendVerification(); - } - } else if (isSubscriptionTrial(this._subscription)) { - const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); - - const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; - const learn: MessageItem = { title: 'Learn More' }; - const result = await window.showInformationMessage( - `Welcome to ${ - effective.name - } (Trial). You can now try Pro features on privately hosted repos for ${pluralize( - 'more day', - remaining ?? 0, - )}.`, - { modal: true }, - confirm, - learn, - ); - - if (result === learn) { - void this.learnAboutPreviewOrTrial(); - } - } else if (isSubscriptionPaid(this._subscription)) { - void window.showInformationMessage( - `Welcome to ${actual.name}. You can now use Pro features on privately hosted repos.`, - 'OK', - ); - } else { - void window.showInformationMessage( - `Welcome to ${actual.name}. You can use Pro features on local & publicly hosted repos.`, - 'OK', - ); - } - } - return loggedIn; - } - - @log() - async logout(reset: boolean = false): Promise { - return this.logoutCore(reset); - } - - private async logoutCore(reset: boolean = false): Promise { - this._lastValidatedDate = undefined; - if (this._validationTimer != null) { - clearInterval(this._validationTimer); - this._validationTimer = undefined; - } - - await this.container.accountAuthentication.abort(); - - this._sessionPromise = undefined; - if (this._session != null) { - void this.container.accountAuthentication.removeSession(this._session.id); - this._session = undefined; - } else { - // Even if we don't have a session, make sure to remove any other matching sessions - void this.container.accountAuthentication.removeSessionsByScopes(authenticationProviderScopes); - } - - if (reset && this.container.debugging) { - this.changeSubscription(undefined); - - return; - } - - this.changeSubscription({ - ...this._subscription, - plan: { - actual: getSubscriptionPlan( - SubscriptionPlanId.Free, - false, - undefined, - this._subscription.plan?.actual?.startedOn != null - ? new Date(this._subscription.plan.actual.startedOn) - : undefined, - ), - effective: getSubscriptionPlan( - SubscriptionPlanId.Free, - false, - undefined, - this._subscription.plan?.effective?.startedOn != null - ? new Date(this._subscription.plan.actual.startedOn) - : undefined, - ), - }, - account: undefined, - }); - } - - @log() - manage(): void { - void env.openExternal(this.connection.getAccountsUri()); - } - - @log() - async purchase(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - if (this._subscription.account == null) { - this.showPlans(); - } else { - void env.openExternal(this.connection.getAccountsUri('subscription', 'product=gitlens&license=PRO')); - } - await this.showAccountView(); - } - - @gate() - @log() - async resendVerification(): Promise { - if (this._subscription.account?.verified) return true; - - const scope = getLogScope(); - - void this.showAccountView(true); - - const session = await this.ensureSession(false); - if (session == null) return false; - - try { - const rsp = await this.connection.fetchApi( - 'resend-email', - { - method: 'POST', - body: JSON.stringify({ id: session.account.id }), - }, - session.accessToken, - ); - - if (!rsp.ok) { - debugger; - Logger.error( - '', - scope, - `Unable to resend verification email; status=(${rsp.status}): ${rsp.statusText}`, - ); - - void window.showErrorMessage(`Unable to resend verification email; Status: ${rsp.statusText}`, 'OK'); - - return false; - } - - const confirm = { title: 'Recheck' }; - const cancel = { title: 'Cancel' }; - const result = await window.showInformationMessage( - "Once you have verified your email address, click 'Recheck'.", - confirm, - cancel, - ); - - if (result === confirm) { - await this.validate({ force: true }); - return true; - } - } catch (ex) { - Logger.error(ex, scope); - debugger; - - void window.showErrorMessage('Unable to resend verification email', 'OK'); - } - - return false; - } - - @log() - async showAccountView(silent: boolean = false): Promise { - if (silent && !configuration.get('plusFeatures.enabled', undefined, true)) return; - - if (!this.container.accountView.visible) { - await executeCommand(Commands.ShowAccountView); - } - } - - private showPlans(): void { - void env.openExternal(this.connection.getSiteUri('gitlens/pricing')); - } - - @gate() - @log() - async startPreviewTrial(silent?: boolean): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - let { plan, previewTrial } = this._subscription; - if (previewTrial != null) { - void this.showAccountView(); - - if (!silent && plan.effective.id === SubscriptionPlanId.Free) { - const confirm: MessageItem = { title: 'Start Free Pro Trial', isCloseAffordance: true }; - const cancel: MessageItem = { title: 'Cancel' }; - const result = await window.showInformationMessage( - 'Your 3-day Pro preview has ended, start a free Pro trial to get an additional 7 days.\n\n✨ A trial or paid plan is required to use Pro features on privately hosted repos.', - { modal: true }, - confirm, - cancel, - ); - - if (result === confirm) { - void this.loginOrSignUp(); - } - } - - return; - } - - // Don't overwrite a trial that is already in progress - if (isSubscriptionInProTrial(this._subscription)) return; - - const startedOn = new Date(); - - let days: number; - let expiresOn = new Date(startedOn); - if (!this.container.debugging) { - // Normalize the date to just before midnight on the same day - expiresOn.setHours(23, 59, 59, 999); - expiresOn = createFromDateDelta(expiresOn, { days: 3 }); - days = 3; - } else { - expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); - days = 0; - } - - previewTrial = { - startedOn: startedOn.toISOString(), - expiresOn: expiresOn.toISOString(), - }; - - this.changeSubscription({ - ...this._subscription, - plan: { - ...this._subscription.plan, - effective: getSubscriptionPlan(SubscriptionPlanId.Pro, false, undefined, startedOn, expiresOn), - }, - previewTrial: previewTrial, - }); - - if (!silent) { - setTimeout(async () => { - const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; - const learn: MessageItem = { title: 'Learn More' }; - const result = await window.showInformationMessage( - `You can now preview Pro features for ${pluralize( - 'day', - days, - )}. After which, you can start a free Pro trial for an additional 7 days.`, - confirm, - learn, - ); - - if (result === learn) { - void this.learnAboutPreviewOrTrial(); - } - }, 1); - } - } - - @gate() - @log() - async validate(options?: { force?: boolean }): Promise { - const scope = getLogScope(); - - const session = await this.ensureSession(false); - if (session == null) { - this.changeSubscription(this._subscription); - return; - } - - try { - await this.checkInAndValidate(session, options); - } catch (ex) { - Logger.error(ex, scope); - debugger; - } - } - - private _lastValidatedDate: Date | undefined; - @gate(s => s.account.id) - private async checkInAndValidate( - session: AuthenticationSession, - options?: { force?: boolean; showSlowProgress?: boolean }, - ): Promise { - // Only check in if we haven't in the last 12 hours - if ( - !options?.force && - this._lastValidatedDate != null && - Date.now() - this._lastValidatedDate.getTime() < 12 * 60 * 60 * 1000 && - !isSubscriptionExpired(this._subscription) - ) { - return; - } - - if (!options?.showSlowProgress) return this.checkInAndValidateCore(session); - - const validating = this.checkInAndValidateCore(session); - const result = await Promise.race([ - validating, - new Promise(resolve => setTimeout(resolve, 3000, true)), - ]); - - if (result) { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Validating your GitKraken account...', - }, - () => validating, - ); - } - } - - @debug({ args: { 0: s => s?.account.label } }) - private async checkInAndValidateCore(session: AuthenticationSession): Promise { - const scope = getLogScope(); - this._lastValidatedDate = undefined; - - try { - const checkInData = { - id: session.account.id, - platform: getPlatform(), - gitlensVersion: this.container.version, - machineId: env.machineId, - sessionId: env.sessionId, - vscodeEdition: env.appName, - vscodeHost: env.appHost, - vscodeVersion: codeVersion, - previewStartedOn: this._subscription.previewTrial?.startedOn, - previewExpiresOn: this._subscription.previewTrial?.expiresOn, - }; - - const rsp = await this.connection.fetchApi( - 'gitlens/checkin', - { - method: 'POST', - body: JSON.stringify(checkInData), - }, - session.accessToken, - ); - - if (!rsp.ok) { - throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); - } - - const data: GKLicenseInfo = await rsp.json(); - this.validateSubscription(data); - } catch (ex) { - Logger.error(ex, scope); - debugger; - if (ex instanceof AccountValidationError) throw ex; - - throw new AccountValidationError('Unable to validate account', ex); - } finally { - this.startDailyValidationTimer(); - } - } - - private startDailyValidationTimer(): void { - if (this._validationTimer != null) { - clearInterval(this._validationTimer); - } - - // Check 4 times a day to ensure we validate at least once a day - this._validationTimer = setInterval( - () => { - if (this._lastValidatedDate == null || this._lastValidatedDate.getDate() !== new Date().getDate()) { - void this.ensureSession(false, true); - } - }, - 6 * 60 * 60 * 1000, - ); - } - - @debug() - private validateSubscription(data: GKLicenseInfo) { - const account: Subscription['account'] = { - id: data.user.id, - name: data.user.name, - email: data.user.email, - verified: data.user.status === 'activated', - createdOn: data.user.createdDate, - organizationIds: data.orgIds ?? [], - }; - - const effectiveLicenses = Object.entries(data.licenses.effectiveLicenses) as [GKLicenseType, GKLicense][]; - const paidLicenses = Object.entries(data.licenses.paidLicenses) as [GKLicenseType, GKLicense][]; - - let actual: Subscription['plan']['actual'] | undefined; - if (paidLicenses.length > 0) { - if (paidLicenses.length > 1) { - paidLicenses.sort( - (a, b) => - getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + - licenseStatusPriority(b[1].latestStatus) - - (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + - licenseStatusPriority(a[1].latestStatus)), - ); - } - - const [licenseType, license] = paidLicenses[0]; - actual = getSubscriptionPlan( - convertLicenseTypeToPlanId(licenseType), - isBundleLicenseType(licenseType), - license.organizationId, - new Date(license.latestStartDate), - new Date(license.latestEndDate), - license.latestStatus === 'cancelled', - ); - } - - if (actual == null) { - actual = getSubscriptionPlan( - SubscriptionPlanId.FreePlus, - false, - undefined, - data.user.firstGitLensCheckIn != null - ? new Date(data.user.firstGitLensCheckIn) - : data.user.createdDate != null - ? new Date(data.user.createdDate) - : undefined, - ); - } - - let effective: Subscription['plan']['effective'] | undefined; - if (effectiveLicenses.length > 0) { - if (effectiveLicenses.length > 1) { - effectiveLicenses.sort( - (a, b) => - getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + - licenseStatusPriority(b[1].latestStatus) - - (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + - licenseStatusPriority(a[1].latestStatus)), - ); - } - - const [licenseType, license] = effectiveLicenses[0]; - effective = getSubscriptionPlan( - convertLicenseTypeToPlanId(licenseType), - isBundleLicenseType(licenseType), - license.organizationId, - new Date(license.latestStartDate), - new Date(license.latestEndDate), - license.latestStatus === 'cancelled', - ); - } - - if (effective == null || getSubscriptionPlanPriority(actual.id) >= getSubscriptionPlanPriority(effective.id)) { - effective = { ...actual }; - } - - this._lastValidatedDate = new Date(); - this.changeSubscription( - { - ...this._subscription, - plan: { - actual: actual, - effective: effective, - }, - account: account, - }, - { store: true }, - ); - } - - private _sessionPromise: Promise | undefined; - private _session: AuthenticationSession | null | undefined; - - @gate() - @debug() - private async ensureSession(createIfNeeded: boolean, force?: boolean): Promise { - if (this._sessionPromise != null && this._session === undefined) { - void (await this._sessionPromise); - } - - if (!force && this._session != null) return this._session; - if (this._session === null && !createIfNeeded) return undefined; - - if (this._sessionPromise === undefined) { - this._sessionPromise = this.getOrCreateSession(createIfNeeded).then( - s => { - this._session = s; - this._sessionPromise = undefined; - return this._session; - }, - () => { - this._session = null; - this._sessionPromise = undefined; - return this._session; - }, - ); - } - - const session = await this._sessionPromise; - return session ?? undefined; - } - - @debug() - private async getOrCreateSession(createIfNeeded: boolean): Promise { - const scope = getLogScope(); - - let session: AuthenticationSession | null | undefined; - - try { - session = await authentication.getSession(authenticationProviderId, authenticationProviderScopes, { - createIfNone: createIfNeeded, - silent: !createIfNeeded, - }); - } catch (ex) { - session = null; - - if (ex instanceof Error && ex.message.includes('User did not consent')) { - Logger.debug(scope, 'User declined authentication'); - await this.logoutCore(); - return null; - } - - Logger.error(ex, scope); - } - - if (session == null) { - Logger.debug(scope, 'No valid session was found'); - await this.logoutCore(); - return session ?? null; - } - - try { - await this.checkInAndValidate(session, { showSlowProgress: createIfNeeded, force: createIfNeeded }); - } catch (ex) { - Logger.error(ex, scope); - debugger; - - this.container.telemetry.sendEvent('account/validation/failed', { - 'account.id': session.account.id, - exception: String(ex), - code: ex.original?.code, - statusCode: ex.statusCode, - }); - - Logger.debug(scope, `Account validation failed (${ex.statusCode ?? ex.original?.code})`); - - if (ex instanceof AccountValidationError) { - const name = session.account.label; - - // if ( - // (ex.statusCode != null && ex.statusCode < 500) || - // (ex.statusCode == null && (ex.original as any)?.code !== 'ENOTFOUND') - // ) { - if ( - (ex.original as any)?.code !== 'ENOTFOUND' && - ex.statusCode != null && - ex.statusCode < 500 && - ex.statusCode >= 400 - ) { - session = null; - await this.logoutCore(); - - if (createIfNeeded) { - const unauthorized = ex.statusCode === 401; - queueMicrotask(async () => { - const confirm: MessageItem = { title: 'Retry Sign In' }; - const result = await window.showErrorMessage( - `Unable to sign in to your (${name}) GitKraken account. Please try again. If this issue persists, please contact support.${ - unauthorized ? '' : ` Error=${ex.message}` - }`, - confirm, - ); - - if (result === confirm) { - void this.loginOrSignUp(); - } - }); - } - } else { - session = session ?? null; - - // if ((ex.original as any)?.code !== 'ENOTFOUND') { - // void window.showErrorMessage( - // `Unable to sign in to your (${name}) GitKraken account right now. Please try again in a few minutes. If this issue persists, please contact support. Error=${ex.message}`, - // 'OK', - // ); - // } - } - } - } - - return session; - } - - @debug() - private changeSubscription( - subscription: Optional | undefined, - options?: { silent?: boolean; store?: boolean }, - ): void { - if (subscription == null) { - subscription = { - plan: { - actual: getSubscriptionPlan(SubscriptionPlanId.Free, false, undefined), - effective: getSubscriptionPlan(SubscriptionPlanId.Free, false, undefined), - }, - account: undefined, - state: SubscriptionState.Free, - }; - } - - // If the effective plan has expired, then replace it with the actual plan - if (isSubscriptionExpired(subscription)) { - subscription = { - ...subscription, - plan: { - ...subscription.plan, - effective: subscription.plan.actual, - }, - }; - } - - // If we don't have a paid plan (or a non-preview trial), check if the preview trial has expired, if not apply it - if ( - !isSubscriptionPaid(subscription) && - subscription.previewTrial != null && - (getTimeRemaining(subscription.previewTrial.expiresOn) ?? 0) > 0 - ) { - subscription = { - ...subscription, - plan: { - ...subscription.plan, - effective: getSubscriptionPlan( - SubscriptionPlanId.Pro, - false, - undefined, - new Date(subscription.previewTrial.startedOn), - new Date(subscription.previewTrial.expiresOn), - ), - }, - }; - } - - subscription.state = computeSubscriptionState(subscription); - assertSubscriptionState(subscription); - - const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor - // Check the previous and new subscriptions are exactly the same - const matches = previous != null && JSON.stringify(previous) === JSON.stringify(subscription); - - // If the previous and new subscriptions are exactly the same, kick out - if (matches) { - if (options?.store) { - void this.storeSubscription(subscription); - } - return; - } - - queueMicrotask(() => { - let data = flattenSubscription(subscription); - this.container.telemetry.setGlobalAttributes(data); - - data = { - ...data, - ...(!matches ? flattenSubscription(previous, 'previous') : {}), - }; - - this.container.telemetry.sendEvent(previous == null ? 'subscription' : 'subscription/changed', data); - }); - - void this.storeSubscription(subscription); - - this._subscription = subscription; - this._etag = Date.now(); - - if (!options?.silent) { - this.updateContext(); - - if (previous != null) { - this._onDidChange.fire({ current: subscription, previous: previous, etag: this._etag }); - } - } - } - - private getStoredSubscription(): Subscription | undefined { - const storedSubscription = this.container.storage.get('premium:subscription'); - - let lastValidatedAt: number | undefined; - let subscription: Subscription | undefined; - if (storedSubscription?.data != null) { - ({ lastValidatedAt, ...subscription } = storedSubscription.data); - this._lastValidatedDate = lastValidatedAt != null ? new Date(lastValidatedAt) : undefined; - } else { - subscription = undefined; - } - - if (subscription != null) { - // Migrate the plan names to the latest names - (subscription.plan.actual as Mutable).name = getSubscriptionPlanName( - subscription.plan.actual.id, - ); - (subscription.plan.effective as Mutable).name = getSubscriptionPlanName( - subscription.plan.effective.id, - ); - } - - return subscription; - } - - private async storeSubscription(subscription: Subscription): Promise { - return this.container.storage.store('premium:subscription', { - v: 1, - data: { ...subscription, lastValidatedAt: this._lastValidatedDate?.getTime() }, - }); - } - - private _cancellationSource: CancellationTokenSource | undefined; - private _updateAccessContextDebounced: Deferrable | undefined; - - private updateContext(): void { - this._updateAccessContextDebounced?.cancel(); - if (this._updateAccessContextDebounced == null) { - this._updateAccessContextDebounced = debounce(this.updateAccessContext.bind(this), 500); - } - - if (this._cancellationSource != null) { - this._cancellationSource.cancel(); - } - this._cancellationSource = new CancellationTokenSource(); - - void this._updateAccessContextDebounced(this._cancellationSource.token); - this.updateStatusBar(); - - const { - plan: { actual }, - state, - } = this._subscription; - - void setContext('gitlens:plus', actual.id != SubscriptionPlanId.Free ? actual.id : undefined); - void setContext('gitlens:plus:state', state); - } - - private async updateAccessContext(cancellation: CancellationToken): Promise { - let allowed: boolean | 'mixed' = false; - // For performance reasons, only check if we have any repositories - if (this.container.git.repositoryCount !== 0) { - ({ allowed } = await this.container.git.access()); - if (cancellation.isCancellationRequested) return; - } - - const plusFeatures = configuration.get('plusFeatures.enabled') ?? true; - - let disallowedRepos: string[] | undefined; - - if (!plusFeatures && allowed === 'mixed') { - disallowedRepos = []; - for (const repo of this.container.git.repositories) { - if (repo.closed) continue; - - const access = await this.container.git.access(undefined, repo.uri); - if (cancellation.isCancellationRequested) return; - - if (!access.allowed) { - disallowedRepos.push(repo.uri.toString()); - } - } - } - - void setContext('gitlens:plus:enabled', Boolean(allowed) || plusFeatures); - void setContext('gitlens:plus:required', allowed === false); - void setContext('gitlens:plus:disallowedRepos', disallowedRepos); - } - - private updateStatusBar(): void { - const { - account, - plan: { effective }, - } = this._subscription; - - if (effective.id === SubscriptionPlanId.Free) { - this._statusBarSubscription?.dispose(); - this._statusBarSubscription = undefined; - return; - } - - const trial = isSubscriptionTrial(this._subscription); - if (!trial && account?.verified !== false) { - this._statusBarSubscription?.dispose(); - this._statusBarSubscription = undefined; - return; - } - - if (this._statusBarSubscription == null) { - this._statusBarSubscription = window.createStatusBarItem( - 'gitlens.plus.subscription', - StatusBarAlignment.Left, - 1, - ); - } - - this._statusBarSubscription.name = 'GitKraken Subscription'; - this._statusBarSubscription.command = Commands.ShowHomeView; - - if (account?.verified === false) { - this._statusBarSubscription.text = `$(warning) ${effective.name} (Unverified)`; - this._statusBarSubscription.backgroundColor = new ThemeColor( - 'statusBarItem.warningBackground' satisfies CoreColors, - ); - this._statusBarSubscription.tooltip = new MarkdownString( - trial - ? `**Please verify your email**\n\nYou must verify your email before you can start your **${effective.name}** trial.\n\nClick for details` - : `**Please verify your email**\n\nYou must verify your email before you can use Pro features on privately hosted repos.\n\nClick for details`, - true, - ); - } else { - const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); - - this._statusBarSubscription.text = `${effective.name} (Trial)`; - this._statusBarSubscription.tooltip = new MarkdownString( - `You have ${pluralize('day', remaining ?? 0)} left in your free **${ - effective.name - }** trial, which gives you additional access to Pro features on privately hosted repos.\n\nClick for details`, - true, - ); - } - - this._statusBarSubscription.show(); - } -} - -function flattenSubscription(subscription: Optional | undefined, prefix?: string) { - if (subscription == null) return {}; - - return { - ...flatten(subscription.account, { - arrays: 'join', - prefix: `${prefix ? `${prefix}.` : ''}account`, - skipPaths: ['name', 'email'], - skipNulls: true, - stringify: true, - }), - ...flatten(subscription.plan, { - prefix: `${prefix ? `${prefix}.` : ''}subscription`, - skipPaths: ['actual.name', 'effective.name'], - skipNulls: true, - stringify: true, - }), - ...flatten(subscription.previewTrial, { - prefix: `${prefix ? `${prefix}.` : ''}subscription.previewTrial`, - skipPaths: ['actual.name', 'effective.name'], - skipNulls: true, - stringify: true, - }), - 'subscription.state': subscription.state, - }; -} - -function assertSubscriptionState(subscription: Optional): asserts subscription is Subscription {} - -interface GKLicenseInfo { - readonly user: GKUser; - readonly licenses: { - readonly paidLicenses: Record; - readonly effectiveLicenses: Record; - }; - readonly orgIds?: string[]; -} - -interface GKLicense { - readonly latestStatus: 'active' | 'canceled' | 'cancelled' | 'expired' | 'in_trial' | 'non_renewing' | 'trial'; - readonly latestStartDate: string; - readonly latestEndDate: string; - readonly organizationId: string | undefined; -} - -type GKLicenseType = - | 'gitlens-pro' - | 'gitlens-teams' - | 'gitlens-hosted-enterprise' - | 'gitlens-self-hosted-enterprise' - | 'gitlens-standalone-enterprise' - | 'bundle-pro' - | 'bundle-teams' - | 'bundle-hosted-enterprise' - | 'bundle-self-hosted-enterprise' - | 'bundle-standalone-enterprise'; - -interface GKUser { - readonly id: string; - readonly name: string; - readonly email: string; - readonly status: 'activated' | 'pending'; - readonly createdDate: string; - readonly firstGitLensCheckIn?: string; -} - -function convertLicenseTypeToPlanId(licenseType: GKLicenseType): SubscriptionPlanId { - switch (licenseType) { - case 'gitlens-pro': - case 'bundle-pro': - return SubscriptionPlanId.Pro; - case 'gitlens-teams': - case 'bundle-teams': - return SubscriptionPlanId.Teams; - case 'gitlens-hosted-enterprise': - case 'gitlens-self-hosted-enterprise': - case 'gitlens-standalone-enterprise': - case 'bundle-hosted-enterprise': - case 'bundle-self-hosted-enterprise': - case 'bundle-standalone-enterprise': - return SubscriptionPlanId.Enterprise; - default: - return SubscriptionPlanId.FreePlus; - } -} - -function isBundleLicenseType(licenseType: GKLicenseType): boolean { - switch (licenseType) { - case 'bundle-pro': - case 'bundle-teams': - case 'bundle-hosted-enterprise': - case 'bundle-self-hosted-enterprise': - case 'bundle-standalone-enterprise': - return true; - default: - return false; - } -} - -function licenseStatusPriority(status: GKLicense['latestStatus']): number { - switch (status) { - case 'active': - return 100; - case 'expired': - case 'cancelled': - return -100; - case 'in_trial': - case 'trial': - return 1; - case 'canceled': - case 'non_renewing': - return 0; - } -} diff --git a/src/plus/utils.ts b/src/plus/utils.ts new file mode 100644 index 0000000000000..1839316ca870d --- /dev/null +++ b/src/plus/utils.ts @@ -0,0 +1,176 @@ +import type { MessageItem } from 'vscode'; +import { window } from 'vscode'; +import type { Source } from '../constants'; +import { urls } from '../constants'; +import type { Container } from '../container'; +import { openUrl } from '../system/utils'; +import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from './gk/account/subscription'; + +export async function ensurePaidPlan( + container: Container, + title: string, + source: Source, + options?: { allowPreview?: boolean }, +): Promise { + while (true) { + const subscription = await container.subscription.getSubscription(); + if (subscription.account?.verified === false) { + const resend = { title: 'Resend Email' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nYou must verify your email before you can continue.`, + { modal: true }, + resend, + cancel, + ); + + if (result === resend) { + if (await container.subscription.resendVerification(source)) { + continue; + } + } + + return false; + } + + const plan = subscription.plan.effective.id; + if (isSubscriptionPaidPlan(plan)) break; + + if (options?.allowPreview && subscription.account == null && !isSubscriptionPreviewTrialExpired(subscription)) { + const startTrial = { title: 'Continue' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to continue to get immediate access to preview local Pro features for 3 days?`, + { modal: true }, + startTrial, + cancel, + ); + + if (result !== startTrial) return false; + + void container.subscription.startPreviewTrial(source); + break; + } else if (subscription.account == null) { + const signUp = { title: 'Start Pro Trial' }; + const signIn = { title: 'Sign In' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to start your free 7-day Pro trial for full access to Pro features?`, + { modal: true }, + signUp, + signIn, + cancel, + ); + + if (result === signUp || result === signIn) { + if (await container.subscription.loginOrSignUp(result === signUp, source)) { + continue; + } + } + } else { + const upgrade = { title: 'Upgrade to Pro' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to upgrade for full access to Pro features?`, + { modal: true }, + upgrade, + cancel, + ); + + if (result === upgrade) { + void container.subscription.upgrade(source); + } + } + + return false; + } + + return true; +} + +export async function ensureAccount(container: Container, title: string, source: Source): Promise { + while (true) { + const subscription = await container.subscription.getSubscription(); + if (subscription.account?.verified === false) { + const resend = { title: 'Resend Email' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nYou must verify your email before you can continue.`, + { modal: true }, + resend, + cancel, + ); + + if (result === resend) { + if (await container.subscription.resendVerification(source)) { + continue; + } + } + + return false; + } + + if (subscription.account != null) break; + + const signUp = { title: 'Sign Up' }; + const signIn = { title: 'Sign In' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nSign up for access to Pro features and our DevEx platform, or sign in`, + { modal: true }, + signUp, + signIn, + cancel, + ); + + if (result === signIn) { + if (await container.subscription.loginOrSignUp(false, source)) { + continue; + } + } else if (result === signUp) { + if (await container.subscription.loginOrSignUp(true, source)) { + continue; + } + } + + return false; + } + + return true; +} + +export async function confirmDraftStorage(container: Container): Promise { + if (container.storage.get('confirm:draft:storage', false)) return true; + + while (true) { + const accept: MessageItem = { title: 'Continue' }; + const decline: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const moreInfo: MessageItem = { title: 'Learn More' }; + const security: MessageItem = { title: 'Security' }; + const result = await window.showInformationMessage( + `Cloud Patches are securely stored by GitKraken and can be accessed by anyone with the link and a GitKraken account.`, + { modal: true }, + accept, + moreInfo, + security, + decline, + ); + + if (result === accept) { + void container.storage.store('confirm:draft:storage', true); + return true; + } + + if (result === security) { + void openUrl(urls.security); + continue; + } + + if (result === moreInfo) { + void openUrl(urls.cloudPatches); + continue; + } + + return false; + } +} diff --git a/src/plus/webviews/account/accountWebview.ts b/src/plus/webviews/account/accountWebview.ts index 0abd69ffb0252..53673ae803d74 100644 --- a/src/plus/webviews/account/accountWebview.ts +++ b/src/plus/webviews/account/accountWebview.ts @@ -1,21 +1,21 @@ import { Disposable, window } from 'vscode'; import { getAvatarUriFromGravatarEmail } from '../../../avatars'; import type { Container } from '../../../container'; -import type { Subscription } from '../../../subscription'; import { registerCommand } from '../../../system/command'; import type { Deferrable } from '../../../system/function'; import { debounce } from '../../../system/function'; -import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; +import type { WebviewHost, WebviewProvider } from '../../../webviews/webviewProvider'; +import type { Subscription } from '../../gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { State } from './protocol'; -import { DidChangeSubscriptionNotificationType } from './protocol'; +import { DidChangeSubscriptionNotification } from './protocol'; export class AccountWebviewProvider implements WebviewProvider { private readonly _disposable: Disposable; constructor( private readonly container: Container, - private readonly host: WebviewController, + private readonly host: WebviewHost, ) { this._disposable = Disposable.from(this.container.subscription.onDidChange(this.onSubscriptionChanged, this)); } @@ -80,6 +80,7 @@ export class AccountWebviewProvider implements WebviewProvider { return { subscription: sub, avatar: avatar, + organizationsCount: ((await this.container.organizations.getOrganizations()) ?? []).length, }; } @@ -87,18 +88,18 @@ export class AccountWebviewProvider implements WebviewProvider { const subscriptionResult = await this.getSubscription(subscription); return { - webviewId: this.host.id, - timestamp: Date.now(), + ...this.host.baseWebviewState, webroot: this.host.getWebRoot(), subscription: subscriptionResult.subscription, avatar: subscriptionResult.avatar, + organizationsCount: subscriptionResult.organizationsCount, }; } private notifyDidChangeSubscription(subscription?: Subscription) { return window.withProgress({ location: { viewId: this.host.id } }, async () => { const sub = await this.getSubscription(subscription); - return this.host.notify(DidChangeSubscriptionNotificationType, { + return this.host.notify(DidChangeSubscriptionNotification, { ...sub, }); }); diff --git a/src/plus/webviews/account/protocol.ts b/src/plus/webviews/account/protocol.ts index 30b27ffaa6122..3ba7ea04a1983 100644 --- a/src/plus/webviews/account/protocol.ts +++ b/src/plus/webviews/account/protocol.ts @@ -1,20 +1,24 @@ -import type { WebviewIds, WebviewViewIds } from '../../../constants'; -import type { Subscription } from '../../../subscription'; -import { IpcNotificationType } from '../../../webviews/protocol'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcNotification } from '../../../webviews/protocol'; +import type { Subscription } from '../../gk/account/subscription'; -export interface State { - webviewId: WebviewIds | WebviewViewIds; - timestamp: number; +export const scope: IpcScope = 'account'; +export interface State extends WebviewState { webroot?: string; subscription: Subscription; avatar?: string; + organizationsCount?: number; } +// NOTIFICATIONS + export interface DidChangeSubscriptionParams { subscription: Subscription; avatar?: string; + organizationsCount?: number; } -export const DidChangeSubscriptionNotificationType = new IpcNotificationType( +export const DidChangeSubscriptionNotification = new IpcNotification( + scope, 'subscription/didChange', ); diff --git a/src/plus/webviews/account/registration.ts b/src/plus/webviews/account/registration.ts index 8bdac463c248e..ff16be8862fad 100644 --- a/src/plus/webviews/account/registration.ts +++ b/src/plus/webviews/account/registration.ts @@ -15,7 +15,9 @@ export function registerAccountWebviewView(controller: WebviewsController) { }, }, async (container, host) => { - const { AccountWebviewProvider } = await import(/* webpackChunkName: "account" */ './accountWebview'); + const { AccountWebviewProvider } = await import( + /* webpackChunkName: "webview-account" */ './accountWebview' + ); return new AccountWebviewProvider(container, host); }, ); diff --git a/src/plus/webviews/focus/focusWebview.ts b/src/plus/webviews/focus/focusWebview.ts index ce943c10a392a..be40c3bd5567d 100644 --- a/src/plus/webviews/focus/focusWebview.ts +++ b/src/plus/webviews/focus/focusWebview.ts @@ -1,7 +1,9 @@ +import { EntityIdentifierUtils } from '@gitkraken/provider-apis'; import { Disposable, Uri, window } from 'vscode'; -import type { GHPRPullRequest } from '../../../commands'; +import type { GHPRPullRequest } from '../../../commands/ghpr/openOrCreateWorktree'; import { Commands } from '../../../constants'; import type { Container } from '../../../container'; +import type { FeatureAccess, RepoFeatureAccess } from '../../../features'; import { PlusFeatures } from '../../../features'; import { add as addRemote } from '../../../git/actions/remote'; import * as RepoActions from '../../../git/actions/repository'; @@ -19,27 +21,47 @@ import { createReference } from '../../../git/models/reference'; import type { GitRemote } from '../../../git/models/remote'; import type { Repository, RepositoryChangeEvent } from '../../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import type { GitWorktree } from '../../../git/models/worktree'; import { getWorktreeForBranch } from '../../../git/models/worktree'; import { parseGitRemoteUrl } from '../../../git/parsers/remoteParser'; -import type { RichRemoteProvider } from '../../../git/remotes/richRemoteProvider'; -import { executeCommand, registerCommand } from '../../../system/command'; +import type { RemoteProvider } from '../../../git/remotes/remoteProvider'; +import { executeCommand } from '../../../system/command'; +import { debug } from '../../../system/decorators/log'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import { PageableResult } from '../../../system/paging'; import { getSettledValue } from '../../../system/promise'; import type { IpcMessage } from '../../../webviews/protocol'; -import { onIpc } from '../../../webviews/protocol'; -import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; +import type { WebviewHost, WebviewProvider } from '../../../webviews/webviewProvider'; +import type { EnrichableItem, EnrichedItem } from '../../focus/enrichmentService'; +import { convertRemoteProviderToEnrichProvider } from '../../focus/enrichmentService'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; +import { getEntityIdentifierInput } from '../../integrations/providers/utils'; import type { ShowInCommitGraphCommandArgs } from '../graph/protocol'; -import type { OpenBranchParams, OpenWorktreeParams, State, SwitchToBranchParams } from './protocol'; +import type { + OpenBranchParams, + OpenWorktreeParams, + PinIssueParams, + PinPrParams, + SnoozeIssueParams, + SnoozePrParams, + State, + SwitchToBranchParams, +} from './protocol'; import { - DidChangeNotificationType, - OpenBranchCommandType, - OpenWorktreeCommandType, - SwitchToBranchCommandType, + DidChangeNotification, + OpenBranchCommand, + OpenWorktreeCommand, + PinIssueCommand, + PinPRCommand, + SnoozeIssueCommand, + SnoozePRCommand, + SwitchToBranchCommand, } from './protocol'; interface RepoWithRichRemote { repo: Repository; - remote: GitRemote; + remote: GitRemote; isConnected: boolean; isGitHub: boolean; } @@ -51,46 +73,214 @@ interface SearchedPullRequestWithRemote extends SearchedPullRequest { isCurrentBranch?: boolean; hasWorktree?: boolean; isCurrentWorktree?: boolean; + rank: number; + enriched?: EnrichedItem[]; +} + +interface SearchedIssueWithRank extends SearchedIssue { + repoAndRemote: RepoWithRichRemote; + rank: number; + enriched?: EnrichedItem[]; } export class FocusWebviewProvider implements WebviewProvider { private _pullRequests: SearchedPullRequestWithRemote[] = []; - private _issues: SearchedIssue[] = []; + private _issues: SearchedIssueWithRank[] = []; + private _discovering: Promise | undefined; private readonly _disposable: Disposable; + private _etag?: number; private _etagSubscription?: number; private _repositoryEventsDisposable?: Disposable; private _repos?: RepoWithRichRemote[]; + private _enrichedItems?: EnrichedItem[]; constructor( private readonly container: Container, - private readonly host: WebviewController, + private readonly host: WebviewHost, ) { this._disposable = Disposable.from( this.container.subscription.onDidChange(this.onSubscriptionChanged, this), - this.container.git.onDidChangeRepositories(() => void this.host.refresh(true)), + this.container.git.onDidChangeRepositories(async () => { + if (this._etag !== this.container.git.etag) { + if (this._discovering != null) { + this._etag = await this._discovering; + if (this._etag === this.container.git.etag) return; + } + + void this.host.refresh(true); + } + }), ); } dispose() { + if (this.enrichmentExpirationTimeout != null) { + clearTimeout(this.enrichmentExpirationTimeout); + this.enrichmentExpirationTimeout = undefined; + } this._disposable.dispose(); } - registerCommands(): Disposable[] { - return [registerCommand(Commands.RefreshFocus, () => this.host.refresh(true))]; - } - onMessageReceived(e: IpcMessage) { - switch (e.method) { - case OpenBranchCommandType.method: - onIpc(OpenBranchCommandType, e, params => this.onOpenBranch(params)); + switch (true) { + case OpenBranchCommand.is(e): + void this.onOpenBranch(e.params); break; - case SwitchToBranchCommandType.method: - onIpc(SwitchToBranchCommandType, e, params => this.onSwitchBranch(params)); + + case SwitchToBranchCommand.is(e): + void this.onSwitchBranch(e.params); break; - case OpenWorktreeCommandType.method: - onIpc(OpenWorktreeCommandType, e, params => this.onOpenWorktree(params)); + + case OpenWorktreeCommand.is(e): + void this.onOpenWorktree(e.params); break; + + case SnoozePRCommand.is(e): + void this.onSnoozePr(e.params); + break; + + case PinPRCommand.is(e): + void this.onPinPr(e.params); + break; + + case SnoozeIssueCommand.is(e): + void this.onSnoozeIssue(e.params); + break; + + case PinIssueCommand.is(e): + void this.onPinIssue(e.params); + break; + } + } + + @debug({ args: false }) + private async onPinIssue({ issue, pin }: PinIssueParams) { + const issueWithRemote = this._issues?.find(r => r.issue.nodeId === issue.nodeId); + if (issueWithRemote == null) return; + + if (pin) { + await this.container.enrichments.unpinItem(pin); + this._enrichedItems = this._enrichedItems?.filter(e => e.id !== pin); + issueWithRemote.enriched = issueWithRemote.enriched?.filter(e => e.id !== pin); + } else { + const focusItem: EnrichableItem = { + type: 'issue', + id: EntityIdentifierUtils.encode(getEntityIdentifierInput(issueWithRemote.issue)), + provider: convertRemoteProviderToEnrichProvider(issueWithRemote.repoAndRemote.remote.provider), + url: issueWithRemote.issue.url, + }; + const enrichedItem = await this.container.enrichments.pinItem(focusItem); + if (enrichedItem == null) return; + if (this._enrichedItems == null) { + this._enrichedItems = []; + } + this._enrichedItems.push(enrichedItem); + if (issueWithRemote.enriched == null) { + issueWithRemote.enriched = []; + } + issueWithRemote.enriched.push(enrichedItem); + } + + void this.notifyDidChangeState(); + } + + @debug({ args: false }) + private async onSnoozeIssue({ issue, snooze, expiresAt }: SnoozeIssueParams) { + const issueWithRemote = this._issues?.find(r => r.issue.nodeId === issue.nodeId); + if (issueWithRemote == null) return; + + if (snooze) { + await this.container.enrichments.unsnoozeItem(snooze); + this._enrichedItems = this._enrichedItems?.filter(e => e.id !== snooze); + issueWithRemote.enriched = issueWithRemote.enriched?.filter(e => e.id !== snooze); + } else { + const focusItem: EnrichableItem = { + type: 'issue', + id: EntityIdentifierUtils.encode(getEntityIdentifierInput(issueWithRemote.issue)), + provider: convertRemoteProviderToEnrichProvider(issueWithRemote.repoAndRemote.remote.provider), + url: issueWithRemote.issue.url, + }; + if (expiresAt != null) { + focusItem.expiresAt = expiresAt; + } + const enrichedItem = await this.container.enrichments.snoozeItem(focusItem); + if (enrichedItem == null) return; + if (this._enrichedItems == null) { + this._enrichedItems = []; + } + this._enrichedItems.push(enrichedItem); + if (issueWithRemote.enriched == null) { + issueWithRemote.enriched = []; + } + issueWithRemote.enriched.push(enrichedItem); + } + + void this.notifyDidChangeState(); + } + + @debug({ args: false }) + private async onPinPr({ pullRequest, pin }: PinPrParams) { + const prWithRemote = this._pullRequests?.find(r => r.pullRequest.nodeId === pullRequest.nodeId); + if (prWithRemote == null) return; + + if (pin) { + await this.container.enrichments.unpinItem(pin); + this._enrichedItems = this._enrichedItems?.filter(e => e.id !== pin); + prWithRemote.enriched = prWithRemote.enriched?.filter(e => e.id !== pin); + } else { + const focusItem: EnrichableItem = { + type: 'pr', + id: EntityIdentifierUtils.encode(getEntityIdentifierInput(prWithRemote.pullRequest)), + provider: convertRemoteProviderToEnrichProvider(prWithRemote.repoAndRemote.remote.provider), + url: prWithRemote.pullRequest.url, + }; + const enrichedItem = await this.container.enrichments.pinItem(focusItem); + if (enrichedItem == null) return; + if (this._enrichedItems == null) { + this._enrichedItems = []; + } + this._enrichedItems.push(enrichedItem); + if (prWithRemote.enriched == null) { + prWithRemote.enriched = []; + } + prWithRemote.enriched.push(enrichedItem); } + + void this.notifyDidChangeState(); + } + + @debug({ args: false }) + private async onSnoozePr({ pullRequest, snooze, expiresAt }: SnoozePrParams) { + const prWithRemote = this._pullRequests?.find(r => r.pullRequest.nodeId === pullRequest.nodeId); + if (prWithRemote == null) return; + + if (snooze) { + await this.container.enrichments.unsnoozeItem(snooze); + this._enrichedItems = this._enrichedItems?.filter(e => e.id !== snooze); + prWithRemote.enriched = prWithRemote.enriched?.filter(e => e.id !== snooze); + } else { + const focusItem: EnrichableItem = { + type: 'pr', + id: EntityIdentifierUtils.encode(getEntityIdentifierInput(prWithRemote.pullRequest)), + provider: convertRemoteProviderToEnrichProvider(prWithRemote.repoAndRemote.remote.provider), + url: prWithRemote.pullRequest.url, + }; + if (expiresAt != null) { + focusItem.expiresAt = expiresAt; + } + const enrichedItem = await this.container.enrichments.snoozeItem(focusItem); + if (enrichedItem == null) return; + if (this._enrichedItems == null) { + this._enrichedItems = []; + } + this._enrichedItems.push(enrichedItem); + if (prWithRemote.enriched == null) { + prWithRemote.enriched = []; + } + prWithRemote.enriched.push(enrichedItem); + } + + void this.notifyDidChangeState(); } private findSearchedPullRequest(pullRequest: PullRequestShape): SearchedPullRequestWithRemote | undefined { @@ -102,7 +292,7 @@ export class FocusWebviewProvider implements WebviewProvider { const repoAndRemote = searchedPullRequest.repoAndRemote; const localUri = repoAndRemote.repo.uri; - const repo = await repoAndRemote.repo.getMainRepository(); + const repo = await repoAndRemote.repo.getCommonRepository(); if (repo == null) { void window.showWarningMessage( `Unable to find main repository(${localUri.toString()}) for PR #${pullRequest.id}`, @@ -129,8 +319,8 @@ export class FocusWebviewProvider implements WebviewProvider { const result = await window.showInformationMessage( `Unable to find a remote for '${remoteUrl}'. Would you like to add a new remote?`, { modal: true }, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, + { title: 'Add Remote' }, + { title: 'Cancel', isCloseAffordance: true }, ); if (result?.title !== 'Yes') return; @@ -167,6 +357,7 @@ export class FocusWebviewProvider implements WebviewProvider { }; } + @debug({ args: false }) private async onOpenBranch({ pullRequest }: OpenBranchParams) { const prWithRemote = this.findSearchedPullRequest(pullRequest); if (prWithRemote == null) return; @@ -182,6 +373,7 @@ export class FocusWebviewProvider implements WebviewProvider { void executeCommand(Commands.ShowInCommitGraph, { ref: remoteBranch.reference }); } + @debug({ args: false }) private async onSwitchBranch({ pullRequest }: SwitchToBranchParams) { const prWithRemote = this.findSearchedPullRequest(pullRequest); if (prWithRemote == null || prWithRemote.isCurrentBranch) return; @@ -201,6 +393,7 @@ export class FocusWebviewProvider implements WebviewProvider { return RepoActions.switchTo(remoteBranch.remote.repoPath, remoteBranch.reference); } + @debug({ args: false }) private async onOpenWorktree({ pullRequest }: OpenWorktreeParams) { const searchedPullRequestWithRemote = this.findSearchedPullRequest(pullRequest); if (searchedPullRequestWithRemote?.repoAndRemote == null) { @@ -239,30 +432,89 @@ export class FocusWebviewProvider implements WebviewProvider { if (e.etag === this._etagSubscription) return; this._etagSubscription = e.etag; - void this.notifyDidChangeState(true); + this._access = undefined; + void this.notifyDidChangeState(); + } + + private _access: FeatureAccess | RepoFeatureAccess | undefined; + @debug() + private async getAccess(force?: boolean) { + if (force || this._access == null) { + this._access = await this.container.git.access(PlusFeatures.Focus); + } + return this._access; + } + + private enrichmentExpirationTimeout?: ReturnType; + private ensureEnrichmentExpirationCore(appliedEnrichments?: EnrichedItem[]) { + if (this.enrichmentExpirationTimeout != null) { + clearTimeout(this.enrichmentExpirationTimeout); + this.enrichmentExpirationTimeout = undefined; + } + + if (appliedEnrichments == null || appliedEnrichments.length === 0) return; + + const nowTime = Date.now(); + let expirableEnrichmentTime: number | undefined; + for (const item of appliedEnrichments) { + if (item.expiresAt == null) continue; + + const expiresAtTime = new Date(item.expiresAt).getTime(); + if ( + expirableEnrichmentTime == null || + (expiresAtTime > nowTime && expiresAtTime < expirableEnrichmentTime) + ) { + expirableEnrichmentTime = expiresAtTime; + } + } + + if (expirableEnrichmentTime == null) return; + const debounceTime = expirableEnrichmentTime + 1000 * 60 * 15; // 15 minutes + // find the item in appliedEnrichments with largest expiresAtTime that is less than the debounce time + expirableEnrichmentTime + for (const item of appliedEnrichments) { + if (item.expiresAt == null) continue; + + const expiresAtTime = new Date(item.expiresAt).getTime(); + if (expiresAtTime > expirableEnrichmentTime && expiresAtTime < debounceTime) { + expirableEnrichmentTime = expiresAtTime; + } + } + + const expiresTimeout = expirableEnrichmentTime - nowTime + 60000; + this.enrichmentExpirationTimeout = setTimeout(() => { + void this.notifyDidChangeState(true); + }, expiresTimeout); } - private async getState(deferState?: boolean): Promise { - const webviewId = this.host.id; + @debug() + private async getState(force?: boolean, deferState?: boolean): Promise { + const baseState = this.host.baseWebviewState; - const access = await this.container.git.access(PlusFeatures.Focus); + this._etag = this.container.git.etag; + if (this.container.git.isDiscoveringRepositories) { + this._discovering = this.container.git.isDiscoveringRepositories.then(r => { + this._discovering = undefined; + return r; + }); + this._etag = await this._discovering; + } + + const access = await this.getAccess(force); if (access.allowed !== true) { return { - webviewId: webviewId, - timestamp: Date.now(), + ...baseState, access: access, }; } - const allRichRepos = await this.getRichRepos(); + const allRichRepos = await this.getRichRepos(force); const githubRepos = filterGithubRepos(allRichRepos); const connectedRepos = filterUsableRepos(githubRepos); const hasConnectedRepos = connectedRepos.length > 0; if (!hasConnectedRepos) { return { - webviewId: webviewId, - timestamp: Date.now(), + ...baseState, access: access, repos: githubRepos.map(r => serializeRepoWithRichRemote(r)), }; @@ -271,41 +523,66 @@ export class FocusWebviewProvider implements WebviewProvider { const repos = connectedRepos.map(r => serializeRepoWithRichRemote(r)); const statePromise = Promise.allSettled([ - this.getMyPullRequests(connectedRepos), - this.getMyIssues(connectedRepos), + this.getMyPullRequests(connectedRepos, force), + this.getMyIssues(connectedRepos, force), + this.getEnrichedItems(force), ]); - async function getStateCore() { - const [prsResult, issuesResult] = await statePromise; - return { - webviewId: webviewId, - timestamp: Date.now(), - access: access, - repos: repos, - pullRequests: getSettledValue(prsResult)?.map(pr => ({ + const getStateCore = async () => { + const [prsResult, issuesResult, enrichedItems] = await statePromise; + + const appliedEnrichments: EnrichedItem[] = []; + const pullRequests = getSettledValue(prsResult)?.map(pr => { + const itemEnrichments = findEnrichedItems(pr, getSettledValue(enrichedItems)); + if (itemEnrichments != null) { + appliedEnrichments.push(...itemEnrichments); + } + + return { pullRequest: serializePullRequest(pr.pullRequest), reasons: pr.reasons, isCurrentBranch: pr.isCurrentBranch ?? false, isCurrentWorktree: pr.isCurrentWorktree ?? false, hasWorktree: pr.hasWorktree ?? false, hasLocalBranch: pr.hasLocalBranch ?? false, - })), - issues: getSettledValue(issuesResult)?.map(issue => ({ + enriched: serializeEnrichedItems(itemEnrichments), + rank: pr.rank, + }; + }); + + const issues = getSettledValue(issuesResult)?.map(issue => { + const itemEnrichments = findEnrichedItems(issue, getSettledValue(enrichedItems)); + if (itemEnrichments != null) { + appliedEnrichments.push(...itemEnrichments); + } + + return { issue: serializeIssue(issue.issue), reasons: issue.reasons, - })), + enriched: serializeEnrichedItems(itemEnrichments), + rank: issue.rank, + }; + }); + + this.ensureEnrichmentExpirationCore(appliedEnrichments); + + return { + ...baseState, + access: access, + repos: repos, + pullRequests: pullRequests, + issues: issues, }; - } + }; if (deferState) { queueMicrotask(async () => { const state = await getStateCore(); - void this.host.notify(DidChangeNotificationType, { state: state }); + void this.host.notify(DidChangeNotification, { state: state }); }); return { - webviewId: webviewId, - timestamp: Date.now(), + ...baseState, access: access, repos: repos, }; @@ -316,26 +593,32 @@ export class FocusWebviewProvider implements WebviewProvider { } async includeBootstrap(): Promise { - return this.getState(true); + return this.getState(true, true); } + @debug() private async getRichRepos(force?: boolean): Promise { - if (this._repos == null || force === true) { + if (force || this._repos == null) { const repos = []; const disposables = []; for (const repo of this.container.git.openRepositories) { - const richRemote = await repo.getRichRemote(); - if (richRemote == null || repos.findIndex(repo => repo.remote === richRemote) > -1) { + const remoteWithIntegration = await repo.getBestRemoteWithIntegration({ includeDisconnected: true }); + if ( + remoteWithIntegration == null || + repos.findIndex(repo => repo.remote === remoteWithIntegration) > -1 + ) { continue; } disposables.push(repo.onDidChange(this.onRepositoryChanged, this)); + const integration = await this.container.integrations.getByRemote(remoteWithIntegration); + repos.push({ repo: repo, - remote: richRemote, - isConnected: await richRemote.provider.isConnected(), - isGitHub: richRemote.provider.id === 'github', + remote: remoteWithIntegration, + isConnected: integration?.maybeConnected ?? (await integration?.isConnected()) ?? false, + isGitHub: remoteWithIntegration.provider.id === 'github', }); } if (this._repositoryEventsDisposable) { @@ -349,114 +632,245 @@ export class FocusWebviewProvider implements WebviewProvider { return this._repos; } - private async onRepositoryChanged(e: RepositoryChangeEvent) { + private onRepositoryChanged(e: RepositoryChangeEvent) { if (e.changed(RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { - await this.getRichRepos(true); - void this.notifyDidChangeState(); + void this.notifyDidChangeState(true); } } - private async getMyPullRequests(richRepos: RepoWithRichRemote[]): Promise { - const allPrs: SearchedPullRequestWithRemote[] = []; - for (const richRepo of richRepos) { - const remote = richRepo.remote; - const prs = await this.container.git.getMyPullRequests(remote); - if (prs == null) { - continue; - } + @debug({ args: { 0: false } }) + private async getMyPullRequests( + richRepos: RepoWithRichRemote[], + force?: boolean, + ): Promise { + const scope = getLogScope(); - for (const pr of prs) { - if (pr.reasons.length === 0) { - continue; + if (force || this._pullRequests == null) { + const allPrs: SearchedPullRequestWithRemote[] = []; + + const branchesByRepo = new Map>(); + const worktreesByRepo = new Map(); + + const queries = richRepos.map( + r => [r, this.container.integrations.getMyPullRequestsForRemotes(r.remote)] as const, + ); + for (const [r, query] of queries) { + let prs; + try { + prs = await query; + } catch (ex) { + Logger.error(ex, scope, `Failed to get prs for '${r.remote.url}'`); } - const entry: SearchedPullRequestWithRemote = { - ...pr, - repoAndRemote: richRepo, - isCurrentWorktree: false, - isCurrentBranch: false, - }; - const remoteBranchName = `${entry.pullRequest.refs!.head.owner}/${entry.pullRequest.refs!.head.branch}`; // TODO@eamodio really need to check for upstream url rather than name - - const worktree = await getWorktreeForBranch( - entry.repoAndRemote.repo, - entry.pullRequest.refs!.head.branch, - remoteBranchName, - ); - entry.hasWorktree = worktree != null; - entry.isCurrentWorktree = worktree?.opened === true; - - const branch = await getLocalBranchByUpstream(richRepo.repo, remoteBranchName); - if (branch) { - entry.branch = branch; - entry.hasLocalBranch = true; - entry.isCurrentBranch = branch.current; + if (prs?.error != null) { + Logger.error(prs.error, scope, `Failed to get prs for '${r.remote.url}'`); + continue; } - allPrs.push(entry); + if (prs?.value == null) continue; + + for (const pr of prs.value) { + if (pr.reasons.length === 0) continue; + + const entry: SearchedPullRequestWithRemote = { + ...pr, + repoAndRemote: r, + isCurrentWorktree: false, + isCurrentBranch: false, + rank: getPrRank(pr), + }; + + const remoteBranchName = `${entry.pullRequest.refs!.head.owner}/${ + entry.pullRequest.refs!.head.branch + }`; // TODO@eamodio really need to check for upstream url rather than name + + let branches = branchesByRepo.get(entry.repoAndRemote.repo); + if (branches == null) { + branches = new PageableResult(paging => + entry.repoAndRemote.repo.getBranches(paging != null ? { paging: paging } : undefined), + ); + branchesByRepo.set(entry.repoAndRemote.repo, branches); + } + + let worktrees = worktreesByRepo.get(entry.repoAndRemote.repo); + if (worktrees == null) { + worktrees = await entry.repoAndRemote.repo.getWorktrees(); + worktreesByRepo.set(entry.repoAndRemote.repo, worktrees); + } + + const worktree = await getWorktreeForBranch( + entry.repoAndRemote.repo, + entry.pullRequest.refs!.head.branch, + remoteBranchName, + worktrees, + branches, + ); + + entry.hasWorktree = worktree != null; + entry.isCurrentWorktree = worktree?.opened === true; + + const branch = await getLocalBranchByUpstream(r.repo, remoteBranchName, branches); + if (branch) { + entry.branch = branch; + entry.hasLocalBranch = true; + entry.isCurrentBranch = branch.current; + } + + allPrs.push(entry); + } } + + this._pullRequests = allPrs.sort((a, b) => { + const scoreA = a.rank; + const scoreB = b.rank; + + if (scoreA === scoreB) { + return a.pullRequest.updatedDate.getTime() - b.pullRequest.updatedDate.getTime(); + } + return (scoreB ?? 0) - (scoreA ?? 0); + }); } - function getScore(pr: SearchedPullRequest) { - let score = 0; - if (pr.reasons.includes('authored')) { - score += 1000; - } else if (pr.reasons.includes('assigned')) { - score += 900; - } else if (pr.reasons.includes('review-requested')) { - score += 800; - } else if (pr.reasons.includes('mentioned')) { - score += 700; - } + return this._pullRequests; + } + + @debug({ args: { 0: false } }) + private async getMyIssues(richRepos: RepoWithRichRemote[], force?: boolean): Promise { + const scope = getLogScope(); - if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.Approved) { - if (pr.pullRequest.mergeableState === PullRequestMergeableState.Mergeable) { - score += 100; - } else if (pr.pullRequest.mergeableState === PullRequestMergeableState.Conflicting) { - score += 90; - } else { - score += 80; + if (force || this._pullRequests == null) { + const allIssues = []; + + const queries = richRepos.map( + r => [r, this.container.integrations.getMyIssuesForRemotes(r.remote)] as const, + ); + for (const [r, query] of queries) { + let issues; + try { + issues = await query; + } catch (ex) { + Logger.error(ex, scope, `Failed to get issues for '${r.remote.url}'`); + } + if (issues == null) continue; + + for (const issue of issues) { + if (issue.reasons.length === 0) continue; + + allIssues.push({ + ...issue, + repoAndRemote: r, + rank: 0, // getIssueRank(issue), + }); } - } else if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.ChangesRequested) { - score += 70; } - return score; - } + // this._issues = allIssues.sort((a, b) => { + // const scoreA = a.rank; + // const scoreB = b.rank; - this._pullRequests = allPrs.sort((a, b) => { - const scoreA = getScore(a); - const scoreB = getScore(b); + // if (scoreA === scoreB) { + // return b.issue.updatedDate.getTime() - a.issue.updatedDate.getTime(); + // } + // return (scoreB ?? 0) - (scoreA ?? 0); + // }); - if (scoreA === scoreB) { - return a.pullRequest.date.getTime() - b.pullRequest.date.getTime(); - } - return (scoreB ?? 0) - (scoreA ?? 0); - }); + this._issues = allIssues.sort((a, b) => b.issue.updatedDate.getTime() - a.issue.updatedDate.getTime()); + } - return this._pullRequests; + return this._issues; } - private async getMyIssues(richRepos: RepoWithRichRemote[]): Promise { - const allIssues = []; - for (const { remote } of richRepos) { - const issues = await this.container.git.getMyIssues(remote); - if (issues == null) { - continue; - } - allIssues.push(...issues.filter(pr => pr.reasons.length > 0)); + @debug() + private async getEnrichedItems(force?: boolean): Promise { + // TODO needs cache invalidation + if (force || this._enrichedItems == null) { + const enrichedItems = await this.container.enrichments.get(); + this._enrichedItems = enrichedItems; } + return this._enrichedItems; + } + + private async notifyDidChangeState(force?: boolean, deferState?: boolean) { + void this.host.notify(DidChangeNotification, { state: await this.getState(force, deferState) }); + } +} - this._issues = allIssues.sort((a, b) => b.issue.updatedDate.getTime() - a.issue.updatedDate.getTime()); +function findEnrichedItems( + item: SearchedPullRequestWithRemote | SearchedIssueWithRank, + enrichedItems?: EnrichedItem[], +) { + if (enrichedItems == null || enrichedItems.length === 0) { + item.enriched = undefined; + return; + } - return this._issues; + let result; + // TODO: filter by entity id, type, and gitRepositoryId + if ((item as SearchedPullRequestWithRemote).pullRequest != null) { + result = enrichedItems.filter(e => e.entityUrl === (item as SearchedPullRequestWithRemote).pullRequest.url); + } else { + result = enrichedItems.filter(e => e.entityUrl === (item as SearchedIssueWithRank).issue.url); + } + + if (result.length === 0) return; + + item.enriched = result; + + return result; +} + +function serializeEnrichedItems(enrichedItems: EnrichedItem[] | undefined) { + if (enrichedItems == null || enrichedItems.length === 0) return; + + return enrichedItems.map(enrichedItem => { + return { + id: enrichedItem.id, + type: enrichedItem.type, + expiresAt: enrichedItem.expiresAt, + }; + }); +} + +function getPrRank(pr: SearchedPullRequest) { + let score = 0; + if (pr.reasons.includes('authored')) { + score += 1000; + } else if (pr.reasons.includes('assigned')) { + score += 900; + } else if (pr.reasons.includes('review-requested')) { + score += 800; + } else if (pr.reasons.includes('mentioned')) { + score += 700; } - private async notifyDidChangeState(deferState?: boolean) { - void this.host.notify(DidChangeNotificationType, { state: await this.getState(deferState) }); + if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.Approved) { + if (pr.pullRequest.mergeableState === PullRequestMergeableState.Mergeable) { + score += 100; + } else if (pr.pullRequest.mergeableState === PullRequestMergeableState.Conflicting) { + score += 90; + } else { + score += 80; + } + } else if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.ChangesRequested) { + score += 70; } + + return score; } +// function getIssueRank(issue: SearchedIssue) { +// let score = 0; +// if (issue.reasons.includes('authored')) { +// score += 1000; +// } else if (issue.reasons.includes('assigned')) { +// score += 900; +// } else if (issue.reasons.includes('mentioned')) { +// score += 700; +// } + +// return score; +// } + function filterGithubRepos(list: RepoWithRichRemote[]): RepoWithRichRemote[] { return list.filter(entry => entry.isGitHub); } diff --git a/src/plus/webviews/focus/protocol.ts b/src/plus/webviews/focus/protocol.ts index df1063e2e28b3..229e8339fb909 100644 --- a/src/plus/webviews/focus/protocol.ts +++ b/src/plus/webviews/focus/protocol.ts @@ -1,21 +1,28 @@ -import type { WebviewIds, WebviewViewIds } from '../../../constants'; import type { FeatureAccess } from '../../../features'; import type { IssueShape } from '../../../git/models/issue'; import type { PullRequestShape } from '../../../git/models/pullRequest'; -import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcCommand, IpcNotification } from '../../../webviews/protocol'; +import type { EnrichedItem } from '../../focus/enrichmentService'; -export interface State { - webviewId: WebviewIds | WebviewViewIds; - timestamp: number; +export const scope: IpcScope = 'focus'; +export interface State extends WebviewState { access: FeatureAccess; pullRequests?: PullRequestResult[]; issues?: IssueResult[]; - repos?: RepoWithRichProvider[]; + repos?: RepoWithIntegration[]; } export interface SearchResultBase { reasons: string[]; + rank?: number; + enriched?: EnrichedItemSummary[]; +} + +export interface EnrichedItemSummary { + id: EnrichedItem['id']; + type: EnrichedItem['type']; } export interface IssueResult extends SearchResultBase { @@ -30,32 +37,58 @@ export interface PullRequestResult extends SearchResultBase { hasLocalBranch: boolean; } -export interface RepoWithRichProvider { +export interface RepoWithIntegration { repo: string; isGitHub: boolean; isConnected: boolean; } -// Commands +// COMMANDS export interface OpenWorktreeParams { pullRequest: PullRequestShape; } -export const OpenWorktreeCommandType = new IpcCommandType('focus/pr/openWorktree'); +export const OpenWorktreeCommand = new IpcCommand(scope, 'pr/openWorktree'); export interface OpenBranchParams { pullRequest: PullRequestShape; } -export const OpenBranchCommandType = new IpcCommandType('focus/pr/openBranch'); +export const OpenBranchCommand = new IpcCommand(scope, 'pr/openBranch'); export interface SwitchToBranchParams { pullRequest: PullRequestShape; } -export const SwitchToBranchCommandType = new IpcCommandType('focus/pr/switchToBranch'); +export const SwitchToBranchCommand = new IpcCommand(scope, 'pr/switchToBranch'); + +export interface SnoozePrParams { + pullRequest: PullRequestShape; + expiresAt?: string; + snooze?: string; +} +export const SnoozePRCommand = new IpcCommand(scope, 'pr/snooze'); + +export interface PinPrParams { + pullRequest: PullRequestShape; + pin?: string; +} +export const PinPRCommand = new IpcCommand(scope, 'pr/pin'); + +export interface SnoozeIssueParams { + issue: IssueShape; + expiresAt?: string; + snooze?: string; +} +export const SnoozeIssueCommand = new IpcCommand(scope, 'issue/snooze'); + +export interface PinIssueParams { + issue: IssueShape; + pin?: string; +} +export const PinIssueCommand = new IpcCommand(scope, 'issue/pin'); -// Notifications +// NOTIFICATIONS export interface DidChangeParams { state: State; } -export const DidChangeNotificationType = new IpcNotificationType('focus/didChange', true); +export const DidChangeNotification = new IpcNotification(scope, 'didChange', true); diff --git a/src/plus/webviews/focus/registration.ts b/src/plus/webviews/focus/registration.ts index 44f70f73c0934..f928c4331172f 100644 --- a/src/plus/webviews/focus/registration.ts +++ b/src/plus/webviews/focus/registration.ts @@ -1,16 +1,18 @@ -import { ViewColumn } from 'vscode'; +import { Disposable, ViewColumn } from 'vscode'; import { Commands } from '../../../constants'; -import type { WebviewsController } from '../../../webviews/webviewsController'; +import { registerCommand } from '../../../system/command'; +import { configuration } from '../../../system/configuration'; +import type { WebviewPanelsProxy, WebviewsController } from '../../../webviews/webviewsController'; import type { State } from './protocol'; export function registerFocusWebviewPanel(controller: WebviewsController) { return controller.registerWebviewPanel( - Commands.ShowFocusPage, + { id: Commands.ShowFocusPage, options: { preserveInstance: true } }, { id: 'gitlens.focus', fileName: 'focus.html', iconPath: 'images/gitlens-icon.png', - title: 'Focus View', + title: 'GitLens Launchpad', contextKeyPrefix: `gitlens:webview:focus`, trackingFeature: 'focusWebview', plusFeature: true, @@ -19,10 +21,21 @@ export function registerFocusWebviewPanel(controller: WebviewsController) { retainContextWhenHidden: true, enableFindWidget: true, }, + allowMultipleInstances: configuration.get('launchpad.allowMultiple'), }, async (container, host) => { - const { FocusWebviewProvider } = await import(/* webpackChunkName: "focus" */ './focusWebview'); + const { FocusWebviewProvider } = await import(/* webpackChunkName: "webview-focus" */ './focusWebview'); return new FocusWebviewProvider(container, host); }, ); } + +export function registerFocusWebviewCommands(panels: WebviewPanelsProxy) { + return Disposable.from( + registerCommand(`gitlens.launchpad.refresh`, () => void panels.getActiveInstance()?.refresh(true)), + registerCommand( + `gitlens.launchpad.split`, + () => void panels.splitActiveInstance({ preserveInstance: false, column: ViewColumn.Beside }), + ), + ); +} diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index e2783acff2c84..5b18571290709 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -1,30 +1,34 @@ -import type { ColorTheme, ConfigurationChangeEvent, Uri, ViewColumn } from 'vscode'; +import type { CancellationToken, ColorTheme, ConfigurationChangeEvent, Uri } from 'vscode'; import { CancellationTokenSource, Disposable, env, window } from 'vscode'; -import type { CreatePullRequestActionContext } from '../../../api/gitlens'; +import type { CreatePullRequestActionContext, OpenPullRequestActionContext } from '../../../api/gitlens'; import { getAvatarUri } from '../../../avatars'; -import type { - CopyDeepLinkCommandArgs, - CopyMessageToClipboardCommandArgs, - CopyShaToClipboardCommandArgs, - OpenOnRemoteCommandArgs, - OpenPullRequestOnRemoteCommandArgs, - ShowCommitsInViewCommandArgs, -} from '../../../commands'; import { parseCommandContext } from '../../../commands/base'; +import type { CopyDeepLinkCommandArgs } from '../../../commands/copyDeepLink'; +import type { CopyMessageToClipboardCommandArgs } from '../../../commands/copyMessageToClipboard'; +import type { CopyShaToClipboardCommandArgs } from '../../../commands/copyShaToClipboard'; +import type { InspectCommandArgs } from '../../../commands/inspect'; +import type { OpenOnRemoteCommandArgs } from '../../../commands/openOnRemote'; +import type { OpenPullRequestOnRemoteCommandArgs } from '../../../commands/openPullRequestOnRemote'; +import type { CreatePatchCommandArgs } from '../../../commands/patches'; import type { Config, GraphMinimapMarkersAdditionalTypes, GraphScrollMarkersAdditionalTypes } from '../../../config'; import type { StoredGraphFilters, StoredGraphIncludeOnlyRef, StoredGraphRefType } from '../../../constants'; import { Commands, GlyphChars } from '../../../constants'; import type { Container } from '../../../container'; +import { CancellationError } from '../../../errors'; import type { CommitSelectedEvent } from '../../../eventBus'; import { PlusFeatures } from '../../../features'; import * as BranchActions from '../../../git/actions/branch'; import { openAllChanges, + openAllChangesIndividually, openAllChangesWithWorking, + openAllChangesWithWorkingIndividually, + openComparisonChanges, openFiles, openFilesAtRevision, - openOnlyChangedFiles as openOnlyChangedFilesForCommit, + openOnlyChangedFiles, showGraphDetailsView, + undoCommit, } from '../../../git/actions/commit'; import * as ContributorActions from '../../../git/actions/contributor'; import * as RepoActions from '../../../git/actions/repository'; @@ -32,11 +36,15 @@ import * as StashActions from '../../../git/actions/stash'; import * as TagActions from '../../../git/actions/tag'; import * as WorktreeActions from '../../../git/actions/worktree'; import { GitSearchError } from '../../../git/errors'; +import { CommitFormatter } from '../../../git/formatters/commitFormatter'; import { getBranchId, getBranchNameWithoutRemote, getRemoteNameFromBranchName } from '../../../git/models/branch'; import type { GitCommit } from '../../../git/models/commit'; +import { isStash } from '../../../git/models/commit'; import { uncommitted } from '../../../git/models/constants'; import { GitContributor } from '../../../git/models/contributor'; import type { GitGraph, GitGraphRowType } from '../../../git/models/graph'; +import { getGkProviderThemeIconString } from '../../../git/models/graph'; +import { getComparisonRefsForPullRequest, serializePullRequest } from '../../../git/models/pullRequest'; import type { GitBranchReference, GitReference, @@ -47,7 +55,6 @@ import type { import { createReference, getReferenceFromBranch, - getReferenceLabel, isGitReference, isSha, shortenRevision, @@ -64,35 +71,29 @@ import { import type { GitSearch } from '../../../git/search'; import { getSearchQueryComparisonKey } from '../../../git/search'; import { showRepositoryPicker } from '../../../quickpicks/repositoryPicker'; -import { - executeActionCommand, - executeCommand, - executeCoreCommand, - executeCoreGitCommand, - registerCommand, -} from '../../../system/command'; +import { executeActionCommand, executeCommand, executeCoreCommand, registerCommand } from '../../../system/command'; import { configuration } from '../../../system/configuration'; import { getContext, onDidChangeContext } from '../../../system/context'; import { gate } from '../../../system/decorators/gate'; -import { debug } from '../../../system/decorators/log'; +import { debug, log } from '../../../system/decorators/log'; import type { Deferrable } from '../../../system/function'; import { debounce, disposableInterval } from '../../../system/function'; import { find, last, map } from '../../../system/iterable'; import { updateRecordValue } from '../../../system/object'; -import { getSettledValue } from '../../../system/promise'; +import { getSettledValue, pauseOnCancelOrTimeoutMapTuplePromise } from '../../../system/promise'; import { isDarkTheme, isLightTheme } from '../../../system/utils'; import { isWebviewItemContext, isWebviewItemGroupContext, serializeWebviewItemContext } from '../../../system/webview'; -import { RepositoryFolderNode } from '../../../views/nodes/viewNode'; -import type { IpcMessage, IpcNotificationType } from '../../../webviews/protocol'; -import { onIpc } from '../../../webviews/protocol'; -import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController'; +import { RepositoryFolderNode } from '../../../views/nodes/abstract/repositoryFolderNode'; +import type { IpcCallMessageType, IpcMessage, IpcNotification } from '../../../webviews/protocol'; +import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from '../../../webviews/webviewProvider'; +import type { WebviewPanelShowCommandArgs, WebviewShowOptions } from '../../../webviews/webviewsController'; import { isSerializedState } from '../../../webviews/webviewsController'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { BranchState, - DimMergeCommitsParams, + DidGetRowHoverParams, + DidSearchParams, DoubleClickedParams, - EnsureRowParams, GetMissingAvatarsParams, GetMissingRefsMetadataParams, GetMoreRowsParams, @@ -129,6 +130,7 @@ import type { GraphUpstreamMetadata, GraphUpstreamStatusContextValue, GraphWorkingTreeStats, + OpenPullRequestDetailsParams, SearchOpenInViewParams, SearchParams, State, @@ -139,40 +141,39 @@ import type { UpdateSelectionParams, } from './protocol'; import { - ChooseRepositoryCommandType, - DidChangeAvatarsNotificationType, - DidChangeColumnsNotificationType, - DidChangeFocusNotificationType, - DidChangeGraphConfigurationNotificationType, - DidChangeNotificationType, - DidChangeRefsMetadataNotificationType, - DidChangeRefsVisibilityNotificationType, - DidChangeRowsNotificationType, - DidChangeRowsStatsNotificationType, - DidChangeScrollMarkersNotificationType, - DidChangeSelectionNotificationType, - DidChangeSubscriptionNotificationType, - DidChangeWindowFocusNotificationType, - DidChangeWorkingTreeNotificationType, - DidEnsureRowNotificationType, - DidFetchNotificationType, - DidSearchNotificationType, - DimMergeCommitsCommandType, + ChooseRepositoryCommand, + DidChangeAvatarsNotification, + DidChangeColumnsNotification, + DidChangeGraphConfigurationNotification, + DidChangeNotification, + DidChangeRefsMetadataNotification, + DidChangeRefsVisibilityNotification, + DidChangeRowsNotification, + DidChangeRowsStatsNotification, + DidChangeScrollMarkersNotification, + DidChangeSelectionNotification, + DidChangeSubscriptionNotification, + DidChangeWorkingTreeNotification, + DidFetchNotification, + DidSearchNotification, DoubleClickedCommandType, - EnsureRowCommandType, - GetMissingAvatarsCommandType, - GetMissingRefsMetadataCommandType, - GetMoreRowsCommandType, - SearchCommandType, - SearchOpenInViewCommandType, + EnsureRowRequest, + GetMissingAvatarsCommand, + GetMissingRefsMetadataCommand, + GetMoreRowsCommand, + GetRowHoverRequest, + OpenPullRequestDetailsCommand, + SearchOpenInViewCommand, + SearchRequest, supportedRefMetadataTypes, - UpdateColumnsCommandType, - UpdateExcludeTypeCommandType, - UpdateGraphConfigurationCommandType, - UpdateIncludeOnlyRefsCommandType, - UpdateRefsVisibilityCommandType, - UpdateSelectionCommandType, + UpdateColumnsCommand, + UpdateExcludeTypeCommand, + UpdateGraphConfigurationCommand, + UpdateIncludeOnlyRefsCommand, + UpdateRefsVisibilityCommand, + UpdateSelectionCommand, } from './protocol'; +import type { GraphWebviewShowingArgs } from './registration'; const defaultGraphColumnsSettings: GraphColumnsSettings = { ref: { width: 130, isHidden: false, order: 0 }, @@ -194,7 +195,7 @@ const compactGraphColumnsSettings: GraphColumnsSettings = { sha: { width: 130, isHidden: false, order: 6 }, }; -export class GraphWebviewProvider implements WebviewProvider { +export class GraphWebviewProvider implements WebviewProvider { private _repository?: Repository; private get repository(): Repository | undefined { return this._repository; @@ -219,22 +220,26 @@ export class GraphWebviewProvider implements WebviewProvider { return this._selection?.[0]; } + private _discovering: Promise | undefined; private readonly _disposable: Disposable; + private _etag?: number; private _etagSubscription?: number; private _etagRepository?: number; private _firstSelection = true; private _graph?: GitGraph; - private readonly _ipcNotificationMap = new Map, () => Promise>([ - [DidChangeColumnsNotificationType, this.notifyDidChangeColumns], - [DidChangeGraphConfigurationNotificationType, this.notifyDidChangeConfiguration], - [DidChangeNotificationType, this.notifyDidChangeState], - [DidChangeRefsVisibilityNotificationType, this.notifyDidChangeRefsVisibility], - [DidChangeScrollMarkersNotificationType, this.notifyDidChangeScrollMarkers], - [DidChangeSelectionNotificationType, this.notifyDidChangeSelection], - [DidChangeSubscriptionNotificationType, this.notifyDidChangeSubscription], - [DidChangeWorkingTreeNotificationType, this.notifyDidChangeWorkingTree], - [DidChangeWindowFocusNotificationType, this.notifyDidChangeWindowFocus], - [DidFetchNotificationType, this.notifyDidFetch], + private _hoverCache = new Map>(); + private _hoverCancellation: CancellationTokenSource | undefined; + + private readonly _ipcNotificationMap = new Map, () => Promise>([ + [DidChangeColumnsNotification, this.notifyDidChangeColumns], + [DidChangeGraphConfigurationNotification, this.notifyDidChangeConfiguration], + [DidChangeNotification, this.notifyDidChangeState], + [DidChangeRefsVisibilityNotification, this.notifyDidChangeRefsVisibility], + [DidChangeScrollMarkersNotification, this.notifyDidChangeScrollMarkers], + [DidChangeSelectionNotification, this.notifyDidChangeSelection], + [DidChangeSubscriptionNotification, this.notifyDidChangeSubscription], + [DidChangeWorkingTreeNotification, this.notifyDidChangeWorkingTree], + [DidFetchNotification, this.notifyDidFetch], ]); private _refsMetadata: Map | null | undefined; private _search: GitSearch | undefined; @@ -250,20 +255,25 @@ export class GraphWebviewProvider implements WebviewProvider { constructor( private readonly container: Container, - private readonly host: WebviewController, + private readonly host: WebviewHost, ) { this._showDetailsView = configuration.get('graph.showDetailsView'); this._theme = window.activeColorTheme; this.ensureRepositorySubscriptions(); - if (this.host.isView()) { - this.host.description = '✨'; - } - this._disposable = Disposable.from( configuration.onDidChange(this.onConfigurationChanged, this), this.container.subscription.onDidChange(this.onSubscriptionChanged, this), - this.container.git.onDidChangeRepositories(() => void this.host.refresh(true)), + this.container.git.onDidChangeRepositories(async () => { + if (this._etag !== this.container.git.etag) { + if (this._discovering != null) { + this._etag = await this._discovering; + if (this._etag === this.container.git.etag) return; + } + + void this.host.refresh(true); + } + }), window.onDidChangeActiveColorTheme(this.onThemeChanged, this), { dispose: () => { @@ -279,13 +289,43 @@ export class GraphWebviewProvider implements WebviewProvider { this._disposable.dispose(); } + canReuseInstance(...args: WebviewShowingArgs): boolean | undefined { + if (this.container.git.openRepositoryCount === 1) return true; + + const [arg] = args; + + let repository: Repository | undefined; + if (isRepository(arg)) { + repository = arg; + } else if (hasGitReference(arg)) { + repository = this.container.git.getRepository(arg.ref.repoPath); + } else if (isSerializedState(arg) && arg.state.selectedRepository != null) { + repository = this.container.git.getRepository(arg.state.selectedRepository); + } + + return repository?.uri.toString() === this.repository?.uri.toString() ? true : undefined; + } + + getSplitArgs(): WebviewShowingArgs { + return this.repository != null ? [this.repository] : []; + } + async onShowing( loading: boolean, - _options: { column?: ViewColumn; preserveFocus?: boolean }, - ...args: [Repository, { ref: GitReference }, { state: Partial }] | unknown[] + _options?: WebviewShowOptions, + ...args: WebviewShowingArgs ): Promise { this._firstSelection = true; + this._etag = this.container.git.etag; + if (this.container.git.isDiscoveringRepositories) { + this._discovering = this.container.git.isDiscoveringRepositories.then(r => { + this._discovering = undefined; + return r; + }); + this._etag = await this._discovering; + } + const [arg] = args; if (isRepository(arg)) { this.repository = arg; @@ -344,18 +384,24 @@ export class GraphWebviewProvider implements WebviewProvider { } registerCommands(): Disposable[] { - return [ - registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)), - - ...(this.host.isView() - ? [ - registerCommand( - `${this.host.id}.openInTab`, - () => void executeCommand(Commands.ShowGraphPage, this.repository), + const commands: Disposable[] = []; + + if (this.host.isHost('view')) { + commands.push( + registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)), + registerCommand( + `${this.host.id}.openInTab`, + () => + void executeCommand( + Commands.ShowGraphPage, + undefined, + this.repository, ), - ] - : []), + ), + ); + } + commands.push( this.host.registerWebviewCommand('gitlens.graph.push', this.push), this.host.registerWebviewCommand('gitlens.graph.pull', this.pull), this.host.registerWebviewCommand('gitlens.graph.fetch', this.fetch), @@ -389,19 +435,23 @@ export class GraphWebviewProvider implements WebviewProvider { this.host.registerWebviewCommand('gitlens.graph.copyRemoteCommitUrl', item => this.openCommitOnRemote(item, true), ), - this.host.registerWebviewCommand('gitlens.graph.showInDetailsView', this.openInDetailsView), + this.host.registerWebviewCommand('gitlens.graph.copyRemoteCommitUrl.multi', item => + this.openCommitOnRemote(item, true), + ), this.host.registerWebviewCommand('gitlens.graph.openCommitOnRemote', this.openCommitOnRemote), + this.host.registerWebviewCommand('gitlens.graph.openCommitOnRemote.multi', this.openCommitOnRemote), this.host.registerWebviewCommand('gitlens.graph.openSCM', this.openSCM), this.host.registerWebviewCommand('gitlens.graph.rebaseOntoCommit', this.rebase), this.host.registerWebviewCommand('gitlens.graph.resetCommit', this.resetCommit), this.host.registerWebviewCommand('gitlens.graph.resetToCommit', this.resetToCommit), this.host.registerWebviewCommand('gitlens.graph.resetToTip', this.resetToTip), this.host.registerWebviewCommand('gitlens.graph.revert', this.revertCommit), + this.host.registerWebviewCommand('gitlens.graph.showInDetailsView', this.openInDetailsView), this.host.registerWebviewCommand('gitlens.graph.switchToCommit', this.switchTo), this.host.registerWebviewCommand('gitlens.graph.undoCommit', this.undoCommit), - this.host.registerWebviewCommand('gitlens.graph.saveStash', this.saveStash), - this.host.registerWebviewCommand('gitlens.graph.applyStash', this.applyStash), + this.host.registerWebviewCommand('gitlens.graph.stash.save', this.saveStash), + this.host.registerWebviewCommand('gitlens.graph.stash.apply', this.applyStash), this.host.registerWebviewCommand('gitlens.graph.stash.delete', this.deleteStash), this.host.registerWebviewCommand('gitlens.graph.stash.rename', this.renameStash), @@ -412,11 +462,20 @@ export class GraphWebviewProvider implements WebviewProvider { this.host.registerWebviewCommand('gitlens.graph.createWorktree', this.createWorktree), this.host.registerWebviewCommand('gitlens.graph.createPullRequest', this.createPullRequest), + this.host.registerWebviewCommand('gitlens.graph.openPullRequest', this.openPullRequest), + this.host.registerWebviewCommand('gitlens.graph.openPullRequestChanges', this.openPullRequestChanges), + this.host.registerWebviewCommand('gitlens.graph.openPullRequestComparison', this.openPullRequestComparison), this.host.registerWebviewCommand('gitlens.graph.openPullRequestOnRemote', this.openPullRequestOnRemote), + this.host.registerWebviewCommand( + 'gitlens.graph.openChangedFileDiffsWithMergeBase', + this.openChangedFileDiffsWithMergeBase, + ), + this.host.registerWebviewCommand('gitlens.graph.compareWithUpstream', this.compareWithUpstream), this.host.registerWebviewCommand('gitlens.graph.compareWithHead', this.compareHeadWith), this.host.registerWebviewCommand('gitlens.graph.compareWithWorking', this.compareWorkingWith), + this.host.registerWebviewCommand('gitlens.graph.compareWithMergeBase', this.compareWithMergeBase), this.host.registerWebviewCommand( 'gitlens.graph.compareAncestryWithWorking', this.compareAncestryWithWorking, @@ -480,18 +539,33 @@ export class GraphWebviewProvider implements WebviewProvider { this.host.registerWebviewCommand('gitlens.graph.scrollMarkerTagOff', () => this.toggleScrollMarker('tags', false), ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerPullRequestOn', () => + this.toggleScrollMarker('pullRequests', true), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerPullRequestOff', () => + this.toggleScrollMarker('pullRequests', false), + ), this.host.registerWebviewCommand('gitlens.graph.copyDeepLinkToBranch', this.copyDeepLinkToBranch), this.host.registerWebviewCommand('gitlens.graph.copyDeepLinkToCommit', this.copyDeepLinkToCommit), this.host.registerWebviewCommand('gitlens.graph.copyDeepLinkToRepo', this.copyDeepLinkToRepo), this.host.registerWebviewCommand('gitlens.graph.copyDeepLinkToTag', this.copyDeepLinkToTag), + this.host.registerWebviewCommand('gitlens.graph.shareAsCloudPatch', this.shareAsCloudPatch), this.host.registerWebviewCommand('gitlens.graph.openChangedFiles', this.openFiles), this.host.registerWebviewCommand('gitlens.graph.openOnlyChangedFiles', this.openOnlyChangedFiles), - this.host.registerWebviewCommand('gitlens.graph.openChangedFileDiffs', this.openAllChanges), - this.host.registerWebviewCommand( - 'gitlens.graph.openChangedFileDiffsWithWorking', - this.openAllChangesWithWorking, + this.host.registerWebviewCommand('gitlens.graph.openChangedFileDiffs', item => + this.openAllChanges(item), + ), + this.host.registerWebviewCommand('gitlens.graph.openChangedFileDiffsWithWorking', item => + this.openAllChangesWithWorking(item), + ), + this.host.registerWebviewCommand('gitlens.graph.openChangedFileDiffsIndividually', item => + this.openAllChanges(item, true), + ), + this.host.registerWebviewCommand( + 'gitlens.graph.openChangedFileDiffsWithWorkingIndividually', + item => this.openAllChangesWithWorking(item, true), ), this.host.registerWebviewCommand('gitlens.graph.openChangedFileRevisions', this.openRevisions), @@ -501,19 +575,24 @@ export class GraphWebviewProvider implements WebviewProvider { this.host.registerWebviewCommand('gitlens.graph.resetColumnsCompact', () => this.updateColumns(compactGraphColumnsSettings), ), - ]; + + this.host.registerWebviewCommand( + 'gitlens.graph.copyWorkingChangesToWorktree', + this.copyWorkingChangesToWorktree, + ), + ); + + return commands; } onWindowFocusChanged(focused: boolean): void { this.isWindowFocused = focused; - void this.notifyDidChangeWindowFocus(); } onFocusChanged(focused: boolean): void { - void this.notifyDidChangeFocus(focused); + this._showActiveSelectionDetailsDebounced?.cancel(); if (!focused || this.activeSelection == null || !this.container.commitDetailsView.visible) { - this._showActiveSelectionDetailsDebounced?.cancel(); return; } @@ -535,9 +614,7 @@ export class GraphWebviewProvider implements WebviewProvider { } if (visible) { - if (this.host.ready) { - this.host.sendPendingIpcNotifications(); - } + this.host.sendPendingIpcNotifications(); const { activeSelection } = this; if (activeSelection == null) return; @@ -547,56 +624,58 @@ export class GraphWebviewProvider implements WebviewProvider { } onMessageReceived(e: IpcMessage) { - switch (e.method) { - case ChooseRepositoryCommandType.method: - onIpc(ChooseRepositoryCommandType, e, () => this.onChooseRepository()); + switch (true) { + case ChooseRepositoryCommand.is(e): + void this.onChooseRepository(); break; - case DimMergeCommitsCommandType.method: - onIpc(DimMergeCommitsCommandType, e, params => this.dimMergeCommits(params)); + case DoubleClickedCommandType.is(e): + void this.onDoubleClick(e.params); break; - case DoubleClickedCommandType.method: - onIpc(DoubleClickedCommandType, e, params => this.onDoubleClick(params)); + case EnsureRowRequest.is(e): + void this.onEnsureRowRequest(EnsureRowRequest, e); break; - case EnsureRowCommandType.method: - onIpc(EnsureRowCommandType, e, params => this.onEnsureRow(params, e.completionId)); + case GetMissingAvatarsCommand.is(e): + void this.onGetMissingAvatars(e.params); break; - case GetMissingAvatarsCommandType.method: - onIpc(GetMissingAvatarsCommandType, e, params => this.onGetMissingAvatars(params)); + case GetMissingRefsMetadataCommand.is(e): + void this.onGetMissingRefMetadata(e.params); break; - case GetMissingRefsMetadataCommandType.method: - onIpc(GetMissingRefsMetadataCommandType, e, params => this.onGetMissingRefMetadata(params)); + case GetMoreRowsCommand.is(e): + void this.onGetMoreRows(e.params); break; - case GetMoreRowsCommandType.method: - onIpc(GetMoreRowsCommandType, e, params => this.onGetMoreRows(params)); + case GetRowHoverRequest.is(e): + void this.onHoverRowRequest(GetRowHoverRequest, e); break; - case SearchCommandType.method: - onIpc(SearchCommandType, e, params => this.onSearch(params, e.completionId)); + case OpenPullRequestDetailsCommand.is(e): + void this.onOpenPullRequestDetails(e.params); break; - case SearchOpenInViewCommandType.method: - onIpc(SearchOpenInViewCommandType, e, params => this.onSearchOpenInView(params)); + case SearchRequest.is(e): + void this.onSearchRequest(SearchRequest, e); break; - case UpdateColumnsCommandType.method: - onIpc(UpdateColumnsCommandType, e, params => this.onColumnsChanged(params)); + case SearchOpenInViewCommand.is(e): + this.onSearchOpenInView(e.params); break; - case UpdateGraphConfigurationCommandType.method: - onIpc(UpdateGraphConfigurationCommandType, e, params => this.updateGraphConfig(params)); + case UpdateColumnsCommand.is(e): + this.onColumnsChanged(e.params); break; - case UpdateRefsVisibilityCommandType.method: - onIpc(UpdateRefsVisibilityCommandType, e, params => this.onRefsVisibilityChanged(params)); + case UpdateGraphConfigurationCommand.is(e): + this.updateGraphConfig(e.params); break; - case UpdateSelectionCommandType.method: - onIpc(UpdateSelectionCommandType, e, this.onSelectionChanged.bind(this)); + case UpdateRefsVisibilityCommand.is(e): + this.onRefsVisibilityChanged(e.params); break; - case UpdateExcludeTypeCommandType.method: - onIpc(UpdateExcludeTypeCommandType, e, params => this.updateExcludedType(this._graph, params)); + case UpdateSelectionCommand.is(e): + this.onSelectionChanged(e.params); break; - case UpdateIncludeOnlyRefsCommandType.method: - onIpc(UpdateIncludeOnlyRefsCommandType, e, params => - this.updateIncludeOnlyRefs(this._graph, params.refs), - ); + case UpdateExcludeTypeCommand.is(e): + this.updateExcludedType(this._graph, e.params); + break; + case UpdateIncludeOnlyRefsCommand.is(e): + this.updateIncludeOnlyRefs(this._graph, e.params.refs); break; } } + updateGraphConfig(params: UpdateGraphConfigurationParams) { const config = this.getComponentConfig(); @@ -620,6 +699,7 @@ export class GraphWebviewProvider implements WebviewProvider { case 'remoteBranches': case 'stashes': case 'tags': + case 'pullRequests': additionalTypes.push(marker); break; } @@ -627,6 +707,12 @@ export class GraphWebviewProvider implements WebviewProvider { void configuration.updateEffective('graph.minimap.additionalTypes', additionalTypes); break; } + case 'dimMergeCommits': + void configuration.updateEffective('graph.dimMergeCommits', params.changes[key]); + break; + case 'onlyFollowFirstParent': + void configuration.updateEffective('graph.onlyFollowFirstParent', params.changes[key]); + break; default: // TODO:@eamodio add more config options as needed debugger; @@ -650,7 +736,7 @@ export class GraphWebviewProvider implements WebviewProvider { private showActiveSelectionDetailsCore() { const { activeSelection } = this; - if (activeSelection == null) return; + if (activeSelection == null || !this.host.active) return; this.container.events.fire( 'commit:selected', @@ -681,30 +767,17 @@ export class GraphWebviewProvider implements WebviewProvider { configuration.changed(e, 'defaultDateFormat') || configuration.changed(e, 'defaultDateStyle') || configuration.changed(e, 'advanced.abbreviatedShaLength') || - configuration.changed(e, 'graph.avatars') || - configuration.changed(e, 'graph.dateFormat') || - configuration.changed(e, 'graph.dateStyle') || - configuration.changed(e, 'graph.dimMergeCommits') || - configuration.changed(e, 'graph.highlightRowsOnRefHover') || - configuration.changed(e, 'graph.scrollRowPadding') || - configuration.changed(e, 'graph.scrollMarkers.enabled') || - configuration.changed(e, 'graph.scrollMarkers.additionalTypes') || - configuration.changed(e, 'graph.showGhostRefsOnRowHover') || - configuration.changed(e, 'graph.pullRequests.enabled') || - configuration.changed(e, 'graph.showRemoteNames') || - configuration.changed(e, 'graph.showUpstreamStatus') || - configuration.changed(e, 'graph.minimap.enabled') || - configuration.changed(e, 'graph.minimap.dataType') || - configuration.changed(e, 'graph.minimap.additionalTypes') + configuration.changed(e, 'graph') ) { void this.notifyDidChangeConfiguration(); if ( - (configuration.changed(e, 'graph.minimap.enabled') || + configuration.changed(e, 'graph.onlyFollowFirstParent') || + ((configuration.changed(e, 'graph.minimap.enabled') || configuration.changed(e, 'graph.minimap.dataType')) && - configuration.get('graph.minimap.enabled') && - configuration.get('graph.minimap.dataType') === 'lines' && - !this._graph?.includes?.stats + configuration.get('graph.minimap.enabled') && + configuration.get('graph.minimap.dataType') === 'lines' && + !this._graph?.includes?.stats) ) { this.updateState(); } @@ -755,23 +828,17 @@ export class GraphWebviewProvider implements WebviewProvider { } private onThemeChanged(theme: ColorTheme) { - if (this._theme != null) { - if ( - (isDarkTheme(theme) && isDarkTheme(this._theme)) || - (isLightTheme(theme) && isLightTheme(this._theme)) - ) { - return; - } + if ( + this._theme != null && + ((isDarkTheme(theme) && isDarkTheme(this._theme)) || (isLightTheme(theme) && isLightTheme(this._theme))) + ) { + return; } this._theme = theme; this.updateState(); } - private dimMergeCommits(e: DimMergeCommitsParams) { - void configuration.updateEffective('graph.dimMergeCommits', e.dim); - } - private onColumnsChanged(e: UpdateColumnsParams) { this.updateColumns(e.config); } @@ -814,6 +881,8 @@ export class GraphWebviewProvider implements WebviewProvider { ); } } else if (e.type === 'row' && e.row) { + this._showActiveSelectionDetailsDebounced?.cancel(); + const commit = this.getRevisionReference(this.repository?.path, e.row.id, e.row.type); if (commit != null) { this.container.events.fire( @@ -829,7 +898,9 @@ export class GraphWebviewProvider implements WebviewProvider { }, ); - const details = this.host.isView() ? this.container.graphDetailsView : this.container.commitDetailsView; + const details = this.host.isHost('editor') + ? this.container.commitDetailsView + : this.container.graphDetailsView; if (!details.ready) { void details.show({ preserveFocus: e.preserveFocus }, { commit: commit, @@ -843,10 +914,160 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } + private async onHoverRowRequest(requestType: T, msg: IpcCallMessageType) { + const hover: DidGetRowHoverParams = { + id: msg.params.id, + cancelled: true, + }; + + if (this._hoverCancellation != null) { + this._hoverCancellation.cancel(); + } + + if (this._graph != null) { + const id = msg.params.id; + + let markdown = this._hoverCache.get(id); + if (markdown == null) { + const cancellation = new CancellationTokenSource(); + this._hoverCancellation = cancellation; + + let cache = true; + let commit; + switch (msg.params.type) { + case 'work-dir-changes': + cache = false; + commit = await this.container.git.getCommit(this._graph.repoPath, uncommitted); + break; + case 'stash-node': { + const stash = await this.container.git.getStash(this._graph.repoPath); + commit = stash?.commits.get(msg.params.id); + break; + } + default: { + commit = await this.container.git.getCommit(this._graph.repoPath, msg.params.id); + break; + } + } + + if (commit != null && !cancellation.token.isCancellationRequested) { + // Check if we have calculated stats for the row and if so apply it to the commit + const stats = this._graph.rowsStats?.get(commit.sha); + if (stats != null) { + commit = commit.with({ + stats: { + ...commit.stats, + additions: stats.additions, + deletions: stats.deletions, + // If `changedFiles` already exists, then use it, otherwise use the files count + changedFiles: commit.stats?.changedFiles ? commit.stats.changedFiles : stats.files, + }, + }); + } + + markdown = this.getCommitTooltip(commit, cancellation.token).catch((ex: unknown) => { + this._hoverCache.delete(id); + throw ex; + }); + if (cache) { + this._hoverCache.set(id, markdown); + } + } + } + + if (markdown != null) { + try { + hover.markdown = await markdown; + hover.cancelled = false; + } catch {} + } + } + + void this.host.respond(requestType, msg, hover); + } + + private async getCommitTooltip(commit: GitCommit, cancellation: CancellationToken) { + const [remotesResult, _] = await Promise.allSettled([ + this.container.git.getBestRemotesWithProviders(commit.repoPath), + commit.ensureFullDetails(), + ]); + + if (cancellation.isCancellationRequested) throw new CancellationError(); + + const remotes = getSettledValue(remotesResult, []); + const [remote] = remotes; + + let enrichedAutolinks; + let pr; + + if (remote?.hasIntegration()) { + const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ + pauseOnCancelOrTimeoutMapTuplePromise(commit.getEnrichedAutolinks(remote), cancellation), + commit.getAssociatedPullRequest(remote), + ]); + + if (cancellation.isCancellationRequested) throw new CancellationError(); + + const enrichedAutolinksMaybeResult = getSettledValue(enrichedAutolinksResult); + if (!enrichedAutolinksMaybeResult?.paused) { + enrichedAutolinks = enrichedAutolinksMaybeResult?.value; + } + pr = getSettledValue(prResult); + } + + let template; + if (isStash(commit)) { + template = configuration.get('views.formats.stashes.tooltip'); + } else { + template = configuration.get('views.formats.commits.tooltip'); + } + + const tooltip = await CommitFormatter.fromTemplateAsync(template, commit, { + enrichedAutolinks: enrichedAutolinks, + dateFormat: configuration.get('defaultDateFormat'), + getBranchAndTagTips: this.getBranchAndTagTips.bind(this), + messageAutolinks: true, + messageIndent: 4, + pullRequest: pr, + outputFormat: 'markdown', + remotes: remotes, + // unpublished: this.unpublished, + }); + + return tooltip; + } + + private getBranchAndTagTips(sha: string, options?: { compact?: boolean; icons?: boolean }): string | undefined { + if (this._graph == null) return undefined; + + const row = this._graph.rows.find(r => r.sha === sha); + if (row == null) return undefined; + + const tips = []; + if (row.heads?.length) { + tips.push(...row.heads.map(h => (options?.icons ? `$(git-branch) ${h.name}` : h.name))); + } + + if (row.remotes?.length) { + tips.push( + ...row.remotes.map(h => { + const name = `${h.owner ? `${h.owner}/` : ''}${h.name}`; + return options?.icons ? `$(${getGkProviderThemeIconString(h.hostingServiceType)}) ${name}` : name; + }), + ); + } + if (row.tags?.length) { + tips.push(...row.tags.map(h => (options?.icons ? `$(tag) ${h.name}` : h.name))); + } + + return tips.join(', ') || undefined; + } + @debug() - private async onEnsureRow(e: EnsureRowParams, completionId?: string) { + private async onEnsureRowRequest(requestType: T, msg: IpcCallMessageType) { if (this._graph == null) return; + const e = msg.params; const ensureId = this._graph.remappedIds?.get(e.id) ?? e.id; let id: string | undefined; @@ -863,7 +1084,7 @@ export class GraphWebviewProvider implements WebviewProvider { } } - void this.host.notify(DidEnsureRowNotificationType, { id: id, remapped: remapped }, completionId); + void this.host.respond(requestType, msg, { id: id, remapped: remapped }); } private async onGetMissingAvatars(e: GetMissingAvatarsParams) { @@ -891,7 +1112,13 @@ export class GraphWebviewProvider implements WebviewProvider { } private async onGetMissingRefMetadata(e: GetMissingRefsMetadataParams) { - if (this._graph == null || this._refsMetadata === null || !getContext('gitlens:hasConnectedRemotes')) return; + if ( + this._graph == null || + this._refsMetadata === null || + !getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(this._graph.repoPath) + ) { + return; + } const repoPath = this._graph.repoPath; @@ -942,15 +1169,23 @@ export class GraphWebviewProvider implements WebviewProvider { id: Number.parseInt(pr.id) || 0, title: pr.title, author: pr.author.name, - date: (pr.mergedDate ?? pr.closedDate ?? pr.date)?.getTime(), + date: (pr.mergedDate ?? pr.closedDate ?? pr.updatedDate)?.getTime(), state: pr.state, url: pr.url, context: serializeWebviewItemContext({ - webviewItem: 'gitlens:pullrequest', + webviewItem: `gitlens:pullrequest${pr.refs ? '+refs' : ''}`, webviewItemValue: { type: 'pullrequest', id: pr.id, url: pr.url, + repoPath: repoPath, + refs: pr.refs, + provider: { + id: pr.provider.id, + name: pr.provider.name, + domain: pr.provider.domain, + icon: pr.provider.icon, + }, }, }), }; @@ -1019,16 +1254,40 @@ export class GraphWebviewProvider implements WebviewProvider { void this.notifyDidChangeRows(sendSelectedRows); } + @log() + async onOpenPullRequestDetails(_params: OpenPullRequestDetailsParams) { + // TODO: a hack for now, since we aren't using the params at all right now and always opening the current branch's PR + const repo = this.repository; + if (repo == null) return undefined; + + const branch = await repo.getBranch(); + if (branch == null) return undefined; + + const pr = await branch.getAssociatedPullRequest(); + if (pr == null) return undefined; + + return this.container.pullRequestView.showPullRequest(pr, branch); + } + @debug() - private async onSearch(e: SearchParams, completionId?: string) { + private async onSearchRequest(requestType: T, msg: IpcCallMessageType) { + try { + const results = await this.getSearchResults(msg.params); + void this.host.respond(requestType, msg, results); + } catch (ex) { + void this.host.respond(requestType, msg, { + results: + ex instanceof CancellationError + ? undefined + : { error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error' }, + }); + } + } + + private async getSearchResults(e: SearchParams): Promise { if (e.search == null) { this.resetSearchState(); - - // This shouldn't happen, but just in case - if (completionId != null) { - debugger; - } - return; + return { results: undefined }; } let search: GitSearch | undefined = this._search; @@ -1039,29 +1298,25 @@ export class GraphWebviewProvider implements WebviewProvider { this._search = search; void (await this.ensureSearchStartsInRange(this._graph!, search)); - void this.host.notify( - DidSearchNotificationType, - { - results: - search.results.size > 0 - ? { - ids: Object.fromEntries( - map(search.results, ([k, v]) => [this._graph?.remappedIds?.get(k) ?? k, v]), - ), - count: search.results.size, - paging: { hasMore: search.paging?.hasMore ?? false }, - } - : undefined, - }, - completionId, - ); + return { + results: + search.results.size > 0 + ? { + ids: Object.fromEntries( + map(search.results, ([k, v]) => [this._graph?.remappedIds?.get(k) ?? k, v]), + ), + count: search.results.size, + paging: { hasMore: search.paging?.hasMore ?? false }, + } + : undefined, + }; } - return; + return { results: undefined }; } if (search == null || search.comparisonKey !== getSearchQueryComparisonKey(e.search)) { - if (this.repository == null) return; + if (this.repository == null) return { results: { error: 'No repository' } }; if (this.repository.etag !== this._etagRepository) { this.updateState(true); @@ -1082,24 +1337,17 @@ export class GraphWebviewProvider implements WebviewProvider { }); } catch (ex) { this._search = undefined; - - void this.host.notify( - DidSearchNotificationType, - { - results: { - error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error', - }, - }, - completionId, - ); - return; + throw ex; + // return { + // results: { + // error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error', + // }, + // }; } if (cancellation.token.isCancellationRequested) { - if (completionId != null) { - void this.host.notify(DidSearchNotificationType, { results: undefined }, completionId); - } - return; + throw new CancellationError(); + // return { results: undefined }; } this._search = search; @@ -1115,23 +1363,19 @@ export class GraphWebviewProvider implements WebviewProvider { this.setSelectedRows(firstResult); } - void this.host.notify( - DidSearchNotificationType, - { - results: - search.results.size === 0 - ? { count: 0 } - : { - ids: Object.fromEntries( - map(search.results, ([k, v]) => [this._graph?.remappedIds?.get(k) ?? k, v]), - ), - count: search.results.size, - paging: { hasMore: search.paging?.hasMore ?? false }, - }, - selectedRows: sendSelectedRows ? this._selectedRows : undefined, - }, - completionId, - ); + return { + results: + search.results.size === 0 + ? { count: 0 } + : { + ids: Object.fromEntries( + map(search.results, ([k, v]) => [this._graph?.remappedIds?.get(k) ?? k, v]), + ), + count: search.results.size, + paging: { hasMore: search.paging?.hasMore ?? false }, + }, + selectedRows: sendSelectedRows ? this._selectedRows : undefined, + }; } private onSearchOpenInView(e: SearchOpenInViewParams) { @@ -1163,18 +1407,20 @@ export class GraphWebviewProvider implements WebviewProvider { ); if (pick == null) return; - this.repository = pick.item; + this.repository = pick; } private _fireSelectionChangedDebounced: Deferrable | undefined = undefined; private onSelectionChanged(e: UpdateSelectionParams) { + this._showActiveSelectionDetailsDebounced?.cancel(); + const item = e.selection[0]; this.setSelectedRows(item?.id); if (this._fireSelectionChangedDebounced == null) { - this._fireSelectionChangedDebounced = debounce(this.fireSelectionChanged.bind(this), 250); + this._fireSelectionChangedDebounced = debounce(this.fireSelectionChanged.bind(this), 50); } this._fireSelectionChangedDebounced(item?.id, item?.type); @@ -1189,6 +1435,7 @@ export class GraphWebviewProvider implements WebviewProvider { this._selection = commits; if (commits == null) return; + if (!this._firstSelection && this.host.isHost('editor') && !this.host.active) return; this.container.events.fire( 'commit:selected', @@ -1249,27 +1496,6 @@ export class GraphWebviewProvider implements WebviewProvider { void this._notifyDidChangeStateDebounced(); } - @debug() - private async notifyDidChangeFocus(focused: boolean): Promise { - if (!this.host.ready || !this.host.visible) return false; - - return this.host.notify(DidChangeFocusNotificationType, { - focused: focused, - }); - } - - @debug() - private async notifyDidChangeWindowFocus(): Promise { - if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification(DidChangeWindowFocusNotificationType, this._ipcNotificationMap, this); - return false; - } - - return this.host.notify(DidChangeWindowFocusNotificationType, { - focused: this.isWindowFocused, - }); - } - private _notifyDidChangeAvatarsDebounced: Deferrable | undefined = undefined; @@ -1292,7 +1518,7 @@ export class GraphWebviewProvider implements WebviewProvider { if (this._graph == null) return; const data = this._graph; - return this.host.notify(DidChangeAvatarsNotificationType, { + return this.host.notify(DidChangeAvatarsNotification, { avatars: Object.fromEntries(data.avatars), }); } @@ -1317,7 +1543,7 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidChangeRefsMetadata() { - return this.host.notify(DidChangeRefsMetadataNotificationType, { + return this.host.notify(DidChangeRefsMetadataNotification, { metadata: this._refsMetadata != null ? Object.fromEntries(this._refsMetadata) : this._refsMetadata, }); } @@ -1325,13 +1551,13 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidChangeColumns() { if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification(DidChangeColumnsNotificationType, this._ipcNotificationMap, this); + this.host.addPendingIpcNotification(DidChangeColumnsNotification, this._ipcNotificationMap, this); return false; } const columns = this.getColumns(); const columnSettings = this.getColumnSettings(columns); - return this.host.notify(DidChangeColumnsNotificationType, { + return this.host.notify(DidChangeColumnsNotification, { columns: columnSettings, context: this.getColumnHeaderContext(columnSettings), settingsContext: this.getGraphSettingsIconContext(columnSettings), @@ -1341,13 +1567,13 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidChangeScrollMarkers() { if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification(DidChangeScrollMarkersNotificationType, this._ipcNotificationMap, this); + this.host.addPendingIpcNotification(DidChangeScrollMarkersNotification, this._ipcNotificationMap, this); return false; } const columns = this.getColumns(); const columnSettings = this.getColumnSettings(columns); - return this.host.notify(DidChangeScrollMarkersNotificationType, { + return this.host.notify(DidChangeScrollMarkersNotification, { context: this.getGraphSettingsIconContext(columnSettings), }); } @@ -1355,15 +1581,11 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidChangeRefsVisibility() { if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification( - DidChangeRefsVisibilityNotificationType, - this._ipcNotificationMap, - this, - ); + this.host.addPendingIpcNotification(DidChangeRefsVisibilityNotification, this._ipcNotificationMap, this); return false; } - return this.host.notify(DidChangeRefsVisibilityNotificationType, { + return this.host.notify(DidChangeRefsVisibilityNotification, { excludeRefs: this.getExcludedRefs(this._graph), excludeTypes: this.getExcludedTypes(this._graph), includeOnlyRefs: this.getIncludeOnlyRefs(this._graph), @@ -1374,14 +1596,14 @@ export class GraphWebviewProvider implements WebviewProvider { private async notifyDidChangeConfiguration() { if (!this.host.ready || !this.host.visible) { this.host.addPendingIpcNotification( - DidChangeGraphConfigurationNotificationType, + DidChangeGraphConfigurationNotification, this._ipcNotificationMap, this, ); return false; } - return this.host.notify(DidChangeGraphConfigurationNotificationType, { + return this.host.notify(DidChangeGraphConfigurationNotification, { config: this.getComponentConfig(), }); } @@ -1389,12 +1611,12 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidFetch() { if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification(DidFetchNotificationType, this._ipcNotificationMap, this); + this.host.addPendingIpcNotification(DidFetchNotification, this._ipcNotificationMap, this); return false; } const lastFetched = await this.repository!.getLastFetched(); - return this.host.notify(DidFetchNotificationType, { + return this.host.notify(DidFetchNotification, { lastFetched: new Date(lastFetched), }); } @@ -1405,7 +1627,7 @@ export class GraphWebviewProvider implements WebviewProvider { const graph = this._graph; return this.host.notify( - DidChangeRowsNotificationType, + DidChangeRowsNotification, { rows: graph.rows, avatars: Object.fromEntries(graph.avatars), @@ -1428,7 +1650,7 @@ export class GraphWebviewProvider implements WebviewProvider { private async notifyDidChangeRowsStats(graph: GitGraph) { if (graph.rowsStats == null) return; - return this.host.notify(DidChangeRowsStatsNotificationType, { + return this.host.notify(DidChangeRowsStatsNotification, { rowsStats: Object.fromEntries(graph.rowsStats), rowsStatsLoading: graph.rowsStatsDeferred?.isLoaded != null ? !graph.rowsStatsDeferred.isLoaded() : false, }); @@ -1437,11 +1659,11 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidChangeWorkingTree() { if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification(DidChangeWorkingTreeNotificationType, this._ipcNotificationMap, this); + this.host.addPendingIpcNotification(DidChangeWorkingTreeNotification, this._ipcNotificationMap, this); return false; } - return this.host.notify(DidChangeWorkingTreeNotificationType, { + return this.host.notify(DidChangeWorkingTreeNotification, { stats: (await this.getWorkingTreeStats()) ?? { added: 0, deleted: 0, modified: 0 }, }); } @@ -1449,11 +1671,11 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidChangeSelection() { if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification(DidChangeSelectionNotificationType, this._ipcNotificationMap, this); + this.host.addPendingIpcNotification(DidChangeSelectionNotification, this._ipcNotificationMap, this); return false; } - return this.host.notify(DidChangeSelectionNotificationType, { + return this.host.notify(DidChangeSelectionNotification, { selection: this._selectedRows ?? {}, }); } @@ -1461,12 +1683,12 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidChangeSubscription() { if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification(DidChangeSubscriptionNotificationType, this._ipcNotificationMap, this); + this.host.addPendingIpcNotification(DidChangeSubscriptionNotification, this._ipcNotificationMap, this); return false; } const [access] = await this.getGraphAccess(); - return this.host.notify(DidChangeSubscriptionNotificationType, { + return this.host.notify(DidChangeSubscriptionNotification, { subscription: access.subscription.current, allowed: access.allowed !== false, }); @@ -1475,11 +1697,12 @@ export class GraphWebviewProvider implements WebviewProvider { @debug() private async notifyDidChangeState() { if (!this.host.ready || !this.host.visible) { - this.host.addPendingIpcNotification(DidChangeNotificationType, this._ipcNotificationMap, this); + this.host.addPendingIpcNotification(DidChangeNotification, this._ipcNotificationMap, this); return false; } - return this.host.notify(DidChangeNotificationType, { state: await this.getState() }); + this._notifyDidChangeStateDebounced?.cancel(); + return this.host.notify(DidChangeNotification, { state: await this.getState() }); } private ensureRepositorySubscriptions(force?: boolean) { @@ -1496,10 +1719,10 @@ export class GraphWebviewProvider implements WebviewProvider { this._repositoryEventsDisposable = Disposable.from( repo.onDidChange(this.onRepositoryChanged, this), - repo.startWatchingFileSystem(), + repo.watchFileSystem(1000), repo.onDidChangeFileSystem(this.onRepositoryFileSystemChanged, this), onDidChangeContext(key => { - if (key !== 'gitlens:hasConnectedRemotes') return; + if (key !== 'gitlens:repos:withHostingIntegrationsConnected') return; this.resetRefsMetadata(); this.updateRefsMetadata(); @@ -1755,6 +1978,7 @@ export class GraphWebviewProvider implements WebviewProvider { 'remoteBranches', 'stashes', 'tags', + 'pullRequests', ]; const enabledScrollMarkerTypes = configuration.get('graph.scrollMarkers.additionalTypes'); for (const type of configurableScrollMarkerTypes) { @@ -1775,11 +1999,12 @@ export class GraphWebviewProvider implements WebviewProvider { dateStyle: configuration.get('graph.dateStyle') ?? configuration.get('defaultDateStyle'), enabledRefMetadataTypes: this.getEnabledRefMetadataTypes(), dimMergeCommits: configuration.get('graph.dimMergeCommits'), - enableMultiSelection: false, + enableMultiSelection: this.container.prereleaseOrDebugging, highlightRowsOnRefHover: configuration.get('graph.highlightRowsOnRefHover'), minimap: configuration.get('graph.minimap.enabled'), minimapDataType: configuration.get('graph.minimap.dataType'), minimapMarkerTypes: this.getMinimapMarkerTypes(), + onlyFollowFirstParent: configuration.get('graph.onlyFollowFirstParent'), scrollRowPadding: configuration.get('graph.scrollRowPadding'), scrollMarkerTypes: this.getScrollMarkerTypes(), showGhostRefsOnRowHover: configuration.get('graph.showGhostRefsOnRowHover'), @@ -1837,7 +2062,7 @@ export class GraphWebviewProvider implements WebviewProvider { // If we don't have access, but the preview trial hasn't been started, auto-start it if (access.allowed === false && access.subscription.current.previewTrial == null) { - await this.container.subscription.startPreviewTrial(); + // await this.container.subscription.startPreviewTrial(); access = await this.container.git.access(PlusFeatures.Graph, this.repository?.path); } @@ -1879,13 +2104,13 @@ export class GraphWebviewProvider implements WebviewProvider { private async getState(deferRows?: boolean): Promise { if (this.container.git.repositoryCount === 0) { - return { webviewId: this.host.id, timestamp: Date.now(), allowed: true, repositories: [] }; + return { ...this.host.baseWebviewState, allowed: true, repositories: [] }; } if (this.repository == null) { this.repository = this.container.git.getBestRepositoryOrFirst(); if (this.repository == null) { - return { webviewId: this.host.id, timestamp: Date.now(), allowed: true, repositories: [] }; + return { ...this.host.baseWebviewState, allowed: true, repositories: [] }; } } @@ -1897,13 +2122,14 @@ export class GraphWebviewProvider implements WebviewProvider { // If we have a set of data refresh to the same set const limit = Math.max(defaultItemLimit, this._graph?.ids.size ?? defaultItemLimit); - const ref = this._selectedId == null || this._selectedId === uncommitted ? 'HEAD' : this._selectedId; + const selectedId = this._selectedId; + const ref = selectedId == null || selectedId === uncommitted ? 'HEAD' : selectedId; const columns = this.getColumns(); const columnSettings = this.getColumnSettings(columns); const dataPromise = this.container.git.getCommitsForGraph( - this.repository.path, + this.repository.uri, uri => this.host.asWebviewUri(uri), { include: { @@ -1930,7 +2156,9 @@ export class GraphWebviewProvider implements WebviewProvider { queueMicrotask(async () => { const data = await dataPromise; this.setGraph(data); - this.setSelectedRows(data.id); + if (selectedId !== uncommitted) { + this.setSelectedRows(data.id); + } void this.notifyDidChangeRefsVisibility(); void this.notifyDidChangeRows(true); @@ -1938,7 +2166,9 @@ export class GraphWebviewProvider implements WebviewProvider { } else { data = await dataPromise; this.setGraph(data); - this.setSelectedRows(data.id); + if (selectedId !== uncommitted) { + this.setSelectedRows(data.id); + } } const [accessResult, workingStatsResult, branchResult, lastFetchedResult] = await promises; @@ -1953,7 +2183,12 @@ export class GraphWebviewProvider implements WebviewProvider { if (branch.upstream != null) { branchState.upstream = branch.upstream.name; - const remote = await branch.getRemote(); + const [remoteResult, prResult] = await Promise.allSettled([ + branch.getRemote(), + branch.getAssociatedPullRequest(), + ]); + + const remote = getSettledValue(remoteResult); if (remote?.provider != null) { branchState.provider = { name: remote.provider.name, @@ -1961,12 +2196,16 @@ export class GraphWebviewProvider implements WebviewProvider { url: remote.provider.url({ type: RemoteResourceType.Repo }), }; } + + const pr = getSettledValue(prResult); + if (pr != null) { + branchState.pr = serializePullRequest(pr); + } } } return { - webviewId: this.host.id, - timestamp: Date.now(), + ...this.host.baseWebviewState, windowFocused: this.isWindowFocused, repositories: formatRepositories(this.container.git.openRepositories), selectedRepository: this.repository.path, @@ -2076,8 +2315,14 @@ export class GraphWebviewProvider implements WebviewProvider { void this.notifyDidChangeRefsVisibility(); } + private resetHoverCache() { + this._hoverCache.clear(); + this._hoverCancellation?.dispose(); + this._hoverCancellation = undefined; + } + private resetRefsMetadata(): null | undefined { - this._refsMetadata = getContext('gitlens:hasConnectedRemotes') ? undefined : null; + this._refsMetadata = getContext('gitlens:repos:withHostingIntegrationsConnected') ? undefined : null; return this._refsMetadata; } @@ -2105,6 +2350,7 @@ export class GraphWebviewProvider implements WebviewProvider { private setGraph(graph: GitGraph | undefined) { this._graph = graph; if (graph == null) { + this.resetHoverCache(); this.resetRefsMetadata(); this.resetSearchState(); } else { @@ -2125,32 +2371,45 @@ export class GraphWebviewProvider implements WebviewProvider { const remapped = updatedGraph.remappedIds?.get(lastId) ?? lastId; if (updatedGraph.ids.has(remapped)) { - queueMicrotask(() => void this.onSearch({ search: search.query, more: true })); + queueMicrotask(async () => { + try { + const results = await this.getSearchResults({ search: search.query, more: true }); + void this.host.notify(DidSearchNotification, results); + } catch (ex) { + if (ex instanceof CancellationError) return; + + void this.host.notify(DidSearchNotification, { + results: { + error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error', + }, + }); + } + }); } } else { debugger; } } - @debug() + @log() private fetch(item?: GraphItemContext) { const ref = item != null ? this.getGraphItemRef(item, 'branch') : undefined; void RepoActions.fetch(this.repository, ref); } - @debug() + @log() private pull(item?: GraphItemContext) { const ref = item != null ? this.getGraphItemRef(item, 'branch') : undefined; void RepoActions.pull(this.repository, ref); } - @debug() + @log() private push(item?: GraphItemContext) { const ref = item != null ? this.getGraphItemRef(item) : undefined; void RepoActions.push(this.repository, undefined, ref); } - @debug() + @log() private createBranch(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2158,7 +2417,7 @@ export class GraphWebviewProvider implements WebviewProvider { return BranchActions.create(ref.repoPath, ref); } - @debug() + @log() private deleteBranch(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2168,7 +2427,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private mergeBranchInto(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2178,17 +2437,24 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private openBranchOnRemote(item?: GraphItemContext, clipboard?: boolean) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; + let remote; + if (ref.remote) { + remote = getRemoteNameFromBranchName(ref.name); + } else if (ref.upstream != null) { + remote = getRemoteNameFromBranchName(ref.upstream.name); + } + return executeCommand(Commands.OpenOnRemote, { repoPath: ref.repoPath, resource: { type: RemoteResourceType.Branch, branch: ref.name, }, - remote: ref.upstream?.name, + remote: remote, clipboard: clipboard, }); } @@ -2196,7 +2462,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private publishBranch(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2206,7 +2472,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private rebase(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2214,7 +2480,7 @@ export class GraphWebviewProvider implements WebviewProvider { return RepoActions.rebase(ref.repoPath, ref); } - @debug() + @log() private rebaseToRemote(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2233,7 +2499,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private renameBranch(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2243,7 +2509,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private cherryPick(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2251,25 +2517,29 @@ export class GraphWebviewProvider implements WebviewProvider { return RepoActions.cherryPick(ref.repoPath, ref); } - @debug() + @log() private async copy(item?: GraphItemContext) { - const ref = this.getGraphItemRef(item); - if (ref != null) { - await env.clipboard.writeText( - ref.refType === 'revision' && ref.message ? `${ref.name}: ${ref.message}` : ref.name, - ); + let data; + + const { selection } = this.getGraphItemRefs(item); + if (selection.length) { + data = selection + .map(r => (r.refType === 'revision' && r.message ? `${r.name}: ${r.message.trim()}` : r.name)) + .join('\n'); } else if (isGraphItemTypedContext(item, 'contributor')) { const { name, email } = item.webviewItemValue; - await env.clipboard.writeText(`${name}${email ? ` <${email}>` : ''}`); + data = `${name}${email ? ` <${email}>` : ''}`; } else if (isGraphItemTypedContext(item, 'pullrequest')) { const { url } = item.webviewItemValue; - await env.clipboard.writeText(url); + data = url; } - return Promise.resolve(); + if (data != null) { + await env.clipboard.writeText(data); + } } - @debug() + @log() private copyMessage(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2281,7 +2551,7 @@ export class GraphWebviewProvider implements WebviewProvider { }); } - @debug() + @log() private async copySha(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2296,22 +2566,19 @@ export class GraphWebviewProvider implements WebviewProvider { }); } - @debug() + @log() private openInDetailsView(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); - if (this.host.isView()) { + if (this.host.isHost('view')) { return void showGraphDetailsView(ref, { preserveFocus: true, preserveVisibility: false }); } - return executeCommand(Commands.ShowInDetailsView, { - repoPath: ref.repoPath, - refs: [ref.ref], - }); + return executeCommand(Commands.ShowInDetailsView, { ref: ref }); } - @debug() + @log() private openSCM(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2319,22 +2586,19 @@ export class GraphWebviewProvider implements WebviewProvider { return executeCoreCommand('workbench.view.scm'); } - @debug() + @log() private openCommitOnRemote(item?: GraphItemContext, clipboard?: boolean) { - const ref = this.getGraphItemRef(item, 'revision'); - if (ref == null) return Promise.resolve(); + const { selection } = this.getGraphItemRefs(item, 'revision'); + if (selection == null) return Promise.resolve(); return executeCommand(Commands.OpenOnRemote, { - repoPath: ref.repoPath, - resource: { - type: RemoteResourceType.Commit, - sha: ref.ref, - }, + repoPath: selection[0].repoPath, + resource: selection.map(r => ({ type: RemoteResourceType.Commit, sha: r.ref })), clipboard: clipboard, }); } - @debug() + @log() private copyDeepLinkToBranch(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2344,7 +2608,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private copyDeepLinkToCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2352,7 +2616,7 @@ export class GraphWebviewProvider implements WebviewProvider { return executeCommand(Commands.CopyDeepLinkToCommit, { refOrRepoPath: ref }); } - @debug() + @log() private copyDeepLinkToRepo(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2367,7 +2631,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private copyDeepLinkToTag(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'tag')) { const { ref } = item.webviewItemValue; @@ -2377,7 +2641,18 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() + private async shareAsCloudPatch(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item, 'revision') ?? this.getGraphItemRef(item, 'stash'); + if (ref == null) return Promise.resolve(); + + return executeCommand(Commands.CreateCloudPatch, { + to: ref.ref, + repoPath: ref.repoPath, + }); + } + + @log() private resetCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2392,7 +2667,7 @@ export class GraphWebviewProvider implements WebviewProvider { ); } - @debug() + @log() private resetToCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2400,7 +2675,7 @@ export class GraphWebviewProvider implements WebviewProvider { return RepoActions.reset(ref.repoPath, ref); } - @debug() + @log() private resetToTip(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'branch'); if (ref == null) return Promise.resolve(); @@ -2411,7 +2686,7 @@ export class GraphWebviewProvider implements WebviewProvider { ); } - @debug() + @log() private revertCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2419,7 +2694,7 @@ export class GraphWebviewProvider implements WebviewProvider { return RepoActions.revert(ref.repoPath, ref); } - @debug() + @log() private switchTo(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2427,7 +2702,7 @@ export class GraphWebviewProvider implements WebviewProvider { return RepoActions.switchTo(ref.repoPath, ref); } - @debug() + @log() private hideRef(item?: GraphItemContext, options?: { group?: boolean; remote?: boolean }) { let refs; if (options?.group && isGraphItemRefGroupContext(item)) { @@ -2458,7 +2733,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private switchToAnother(item?: GraphItemContext | unknown) { const ref = this.getGraphItemRef(item); if (ref == null) return RepoActions.switchTo(this.repository?.path); @@ -2466,29 +2741,15 @@ export class GraphWebviewProvider implements WebviewProvider { return RepoActions.switchTo(ref.repoPath); } - @debug() + @log() private async undoCommit(item?: GraphItemContext) { - const ref = this.getGraphItemRef(item); + const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); - const repo = await this.container.git.getOrOpenScmRepository(ref.repoPath); - const commit = await repo?.getCommit('HEAD'); - - if (commit?.hash !== ref.ref) { - void window.showWarningMessage( - `Commit ${getReferenceLabel(ref, { - capitalize: true, - icon: false, - })} cannot be undone, because it is no longer the most recent commit.`, - ); - - return; - } - - return void executeCoreGitCommand('git.undoCommit', ref.repoPath); + await undoCommit(this.container, ref); } - @debug() + @log() private saveStash(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2496,7 +2757,7 @@ export class GraphWebviewProvider implements WebviewProvider { return StashActions.push(ref.repoPath); } - @debug() + @log() private applyStash(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'stash'); if (ref == null) return Promise.resolve(); @@ -2504,15 +2765,15 @@ export class GraphWebviewProvider implements WebviewProvider { return StashActions.apply(ref.repoPath, ref); } - @debug() + @log() private deleteStash(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'stash'); if (ref == null) return Promise.resolve(); - return StashActions.drop(ref.repoPath, ref); + return StashActions.drop(ref.repoPath, [ref]); } - @debug() + @log() private renameStash(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'stash'); if (ref == null) return Promise.resolve(); @@ -2520,7 +2781,7 @@ export class GraphWebviewProvider implements WebviewProvider { return StashActions.rename(ref.repoPath, ref); } - @debug() + @log() private async createTag(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2528,7 +2789,7 @@ export class GraphWebviewProvider implements WebviewProvider { return TagActions.create(ref.repoPath, ref); } - @debug() + @log() private deleteTag(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'tag')) { const { ref } = item.webviewItemValue; @@ -2538,7 +2799,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private async createWorktree(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2546,7 +2807,7 @@ export class GraphWebviewProvider implements WebviewProvider { return WorktreeActions.create(ref.repoPath, undefined, ref); } - @debug() + @log() private async createPullRequest(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2583,13 +2844,64 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() + private openPullRequest(item?: GraphItemContext) { + if (isGraphItemTypedContext(item, 'pullrequest')) { + const pr = item.webviewItemValue; + return executeActionCommand('openPullRequest', { + repoPath: pr.repoPath, + provider: { + id: pr.provider.id, + name: pr.provider.name, + domain: pr.provider.domain, + }, + pullRequest: { + id: pr.id, + url: pr.url, + }, + }); + } + + return Promise.resolve(); + } + + @log() + private async openPullRequestChanges(item?: GraphItemContext) { + if (isGraphItemTypedContext(item, 'pullrequest')) { + const pr = item.webviewItemValue; + if (pr.refs?.base != null && pr.refs.head != null) { + const refs = await getComparisonRefsForPullRequest(this.container, pr.repoPath, pr.refs); + return openComparisonChanges( + this.container, + { + repoPath: refs.repoPath, + lhs: refs.base.ref, + rhs: refs.head.ref, + }, + { title: `Changes in Pull Request #${pr.id}` }, + ); + } + } + + return Promise.resolve(); + } + + @log() + private async openPullRequestComparison(item?: GraphItemContext) { + if (isGraphItemTypedContext(item, 'pullrequest')) { + const pr = item.webviewItemValue; + if (pr.refs?.base != null && pr.refs.head != null) { + const refs = await getComparisonRefsForPullRequest(this.container, pr.repoPath, pr.refs); + return this.container.searchAndCompareView.compare(refs.repoPath, refs.head, refs.base); + } + } + + return Promise.resolve(); + } + + @log() private openPullRequestOnRemote(item?: GraphItemContext, clipboard?: boolean) { - if ( - isGraphItemContext(item) && - typeof item.webviewItemValue === 'object' && - item.webviewItemValue.type === 'pullrequest' - ) { + if (isGraphItemTypedContext(item, 'pullrequest')) { const { url } = item.webviewItemValue; return executeCommand(Commands.OpenPullRequestOnRemote, { pr: { url: url }, @@ -2600,7 +2912,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private async compareAncestryWithWorking(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2611,17 +2923,13 @@ export class GraphWebviewProvider implements WebviewProvider { const commonAncestor = await this.container.git.getMergeBase(ref.repoPath, branch.ref, ref.ref); if (commonAncestor == null) return undefined; - return this.container.searchAndCompareView.compare( - ref.repoPath, - { - ref: commonAncestor, - label: `ancestry with ${ref.ref} (${shortenRevision(commonAncestor)})`, - }, - '', - ); + return this.container.searchAndCompareView.compare(ref.repoPath, '', { + ref: commonAncestor, + label: `${branch.ref} (${shortenRevision(commonAncestor)})`, + }); } - @debug() + @log() private compareHeadWith(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2629,7 +2937,46 @@ export class GraphWebviewProvider implements WebviewProvider { return this.container.searchAndCompareView.compare(ref.repoPath, 'HEAD', ref.ref); } - @debug() + @log() + private async compareWithMergeBase(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item); + if (ref == null) return Promise.resolve(); + + const branch = await this.container.git.getBranch(ref.repoPath); + if (branch == null) return undefined; + + const commonAncestor = await this.container.git.getMergeBase(ref.repoPath, branch.ref, ref.ref); + if (commonAncestor == null) return undefined; + + return this.container.searchAndCompareView.compare(ref.repoPath, ref.ref, { + ref: commonAncestor, + label: `${branch.ref} (${shortenRevision(commonAncestor)})`, + }); + } + + @log() + private async openChangedFileDiffsWithMergeBase(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item); + if (ref == null) return Promise.resolve(); + + const branch = await this.container.git.getBranch(ref.repoPath); + if (branch == null) return undefined; + + const commonAncestor = await this.container.git.getMergeBase(ref.repoPath, branch.ref, ref.ref); + if (commonAncestor == null) return undefined; + + return openComparisonChanges( + this.container, + { repoPath: ref.repoPath, lhs: commonAncestor, rhs: ref.ref }, + { + title: `Changes between ${branch.ref} (${shortenRevision(commonAncestor)}) ${ + GlyphChars.ArrowLeftRightLong + } ${shortenRevision(ref.ref, { strings: { working: 'Working Tree' } })}`, + }, + ); + } + + @log() private compareWithUpstream(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2641,7 +2988,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private compareWorkingWith(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2649,7 +2996,14 @@ export class GraphWebviewProvider implements WebviewProvider { return this.container.searchAndCompareView.compare(ref.repoPath, '', ref.ref); } - @debug() + private copyWorkingChangesToWorktree(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item); + if (ref == null) return Promise.resolve(); + + return WorktreeActions.copyChangesToWorktree('working-tree', ref.repoPath); + } + + @log() private async openFiles(item?: GraphItemContext) { const commit = await this.getCommitFromGraphItemRef(item); if (commit == null) return; @@ -2657,23 +3011,29 @@ export class GraphWebviewProvider implements WebviewProvider { return openFiles(commit); } - @debug() - private async openAllChanges(item?: GraphItemContext) { + @log() + private async openAllChanges(item?: GraphItemContext, individually?: boolean) { const commit = await this.getCommitFromGraphItemRef(item); if (commit == null) return; + if (individually) { + return openAllChangesIndividually(commit); + } return openAllChanges(commit); } - @debug() - private async openAllChangesWithWorking(item?: GraphItemContext) { + @log() + private async openAllChangesWithWorking(item?: GraphItemContext, individually?: boolean) { const commit = await this.getCommitFromGraphItemRef(item); if (commit == null) return; + if (individually) { + return openAllChangesWithWorkingIndividually(commit); + } return openAllChangesWithWorking(commit); } - @debug() + @log() private async openRevisions(item?: GraphItemContext) { const commit = await this.getCommitFromGraphItemRef(item); if (commit == null) return; @@ -2681,15 +3041,15 @@ export class GraphWebviewProvider implements WebviewProvider { return openFilesAtRevision(commit); } - @debug() + @log() private async openOnlyChangedFiles(item?: GraphItemContext) { const commit = await this.getCommitFromGraphItemRef(item); if (commit == null) return; - return openOnlyChangedFilesForCommit(commit); + return openOnlyChangedFiles(commit); } - @debug() + @log() private addAuthor(item?: GraphItemContext) { if (isGraphItemTypedContext(item, 'contributor')) { const { repoPath, name, email, current } = item.webviewItemValue; @@ -2702,7 +3062,7 @@ export class GraphWebviewProvider implements WebviewProvider { return Promise.resolve(); } - @debug() + @log() private async toggleColumn(name: GraphColumnName, visible: boolean) { let columns = this.container.storage.getWorkspace('graph:columns'); let column = columns?.[name]; @@ -2722,7 +3082,7 @@ export class GraphWebviewProvider implements WebviewProvider { } } - @debug() + @log() private async toggleScrollMarker(type: GraphScrollMarkersAdditionalTypes, enabled: boolean) { let scrollMarkers = configuration.get('graph.scrollMarkers.additionalTypes'); let updated = false; @@ -2740,7 +3100,7 @@ export class GraphWebviewProvider implements WebviewProvider { } } - @debug() + @log() private async setColumnMode(name: GraphColumnName, mode?: string) { let columns = this.container.storage.getWorkspace('graph:columns'); let column = columns?.[name]; @@ -2804,8 +3164,61 @@ export class GraphWebviewProvider implements WebviewProvider { return isGraphItemRefContext(item) ? item.webviewItemValue.ref : undefined; } } + + private getGraphItemRefs( + item: GraphItemContext | unknown | undefined, + refType: 'branch', + ): GraphItemRefs; + private getGraphItemRefs( + item: GraphItemContext | unknown | undefined, + refType: 'revision', + ): GraphItemRefs; + private getGraphItemRefs( + item: GraphItemContext | unknown | undefined, + refType: 'stash', + ): GraphItemRefs; + private getGraphItemRefs( + item: GraphItemContext | unknown | undefined, + refType: 'tag', + ): GraphItemRefs; + private getGraphItemRefs(item: GraphItemContext | unknown | undefined): GraphItemRefs; + private getGraphItemRefs( + item: GraphItemContext | unknown, + refType?: 'branch' | 'revision' | 'stash' | 'tag', + ): GraphItemRefs { + if (item == null) return { active: undefined, selection: [] }; + + switch (refType) { + case 'branch': + if (!isGraphItemRefContext(item, 'branch') && !isGraphItemTypedContext(item, 'upstreamStatus')) + return { active: undefined, selection: [] }; + break; + case 'revision': + if (!isGraphItemRefContext(item, 'revision')) return { active: undefined, selection: [] }; + break; + case 'stash': + if (!isGraphItemRefContext(item, 'stash')) return { active: undefined, selection: [] }; + break; + case 'tag': + if (!isGraphItemRefContext(item, 'tag')) return { active: undefined, selection: [] }; + break; + default: + if (!isGraphItemRefContext(item)) return { active: undefined, selection: [] }; + } + + const selection = item.webviewItemsValues?.map(i => i.webviewItemValue.ref) ?? []; + if (!selection.length) { + selection.push(item.webviewItemValue.ref); + } + return { active: item.webviewItemValue.ref, selection: selection }; + } } +type GraphItemRefs = { + active: T | undefined; + selection: T[]; +}; + function formatRepositories(repositories: Repository[]): GraphRepository[] { if (repositories.length === 0) return []; diff --git a/src/plus/webviews/graph/protocol.ts b/src/plus/webviews/graph/protocol.ts index cfe1144dd0b03..04b43c88567f9 100644 --- a/src/plus/webviews/graph/protocol.ts +++ b/src/plus/webviews/graph/protocol.ts @@ -23,10 +23,10 @@ import type { WorkDirStats, } from '@gitkraken/gitkraken-components'; import type { Config, DateStyle } from '../../../config'; -import type { WebviewIds, WebviewViewIds } from '../../../constants'; import type { RepositoryVisibility } from '../../../git/gitProvider'; import type { GitTrackingState } from '../../../git/models/branch'; import type { GitGraphRowType } from '../../../git/models/graph'; +import type { PullRequestRefs, PullRequestShape } from '../../../git/models/pullRequest'; import type { GitBranchReference, GitReference, @@ -34,14 +34,18 @@ import type { GitStashReference, GitTagReference, } from '../../../git/models/reference'; +import type { ProviderReference } from '../../../git/models/remoteProvider'; import type { GitSearchResultData, SearchQuery } from '../../../git/search'; -import type { Subscription } from '../../../subscription'; import type { DateTimeFormat } from '../../../system/date'; import type { WebviewItemContext, WebviewItemGroupContext } from '../../../system/webview'; -import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcCommand, IpcNotification, IpcRequest } from '../../../webviews/protocol'; +import type { Subscription } from '../../gk/account/subscription'; export type { GraphRefType } from '@gitkraken/gitkraken-components'; +export const scope: IpcScope = 'graph'; + export type GraphColumnsSettings = Record; export type GraphSelectedRows = Record; export type GraphAvatars = Record; @@ -64,6 +68,7 @@ export type GraphScrollMarkerTypes = | 'head' | 'highlights' | 'localBranches' + | 'pullRequests' | 'remoteBranches' | 'stashes' | 'tags' @@ -74,6 +79,7 @@ export type GraphMinimapMarkerTypes = | 'head' | 'highlights' | 'localBranches' + | 'pullRequests' | 'remoteBranches' | 'stashes' | 'tags' @@ -81,10 +87,7 @@ export type GraphMinimapMarkerTypes = export const supportedRefMetadataTypes: GraphRefMetadataType[] = ['upstream', 'pullRequest', 'issue']; -export interface State { - webviewId: WebviewIds | WebviewViewIds; - timestamp: number; - +export interface State extends WebviewState { windowFocused?: boolean; repositories?: GraphRepository[]; selectedRepository?: string; @@ -130,6 +133,7 @@ export interface BranchState extends GitTrackingState { icon?: string; url?: string; }; + pr?: PullRequestShape; } export type GraphWorkingTreeStats = WorkDirStats; @@ -177,6 +181,7 @@ export interface GraphComponentConfig { minimap?: boolean; minimapDataType?: Config['graph']['minimap']['dataType']; minimapMarkerTypes?: GraphMinimapMarkerTypes[]; + onlyFollowFirstParent?: boolean; scrollMarkerTypes?: GraphScrollMarkerTypes[]; scrollRowPadding?: number; showGhostRefsOnRowHover?: boolean; @@ -206,18 +211,13 @@ export type InternalNotificationType = 'didChangeTheme'; export type UpdateStateCallback = ( state: State, - type?: IpcNotificationType | InternalNotificationType, + type?: IpcNotification | InternalNotificationType, themingChanged?: boolean, ) => void; -// Commands - -export const ChooseRepositoryCommandType = new IpcCommandType('graph/chooseRepository'); +// COMMANDS -export interface DimMergeCommitsParams { - dim: boolean; -} -export const DimMergeCommitsCommandType = new IpcCommandType('graph/dimMergeCommits'); +export const ChooseRepositoryCommand = new IpcCommand(scope, 'chooseRepository'); export type DoubleClickedParams = | { @@ -230,117 +230,153 @@ export type DoubleClickedParams = row: { id: string; type: GitGraphRowType }; preserveFocus?: boolean; }; -export const DoubleClickedCommandType = new IpcCommandType('graph/dblclick'); - -export interface EnsureRowParams { - id: string; - select?: boolean; -} -export const EnsureRowCommandType = new IpcCommandType('graph/rows/ensure'); +export const DoubleClickedCommandType = new IpcCommand(scope, 'dblclick'); export interface GetMissingAvatarsParams { emails: GraphAvatars; } -export const GetMissingAvatarsCommandType = new IpcCommandType('graph/avatars/get'); +export const GetMissingAvatarsCommand = new IpcCommand(scope, 'avatars/get'); export interface GetMissingRefsMetadataParams { metadata: GraphMissingRefsMetadata; } -export const GetMissingRefsMetadataCommandType = new IpcCommandType( - 'graph/refs/metadata/get', -); +export const GetMissingRefsMetadataCommand = new IpcCommand(scope, 'refs/metadata/get'); export interface GetMoreRowsParams { id?: string; } -export const GetMoreRowsCommandType = new IpcCommandType('graph/rows/get'); +export const GetMoreRowsCommand = new IpcCommand(scope, 'rows/get'); -export interface SearchParams { - search?: SearchQuery; - limit?: number; - more?: boolean; +export interface OpenPullRequestDetailsParams { + id?: string; } -export const SearchCommandType = new IpcCommandType('graph/search'); +export const OpenPullRequestDetailsCommand = new IpcCommand( + scope, + 'pullRequest/openDetails', +); export interface SearchOpenInViewParams { search: SearchQuery; } -export const SearchOpenInViewCommandType = new IpcCommandType('graph/search/openInView'); +export const SearchOpenInViewCommand = new IpcCommand(scope, 'search/openInView'); export interface UpdateColumnsParams { config: GraphColumnsConfig; } -export const UpdateColumnsCommandType = new IpcCommandType('graph/columns/update'); +export const UpdateColumnsCommand = new IpcCommand(scope, 'columns/update'); export interface UpdateRefsVisibilityParams { refs: GraphExcludedRef[]; visible: boolean; } -export const UpdateRefsVisibilityCommandType = new IpcCommandType( - 'graph/refs/update/visibility', -); +export const UpdateRefsVisibilityCommand = new IpcCommand(scope, 'refs/update/visibility'); export interface UpdateExcludeTypeParams { key: keyof GraphExcludeTypes; value: boolean; } -export const UpdateExcludeTypeCommandType = new IpcCommandType( - 'graph/fitlers/update/excludeType', -); +export const UpdateExcludeTypeCommand = new IpcCommand(scope, 'fitlers/update/excludeType'); export interface UpdateGraphConfigurationParams { changes: { [key in keyof GraphComponentConfig]?: GraphComponentConfig[key] }; } -export const UpdateGraphConfigurationCommandType = new IpcCommandType( - 'graph/configuration/update', +export const UpdateGraphConfigurationCommand = new IpcCommand( + scope, + 'configuration/update', ); export interface UpdateIncludeOnlyRefsParams { refs?: GraphIncludeOnlyRef[]; } -export const UpdateIncludeOnlyRefsCommandType = new IpcCommandType( - 'graph/fitlers/update/includeOnlyRefs', +export const UpdateIncludeOnlyRefsCommand = new IpcCommand( + scope, + 'fitlers/update/includeOnlyRefs', ); export interface UpdateSelectionParams { selection: { id: string; type: GitGraphRowType }[]; } -export const UpdateSelectionCommandType = new IpcCommandType('graph/selection/update'); +export const UpdateSelectionCommand = new IpcCommand(scope, 'selection/update'); -// Notifications +// REQUESTS + +export interface EnsureRowParams { + id: string; + select?: boolean; +} +export interface DidEnsureRowParams { + id?: string; // `undefined` if the row was not found + remapped?: string; +} +export const EnsureRowRequest = new IpcRequest(scope, 'rows/ensure'); + +export type GetRowHoverParams = { + type: GitGraphRowType; + id: string; +}; + +export interface DidGetRowHoverParams { + id: string; + markdown?: string; + cancelled: boolean; +} + +export const GetRowHoverRequest = new IpcRequest(scope, 'row/hover/get'); + +export interface SearchParams { + search?: SearchQuery; + limit?: number; + more?: boolean; +} +export interface GraphSearchResults { + ids?: Record; + count: number; + paging?: { hasMore: boolean }; +} +export interface GraphSearchResultsError { + error: string; +} +export interface DidSearchParams { + results: GraphSearchResults | GraphSearchResultsError | undefined; + selectedRows?: GraphSelectedRows; +} +export const SearchRequest = new IpcRequest(scope, 'search'); + +// NOTIFICATIONS export interface DidChangeParams { state: State; } -export const DidChangeNotificationType = new IpcNotificationType('graph/didChange', true); +export const DidChangeNotification = new IpcNotification(scope, 'didChange', true, true); export interface DidChangeGraphConfigurationParams { config: GraphComponentConfig; } -export const DidChangeGraphConfigurationNotificationType = new IpcNotificationType( - 'graph/configuration/didChange', +export const DidChangeGraphConfigurationNotification = new IpcNotification( + scope, + 'configuration/didChange', ); export interface DidChangeSubscriptionParams { subscription: Subscription; allowed: boolean; } -export const DidChangeSubscriptionNotificationType = new IpcNotificationType( - 'graph/subscription/didChange', +export const DidChangeSubscriptionNotification = new IpcNotification( + scope, + 'subscription/didChange', ); export interface DidChangeAvatarsParams { avatars: GraphAvatars; } -export const DidChangeAvatarsNotificationType = new IpcNotificationType( - 'graph/avatars/didChange', -); +export const DidChangeAvatarsNotification = new IpcNotification(scope, 'avatars/didChange'); export interface DidChangeRefsMetadataParams { metadata: GraphRefsMetadata | null | undefined; } -export const DidChangeRefsMetadataNotificationType = new IpcNotificationType( - 'graph/refs/didChangeMetadata', +export const DidChangeRefsMetadataNotification = new IpcNotification( + scope, + 'refs/didChangeMetadata', ); export interface DidChangeColumnsParams { @@ -348,27 +384,14 @@ export interface DidChangeColumnsParams { context?: string; settingsContext?: string; } -export const DidChangeColumnsNotificationType = new IpcNotificationType( - 'graph/columns/didChange', -); +export const DidChangeColumnsNotification = new IpcNotification(scope, 'columns/didChange'); export interface DidChangeScrollMarkersParams { context?: string; } -export const DidChangeScrollMarkersNotificationType = new IpcNotificationType( - 'graph/scrollMarkers/didChange', -); - -export interface DidChangeFocusParams { - focused: boolean; -} -export const DidChangeFocusNotificationType = new IpcNotificationType('graph/focus/didChange'); - -export interface DidChangeWindowFocusParams { - focused: boolean; -} -export const DidChangeWindowFocusNotificationType = new IpcNotificationType( - 'graph/window/focus/didChange', +export const DidChangeScrollMarkersNotification = new IpcNotification( + scope, + 'scrollMarkers/didChange', ); export interface DidChangeRefsVisibilityParams { @@ -376,8 +399,9 @@ export interface DidChangeRefsVisibilityParams { excludeTypes?: GraphExcludeTypes; includeOnlyRefs?: GraphIncludeOnlyRefs; } -export const DidChangeRefsVisibilityNotificationType = new IpcNotificationType( - 'graph/refs/didChangeVisibility', +export const DidChangeRefsVisibilityNotification = new IpcNotification( + scope, + 'refs/didChangeVisibility', ); export interface DidChangeRowsParams { @@ -390,56 +414,44 @@ export interface DidChangeRowsParams { rowsStatsLoading: boolean; selectedRows?: GraphSelectedRows; } -export const DidChangeRowsNotificationType = new IpcNotificationType('graph/rows/didChange'); +export const DidChangeRowsNotification = new IpcNotification( + scope, + 'rows/didChange', + undefined, + true, +); export interface DidChangeRowsStatsParams { rowsStats: Record; rowsStatsLoading: boolean; } -export const DidChangeRowsStatsNotificationType = new IpcNotificationType( - 'graph/rows/stats/didChange', +export const DidChangeRowsStatsNotification = new IpcNotification( + scope, + 'rows/stats/didChange', ); export interface DidChangeSelectionParams { selection: GraphSelectedRows; } -export const DidChangeSelectionNotificationType = new IpcNotificationType( - 'graph/selection/didChange', +export const DidChangeSelectionNotification = new IpcNotification( + scope, + 'selection/didChange', ); export interface DidChangeWorkingTreeParams { stats: WorkDirStats; } -export const DidChangeWorkingTreeNotificationType = new IpcNotificationType( - 'graph/workingTree/didChange', +export const DidChangeWorkingTreeNotification = new IpcNotification( + scope, + 'workingTree/didChange', ); -export interface DidEnsureRowParams { - id?: string; // `undefined` if the row was not found - remapped?: string; -} -export const DidEnsureRowNotificationType = new IpcNotificationType('graph/rows/didEnsure'); - -export interface GraphSearchResults { - ids?: Record; - count: number; - paging?: { hasMore: boolean }; -} - -export interface GraphSearchResultsError { - error: string; -} - -export interface DidSearchParams { - results: GraphSearchResults | GraphSearchResultsError | undefined; - selectedRows?: GraphSelectedRows; -} -export const DidSearchNotificationType = new IpcNotificationType('graph/didSearch'); +export const DidSearchNotification = new IpcNotification(scope, 'didSearch'); export interface DidFetchParams { lastFetched: Date; } -export const DidFetchNotificationType = new IpcNotificationType('graph/didFetch'); +export const DidFetchNotification = new IpcNotification(scope, 'didFetch'); export interface ShowInCommitGraphCommandArgs { ref: GitReference; @@ -484,6 +496,9 @@ export interface GraphPullRequestContextValue { type: 'pullrequest'; id: string; url: string; + repoPath: string; + refs?: PullRequestRefs; + provider: ProviderReference; } export interface GraphBranchContextValue { diff --git a/src/plus/webviews/graph/registration.ts b/src/plus/webviews/graph/registration.ts index ebc4b9b36c8ef..226019583057c 100644 --- a/src/plus/webviews/graph/registration.ts +++ b/src/plus/webviews/graph/registration.ts @@ -1,21 +1,31 @@ import { Disposable, ViewColumn } from 'vscode'; +import { isScm } from '../../../commands/base'; import { Commands } from '../../../constants'; import type { Container } from '../../../container'; +import type { GitReference } from '../../../git/models/reference'; import type { Repository } from '../../../git/models/repository'; import { executeCommand, executeCoreCommand, registerCommand } from '../../../system/command'; import { configuration } from '../../../system/configuration'; import { getContext } from '../../../system/context'; +import { ViewNode } from '../../../views/nodes/abstract/viewNode'; import type { BranchNode } from '../../../views/nodes/branchNode'; import type { CommitFileNode } from '../../../views/nodes/commitFileNode'; import type { CommitNode } from '../../../views/nodes/commitNode'; +import { PullRequestNode } from '../../../views/nodes/pullRequestNode'; import type { StashNode } from '../../../views/nodes/stashNode'; import type { TagNode } from '../../../views/nodes/tagNode'; -import type { WebviewPanelProxy, WebviewsController } from '../../../webviews/webviewsController'; +import type { + WebviewPanelShowCommandArgs, + WebviewPanelsProxy, + WebviewsController, +} from '../../../webviews/webviewsController'; import type { ShowInCommitGraphCommandArgs, State } from './protocol'; +export type GraphWebviewShowingArgs = [Repository | { ref: GitReference }]; + export function registerGraphWebviewPanel(controller: WebviewsController) { - return controller.registerWebviewPanel( - Commands.ShowGraphPage, + return controller.registerWebviewPanel( + { id: Commands.ShowGraphPage, options: { preserveInstance: true } }, { id: 'gitlens.graph', fileName: 'graph.html', @@ -29,16 +39,17 @@ export function registerGraphWebviewPanel(controller: WebviewsController) { retainContextWhenHidden: true, enableFindWidget: false, }, + allowMultipleInstances: configuration.get('graph.allowMultiple'), }, async (container, host) => { - const { GraphWebviewProvider } = await import(/* webpackChunkName: "graph" */ './graphWebview'); + const { GraphWebviewProvider } = await import(/* webpackChunkName: "webview-graph" */ './graphWebview'); return new GraphWebviewProvider(container, host); }, ); } export function registerGraphWebviewView(controller: WebviewsController) { - return controller.registerWebviewView( + return controller.registerWebviewView( { id: 'gitlens.views.graph', fileName: 'graph.html', @@ -51,24 +62,51 @@ export function registerGraphWebviewView(controller: WebviewsController) { }, }, async (container, host) => { - const { GraphWebviewProvider } = await import(/* webpackChunkName: "graph" */ './graphWebview'); + const { GraphWebviewProvider } = await import(/* webpackChunkName: "webview-graph" */ './graphWebview'); return new GraphWebviewProvider(container, host); }, ); } -export function registerGraphWebviewCommands(container: Container, webview: WebviewPanelProxy) { +export function registerGraphWebviewCommands( + container: Container, + panels: WebviewPanelsProxy, +) { return Disposable.from( - registerCommand(Commands.ShowGraph, (...args: any[]) => - configuration.get('graph.layout') === 'panel' - ? executeCommand(Commands.ShowGraphView, ...args) - : executeCommand(Commands.ShowGraphPage, ...args), - ), - registerCommand('gitlens.graph.switchToEditorLayout', async () => { + registerCommand(Commands.ShowGraph, (...args: unknown[]) => { + const [arg] = args; + + let showInGraphArg; + if (isScm(arg)) { + if (arg.rootUri != null) { + const repo = container.git.getRepository(arg.rootUri); + if (repo != null) { + showInGraphArg = repo; + } + } + args = []; + } else if (arg instanceof ViewNode) { + if (arg.is('repo-folder')) { + showInGraphArg = arg.repo; + } + args = []; + } + + if (showInGraphArg != null) { + return executeCommand(Commands.ShowInCommitGraph, showInGraphArg); + } + + if (configuration.get('graph.layout') === 'panel') { + return executeCommand(Commands.ShowGraphView, ...args); + } + + return executeCommand(Commands.ShowGraphPage, undefined, ...args); + }), + registerCommand(`${panels.id}.switchToEditorLayout`, async () => { await configuration.updateEffective('graph.layout', 'editor'); - queueMicrotask(() => void executeCommand(Commands.ShowGraphPage)); + queueMicrotask(() => void executeCommand(Commands.ShowGraphPage)); }), - registerCommand('gitlens.graph.switchToPanelLayout', async () => { + registerCommand(`${panels.id}.switchToPanelLayout`, async () => { await configuration.updateEffective('graph.layout', 'panel'); queueMicrotask(async () => { await executeCoreCommand('gitlens.views.graph.resetViewLocation'); @@ -100,14 +138,29 @@ export function registerGraphWebviewCommands(container: Container, webview: Webv | BranchNode | CommitNode | CommitFileNode + | PullRequestNode | StashNode | TagNode, ) => { + if (args instanceof PullRequestNode) { + if (args.ref == null) return; + + args = { ref: args.ref }; + } + const preserveFocus = 'preserveFocus' in args ? args.preserveFocus ?? false : false; if (configuration.get('graph.layout') === 'panel') { + if (!container.graphView.visible) { + const instance = panels.getBestInstance({ preserveFocus: preserveFocus }, args); + if (instance != null) { + void instance.show({ preserveFocus: preserveFocus }, args); + return; + } + } + void container.graphView.show({ preserveFocus: preserveFocus }, args); } else { - void webview.show({ preserveFocus: preserveFocus }, args); + void panels.show({ preserveFocus: preserveFocus }, args); } }, ), @@ -120,12 +173,24 @@ export function registerGraphWebviewCommands(container: Container, webview: Webv | BranchNode | CommitNode | CommitFileNode + | PullRequestNode | StashNode | TagNode, ) => { + if (args instanceof PullRequestNode) { + if (args.ref == null) return; + + args = { ref: args.ref }; + } + const preserveFocus = 'preserveFocus' in args ? args.preserveFocus ?? false : false; void container.graphView.show({ preserveFocus: preserveFocus }, args); }, ), + registerCommand(`${panels.id}.refresh`, () => void panels.getActiveInstance()?.refresh(true)), + registerCommand( + `${panels.id}.split`, + () => void panels.splitActiveInstance({ preserveInstance: false, column: ViewColumn.Beside }), + ), ); } diff --git a/src/plus/webviews/graph/statusbar.ts b/src/plus/webviews/graph/statusbar.ts index 91e38ec71d46e..b3dfb435ec55c 100644 --- a/src/plus/webviews/graph/statusbar.ts +++ b/src/plus/webviews/graph/statusbar.ts @@ -5,8 +5,8 @@ import type { Container } from '../../../container'; import { configuration } from '../../../system/configuration'; import { getContext, onDidChangeContext } from '../../../system/context'; import { once } from '../../../system/function'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; -import { arePlusFeaturesEnabled } from '../../subscription/utils'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; +import { arePlusFeaturesEnabled } from '../../gk/utils'; export class GraphStatusBarController implements Disposable { private readonly _disposable: Disposable; @@ -44,11 +44,11 @@ export class GraphStatusBarController implements Disposable { configuration.get('graph.statusBar.enabled') && getContext('gitlens:enabled') && arePlusFeaturesEnabled(); if (enabled) { if (this._statusBarItem == null) { - this._statusBarItem = window.createStatusBarItem('gitlens.graph', StatusBarAlignment.Left, 10000 - 3); + this._statusBarItem = window.createStatusBarItem('gitlens.graph', StatusBarAlignment.Left, 10000 - 2); this._statusBarItem.name = 'GitLens Commit Graph'; this._statusBarItem.command = Commands.ShowGraph; this._statusBarItem.text = '$(gitlens-graph)'; - this._statusBarItem.tooltip = new MarkdownString('Visualize commits on the Commit Graph ✨'); + this._statusBarItem.tooltip = new MarkdownString('Visualize commits on the Commit Graph'); this._statusBarItem.accessibilityInformation = { label: `Show the GitLens Commit Graph`, }; diff --git a/src/plus/webviews/patchDetails/patchDetailsWebview.ts b/src/plus/webviews/patchDetails/patchDetailsWebview.ts new file mode 100644 index 0000000000000..d4efe6c42881a --- /dev/null +++ b/src/plus/webviews/patchDetails/patchDetailsWebview.ts @@ -0,0 +1,1640 @@ +import type { ConfigurationChangeEvent } from 'vscode'; +import { Disposable, env, Uri, window } from 'vscode'; +import { extractDraftMessage } from '../../../ai/aiProviderService'; +import { getAvatarUri } from '../../../avatars'; +import type { ContextKeys, Sources } from '../../../constants'; +import { Commands, GlyphChars, previewBadge } from '../../../constants'; +import type { Container } from '../../../container'; +import { CancellationError } from '../../../errors'; +import { openChanges, openChangesWithWorking, openFile } from '../../../git/actions/commit'; +import { ApplyPatchCommitError, ApplyPatchCommitErrorReason } from '../../../git/errors'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; +import type { GitCommit } from '../../../git/models/commit'; +import { uncommitted, uncommittedStaged } from '../../../git/models/constants'; +import { GitFileChange } from '../../../git/models/file'; +import type { PatchRevisionRange } from '../../../git/models/patch'; +import { createReference, shortenRevision } from '../../../git/models/reference'; +import type { Repository } from '../../../git/models/repository'; +import { isRepository } from '../../../git/models/repository'; +import type { + CreateDraftChange, + Draft, + DraftArchiveReason, + DraftPatch, + DraftPatchFileChange, + DraftPendingUser, + DraftUser, + DraftVisibility, + LocalDraft, +} from '../../../gk/models/drafts'; +import type { GkRepositoryId } from '../../../gk/models/repositoryIdentities'; +import { showNewOrSelectBranchPicker } from '../../../quickpicks/branchPicker'; +import { showOrganizationMembersPicker } from '../../../quickpicks/organizationMembersPicker'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../../../quickpicks/referencePicker'; +import { executeCommand, registerCommand } from '../../../system/command'; +import { configuration } from '../../../system/configuration'; +import { getContext, onDidChangeContext, setContext } from '../../../system/context'; +import { gate } from '../../../system/decorators/gate'; +import { debug } from '../../../system/decorators/log'; +import type { Deferrable } from '../../../system/function'; +import { debounce } from '../../../system/function'; +import { find, some } from '../../../system/iterable'; +import { basename } from '../../../system/path'; +import type { Serialized } from '../../../system/serialize'; +import { serialize } from '../../../system/serialize'; +import { showInspectView } from '../../../webviews/commitDetails/actions'; +import type { IpcCallMessageType, IpcMessage } from '../../../webviews/protocol'; +import type { WebviewHost, WebviewProvider } from '../../../webviews/webviewProvider'; +import type { WebviewShowOptions } from '../../../webviews/webviewsController'; +import { showPatchesView } from '../../drafts/actions'; +import { getDraftEntityIdentifier } from '../../drafts/draftsService'; +import type { OrganizationMember } from '../../gk/account/organization'; +import { confirmDraftStorage, ensureAccount } from '../../utils'; +import type { ShowInCommitGraphCommandArgs } from '../graph/protocol'; +import type { + ApplyPatchParams, + Change, + CreateDraft, + CreatePatchParams, + DidExplainParams, + DidGenerateParams, + DraftPatchCheckedParams, + DraftUserSelection, + ExecuteFileActionParams, + Mode, + Preferences, + State, + SwitchModeParams, + UpdateablePreferences, + UpdateCreatePatchMetadataParams, + UpdateCreatePatchRepositoryCheckedStateParams, + UpdatePatchDetailsMetadataParams, + UpdatePatchUserSelection, +} from './protocol'; +import { + ApplyPatchCommand, + ArchiveDraftCommand, + CopyCloudLinkCommand, + CreatePatchCommand, + DidChangeCreateNotification, + DidChangeDraftNotification, + DidChangeNotification, + DidChangePatchRepositoryNotification, + DidChangePreferencesNotification, + DraftPatchCheckedCommand, + ExplainRequest, + GenerateRequest, + OpenFileCommand, + OpenFileComparePreviousCommand, + OpenFileCompareWorkingCommand, + OpenInCommitGraphCommand, + SwitchModeCommand, + UpdateCreatePatchMetadataCommand, + UpdateCreatePatchRepositoryCheckedStateCommand, + UpdatePatchDetailsMetadataCommand, + UpdatePatchDetailsPermissionsCommand, + UpdatePatchUsersCommand, + UpdatePatchUserSelectionCommand, + UpdatePreferencesCommand, +} from './protocol'; +import type { PatchDetailsWebviewShowingArgs } from './registration'; +import type { RepositoryChangeset } from './repositoryChangeset'; +import { RepositoryRefChangeset, RepositoryWipChangeset } from './repositoryChangeset'; + +interface DraftUserState { + users: DraftUser[]; + selections: DraftUserSelection[]; +} +interface Context { + mode: Mode; + draft: LocalDraft | Draft | undefined; + draftGkDevUrl: string | undefined; + draftVisibiltyState: DraftVisibility | undefined; + draftUserState: DraftUserState | undefined; + create: + | { + title?: string; + description?: string; + changes: Map; + showingAllRepos: boolean; + visibility: DraftVisibility; + userSelections?: DraftUserSelection[]; + } + | undefined; + preferences: Preferences; + orgSettings: State['orgSettings']; +} + +export class PatchDetailsWebviewProvider + implements WebviewProvider, PatchDetailsWebviewShowingArgs> +{ + private _context: Context; + private readonly _disposable: Disposable; + + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + ) { + this._context = { + mode: 'create', + draft: undefined, + draftGkDevUrl: undefined, + draftUserState: undefined, + draftVisibiltyState: undefined, + create: undefined, + preferences: this.getPreferences(), + orgSettings: this.getOrgSettings(), + }; + + this.setHostTitle(); + this.host.description = previewBadge; + + this._disposable = Disposable.from( + configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), + container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + onDidChangeContext(this.onContextChanged, this), + ); + } + + dispose() { + this._disposable.dispose(); + } + + canReuseInstance(...args: PatchDetailsWebviewShowingArgs): boolean | undefined { + const [arg] = args; + if (arg?.mode === 'view' && arg.draft != null) { + switch (arg.draft.draftType) { + case 'cloud': + return ( + this._context.draft?.draftType === arg.draft.draftType && + this._context.draft.id === arg.draft.id + ); + + case 'local': + return ( + this._context.draft?.draftType === arg.draft.draftType && + this._context.draft.patch.contents === arg.draft.patch?.contents + ); + } + } + + return false; + } + + async onShowing( + _loading: boolean, + options: WebviewShowOptions, + ...args: PatchDetailsWebviewShowingArgs + ): Promise { + const [arg] = args; + if (arg?.mode === 'view' && arg.draft != null) { + await this.updateViewDraftState(arg.draft); + void this.trackViewDraft(this._context.draft, arg.source); + } else { + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; + } + + const create = arg?.mode === 'create' && arg.create != null ? arg.create : { repositories: undefined }; + this.updateCreateDraftState(create); + } + + if (options?.preserveVisibility && !this.host.visible) return false; + + return true; + } + + includeBootstrap(): Promise> { + return this.getState(this._context); + } + + registerCommands(): Disposable[] { + const commands: Disposable[] = []; + + if (this.host.isHost('view')) { + commands.push( + registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)), + registerCommand(`${this.host.id}.close`, () => this.closeView()), + ); + } + + return commands; + } + + onMessageReceived(e: IpcMessage) { + switch (true) { + case ApplyPatchCommand.is(e): + void this.applyPatch(e.params); + break; + + case CopyCloudLinkCommand.is(e): + this.copyCloudLink(); + break; + + // case CreateFromLocalPatchCommandType.method: + // this.shareLocalPatch(); + // break; + + case CreatePatchCommand.is(e): + void this.createDraft(e.params); + break; + + case ExplainRequest.is(e): + void this.explainRequest(ExplainRequest, e); + break; + + case GenerateRequest.is(e): + void this.generateRequest(GenerateRequest, e); + break; + + case OpenFileComparePreviousCommand.is(e): + void this.openFileComparisonWithPrevious(e.params); + break; + + case OpenFileCompareWorkingCommand.is(e): + void this.openFileComparisonWithWorking(e.params); + break; + + case OpenFileCommand.is(e): + void this.openFile(e.params); + break; + + case OpenInCommitGraphCommand.is(e): + void executeCommand(Commands.ShowInCommitGraph, { + ref: createReference(e.params.ref, e.params.repoPath, { refType: 'revision' }), + }); + break; + + // case SelectPatchBaseCommandType.is(e): + // void this.selectPatchBase(); + // break; + + // case SelectPatchRepoCommandType.is(e): + // void this.selectPatchRepo(); + // break; + + case SwitchModeCommand.is(e): + this.switchMode(e.params); + break; + + case UpdateCreatePatchMetadataCommand.is(e): + this.updateCreateMetadata(e.params); + break; + + case UpdatePatchDetailsMetadataCommand.is(e): + this.updateDraftMetadata(e.params); + break; + + case UpdatePatchDetailsPermissionsCommand.is(e): + void this.updateDraftPermissions(); + break; + + case UpdateCreatePatchRepositoryCheckedStateCommand.is(e): + this.updateCreateCheckedState(e.params); + break; + + case UpdatePreferencesCommand.is(e): + this.updatePreferences(e.params); + break; + + case DraftPatchCheckedCommand.is(e): + this.onPatchChecked(e.params); + break; + + case UpdatePatchUsersCommand.is(e): + void this.onInviteUsers(); + break; + + case UpdatePatchUserSelectionCommand.is(e): + this.onUpdatePatchUserSelection(e.params); + break; + + case ArchiveDraftCommand.is(e): + void this.archiveDraft(e.params.reason); + break; + } + } + + onRefresh(): void { + this.updateState(true); + } + + onReloaded(): void { + this.updateState(true); + } + + onVisibilityChanged(visible: boolean) { + // TODO@eamodio ugly -- clean this up later + this._context.create?.changes.forEach(c => (visible ? c.resume() : c.suspend())); + + if (visible) { + this.host.sendPendingIpcNotifications(); + } + } + + private onAnyConfigurationChanged(e: ConfigurationChangeEvent) { + if ( + configuration.changed(e, ['defaultDateFormat', 'views.patchDetails.files', 'views.patchDetails.avatars']) || + configuration.changedCore(e, 'workbench.tree.renderIndentGuides') || + configuration.changedCore(e, 'workbench.tree.indent') + ) { + this._context.preferences = { ...this._context.preferences, ...this.getPreferences() }; + this.updateState(); + } + } + + private getPreferences(): Preferences { + return { + avatars: configuration.get('views.patchDetails.avatars'), + dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma', + files: configuration.get('views.patchDetails.files'), + indentGuides: configuration.getCore('workbench.tree.renderIndentGuides') ?? 'onHover', + indent: configuration.getCore('workbench.tree.indent'), + }; + } + + private onContextChanged(key: keyof ContextKeys) { + if (['gitlens:gk:organization:ai:enabled', 'gitlens:gk:organization:drafts:enabled'].includes(key)) { + this._context.orgSettings = this.getOrgSettings(); + this.updateState(); + } + } + + private getOrgSettings(): State['orgSettings'] { + return { + ai: getContext('gitlens:gk:organization:ai:enabled', false), + byob: getContext('gitlens:gk:organization:drafts:byob', false), + }; + } + + private onRepositoriesChanged(e: RepositoriesChangeEvent) { + if (this.mode === 'create' && this._context.create != null) { + if (this._context.create?.showingAllRepos) { + for (const repo of e.added) { + this._context.create.changes.set( + repo.uri.toString(), + new RepositoryWipChangeset( + this.container, + repo, + { to: uncommitted, from: 'HEAD' }, + this.onRepositoryWipChanged.bind(this), + false, + true, + ), + ); + } + } + + for (const repo of e.removed) { + this._context.create.changes.delete(repo.uri.toString()); + } + + void this.notifyDidChangeCreateDraftState(); + } + } + + private onRepositoryWipChanged(_e: RepositoryWipChangeset) { + void this.notifyDidChangeCreateDraftState(); + } + + private get mode(): Mode { + return this._context.mode; + } + private setMode(mode: Mode, silent?: boolean) { + this._context.mode = mode; + this.setHostTitle(mode); + void setContext( + 'gitlens:views:patchDetails:mode', + configuration.get('cloudPatches.experimental.layout') === 'editor' ? undefined : mode, + ); + if (!silent) { + this.updateState(true); + } + } + + private setHostTitle(mode: Mode = this._context.mode) { + if (mode === 'create') { + this.host.title = 'Create Cloud Patch'; + } else if (this._context.draft?.draftType === 'cloud' && this._context.draft.type === 'suggested_pr_change') { + this.host.title = 'Cloud Suggestion'; + } else { + this.host.title = 'Cloud Patch Details'; + } + } + + private async applyPatch(params: ApplyPatchParams) { + // if (params.details.repoPath == null || params.details.commit == null) return; + // void this.container.git.applyPatchCommit(params.details.repoPath, params.details.commit, { + // branchName: params.targetRef, + // }); + if (this._context.draft == null || this._context.draft.draftType === 'local' || !params.selected?.length) { + return; + } + + const changeset = this._context.draft.changesets?.[0]; + if (changeset == null) return; + + // TODO: should be overridable with targetRef + const shouldPickBranch = params.target === 'branch'; + for (const patch of changeset.patches) { + if (!params.selected.includes(patch.id)) continue; + + try { + const commit = patch.commit ?? (await this.getOrCreateCommitForPatch(patch.gkRepositoryId)); + if (!commit) { + // TODO: say we can't apply this patch + continue; + } + + let options: + | { + branchName?: string; + createBranchIfNeeded?: boolean; + createWorktreePath?: string; + } + | undefined = undefined; + + if (shouldPickBranch) { + const repo = commit.getRepository(); + const branch = await showNewOrSelectBranchPicker( + `Choose a Branch ${GlyphChars.Dot} ${repo?.name}`, + // 'Choose a branch to apply the Cloud Patch to', + repo, + ); + + if (branch == null) { + void window.showErrorMessage( + `Unable to apply patch to '${patch.repository!.name}': No branch selected`, + ); + continue; + } + + const isString = typeof branch === 'string'; + options = { + branchName: isString ? branch : branch.ref, + createBranchIfNeeded: isString, + }; + } + + await this.container.git.applyUnreachableCommitForPatch(commit.repoPath, commit.ref, { + stash: 'prompt', + ...options, + }); + void window.showInformationMessage(`Patch applied successfully`); + } catch (ex) { + if (ex instanceof CancellationError) return; + + if (ex instanceof ApplyPatchCommitError) { + if (ex.reason === ApplyPatchCommitErrorReason.AppliedWithConflicts) { + void window.showWarningMessage('Patch applied with conflicts'); + } else { + void window.showErrorMessage(ex.message); + } + } else { + void window.showErrorMessage(`Unable to apply patch onto '${patch.baseRef}': ${ex.message}`); + } + } + } + } + + private closeView() { + void setContext('gitlens:views:patchDetails:mode', undefined); + + if (this._context.mode === 'create') { + void this.container.draftsView.show(); + } else if (this._context.draft?.draftType === 'cloud') { + if (this._context.draft.type === 'suggested_pr_change') { + const repositoryOrIdentity = this._context.draft.changesets?.[0].patches[0].repository; + void showInspectView({ + type: 'wip', + repository: isRepoLocated(repositoryOrIdentity) ? (repositoryOrIdentity as Repository) : undefined, + source: 'patchDetails', + }); + } else { + void this.container.draftsView.revealDraft(this._context.draft); + } + } + } + + private copyCloudLink() { + if (this._context.draft?.draftType !== 'cloud') return; + + void env.clipboard.writeText(this._context.draft.deepLinkUrl); + } + + private async getOrganizationMembers() { + return this.container.organizations.getMembers(); + } + + private async onInviteUsers() { + let owner; + let ownerSelection; + let pickedMemberIds: string[] | undefined; + if (this.mode === 'create') { + pickedMemberIds = this._context.create?.userSelections?.map(u => u.member.id); + ownerSelection = this._context.create?.userSelections?.find(u => u.member.role === 'owner'); + owner = ownerSelection?.user; + } else { + pickedMemberIds = this._context.draftUserState?.selections + ?.filter(s => s.change !== 'delete') + ?.map(u => u.member.id); + owner = this._context.draftUserState?.users.find(u => u.role === 'owner'); + } + + const members = await showOrganizationMembersPicker( + 'Select Collaborators', + 'Select the collaborators to share this patch with', + this.getOrganizationMembers(), + { + multiselect: true, + filter: m => m.id !== owner?.userId, + picked: m => pickedMemberIds?.includes(m.id) ?? false, + }, + ); + if (members == null) return; + + if (this.mode === 'create') { + const userSelections = members.map(member => toDraftUserSelection(member, undefined, 'editor', 'add')); + if (ownerSelection != null) { + userSelections.push(ownerSelection); + } + this._context.create!.userSelections = userSelections; + void this.notifyDidChangeCreateDraftState(); + return; + } + + const draftUserState = this._context.draftUserState!; + + const currentSelections = draftUserState.selections; + const preserveSelections = new Map(); + const updatedMemberIds = new Set(members.map(member => member.id)); + const updatedSelections: DraftUserSelection[] = []; + + for (const selection of currentSelections) { + if (updatedMemberIds.has(selection.member.id) || selection.member.role === 'owner') { + preserveSelections.set(selection.member.id, selection); + continue; + } + + updatedSelections.push({ ...selection, change: 'delete' }); + } + + for (const member of members) { + const selection = preserveSelections.get(member.id); + // If we have an existing selection, and it's marked for deletion, we need to undo the deletion + if (selection != null && selection.change === 'delete') { + selection.change = undefined; + } + + updatedSelections.push( + selection != null ? selection : toDraftUserSelection(member, undefined, 'editor', 'add'), + ); + } + + if (updatedSelections.length) { + draftUserState.selections = updatedSelections; + void this.notifyDidChangeViewDraftState(); + } + } + + private onUpdatePatchUserSelection(params: UpdatePatchUserSelection) { + if (this.mode === 'create') { + const userSelections = this._context.create?.userSelections; + if (userSelections == null) return; + + if (params.role === 'remove') { + const selection = userSelections.findIndex(u => u.member.id === params.selection.member.id); + if (selection === -1) return; + userSelections.splice(selection, 1); + } else { + const selection = userSelections.find(u => u.member.id === params.selection.member.id); + if (selection == null) return; + selection.pendingRole = params.role; + } + + void this.notifyDidChangeCreateDraftState(); + return; + } + + const allSelections = this._context.draftUserState!.selections; + const selection = allSelections.find(u => u.member.id === params.selection.member.id); + if (selection == null) return; + + if (params.role === 'remove') { + selection.change = 'delete'; + } else { + selection.change = 'modify'; + selection.pendingRole = params.role; + } + + void this.notifyDidChangeViewDraftState(); + } + + private async createDraft({ + title, + changesets, + description, + visibility, + userSelections, + }: CreatePatchParams): Promise { + if ( + !(await ensureAccount(this.container, 'Cloud Patches are a Preview feature and require an account.', { + source: 'cloud-patches', + detail: 'create', + })) || + !(await confirmDraftStorage(this.container)) + ) { + return; + } + + const createChanges: CreateDraftChange[] = []; + + const changes = Object.entries(changesets); + const ignoreChecked = changes.length === 1; + + for (const [id, change] of changes) { + if (!ignoreChecked && change.checked === false) continue; + + const repoChangeset = this._context.create?.changes?.get(id); + if (repoChangeset == null) continue; + + let { revision, repository } = repoChangeset; + if (change.type === 'wip' && change.checked === 'staged') { + revision = { ...revision, to: uncommittedStaged }; + } + + createChanges.push({ + repository: repository, + revision: revision, + }); + } + if (createChanges == null) return; + + try { + const options = { + description: description, + visibility: visibility, + }; + const draft = await this.container.drafts.createDraft('patch', title, createChanges, options); + + if (userSelections != null && userSelections.length !== 0) { + await this.container.drafts.addDraftUsers( + draft.id, + userSelections.map(u => ({ + userId: u.member.id, + role: u.pendingRole!, + })), + ); + } + + async function showNotification() { + const view = { title: 'View Patch' }; + const copy = { title: 'Copy Link' }; + let copied = false; + while (true) { + const result = await window.showInformationMessage( + `Cloud Patch successfully created${copied ? '\u2014 link copied to the clipboard' : ''}`, + view, + copy, + ); + + if (result === copy) { + void env.clipboard.writeText(draft.deepLinkUrl); + copied = true; + continue; + } + + if (result === view) { + void showPatchesView({ mode: 'view', draft: draft }); + } + + break; + } + } + + void showNotification(); + void this.container.draftsView.refresh(true).then(() => void this.container.draftsView.revealDraft(draft)); + + this.closeView(); + } catch (ex) { + debugger; + + void window.showErrorMessage(`Unable to create draft: ${ex.message}`); + } + } + + private async archiveDraft(reason?: Exclude) { + if (this._context.draft?.draftType !== 'cloud') return; + + const isCodeSuggestion = this._context.draft.type === 'suggested_pr_change'; + let label = 'Cloud Patch'; + if (isCodeSuggestion) { + label = 'Code Suggestion'; + } + + try { + await this.container.drafts.archiveDraft(this._context.draft, { archiveReason: reason }); + this._context.draft = { + ...this._context.draft, + isArchived: true, + archivedReason: reason, + }; + + let action = 'archived'; + if (isCodeSuggestion) { + switch (reason) { + case 'accepted': + action = 'accepted'; + break; + case 'rejected': + action = 'declined'; + break; + } + } + + void window.showInformationMessage(`${label} successfully ${action}`); + void this.notifyDidChangeViewDraftState(); + if (isCodeSuggestion) { + void this.trackArchiveDraft(this._context.draft); + } + } catch (ex) { + let action = 'archive'; + if (isCodeSuggestion) { + switch (reason) { + case 'accepted': + action = 'accept'; + break; + case 'rejected': + action = 'decline'; + break; + } + } + + void window.showErrorMessage(`Unable to ${action} ${label}: ${ex.message}`); + } + } + + private async trackArchiveDraft(draft: Draft) { + draft = await this.ensureDraftContent(draft); + const patch = draft.changesets?.[0].patches.find(p => isRepoLocated(p.repository)); + + let repoPrivacy; + if (isRepository(patch?.repository)) { + repoPrivacy = await this.container.git.visibility(patch.repository.uri); + } + + const entity = getDraftEntityIdentifier(draft, patch); + + this.container.telemetry.sendEvent( + 'codeSuggestionArchived', + { + provider: entity?.provider, + 'repository.visibility': repoPrivacy, + repoPrivacy: repoPrivacy, + draftId: draft.id, + reason: draft.archivedReason!, + }, + { source: 'patchDetails' }, + ); + } + + private async explainRequest(requestType: T, msg: IpcCallMessageType) { + if (this._context.draft?.draftType !== 'cloud') { + void this.host.respond(requestType, msg, { error: { message: 'Unable to find patch' } }); + return; + } + + let params: DidExplainParams; + + try { + // TODO@eamodio HACK -- only works for the first patch + const patch = await this.getDraftPatch(this._context.draft); + if (patch == null) throw new Error('Unable to find patch'); + + const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId); + if (commit == null) throw new Error('Unable to find commit'); + + const summary = await ( + await this.container.ai + )?.explainCommit(commit, { + progress: { location: { viewId: this.host.id } }, + }); + if (summary == null) throw new Error('Error retrieving content'); + + params = { summary: summary }; + } catch (ex) { + debugger; + params = { error: { message: ex.message } }; + } + + void this.host.respond(requestType, msg, params); + } + + private async generateRequest(requestType: T, msg: IpcCallMessageType) { + let repo: Repository | undefined; + if (this._context.create?.changes != null) { + for (const change of this._context.create.changes.values()) { + if (change.repository) { + repo = change.repository; + break; + } + } + } + + if (!repo) { + void this.host.respond(requestType, msg, { error: { message: 'Unable to find changes' } }); + return; + } + + let params: DidGenerateParams; + + try { + // TODO@eamodio HACK -- only works for the first patch + // const patch = await this.getDraftPatch(this._context.draft); + // if (patch == null) throw new Error('Unable to find patch'); + + // const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId); + // if (commit == null) throw new Error('Unable to find commit'); + + const summary = await ( + await this.container.ai + )?.generateDraftMessage(repo, { + progress: { location: { viewId: this.host.id } }, + }); + if (summary == null) throw new Error('Error retrieving content'); + + params = extractDraftMessage(summary); + } catch (ex) { + debugger; + params = { error: { message: ex.message } }; + } + + void this.host.respond(requestType, msg, params); + } + + private async openPatchContents(_params: ExecuteFileActionParams) { + // TODO@eamodio Open the patch contents for the selected repo in an untitled editor + } + + private onPatchChecked(params: DraftPatchCheckedParams) { + if (params.patch.repository.located || params.checked === false) return; + + const patch = (this._context.draft as Draft)?.changesets?.[0].patches?.find( + p => p.gkRepositoryId === params.patch.gkRepositoryId, + ); + if (patch == null) return; + + void this.getOrLocatePatchRepository(patch, { prompt: true, notifyOnLocation: true }); + } + + private notifyPatchRepositoryUpdated(patch: DraftPatch) { + return this.host.notify(DidChangePatchRepositoryNotification, { + patch: serialize({ + ...patch, + contents: undefined, + commit: undefined, + repository: { + id: patch.gkRepositoryId, + name: patch.repository?.name ?? '', + located: isRepoLocated(patch.repository), + }, + }), + }); + } + + private updateCreateCheckedState(params: UpdateCreatePatchRepositoryCheckedStateParams) { + const changeset = this._context.create?.changes.get(params.repoUri); + if (changeset == null) return; + + changeset.checked = params.checked; + void this.notifyDidChangeCreateDraftState(); + } + + private updateCreateMetadata(params: UpdateCreatePatchMetadataParams) { + if (this._context.create == null) return; + + this._context.create.title = params.title; + this._context.create.description = params.description; + this._context.create.visibility = params.visibility; + void this.notifyDidChangeCreateDraftState(); + } + + private updateDraftMetadata(params: UpdatePatchDetailsMetadataParams) { + if (this._context.draft == null) return; + + this._context.draftVisibiltyState = params.visibility; + void this.notifyDidChangeViewDraftState(); + } + + private async updateDraftPermissions() { + const draft = this._context.draft as Draft; + const draftId = draft.id; + const changes = []; + + if (this._context.draftVisibiltyState != null && this._context.draftVisibiltyState !== draft.visibility) { + changes.push(this.container.drafts.updateDraftVisibility(draftId, this._context.draftVisibiltyState)); + } + + const selections = this._context.draftUserState?.selections; + const adds: DraftPendingUser[] = []; + if (selections != null) { + for (const selection of selections) { + if (selection.change === undefined) continue; + + // modifying an existing user has to be done by deleting and adding back + if (selection.change !== 'delete') { + adds.push({ + userId: selection.member.id, + role: selection.pendingRole!, + }); + } + + if (selection.change !== 'add') { + changes.push(this.container.drafts.removeDraftUser(draftId, selection.member.id)); + } + } + } + + if (changes.length === 0 && adds.length === 0) { + return; + } + if (changes.length !== 0) { + const results = await Promise.all(changes); + console.log(results); + } + + if (adds.length !== 0) { + await this.container.drafts.addDraftUsers(draftId, adds); + } + await this.createDraftUserState(draft, { force: true }); + + void window.showInformationMessage('Cloud Patch successfully updated'); + void this.notifyDidChangeViewDraftState(); + } + + // private shareLocalPatch() { + // if (this._context.open?.draftType !== 'local') return; + + // this.updateCreateFromLocalPatch(this._context.open); + // } + + private switchMode(params: SwitchModeParams) { + this.setMode(params.mode); + } + + private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; + + private updateState(immediate: boolean = false) { + this.host.clearPendingIpcNotifications(); + + if (immediate) { + void this.notifyDidChangeState(); + return; + } + + if (this._notifyDidChangeStateDebounced == null) { + this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); + } + + this._notifyDidChangeStateDebounced(); + } + + @debug({ args: false }) + protected async getState(current: Context): Promise> { + let create; + if (current.mode === 'create' && current.create != null) { + create = await this.getCreateDraftState(current); + } + + let draft; + if (current.mode === 'view' && current.draft != null) { + draft = await this.getViewDraftState(current); + } + + const state = serialize({ + ...this.host.baseWebviewState, + mode: current.mode, + create: create, + draft: draft, + preferences: current.preferences, + orgSettings: current.orgSettings, + }); + return state; + } + + private async notifyDidChangeState() { + this._notifyDidChangeStateDebounced?.cancel(); + return this.host.notify(DidChangeNotification, { state: await this.getState(this._context) }); + } + + private updateCreateDraftState(create: CreateDraft) { + let changesetByRepo: Map; + let allRepos = false; + + if (create.changes != null) { + changesetByRepo = this._context.create?.changes ?? new Map(); + + const updated = new Set(); + for (const change of create.changes) { + const repo = this.container.git.getRepository(Uri.parse(change.repository.uri)); + if (repo == null) continue; + + let changeset: RepositoryChangeset; + if (change.type === 'wip') { + changeset = new RepositoryWipChangeset( + this.container, + repo, + change.revision, + this.onRepositoryWipChanged.bind(this), + change.checked ?? true, + change.expanded ?? true, + ); + } else { + changeset = new RepositoryRefChangeset( + this.container, + repo, + change.revision, + change.files, + change.checked ?? true, + change.expanded ?? true, + ); + } + + updated.add(repo.uri.toString()); + changesetByRepo.set(repo.uri.toString(), changeset); + } + + if (updated.size !== changesetByRepo.size) { + for (const [uri, repoChange] of changesetByRepo) { + if (updated.has(uri)) continue; + repoChange.checked = false; + } + } + } else { + allRepos = create.repositories == null; + const repos = create.repositories ?? this.container.git.openRepositories; + changesetByRepo = new Map( + repos.map(r => [ + r.uri.toString(), + new RepositoryWipChangeset( + this.container, + r, + { to: uncommitted, from: 'HEAD' }, + this.onRepositoryWipChanged.bind(this), + true, + true, // TODO revisit + ), + ]), + ); + } + + this._context.create = { + title: create.title, + description: create.description, + changes: changesetByRepo, + showingAllRepos: allRepos, + visibility: 'public', + }; + this.setMode('create', true); + void this.notifyDidChangeCreateDraftState(); + } + + private async getCreateDraftState(current: Context): Promise { + const { create } = current; + if (create == null) return undefined; + + const repoChanges: Record = {}; + + if (create.changes.size !== 0) { + for (const [id, repo] of create.changes) { + const change = await repo.getChange(); + if (change?.files?.length === 0) continue; // TODO remove when we support dynamic expanded repos + + if (change.checked !== repo.checked) { + change.checked = repo.checked; + } + repoChanges[id] = change; + } + } + + return { + title: create.title, + description: create.description, + changes: repoChanges, + visibility: create.visibility, + userSelections: create.userSelections, + }; + } + + private async notifyDidChangeCreateDraftState() { + return this.host.notify(DidChangeCreateNotification, { + mode: this._context.mode, + create: await this.getCreateDraftState(this._context), + }); + } + + private async trackViewDraft(draft: Draft | LocalDraft | undefined, source?: Sources | undefined) { + if (draft?.draftType !== 'cloud' || draft.type !== 'suggested_pr_change') return; + + draft = await this.ensureDraftContent(draft); + const patch = draft.changesets?.[0].patches.find(p => isRepoLocated(p.repository)); + + let repoPrivacy; + if (isRepository(patch?.repository)) { + repoPrivacy = await this.container.git.visibility(patch.repository.uri); + } + + const entity = getDraftEntityIdentifier(draft, patch); + + this.container.telemetry.sendEvent( + 'codeSuggestionViewed', + { + provider: entity?.provider, + 'repository.visibility': repoPrivacy, + repoPrivacy: repoPrivacy, + draftId: draft.id, + draftPrivacy: draft.visibility, + source: source, + }, + { source: source ?? 'patchDetails' }, + ); + } + + private async updateViewDraftState(draft: LocalDraft | Draft | undefined) { + this._context.draft = draft; + if (draft?.draftType === 'cloud') { + this._context.draftGkDevUrl = this.container.drafts.generateWebUrl(draft); + await this.createDraftUserState(draft, { force: true }); + } + this.setMode('view', true); + void this.notifyDidChangeViewDraftState(); + } + + // eslint-disable-next-line @typescript-eslint/require-await + private async getViewDraftState( + current: Context, + deferredPatchLoading = true, + ): Promise { + if (current.draft == null) return undefined; + + const draft = current.draft; + + // if (draft.draftType === 'local') { + // const { patch } = draft; + // if (patch.repository == null) { + // const repo = this.container.git.getBestRepository(); + // if (repo != null) { + // patch.repository = repo; + // } + // } + + // return { + // draftType: 'local', + // files: patch.files ?? [], + // repoPath: patch.repository?.path, + // repoName: patch.repository?.name, + // baseRef: patch.baseRef, + // }; + // } + + if (draft.draftType === 'cloud') { + if (deferredPatchLoading === true && isDraftMissingContent(draft)) { + setTimeout(async () => { + await this.ensureDraftContent(draft); + + void this.notifyDidChangeViewDraftState(false); + }, 0); + } + + const draftUserState = this._context.draftUserState!; + return { + draftType: 'cloud', + id: draft.id, + type: draft.type, + createdAt: draft.createdAt.getTime(), + updatedAt: draft.updatedAt.getTime(), + author: { + id: draft.author.id, + name: draft.author.name, + email: draft.author.email, + avatar: draft.author.avatarUri?.toString(), + }, + role: draft.role, + title: draft.title, + description: draft.description, + isArchived: draft.isArchived, + archivedReason: draft.archivedReason, + visibility: draft.visibility, + gkDevLink: this._context.draftGkDevUrl, + patches: draft.changesets?.length + ? serialize( + draft.changesets[0].patches.map(p => ({ + ...p, + contents: undefined, + commit: undefined, + repository: { + id: p.gkRepositoryId, + name: p.repository?.name ?? '', + located: isRepoLocated(p.repository), + }, + })), + ) + : undefined, + users: draftUserState.users, + userSelections: draftUserState.selections, + }; + } + + return undefined; + } + + private async createDraftUserState(draft: Draft, options?: { force?: boolean }): Promise { + if (this._context.draftUserState != null && options?.force !== true) { + return; + } + // try to create the state if it doesn't exist + try { + const draftUsers = await this.container.drafts.getDraftUsers(draft.id); + if (draftUsers.length === 0) { + return; + } + + const users: DraftUser[] = []; + const userSelections: DraftUserSelection[] = []; + const members = await this.getOrganizationMembers(); + for (const user of draftUsers) { + users.push(user); + const member = members.find(m => m.id === user.userId)!; + userSelections.push(toDraftUserSelection(member, user)); + } + userSelections.sort( + (a, b) => + ((a.pendingRole ?? a.member.role) === 'owner' ? -1 : 1) - + ((b.pendingRole ?? b.member.role) === 'owner' ? -1 : 1) || + a.member.name.localeCompare(b.member.name), + ); + + this._context.draftUserState = { users: users, selections: userSelections }; + } catch (ex) { + debugger; + } + } + + private async notifyDidChangeViewDraftState(deferredPatchLoading = true) { + return this.host.notify(DidChangeDraftNotification, { + mode: this._context.mode, + draft: serialize(await this.getViewDraftState(this._context, deferredPatchLoading)), + }); + } + + private updatePreferences(preferences: UpdateablePreferences) { + if ( + this._context.preferences?.files?.compact === preferences.files?.compact && + this._context.preferences?.files?.icon === preferences.files?.icon && + this._context.preferences?.files?.layout === preferences.files?.layout && + this._context.preferences?.files?.threshold === preferences.files?.threshold + ) { + return; + } + + if (preferences.files != null) { + if (this._context.preferences?.files?.compact !== preferences.files?.compact) { + void configuration.updateEffective('views.patchDetails.files.compact', preferences.files?.compact); + } + if (this._context.preferences?.files?.icon !== preferences.files?.icon) { + void configuration.updateEffective('views.patchDetails.files.icon', preferences.files?.icon); + } + if (this._context.preferences?.files?.layout !== preferences.files?.layout) { + void configuration.updateEffective('views.patchDetails.files.layout', preferences.files?.layout); + } + if (this._context.preferences?.files?.threshold !== preferences.files?.threshold) { + void configuration.updateEffective('views.patchDetails.files.threshold', preferences.files?.threshold); + } + + this._context.preferences.files = preferences.files; + } + + void this.notifyDidChangePreferences(); + } + + private async notifyDidChangePreferences() { + return this.host.notify(DidChangePreferencesNotification, { preferences: this._context.preferences }); + } + + private async getDraftPatch(draft: Draft, gkRepositoryId?: GkRepositoryId): Promise { + draft.changesets = await this.ensureChangesets(draft); + + const patch = + gkRepositoryId == null + ? draft.changesets[0].patches?.[0] + : draft.changesets[0].patches?.find(p => p.gkRepositoryId === gkRepositoryId); + if (patch == null) return undefined; + + if (patch.contents == null || patch.files == null || patch.repository == null) { + const details = await this.container.drafts.getPatchDetails(patch.id); + patch.contents = details.contents; + patch.files = details.files; + patch.repository = details.repository; + } + + return patch; + } + + private async getFileCommitFromParams( + params: ExecuteFileActionParams, + ): Promise< + | [commit: GitCommit, file: GitFileChange, revision?: Required>] + | undefined + > { + let [commit, revision] = await this.getOrCreateCommit(params); + + if (commit != null && revision != null) { + return [ + commit, + new GitFileChange( + params.repoPath, + params.path, + params.status, + params.originalPath, + undefined, + undefined, + params.staged, + ), + revision, + ]; + } + + commit = await commit?.getCommitForFile(params.path, params.staged); + return commit != null ? [commit, commit.file!, revision] : undefined; + } + + private async getOrCreateCommit( + file: DraftPatchFileChange, + ): Promise<[commit: GitCommit | undefined, revision?: PatchRevisionRange]> { + switch (this.mode) { + case 'create': + return this.getCommitForFile(file); + case 'view': + return [await this.getOrCreateCommitForPatch(file.gkRepositoryId)]; + default: + return [undefined]; + } + } + + async getCommitForFile( + file: DraftPatchFileChange, + ): Promise<[commit: GitCommit | undefined, revision?: PatchRevisionRange]> { + const changeset = find(this._context.create!.changes.values(), cs => cs.repository.path === file.repoPath); + if (changeset == null) return [undefined]; + + const change = await changeset.getChange(); + if (change == null) return [undefined]; + + if (change.type === 'revision') { + const commit = await this.container.git.getCommit(file.repoPath, change.revision.to ?? uncommitted); + if ( + change.revision.to === change.revision.from || + (change.revision.from.length === change.revision.to.length + 1 && + change.revision.from.endsWith('^') && + change.revision.from.startsWith(change.revision.to)) + ) { + return [commit]; + } + + return [commit, change.revision]; + } else if (change.type === 'wip') { + return [await this.container.git.getCommit(file.repoPath, change.revision.to ?? uncommitted)]; + } + + return [undefined]; + } + + async getOrCreateCommitForPatch(gkRepositoryId: GkRepositoryId): Promise { + const draft = this._context.draft!; + if (draft.draftType === 'local') return undefined; // TODO + + const patch = await this.getDraftPatch(draft, gkRepositoryId); + if (patch?.repository == null) return undefined; + + if (patch?.commit == null) { + const repo = await this.getOrLocatePatchRepository(patch, { prompt: true }); + if (repo == null) return undefined; + + let baseRef = patch.baseRef ?? 'HEAD'; + + do { + try { + const commit = await this.container.git.createUnreachableCommitForPatch( + repo.uri, + patch.contents!, + baseRef, + draft.title, + ); + patch.commit = commit; + } catch (ex) { + if (baseRef != null) { + // const head = { title: 'HEAD' }; + const chooseBase = { title: 'Choose Base...' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + + const result = await window.showErrorMessage( + `Unable to apply the patch onto ${ + baseRef === 'HEAD' ? 'HEAD' : `'${shortenRevision(baseRef)}'` + }.\nDo you want to try again on a different base?`, + { modal: true }, + chooseBase, + cancel, + // ...(baseRef === 'HEAD' ? [chooseBase, cancel] : [head, chooseBase, cancel]), + ); + + if (result == null || result === cancel) break; + // if (result === head) { + // baseRef = 'HEAD'; + // continue; + // } + + if (result === chooseBase) { + const ref = await showReferencePicker( + repo.path, + `Choose New Base for Patch`, + `Choose a new base to apply the patch onto`, + { + allowRevisions: true, + include: + ReferencesQuickPickIncludes.BranchesAndTags | ReferencesQuickPickIncludes.HEAD, + }, + ); + if (ref == null) break; + + baseRef = ref.ref; + continue; + } + } else { + void window.showErrorMessage( + `Unable to apply the patch on base '${shortenRevision(baseRef)}': ${ex.message}`, + ); + } + } + + break; + } while (true); + } + + return patch?.commit; + } + + private async openFile(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + void openFile(file, commit, { + preserveFocus: true, + preview: true, + ...params.showOptions, + }); + } + + private getChangesTitleNote() { + if ( + this._context.mode === 'view' && + this._context.draft?.draftType === 'cloud' && + this._context.draft.type === 'suggested_pr_change' + ) { + return 'Code Suggestion'; + } + + return 'Patch'; + } + + private async openFileComparisonWithPrevious(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file, revision] = result; + + const titleNote = this.getChangesTitleNote(); + + void openChanges( + file, + revision != null + ? { repoPath: commit.repoPath, rhs: revision.to ?? uncommitted, lhs: revision.from } + : commit, + { + preserveFocus: true, + preview: true, + ...params.showOptions, + rhsTitle: this.mode === 'view' ? `${basename(file.path)} (${titleNote})` : undefined, + }, + ); + this.container.events.fire('file:selected', { uri: file.uri }, { source: this.host.id }); + } + + private async openFileComparisonWithWorking(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file, revision] = result; + + const titleNote = this.getChangesTitleNote(); + + void openChangesWithWorking(file, revision != null ? { repoPath: commit.repoPath, ref: revision.to } : commit, { + preserveFocus: true, + preview: true, + ...params.showOptions, + lhsTitle: this.mode === 'view' ? `${basename(file.path)} (${titleNote})` : undefined, + }); + } + + private async getOrLocatePatchRepository( + patch: DraftPatch, + options?: { notifyOnLocation?: boolean; prompt?: boolean }, + ): Promise { + if (patch.repository == null || isRepository(patch.repository)) { + return patch.repository; + } + + const repo = await this.container.repositoryIdentity.getRepository(patch.repository, { + openIfNeeded: true, + prompt: options?.prompt ?? false, + }); + if (repo == null) { + void window.showErrorMessage(`Unable to locate repository '${patch.repository.name}'`); + } else { + patch.repository = repo; + + if (options?.notifyOnLocation) { + void this.notifyPatchRepositoryUpdated(patch); + } + } + + return repo; + } + + // Ensures that changesets arent mutated twice on the same draft + @gate(d => d.id) + private async ensureChangesets(draft: Draft) { + draft.changesets ??= await this.container.drafts.getChangesets(draft.id); + return draft.changesets; + } + + private async ensureDraftContent(draft: Draft): Promise { + if (!isDraftMissingContent(draft)) { + return draft; + } + + draft.changesets = await this.ensureChangesets(draft); + + const patches = draft.changesets + .flatMap(cs => cs.patches) + .filter(p => p.contents == null || p.files == null || p.repository == null); + + if (patches.length === 0) { + return draft; + } + + const patchDetails = await Promise.allSettled(patches.map(p => this.container.drafts.getPatchDetails(p))); + + for (const d of patchDetails) { + if (d.status === 'fulfilled') { + const patch = patches.find(p => p.id === d.value.id); + if (patch != null) { + patch.contents = d.value.contents; + patch.files = d.value.files; + patch.repository = d.value.repository; + await this.getOrLocatePatchRepository(patch); + } + } + } + + return draft; + } +} + +function isDraftMissingContent(draft: Draft): boolean { + if (draft.changesets == null) return true; + + return some(draft.changesets, cs => + cs.patches.some(p => p.contents == null || p.files == null || p.repository == null), + ); +} + +function isRepoLocated(repo: DraftPatch['repository']): boolean { + return repo != null && isRepository(repo); +} + +function toDraftUserSelection( + member: OrganizationMember, + user?: DraftUserSelection['user'], + pendingRole?: DraftPendingUser['role'], + change?: DraftUserSelection['change'], +): DraftUserSelection { + return { + change: change, + member: member, + user: user, + pendingRole: pendingRole, + avatarUrl: member?.email != null ? getAvatarUri(member.email, undefined).toString() : undefined, + }; +} diff --git a/src/plus/webviews/patchDetails/protocol.ts b/src/plus/webviews/patchDetails/protocol.ts new file mode 100644 index 0000000000000..a39eb10e8dd6d --- /dev/null +++ b/src/plus/webviews/patchDetails/protocol.ts @@ -0,0 +1,318 @@ +import type { TextDocumentShowOptions } from 'vscode'; +import type { Config } from '../../../config'; +import type { GitFileChangeShape } from '../../../git/models/file'; +import type { PatchRevisionRange } from '../../../git/models/patch'; +import type { Repository } from '../../../git/models/repository'; +import type { + Draft, + DraftArchiveReason, + DraftPatch, + DraftPatchFileChange, + DraftPendingUser, + DraftRole, + DraftType, + DraftUser, + DraftVisibility, + LocalDraft, +} from '../../../gk/models/drafts'; +import type { GkRepositoryId } from '../../../gk/models/repositoryIdentities'; +import type { DateTimeFormat } from '../../../system/date'; +import type { Serialized } from '../../../system/serialize'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcCommand, IpcNotification, IpcRequest } from '../../../webviews/protocol'; +import type { OrganizationMember } from '../../gk/account/organization'; + +export const scope: IpcScope = 'patchDetails'; + +export const messageHeadlineSplitterToken = '\x00\n\x00'; + +export type FileShowOptions = TextDocumentShowOptions; + +export type PatchDetails = Serialized< + Omit & { + repository: { id: GkRepositoryId; name: string; located: boolean }; + } +>; + +interface CreateDraftFromChanges { + title?: string; + description?: string; + changes: Change[]; + repositories?: never; +} + +interface CreateDraftFromRepositories { + title?: string; + description?: string; + changes?: never; + repositories: Repository[] | undefined; +} + +export type CreateDraft = CreateDraftFromChanges | CreateDraftFromRepositories; +export type ViewDraft = LocalDraft | Draft; + +interface LocalDraftDetails { + draftType: 'local'; + + id?: never; + author?: never; + createdAt?: never; + updatedAt?: never; + + title?: string; + description?: string; + + patches?: PatchDetails[]; +} + +export interface CloudDraftDetails { + draftType: 'cloud'; + + id: string; + type: DraftType; + createdAt: number; + updatedAt: number; + author: { + id: string; + name: string; + email: string | undefined; + avatar?: string; + }; + + role: DraftRole; + visibility: DraftVisibility; + + title: string; + description?: string; + + isArchived: boolean; + archivedReason?: DraftArchiveReason; + + gkDevLink?: string; + + patches?: PatchDetails[]; + + users?: DraftUser[]; + userSelections?: DraftUserSelection[]; +} + +export type DraftDetails = LocalDraftDetails | CloudDraftDetails; + +export interface DraftUserSelection { + change: 'add' | 'modify' | 'delete' | undefined; + member: OrganizationMember; + user: DraftUser | undefined; + pendingRole: DraftPendingUser['role'] | undefined; + avatarUrl?: string; +} + +export interface Preferences { + avatars: boolean; + dateFormat: DateTimeFormat | string; + files: Config['views']['patchDetails']['files']; + indentGuides: 'none' | 'onHover' | 'always'; + indent: number | undefined; +} + +export type UpdateablePreferences = Partial>; + +export type Mode = 'create' | 'view'; +export type ChangeType = 'revision' | 'wip'; + +export interface WipChange { + type: 'wip'; + repository: { name: string; path: string; uri: string }; + revision: PatchRevisionRange; + files: GitFileChangeShape[] | undefined; + + checked?: boolean | 'staged'; + expanded?: boolean; +} + +export interface RevisionChange { + type: 'revision'; + repository: { name: string; path: string; uri: string }; + revision: PatchRevisionRange; + files: GitFileChangeShape[]; + + checked?: boolean | 'staged'; + expanded?: boolean; +} + +export type Change = WipChange | RevisionChange; + +export interface CreatePatchState { + title?: string; + description?: string; + changes: Record; + creationError?: string; + visibility: DraftVisibility; + userSelections?: DraftUserSelection[]; +} + +export interface State extends WebviewState { + mode: Mode; + + preferences: Preferences; + orgSettings: { + ai: boolean; + byob: boolean; + }; + + draft?: DraftDetails; + create?: CreatePatchState; +} + +export type ShowCommitDetailsViewCommandArgs = string[]; + +// COMMANDS + +export interface ApplyPatchParams { + details: DraftDetails; + targetRef?: string; // a branch name. default to HEAD if not supplied + target: 'current' | 'branch' | 'worktree'; + selected: PatchDetails['id'][]; +} +export const ApplyPatchCommand = new IpcCommand(scope, 'apply'); + +export interface ArchiveDraftParams { + reason?: Exclude; +} +export const ArchiveDraftCommand = new IpcCommand(scope, 'archive'); + +export interface CreatePatchParams { + title: string; + description?: string; + changesets: Record; + visibility: DraftVisibility; + userSelections?: DraftUserSelection[]; +} +export const CreatePatchCommand = new IpcCommand(scope, 'create'); + +export interface OpenInCommitGraphParams { + repoPath: string; + ref: string; +} +export const OpenInCommitGraphCommand = new IpcCommand(scope, 'openInGraph'); + +export interface DraftPatchCheckedParams { + patch: PatchDetails; + checked: boolean; +} +export const DraftPatchCheckedCommand = new IpcCommand(scope, 'checked'); + +export interface SelectPatchRepoParams { + repoPath: string; +} +export const SelectPatchRepoCommand = new IpcCommand(scope, 'selectRepo'); + +export const SelectPatchBaseCommand = new IpcCommand(scope, 'selectBase'); + +export interface ExecuteFileActionParams extends DraftPatchFileChange { + showOptions?: TextDocumentShowOptions; +} +export const ExecuteFileActionCommand = new IpcCommand(scope, 'file/actions/execute'); +export const OpenFileCommand = new IpcCommand(scope, 'file/open'); +export const OpenFileOnRemoteCommand = new IpcCommand(scope, 'file/openOnRemote'); +export const OpenFileCompareWorkingCommand = new IpcCommand(scope, 'file/compareWorking'); +export const OpenFileComparePreviousCommand = new IpcCommand(scope, 'file/comparePrevious'); + +export type UpdatePreferenceParams = UpdateablePreferences; +export const UpdatePreferencesCommand = new IpcCommand(scope, 'preferences/update'); + +export interface SwitchModeParams { + repoPath?: string; + mode: Mode; +} +export const SwitchModeCommand = new IpcCommand(scope, 'switchMode'); + +export const CopyCloudLinkCommand = new IpcCommand(scope, 'cloud/copyLink'); + +export const CreateFromLocalPatchCommand = new IpcCommand(scope, 'local/createPatch'); + +export interface UpdateCreatePatchRepositoryCheckedStateParams { + repoUri: string; + checked: boolean | 'staged'; +} +export const UpdateCreatePatchRepositoryCheckedStateCommand = + new IpcCommand(scope, 'create/repository/check'); + +export interface UpdateCreatePatchMetadataParams { + title: string; + description: string | undefined; + visibility: DraftVisibility; +} +export const UpdateCreatePatchMetadataCommand = new IpcCommand( + scope, + 'update/create/metadata', +); + +export interface UpdatePatchDetailsMetadataParams { + visibility: DraftVisibility; +} +export const UpdatePatchDetailsMetadataCommand = new IpcCommand( + scope, + 'update/draft/metadata', +); + +export const UpdatePatchDetailsPermissionsCommand = new IpcCommand(scope, 'update/draft/permissions'); + +export const UpdatePatchUsersCommand = new IpcCommand(scope, 'update/users'); + +export interface UpdatePatchUserSelection { + selection: DraftUserSelection; + role: Exclude | 'remove'; +} +export const UpdatePatchUserSelectionCommand = new IpcCommand(scope, 'update/userSelection'); + +// REQUESTS + +export type DidExplainParams = + | { + summary: string | undefined; + error?: undefined; + } + | { error: { message: string } }; +export const ExplainRequest = new IpcRequest(scope, 'explain'); + +export type DidGenerateParams = + | { + title: string | undefined; + description: string | undefined; + error?: undefined; + } + | { error: { message: string } }; +export const GenerateRequest = new IpcRequest(scope, 'generate'); + +// NOTIFICATIONS + +export interface DidChangeParams { + state: Serialized; +} +export const DidChangeNotification = new IpcNotification(scope, 'didChange', true); + +export type DidChangeCreateParams = Pick, 'create' | 'mode'>; +export const DidChangeCreateNotification = new IpcNotification(scope, 'create/didChange'); + +export type DidChangeDraftParams = Pick, 'draft' | 'mode'>; +export const DidChangeDraftNotification = new IpcNotification(scope, 'draft/didChange'); + +export type DidChangePreferencesParams = Pick, 'preferences'>; +export const DidChangePreferencesNotification = new IpcNotification( + scope, + 'preferences/didChange', +); + +export interface DidChangePatchRepositoryParams { + patch: PatchDetails; +} +export const DidChangePatchRepositoryNotification = new IpcNotification( + scope, + 'draft/didChangeRepository', +); + +export type DidChangeOrgSettings = Pick, 'orgSettings'>; +export const DidChangeOrgSettingsNotification = new IpcNotification( + scope, + 'org/settings/didChange', +); diff --git a/src/plus/webviews/patchDetails/registration.ts b/src/plus/webviews/patchDetails/registration.ts new file mode 100644 index 0000000000000..cb3ac8d049035 --- /dev/null +++ b/src/plus/webviews/patchDetails/registration.ts @@ -0,0 +1,84 @@ +import { ViewColumn } from 'vscode'; +import type { Sources } from '../../../constants'; +import { Commands } from '../../../constants'; +import { executeCommand } from '../../../system/command'; +import { configuration } from '../../../system/configuration'; +import { setContext } from '../../../system/context'; +import type { Serialized } from '../../../system/serialize'; +import type { WebviewPanelShowCommandArgs, WebviewsController } from '../../../webviews/webviewsController'; +import type { CreateDraft, State, ViewDraft } from './protocol'; + +export type ShowCreateDraft = { + mode: 'create'; + create?: CreateDraft; + source?: Sources; +}; + +export type ShowViewDraft = { + mode: 'view'; + draft: ViewDraft; + source?: Sources; +}; + +export type PatchDetailsWebviewShowingArgs = [ShowCreateDraft | ShowViewDraft]; + +export function registerPatchDetailsWebviewView(controller: WebviewsController) { + return controller.registerWebviewView, PatchDetailsWebviewShowingArgs>( + { + id: 'gitlens.views.patchDetails', + fileName: 'patchDetails.html', + title: 'Patch', + contextKeyPrefix: `gitlens:webviewView:patchDetails`, + trackingFeature: 'patchDetailsView', + plusFeature: true, + webviewHostOptions: { + retainContextWhenHidden: false, + }, + }, + async (container, host) => { + const { PatchDetailsWebviewProvider } = await import( + /* webpackChunkName: "webview-patchDetails" */ './patchDetailsWebview' + ); + return new PatchDetailsWebviewProvider(container, host); + }, + async (...args) => { + if (configuration.get('cloudPatches.experimental.layout') === 'editor') { + await setContext('gitlens:views:patchDetails:mode', undefined); + void executeCommand(Commands.ShowPatchDetailsPage, undefined, ...args); + return; + } + + const arg = args[0]; + if (arg == null) return; + + await setContext('gitlens:views:patchDetails:mode', 'state' in arg ? arg.state.mode : arg.mode); + }, + ); +} + +export function registerPatchDetailsWebviewPanel(controller: WebviewsController) { + return controller.registerWebviewPanel, PatchDetailsWebviewShowingArgs>( + { id: Commands.ShowPatchDetailsPage, options: { preserveInstance: true } }, + { + id: 'gitlens.patchDetails', + fileName: 'patchDetails.html', + iconPath: 'images/gitlens-icon.png', + title: 'Patch', + contextKeyPrefix: `gitlens:webview:patchDetails`, + trackingFeature: 'patchDetailsWebview', + plusFeature: true, + column: ViewColumn.Active, + webviewHostOptions: { + retainContextWhenHidden: false, + enableFindWidget: false, + }, + allowMultipleInstances: true, + }, + async (container, host) => { + const { PatchDetailsWebviewProvider } = await import( + /* webpackChunkName: "webview-patchDetails" */ './patchDetailsWebview' + ); + return new PatchDetailsWebviewProvider(container, host); + }, + ); +} diff --git a/src/plus/webviews/patchDetails/repositoryChangeset.ts b/src/plus/webviews/patchDetails/repositoryChangeset.ts new file mode 100644 index 0000000000000..8358d70ec69fe --- /dev/null +++ b/src/plus/webviews/patchDetails/repositoryChangeset.ts @@ -0,0 +1,234 @@ +import { Disposable } from 'vscode'; +import type { Container } from '../../../container'; +import type { GitFileChangeShape } from '../../../git/models/file'; +import type { PatchRevisionRange } from '../../../git/models/patch'; +import type { Repository } from '../../../git/models/repository'; +import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import type { Change, ChangeType, RevisionChange } from './protocol'; + +export interface RepositoryChangeset extends Disposable { + type: ChangeType; + repository: Repository; + revision: PatchRevisionRange; + getChange(): Promise; + + suspend(): void; + resume(): void; + + checked: Change['checked']; + expanded: boolean; +} + +export class RepositoryRefChangeset implements RepositoryChangeset { + readonly type = 'revision'; + + constructor( + private readonly container: Container, + public readonly repository: Repository, + public readonly revision: PatchRevisionRange, + private readonly files: RevisionChange['files'], + checked: Change['checked'], + expanded: boolean, + ) { + this.checked = checked; + this.expanded = expanded; + } + + dispose() {} + + suspend() {} + + resume() {} + + private _checked: Change['checked'] = false; + get checked(): Change['checked'] { + return this._checked; + } + set checked(value: Change['checked']) { + this._checked = value; + } + + private _expanded = false; + get expanded(): boolean { + return this._expanded; + } + set expanded(value: boolean) { + if (this._expanded === value) return; + + this._expanded = value; + } + + // private _files: Promise<{ files: Change['files'] }> | undefined; + // eslint-disable-next-line @typescript-eslint/require-await + async getChange(): Promise { + // let filesResult; + // if (this.expanded) { + // if (this._files == null) { + // this._files = this.getFiles(); + // } + + // filesResult = await this._files; + // } + + return { + type: 'revision', + repository: { + name: this.repository.name, + path: this.repository.path, + uri: this.repository.uri.toString(), + }, + revision: this.revision, + files: this.files, //filesResult?.files, + checked: this.checked, + expanded: this.expanded, + }; + } + + // private async getFiles(): Promise<{ files: Change['files'] }> { + // const commit = await this.container.git.getCommit(this.repository.path, this.range.sha!); + + // const files: GitFileChangeShape[] = []; + // if (commit != null) { + // for (const file of commit.files ?? []) { + // const change = { + // repoPath: file.repoPath, + // path: file.path, + // status: file.status, + // originalPath: file.originalPath, + // }; + + // files.push(change); + // } + // } + + // return { files: files }; + // } +} + +export class RepositoryWipChangeset implements RepositoryChangeset { + readonly type = 'wip'; + + private _disposable: Disposable | undefined; + + constructor( + private readonly container: Container, + public readonly repository: Repository, + public readonly revision: PatchRevisionRange, + private readonly onDidChangeRepositoryWip: (e: RepositoryWipChangeset) => void, + checked: Change['checked'], + expanded: boolean, + ) { + this.checked = checked; + this.expanded = expanded; + } + + dispose() { + this._disposable?.dispose(); + this._disposable = undefined; + } + + suspend() { + this._disposable?.dispose(); + this._disposable = undefined; + } + + resume() { + this._files = undefined; + if (this._expanded) { + this.subscribe(); + } + } + + private _checked: Change['checked'] = false; + get checked(): Change['checked'] { + return this._checked; + } + set checked(value: Change['checked']) { + this._checked = value; + } + + private _expanded = false; + get expanded(): boolean { + return this._expanded; + } + set expanded(value: boolean) { + if (this._expanded === value) return; + + this._files = undefined; + if (value) { + this.subscribe(); + } else { + this._disposable?.dispose(); + this._disposable = undefined; + } + this._expanded = value; + } + + private _files: Promise<{ files: Change['files'] }> | undefined; + async getChange(): Promise { + let filesResult; + if (this.expanded) { + if (this._files == null) { + this._files = this.getFiles(); + } + + filesResult = await this._files; + } + + return { + type: 'wip', + repository: { + name: this.repository.name, + path: this.repository.path, + uri: this.repository.uri.toString(), + }, + revision: this.revision, + files: filesResult?.files, + checked: this.checked, + expanded: this.expanded, + }; + } + + private subscribe() { + if (this._disposable != null) return; + + this._disposable = Disposable.from( + this.repository.watchFileSystem(1000), + this.repository.onDidChangeFileSystem(() => this.onDidChangeWip(), this), + this.repository.onDidChange(e => { + if (e.changed(RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { + this.onDidChangeWip(); + } + }), + ); + } + + private onDidChangeWip() { + this._files = undefined; + this.onDidChangeRepositoryWip(this); + } + + private async getFiles(): Promise<{ files: Change['files'] }> { + const status = await this.container.git.getStatusForRepo(this.repository.path); + + const files: GitFileChangeShape[] = []; + if (status != null) { + for (const file of status.files) { + const change = { + repoPath: file.repoPath, + path: file.path, + status: file.status, + originalPath: file.originalPath, + staged: file.staged, + }; + + files.push(change); + if (file.staged && file.wip) { + files.push({ ...change, staged: false }); + } + } + } + + return { files: files }; + } +} diff --git a/src/plus/webviews/timeline/protocol.ts b/src/plus/webviews/timeline/protocol.ts index 4666e0690f1b9..ca184980f5feb 100644 --- a/src/plus/webviews/timeline/protocol.ts +++ b/src/plus/webviews/timeline/protocol.ts @@ -1,11 +1,10 @@ -import type { WebviewIds, WebviewViewIds } from '../../../constants'; import type { FeatureAccess } from '../../../features'; -import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcCommand, IpcNotification } from '../../../webviews/protocol'; -export interface State { - webviewId: WebviewIds | WebviewViewIds; - timestamp: number; +export const scope: IpcScope = 'timeline'; +export interface State extends WebviewState { dataset?: Commit[]; period: Period; title?: string; @@ -31,10 +30,7 @@ export interface Commit { export type Period = `${number}|${'D' | 'M' | 'Y'}` | 'all'; -export interface DidChangeParams { - state: State; -} -export const DidChangeNotificationType = new IpcNotificationType('timeline/didChange'); +// COMMANDS export interface OpenDataPointParams { data?: { @@ -42,9 +38,16 @@ export interface OpenDataPointParams { selected: boolean; }; } -export const OpenDataPointCommandType = new IpcCommandType('timeline/point/open'); +export const OpenDataPointCommand = new IpcCommand(scope, 'point/open'); export interface UpdatePeriodParams { period: Period; } -export const UpdatePeriodCommandType = new IpcCommandType('timeline/period/update'); +export const UpdatePeriodCommand = new IpcCommand(scope, 'period/update'); + +// NOTIFICATIONS + +export interface DidChangeParams { + state: State; +} +export const DidChangeNotification = new IpcNotification(scope, 'didChange'); diff --git a/src/plus/webviews/timeline/registration.ts b/src/plus/webviews/timeline/registration.ts index 957cb208cd7e1..6285b3ea25e8e 100644 --- a/src/plus/webviews/timeline/registration.ts +++ b/src/plus/webviews/timeline/registration.ts @@ -1,11 +1,17 @@ -import { ViewColumn } from 'vscode'; +import type { Uri } from 'vscode'; +import { Disposable, ViewColumn } from 'vscode'; import { Commands } from '../../../constants'; -import type { WebviewsController } from '../../../webviews/webviewsController'; +import { registerCommand } from '../../../system/command'; +import { configuration } from '../../../system/configuration'; +import type { ViewFileNode } from '../../../views/nodes/abstract/viewFileNode'; +import type { WebviewPanelsProxy, WebviewsController } from '../../../webviews/webviewsController'; import type { State } from './protocol'; +export type TimelineWebviewShowingArgs = [Uri | ViewFileNode]; + export function registerTimelineWebviewPanel(controller: WebviewsController) { - return controller.registerWebviewPanel( - Commands.ShowTimelinePage, + return controller.registerWebviewPanel( + { id: Commands.ShowTimelinePage, options: { preserveInstance: true } }, { id: 'gitlens.timeline', fileName: 'timeline.html', @@ -19,16 +25,19 @@ export function registerTimelineWebviewPanel(controller: WebviewsController) { retainContextWhenHidden: false, enableFindWidget: false, }, + allowMultipleInstances: configuration.get('visualHistory.allowMultiple'), }, async (container, host) => { - const { TimelineWebviewProvider } = await import(/* webpackChunkName: "timeline" */ './timelineWebview'); + const { TimelineWebviewProvider } = await import( + /* webpackChunkName: "webview-timeline" */ './timelineWebview' + ); return new TimelineWebviewProvider(container, host); }, ); } export function registerTimelineWebviewView(controller: WebviewsController) { - return controller.registerWebviewView( + return controller.registerWebviewView( { id: 'gitlens.views.timeline', fileName: 'timeline.html', @@ -41,8 +50,24 @@ export function registerTimelineWebviewView(controller: WebviewsController) { }, }, async (container, host) => { - const { TimelineWebviewProvider } = await import(/* webpackChunkName: "timeline" */ './timelineWebview'); + const { TimelineWebviewProvider } = await import( + /* webpackChunkName: "webview-timeline" */ './timelineWebview' + ); return new TimelineWebviewProvider(container, host); }, ); } + +export function registerTimelineWebviewCommands(panels: WebviewPanelsProxy) { + return Disposable.from( + registerCommand( + Commands.ShowInTimeline, + (...args: TimelineWebviewShowingArgs) => void panels.show(undefined, ...args), + ), + registerCommand(`${panels.id}.refresh`, () => void panels.getActiveInstance()?.refresh(true)), + registerCommand( + `${panels.id}.split`, + () => void panels.splitActiveInstance({ preserveInstance: false, column: ViewColumn.Beside }), + ), + ); +} diff --git a/src/plus/webviews/timeline/timelineWebview.ts b/src/plus/webviews/timeline/timelineWebview.ts index 6ddf2467c82c1..6a5599a2de5e1 100644 --- a/src/plus/webviews/timeline/timelineWebview.ts +++ b/src/plus/webviews/timeline/timelineWebview.ts @@ -1,6 +1,6 @@ -import type { TextEditor, ViewColumn } from 'vscode'; -import { commands, Disposable, Uri, window } from 'vscode'; -import { Commands } from '../../../constants'; +import type { TextEditor } from 'vscode'; +import { Disposable, Uri, window } from 'vscode'; +import { Commands, proBadge } from '../../../constants'; import type { Container } from '../../../container'; import type { CommitSelectedEvent, FileSelectedEvent } from '../../../eventBus'; import { PlusFeatures } from '../../../features'; @@ -9,7 +9,7 @@ import { GitUri } from '../../../git/gitUri'; import { getChangedFilesCount } from '../../../git/models/commit'; import type { RepositoryChangeEvent } from '../../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; -import { registerCommand } from '../../../system/command'; +import { executeCommand, registerCommand } from '../../../system/command'; import { configuration } from '../../../system/configuration'; import { createFromDateDelta } from '../../../system/date'; import { debug } from '../../../system/decorators/log'; @@ -17,16 +17,16 @@ import type { Deferrable } from '../../../system/function'; import { debounce } from '../../../system/function'; import { filter } from '../../../system/iterable'; import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; -import type { ViewFileNode } from '../../../views/nodes/viewNode'; -import { isViewFileNode } from '../../../views/nodes/viewNode'; +import { isViewFileNode } from '../../../views/nodes/abstract/viewFileNode'; import type { IpcMessage } from '../../../webviews/protocol'; -import { onIpc } from '../../../webviews/protocol'; -import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController'; import { updatePendingContext } from '../../../webviews/webviewController'; +import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from '../../../webviews/webviewProvider'; +import type { WebviewShowOptions } from '../../../webviews/webviewsController'; import { isSerializedState } from '../../../webviews/webviewsController'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { Commit, Period, State } from './protocol'; -import { DidChangeNotificationType, OpenDataPointCommandType, UpdatePeriodCommandType } from './protocol'; +import { DidChangeNotification, OpenDataPointCommand, UpdatePeriodCommand } from './protocol'; +import type { TimelineWebviewShowingArgs } from './registration'; interface Context { uri: Uri | undefined; @@ -38,7 +38,7 @@ interface Context { const defaultPeriod: Period = '3|M'; -export class TimelineWebviewProvider implements WebviewProvider { +export class TimelineWebviewProvider implements WebviewProvider { private _bootstraping = true; /** The context the webview has */ private _context: Context; @@ -48,7 +48,7 @@ export class TimelineWebviewProvider implements WebviewProvider { constructor( private readonly container: Container, - private readonly host: WebviewController, + private readonly host: WebviewHost, ) { this._context = { uri: undefined, @@ -61,13 +61,13 @@ export class TimelineWebviewProvider implements WebviewProvider { this._context = { ...this._context, ...this._pendingContext }; this._pendingContext = undefined; - if (this.host.isEditor()) { + if (this.host.isHost('editor')) { this._disposable = Disposable.from( this.container.subscription.onDidChange(this.onSubscriptionChanged, this), this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), ); } else { - this.host.description = '✨'; + this.host.description = proBadge; this._disposable = Disposable.from( this.container.subscription.onDidChange(this.onSubscriptionChanged, this), this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), @@ -86,10 +86,33 @@ export class TimelineWebviewProvider implements WebviewProvider { void this.notifyDidChangeState(true); } + canReuseInstance(...args: WebviewShowingArgs): boolean | undefined { + let uri: Uri | undefined; + + const [arg] = args; + if (arg != null) { + if (arg instanceof Uri) { + uri = arg; + } else if (isViewFileNode(arg)) { + uri = arg.uri; + } else if (isSerializedState(arg) && arg.state.uri != null) { + uri = Uri.parse(arg.state.uri); + } + } else { + uri = window.activeTextEditor?.document.uri; + } + + return uri?.toString() === this._context.uri?.toString() ? true : undefined; + } + + getSplitArgs(): WebviewShowingArgs { + return this._context.uri != null ? [this._context.uri] : []; + } + onShowing( loading: boolean, - _options: { column?: ViewColumn; preserveFocus?: boolean }, - ...args: [Uri | ViewFileNode | { state: Partial }] | unknown[] + _options?: WebviewShowOptions, + ...args: WebviewShowingArgs ): boolean { const [arg] = args; if (arg != null) { @@ -127,20 +150,30 @@ export class TimelineWebviewProvider implements WebviewProvider { } registerCommands(): Disposable[] { - if (this.host.isEditor()) { - return [registerCommand(Commands.RefreshTimelinePage, () => this.host.refresh(true))]; + const commands: Disposable[] = []; + + if (this.host.isHost('view')) { + commands.push( + registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true), this), + registerCommand( + `${this.host.id}.openInTab`, + () => { + if (this._context.uri == null) return; + + void executeCommand(Commands.ShowInTimeline, this._context.uri); + }, + this, + ), + ); } - return [ - registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true), this), - registerCommand(`${this.host.id}.openInTab`, () => this.openInTab(), this), - ]; + return commands; } onVisibilityChanged(visible: boolean) { if (!visible) return; - if (this.host.isView()) { + if (this.host.isHost('view')) { this.updatePendingEditor(window.activeTextEditor); } @@ -158,47 +191,42 @@ export class TimelineWebviewProvider implements WebviewProvider { this.updateState(); } - onMessageReceived(e: IpcMessage) { - switch (e.method) { - case OpenDataPointCommandType.method: - onIpc(OpenDataPointCommandType, e, async params => { - if (params.data == null || !params.data.selected || this._context.uri == null) return; - - const repository = this.container.git.getRepository(this._context.uri); - if (repository == null) return; - - const commit = await repository.getCommit(params.data.id); - if (commit == null) return; - - this.container.events.fire( - 'commit:selected', - { - commit: commit, - interaction: 'active', - preserveFocus: true, - preserveVisibility: false, - }, - { source: this.host.id }, - ); - - if (!this.container.commitDetailsView.ready) { - void this.container.commitDetailsView.show({ preserveFocus: true }, { - commit: commit, - interaction: 'active', - preserveVisibility: false, - } satisfies CommitSelectedEvent['data']); - } - }); + async onMessageReceived(e: IpcMessage) { + switch (true) { + case OpenDataPointCommand.is(e): { + if (e.params.data == null || !e.params.data.selected || this._context.uri == null) return; + + const repository = this.container.git.getRepository(this._context.uri); + if (repository == null) return; + + const commit = await repository.getCommit(e.params.data.id); + if (commit == null) return; + + this.container.events.fire( + 'commit:selected', + { + commit: commit, + interaction: 'active', + preserveFocus: true, + preserveVisibility: false, + }, + { source: this.host.id }, + ); - break; - - case UpdatePeriodCommandType.method: - onIpc(UpdatePeriodCommandType, e, params => { - if (this.updatePendingContext({ period: params.period })) { - this.updateState(true); - } - }); + if (!this.container.commitDetailsView.ready) { + void this.container.commitDetailsView.show({ preserveFocus: true }, { + commit: commit, + interaction: 'active', + preserveVisibility: false, + } satisfies CommitSelectedEvent['data']); + } + break; + } + case UpdatePeriodCommand.is(e): + if (this.updatePendingContext({ period: e.params.period })) { + this.updateState(true); + } break; } } @@ -223,10 +251,8 @@ export class TimelineWebviewProvider implements WebviewProvider { if (e.data == null) return; let uri: Uri | undefined = e.data.uri; - if (uri != null) { - if (!this.container.git.isTrackable(uri)) { - uri = undefined; - } + if (uri != null && !this.container.git.isTrackable(uri)) { + uri = undefined; } if (!this.updatePendingUri(uri)) return; @@ -270,11 +296,11 @@ export class TimelineWebviewProvider implements WebviewProvider { const gitUri = current.uri != null ? await GitUri.fromUri(current.uri) : undefined; const repoPath = gitUri?.repoPath; - if (this.host.isEditor()) { + if (this.host.isHost('editor')) { this.host.title = gitUri == null ? this.host.originalTitle : `${this.host.originalTitle}: ${gitUri.fileName}`; } else { - this.host.description = gitUri?.fileName ?? '✨'; + this.host.description = gitUri?.fileName ?? proBadge; } const access = await this.container.git.access(PlusFeatures.Timeline, repoPath); @@ -282,8 +308,7 @@ export class TimelineWebviewProvider implements WebviewProvider { if (current.uri == null || gitUri == null || repoPath == null || access.allowed === false) { const access = await this.container.git.access(PlusFeatures.Timeline, repoPath); return { - webviewId: this.host.id, - timestamp: Date.now(), + ...this.host.baseWebviewState, period: period, title: gitUri?.relativePath, sha: gitUri?.shortSha, @@ -305,8 +330,7 @@ export class TimelineWebviewProvider implements WebviewProvider { if (log == null) { return { - webviewId: this.host.id, - timestamp: Date.now(), + ...this.host.baseWebviewState, dataset: [], period: period, title: gitUri.relativePath, @@ -364,8 +388,7 @@ export class TimelineWebviewProvider implements WebviewProvider { dataset.sort((a, b) => b.sort - a.sort); return { - webviewId: this.host.id, - timestamp: Date.now(), + ...this.host.baseWebviewState, dataset: dataset, period: period, title: gitUri.relativePath, @@ -435,20 +458,9 @@ export class TimelineWebviewProvider implements WebviewProvider { context = this._context; } - const task = async () => - this.host.notify(DidChangeNotificationType, { - state: await this.getState(context), - }); - - if (!this.host.isView()) return task(); - return window.withProgress({ location: { viewId: this.host.id } }, task); - } - - private openInTab() { - const uri = this._context.uri; - if (uri == null) return; - - void commands.executeCommand(Commands.ShowTimelinePage, uri); + return this.host.notify(DidChangeNotification, { + state: await this.getState(context), + }); } } diff --git a/src/plus/workspaces/models.ts b/src/plus/workspaces/models.ts index bc3fb244ef2c6..564ec8dcc46ac 100644 --- a/src/plus/workspaces/models.ts +++ b/src/plus/workspaces/models.ts @@ -149,7 +149,7 @@ export interface CloudWorkspaceRepositoryDescriptor { name: string; description: string; repository_id: string; - provider: string | null; + provider: CloudWorkspaceProviderType | null; provider_project_name: string | null; provider_organization_id: string; provider_organization_name: string | null; @@ -317,7 +317,7 @@ export interface CloudWorkspaceRepositoryData { name: string; description: string; repository_id: string; - provider: string | null; + provider: CloudWorkspaceProviderType | null; provider_project_name: string | null; provider_organization_id: string; provider_organization_name: string | null; diff --git a/src/plus/workspaces/workspacesApi.ts b/src/plus/workspaces/workspacesApi.ts index e76df744e7aa0..adf31f85c958f 100644 --- a/src/plus/workspaces/workspacesApi.ts +++ b/src/plus/workspaces/workspacesApi.ts @@ -164,25 +164,11 @@ export class WorkspacesApi { } queryParams += ')'; - let query = 'query getWorkpaces {'; - query += `memberProjects: projects ${queryParams} { ${queryData} }`; - - // TODO@axosoft-ramint This is a temporary and hacky workaround until projects api returns all projects the - // user belongs to in one query. Update once that is available. - if (options?.cursor == null && options?.includeOrganizations) { - const organizationIds = - (await this.container.subscription.getSubscription())?.account?.organizationIds ?? []; - for (const organizationId of organizationIds) { - let orgQueryParams = `(first: ${options?.count ?? defaultWorkspaceCount}`; - if (options?.page) { - orgQueryParams += `, page: ${options.page}`; - } - orgQueryParams += `, organization_id: "${organizationId}")`; - query += `organizationProjects_${organizationId}: projects ${orgQueryParams} { ${queryData} }`; + const query = ` + query getWorkpaces { + memberProjects: projects ${queryParams} { ${queryData} } } - } - - query += '}'; + `; const rsp = await this.fetch({ query: query }); diff --git a/src/plus/workspaces/workspacesService.ts b/src/plus/workspaces/workspacesService.ts index 0f209981699ec..f6824f2a8864e 100644 --- a/src/plus/workspaces/workspacesService.ts +++ b/src/plus/workspaces/workspacesService.ts @@ -6,13 +6,13 @@ import type { GitRemote } from '../../git/models/remote'; import { RemoteResourceType } from '../../git/models/remoteResource'; import { Repository } from '../../git/models/repository'; import { showRepositoriesPicker } from '../../quickpicks/repositoryPicker'; -import { SubscriptionState } from '../../subscription'; import { log } from '../../system/decorators/log'; import { normalizePath } from '../../system/path'; import type { OpenWorkspaceLocation } from '../../system/utils'; import { openWorkspace } from '../../system/utils'; +import { SubscriptionState } from '../gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../gk/account/subscriptionService'; import type { ServerConnection } from '../gk/serverConnection'; -import type { SubscriptionChangeEvent } from '../subscription/subscriptionService'; import type { AddWorkspaceRepoDescriptor, CloudWorkspaceData, @@ -174,16 +174,17 @@ export class WorkspacesService implements Disposable { const workspaceFileData: LocalWorkspaceData = (await this._workspacesPathProvider.getLocalWorkspaceData())?.workspaces || {}; for (const workspace of Object.values(workspaceFileData)) { + if (workspace.localId == null || workspace.name == null) continue; localWorkspaces.push( new LocalWorkspace( this.container, workspace.localId, workspace.name, - workspace.repositories.map(repositoryPath => ({ + workspace.repositories?.map(repositoryPath => ({ localPath: repositoryPath.localPath, name: repositoryPath.localPath.split(/[\\/]/).pop() ?? 'unknown', workspaceId: workspace.localId, - })), + })) ?? [], this._currentWorkspaceId != null && this._currentWorkspaceId === workspace.localId, ), ); @@ -342,7 +343,7 @@ export class WorkspacesService implements Disposable { repositoriesToAdd, ); if (pick.length === 0) return; - chosenRepoPaths = pick.map(p => p.repoPath); + chosenRepoPaths = pick.map(p => p.path); } else { chosenRepoPaths = repositoriesToAdd.map(r => r.path); } @@ -497,10 +498,14 @@ export class WorkspacesService implements Disposable { provider = workspace.provider; } - if (descriptor.id != null && descriptor.url != null && provider != null) { + if ( + descriptor.id != null && + (descriptor.url != null || + (descriptor.provider_organization_id != null && descriptor.name != null && provider != null)) + ) { await this.container.repositoryPathMapping.writeLocalRepoPath( { - remoteUrl: descriptor.url, + remoteUrl: descriptor.url ?? undefined, repoInfo: { provider: provider, owner: descriptor.provider_organization_id, @@ -509,6 +514,9 @@ export class WorkspacesService implements Disposable { }, repoPath, ); + } + + if (descriptor.id != null) { await this.updateCloudWorkspaceRepoLocalPath(workspaceId, descriptor.id, repoPath); } } @@ -899,7 +907,7 @@ export class WorkspacesService implements Disposable { validRepos, ); if (pick.length === 0) return; - reposOrRepoPaths = pick.map(p => p.repoPath); + reposOrRepoPaths = pick.map(p => p.path); } if (reposOrRepoPaths == null) return; diff --git a/src/quickpicks/aiModelPicker.ts b/src/quickpicks/aiModelPicker.ts index 0109e61a87f14..ed3a8ef3570ff 100644 --- a/src/quickpicks/aiModelPicker.ts +++ b/src/quickpicks/aiModelPicker.ts @@ -1,58 +1,83 @@ -import type { QuickPickItem } from 'vscode'; -import { QuickPickItemKind, window } from 'vscode'; -import type { AnthropicModels } from '../ai/anthropicProvider'; -import type { OpenAIModels } from '../ai/openaiProvider'; -import type { AIProviders } from '../constants'; -import { configuration } from '../system/configuration'; +import type { Disposable, QuickInputButton, QuickPickItem } from 'vscode'; +import { QuickPickItemKind, ThemeIcon, window } from 'vscode'; +import type { AIModel } from '../ai/aiProviderService'; +import type { AIModels, AIProviders } from '../constants'; +import { Commands } from '../constants'; +import type { Container } from '../container'; +import { executeCommand } from '../system/command'; +import { getQuickPickIgnoreFocusOut } from '../system/utils'; export interface ModelQuickPickItem extends QuickPickItem { - provider: AIProviders; - model: OpenAIModels | AnthropicModels; + model: AIModel; } -export async function showAIModelPicker(): Promise { - const provider = configuration.get('ai.experimental.provider') ?? 'openai'; - let model = configuration.get(`ai.experimental.${provider}.model`); - if (model == null) { - model = provider === 'anthropic' ? 'claude-v1' : 'gpt-3.5-turbo'; - } +export async function showAIModelPicker( + container: Container, + current?: { provider: AIProviders; model: AIModels }, +): Promise { + const models = (await (await container.ai)?.getModels()) ?? []; + + const items: ModelQuickPickItem[] = []; + + let lastProvider: AIProviders | undefined; + for (const m of models) { + if (m.hidden) continue; - type QuickPickSeparator = { label: string; kind: QuickPickItemKind.Separator }; - - const items: (ModelQuickPickItem | QuickPickSeparator)[] = [ - { label: 'OpenAI', kind: QuickPickItemKind.Separator }, - { label: 'OpenAI', description: 'GPT 3.5 Turbo', provider: 'openai', model: 'gpt-3.5-turbo' }, - { label: 'OpenAI', description: 'GPT 3.5 Turbo 16k', provider: 'openai', model: 'gpt-3.5-turbo-16k' }, - { label: 'OpenAI', description: 'GPT 4', provider: 'openai', model: 'gpt-4' }, - { label: 'OpenAI', description: 'GPT 4 32k', provider: 'openai', model: 'gpt-4-32k' }, - { label: 'Anthropic', kind: QuickPickItemKind.Separator }, - { label: 'Anthropic', description: 'Claude v1', provider: 'anthropic', model: 'claude-v1' }, - { label: 'Anthropic', description: 'Claude v1 100k', provider: 'anthropic', model: 'claude-v1-100k' }, - { label: 'Anthropic', description: 'Claude Instant v1', provider: 'anthropic', model: 'claude-instant-v1' }, - { - label: 'Anthropic', - description: 'Claude Instant v1 100k', - provider: 'anthropic', - model: 'claude-instant-v1-100k', - }, - { label: 'Anthropic', description: 'Claude 2', provider: 'anthropic', model: 'claude-2' }, - ]; - - for (const item of items) { - if (item.kind === QuickPickItemKind.Separator) continue; - - if (item.model === model) { - item.description = `${item.description} \u2713`; - item.picked = true; - break; + if (lastProvider !== m.provider.id) { + lastProvider = m.provider.id; + items.push({ label: m.provider.name, kind: QuickPickItemKind.Separator } as unknown as ModelQuickPickItem); } + + const picked = m.provider.id === current?.provider && m.id === current?.model; + + items.push({ + label: m.name, + iconPath: picked ? new ThemeIcon('check') : new ThemeIcon('blank'), + // description: m.provider.name, + model: m, + picked: picked, + } satisfies ModelQuickPickItem); } - const pick = (await window.showQuickPick(items, { - title: 'Switch AI Model', - placeHolder: 'select an AI model to use for experimental AI features', - matchOnDescription: true, - })) as ModelQuickPickItem | undefined; + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; - return pick; + const ResetAIKeyButton: QuickInputButton = { + iconPath: new ThemeIcon('clear-all'), + tooltip: 'Reset AI Keys...', + }; + + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + quickpick.onDidTriggerButton(e => { + if (e === ResetAIKeyButton) { + void executeCommand(Commands.ResetAIKey); + } + }), + ); + + quickpick.title = 'Choose AI Model'; + quickpick.placeholder = 'Select an AI model to use for experimental AI features'; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.buttons = [ResetAIKeyButton]; + quickpick.items = items; + + quickpick.show(); + }); + + return pick; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } } diff --git a/src/quickpicks/branchPicker.ts b/src/quickpicks/branchPicker.ts new file mode 100644 index 0000000000000..df461a69edb7b --- /dev/null +++ b/src/quickpicks/branchPicker.ts @@ -0,0 +1,150 @@ +import type { Disposable, QuickPickItem } from 'vscode'; +import { window } from 'vscode'; +import { getBranches } from '../commands/quickCommand.steps'; +import type { GitBranch } from '../git/models/branch'; +import type { Repository } from '../git/models/repository'; +import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import type { BranchQuickPickItem } from './items/gitCommands'; + +export async function showBranchPicker( + title: string | undefined, + placeholder?: string, + repository?: Repository, +): Promise { + if (repository == null) { + return undefined; + } + + const items: BranchQuickPickItem[] = await getBranches(repository, {}); + if (items.length === 0) return undefined; + + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; + + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + ); + + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.items = items; + + quickpick.show(); + }); + + return pick?.item; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } +} + +export async function showNewBranchPicker( + title: string | undefined, + placeholder?: string, + _repository?: Repository, +): Promise { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let newBranchName: string | undefined; + try { + newBranchName = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (value == null) { + input.validationMessage = 'Please enter a valid branch name'; + return; + } + + resolve(value); + }), + ); + + input.title = title; + input.placeholder = placeholder; + input.prompt = 'Enter a name for the new branch'; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + return newBranchName; +} + +export async function showNewOrSelectBranchPicker( + title: string | undefined, + repository?: Repository, +): Promise { + if (repository == null) { + return undefined; + } + + // TODO: needs updating + const createNewBranch = { + label: 'Create new branch', + description: + 'Creates a branch to apply the Cloud Patch to. (Typing an existing branch name will use that branch.)', + }; + const selectExistingBranch = { + label: 'Select existing branch', + description: 'Selects an existing branch to apply the Cloud Patch to.', + }; + + const items: QuickPickItem[] = [createNewBranch, selectExistingBranch]; + + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; + + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + ); + + quickpick.title = title; + quickpick.placeholder = 'Select a branch option'; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.items = items; + + quickpick.show(); + }); + + if (pick === createNewBranch) { + return showNewBranchPicker(title, 'Enter a name for the new branch', repository); + } else if (pick === selectExistingBranch) { + return showBranchPicker(title, 'Select an existing branch', repository); + } + + return undefined; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } +} diff --git a/src/quickpicks/commitPicker.ts b/src/quickpicks/commitPicker.ts index e4beb37eab012..335efc8bcb616 100644 --- a/src/quickpicks/commitPicker.ts +++ b/src/quickpicks/commitPicker.ts @@ -14,20 +14,29 @@ import { CommandQuickPickItem } from './items/common'; import type { DirectiveQuickPickItem } from './items/directive'; import { createDirectiveQuickPickItem, Directive, isDirectiveQuickPickItem } from './items/directive'; import type { CommitQuickPickItem } from './items/gitCommands'; -import { createCommitQuickPickItem } from './items/gitCommands'; +import { createCommitQuickPickItem, createStashQuickPickItem } from './items/gitCommands'; + +type Item = CommandQuickPickItem | CommitQuickPickItem | DirectiveQuickPickItem; export async function showCommitPicker( log: GitLog | undefined | Promise, title: string, placeholder: string, options?: { + empty?: { + getState?: () => + | { items: Item[]; placeholder?: string; title?: string } + | Promise<{ items: Item[]; placeholder?: string; title?: string }>; + }; picked?: string; - keys?: Keys[]; - onDidPressKey?(key: Keys, item: CommitQuickPickItem): void | Promise; + keyboard?: { + keys: Keys[]; + onDidPressKey(key: Keys, item: CommitQuickPickItem): void | Promise; + }; showOtherReferences?: CommandQuickPickItem[]; }, ): Promise { - const quickpick = window.createQuickPick(); + const quickpick = window.createQuickPick(); quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); quickpick.title = title; @@ -40,39 +49,74 @@ export async function showCommitPicker( quickpick.show(); log = await log; + } + + if (log == null) { + quickpick.placeholder = 'No commits found'; - if (log == null) { - quickpick.placeholder = 'Unable to show commit history'; + if (options?.empty?.getState != null) { + const empty = await options.empty.getState(); + quickpick.items = empty.items; + if (empty.placeholder != null) { + quickpick.placeholder = empty.placeholder; + } + if (empty.title != null) { + quickpick.title = empty.title; + } + } else { + quickpick.items = [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })]; } + } else { + quickpick.items = await getItems(log); } - quickpick.items = getItems(log); - if (options?.picked) { quickpick.activeItems = quickpick.items.filter(i => (CommandQuickPickItem.is(i) ? false : i.picked)); } - function getItems(log: GitLog | undefined) { - return log == null - ? [createDirectiveQuickPickItem(Directive.Cancel)] - : [ - ...(options?.showOtherReferences ?? []), - ...map(log.commits.values(), commit => - createCommitQuickPickItem(commit, options?.picked === commit.ref, { - compact: true, - icon: true, - }), - ), - ...(log?.hasMore ? [createDirectiveQuickPickItem(Directive.LoadMore)] : []), - ]; + async function getItems(log: GitLog) { + const items = []; + if (options?.showOtherReferences != null) { + items.push(...options.showOtherReferences); + } + + for await (const item of map(log.commits.values(), async commit => + createCommitQuickPickItem(commit, options?.picked === commit.ref, { compact: true, icon: 'avatar' }), + )) { + items.push(item); + } + + if (log.hasMore) { + items.push(createDirectiveQuickPickItem(Directive.LoadMore)); + } + + return items; } async function loadMore() { + quickpick.ignoreFocusOut = true; quickpick.busy = true; try { log = await (await log)?.more?.(configuration.get('advanced.maxListItems')); - const items = getItems(log); + + let items; + if (log == null) { + if (options?.empty?.getState != null) { + const empty = await options.empty.getState(); + items = empty.items; + if (empty.placeholder != null) { + quickpick.placeholder = empty.placeholder; + } + if (empty.title != null) { + quickpick.title = empty.title; + } + } else { + items = [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })]; + } + } else { + items = await getItems(log); + } let activeIndex = -1; if (quickpick.activeItems.length !== 0) { @@ -98,17 +142,23 @@ export async function showCommitPicker( const disposables: Disposable[] = []; let scope: KeyboardScope | undefined; - if (options?.keys != null && options.keys.length !== 0 && options?.onDidPressKey !== null) { + if (options?.keyboard != null) { + const { keyboard } = options; scope = Container.instance.keyboard.createScope( Object.fromEntries( - options.keys.map(key => [ + keyboard.keys.map(key => [ key, { - onDidPressKey: key => { + onDidPressKey: async key => { if (quickpick.activeItems.length !== 0) { const [item] = quickpick.activeItems; if (item != null && !isDirectiveQuickPickItem(item) && !CommandQuickPickItem.is(item)) { - void options.onDidPressKey!(key, item); + const ignoreFocusOut = quickpick.ignoreFocusOut; + quickpick.ignoreFocusOut = true; + + await keyboard.onDidPressKey(key, item); + + quickpick.ignoreFocusOut = ignoreFocusOut; } } }, @@ -121,45 +171,43 @@ export async function showCommitPicker( } try { - const pick = await new Promise( - resolve => { - disposables.push( - quickpick.onDidHide(() => resolve(undefined)), - quickpick.onDidAccept(() => { - if (quickpick.activeItems.length !== 0) { - const [item] = quickpick.activeItems; - if (isDirectiveQuickPickItem(item)) { - switch (item.directive) { - case Directive.LoadMore: - void loadMore(); - return; - - default: - resolve(undefined); - return; - } + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + const [item] = quickpick.activeItems; + if (isDirectiveQuickPickItem(item)) { + switch (item.directive) { + case Directive.LoadMore: + void loadMore(); + return; + + default: + resolve(undefined); + return; } - - resolve(item); } - }), - quickpick.onDidChangeValue(async e => { - if (scope == null) return; - - // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly - if (e.length !== 0) { - await scope.pause(['left', 'right']); - } else { - await scope.resume(); - } - }), - ); - quickpick.busy = false; + resolve(item); + } + }), + quickpick.onDidChangeValue(value => { + if (scope == null) return; - quickpick.show(); - }, - ); + // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly + if (value.length !== 0) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } else { + void scope.resume(); + } + }), + ); + + quickpick.busy = false; + + quickpick.show(); + }); if (pick == null || isDirectiveQuickPickItem(pick)) return undefined; if (pick instanceof CommandQuickPickItem) { @@ -182,8 +230,10 @@ export async function showStashPicker( options?: { empty?: string; filter?: (c: GitStashCommit) => boolean; - keys?: Keys[]; - onDidPressKey?(key: Keys, item: CommitQuickPickItem): void | Promise; + keyboard?: { + keys: Keys[]; + onDidPressKey(key: Keys, item: CommitQuickPickItem): void | Promise; + }; picked?: string; showOtherReferences?: CommandQuickPickItem[]; }, @@ -211,7 +261,7 @@ export async function showStashPicker( ...map( options?.filter != null ? filter(stash.commits.values(), options.filter) : stash.commits.values(), commit => - createCommitQuickPickItem(commit, options?.picked === commit.ref, { + createStashQuickPickItem(commit, options?.picked === commit.ref, { compact: true, icon: true, }), @@ -231,17 +281,23 @@ export async function showStashPicker( const disposables: Disposable[] = []; let scope: KeyboardScope | undefined; - if (options?.keys != null && options.keys.length !== 0 && options?.onDidPressKey !== null) { + if (options?.keyboard != null) { + const { keyboard } = options; scope = Container.instance.keyboard.createScope( Object.fromEntries( - options.keys.map(key => [ + keyboard.keys.map(key => [ key, { - onDidPressKey: key => { + onDidPressKey: async key => { if (quickpick.activeItems.length !== 0) { const [item] = quickpick.activeItems; if (item != null && !isDirectiveQuickPickItem(item) && !CommandQuickPickItem.is(item)) { - void options.onDidPressKey!(key, item); + const ignoreFocusOut = quickpick.ignoreFocusOut; + quickpick.ignoreFocusOut = true; + + await keyboard.onDidPressKey(key, item); + + quickpick.ignoreFocusOut = ignoreFocusOut; } } }, @@ -270,14 +326,14 @@ export async function showStashPicker( resolve(item); } }), - quickpick.onDidChangeValue(async e => { + quickpick.onDidChangeValue(value => { if (scope == null) return; // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly - if (e.length !== 0) { - await scope.pause(['left', 'right']); + if (value.length !== 0) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); } else { - await scope.resume(); + void scope.resume(); } }), ); diff --git a/src/quickpicks/contributorsPicker.ts b/src/quickpicks/contributorsPicker.ts new file mode 100644 index 0000000000000..10ab1b96d557f --- /dev/null +++ b/src/quickpicks/contributorsPicker.ts @@ -0,0 +1,113 @@ +import type { Disposable } from 'vscode'; +import { window } from 'vscode'; +import { ClearQuickInputButton } from '../commands/quickCommand.buttons'; +import { GlyphChars, quickPickTitleMaxChars } from '../constants'; +import type { Container } from '../container'; +import type { ContributorQuickPickItem, GitContributor } from '../git/models/contributor'; +import { createContributorQuickPickItem, sortContributors } from '../git/models/contributor'; +import type { Repository } from '../git/models/repository'; +import { debounce } from '../system/function'; +import { defer } from '../system/promise'; +import { pad, truncate } from '../system/string'; +import { getQuickPickIgnoreFocusOut } from '../system/utils'; + +export async function showContributorsPicker( + container: Container, + repository: Repository, + title: string, + placeholder: string, + options?: { + appendReposToTitle?: boolean; + clearButton?: boolean; + ignoreFocusOut?: boolean; + multiselect?: boolean; + picked?: (contributor: GitContributor) => boolean; + }, +): Promise { + const deferred = defer(); + const disposables: Disposable[] = []; + + try { + const quickpick = window.createQuickPick(); + disposables.push( + quickpick, + quickpick.onDidHide(() => deferred.fulfill(undefined)), + quickpick.onDidAccept(() => + !quickpick.busy ? deferred.fulfill(quickpick.selectedItems.map(c => c.item)) : undefined, + ), + quickpick.onDidChangeSelection( + debounce(e => { + if (!quickpick.canSelectMany || quickpick.busy) return; + + let update = false; + for (const item of quickpick.items) { + const picked = e.includes(item); + if (item.picked !== picked || item.alwaysShow !== picked) { + item.alwaysShow = item.picked = picked; + update = true; + } + } + + if (update) { + quickpick.items = sortContributors([...quickpick.items]); + quickpick.selectedItems = e; + } + }, 10), + ), + quickpick.onDidTriggerButton(e => { + if (e === ClearQuickInputButton) { + if (quickpick.canSelectMany) { + quickpick.selectedItems = []; + } else { + deferred.fulfill([]); + } + } + }), + ); + + quickpick.ignoreFocusOut = options?.ignoreFocusOut ?? getQuickPickIgnoreFocusOut(); + + quickpick.title = options?.appendReposToTitle ? appendRepoToTitle(container, title, repository) : title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.canSelectMany = options?.multiselect ?? true; + + quickpick.buttons = options?.clearButton ? [ClearQuickInputButton] : []; + + quickpick.busy = true; + quickpick.show(); + + const contributors = await repository.getContributors(); + if (!deferred.pending) return; + + const items = await Promise.all( + contributors.map(c => createContributorQuickPickItem(c, options?.picked?.(c) ?? false)), + ); + + if (!deferred.pending) return; + + quickpick.items = sortContributors(items); + if (quickpick.canSelectMany) { + quickpick.selectedItems = items.filter(i => i.picked); + } else { + quickpick.activeItems = items.filter(i => i.picked); + } + + quickpick.busy = false; + + const picks = await deferred.promise; + return picks; + } finally { + disposables.forEach(d => void d.dispose()); + } +} + +function appendRepoToTitle(container: Container, title: string, repo: Repository) { + return container.git.openRepositoryCount <= 1 + ? title + : `${title}${truncate( + `${pad(GlyphChars.Dot, 2, 2)}${repo.formattedName}`, + quickPickTitleMaxChars - title.length, + )}`; +} diff --git a/src/quickpicks/items/commits.ts b/src/quickpicks/items/commits.ts index a2d80860a8a10..1415b389e1da9 100644 --- a/src/quickpicks/items/commits.ts +++ b/src/quickpicks/items/commits.ts @@ -1,7 +1,6 @@ -import type { QuickPickItem } from 'vscode'; -import { window } from 'vscode'; -import type { OpenOnlyChangedFilesCommandArgs } from '../../commands'; +import { ThemeIcon, window } from 'vscode'; import type { OpenChangedFilesCommandArgs } from '../../commands/openChangedFiles'; +import type { OpenOnlyChangedFilesCommandArgs } from '../../commands/openOnlyChangedFiles'; import { RevealInSideBarQuickInputButton, ShowDetailsViewQuickInputButton } from '../../commands/quickCommand.buttons'; import type { Keys } from '../../constants'; import { Commands, GlyphChars } from '../../constants'; @@ -11,10 +10,11 @@ import * as CommitActions from '../../git/actions/commit'; import { CommitFormatter } from '../../git/formatters/commitFormatter'; import type { GitCommit } from '../../git/models/commit'; import type { GitFile, GitFileChange } from '../../git/models/file'; -import { getGitFileFormattedDirectory, getGitFileStatusCodicon } from '../../git/models/file'; +import { getGitFileFormattedDirectory, getGitFileStatusThemeIcon } from '../../git/models/file'; import type { GitStatusFile } from '../../git/models/status'; import { basename } from '../../system/path'; import { pad } from '../../system/string'; +import type { CompareResultsNode } from '../../views/nodes/compareResultsNode'; import { CommandQuickPickItem } from './common'; export class CommitFilesQuickPickItem extends CommandQuickPickItem { @@ -52,6 +52,7 @@ export class CommitFilesQuickPickItem extends CommandQuickPickItem { }, undefined, undefined, + undefined, { suppressKeyPress: true }, ); } @@ -68,9 +69,10 @@ export class CommitFileQuickPickItem extends CommandQuickPickItem { picked?: boolean, ) { super({ - label: `${pad(getGitFileStatusCodicon(file.status), 0, 2)}${basename(file.path)}`, + label: basename(file.path), description: getGitFileFormattedDirectory(file, true), picked: picked, + iconPath: getGitFileStatusThemeIcon(file.status), }); // TODO@eamodio - add line diff details @@ -109,14 +111,13 @@ export class CommitBrowseRepositoryFromHereCommandQuickPickItem extends CommandQ before?: boolean; openInNewWindow: boolean; }, - item?: QuickPickItem, ) { super( - item ?? - `$(folder-opened) Browse Repository from${executeOptions?.before ? ' Before' : ''} Here${ - executeOptions?.openInNewWindow ? ' in New Window' : '' - }`, + `Browse Repository from${executeOptions?.before ? ' Before' : ''} Here${ + executeOptions?.openInNewWindow ? ' in New Window' : '' + }`, ); + this.iconPath = new ThemeIcon('folder-opened'); } override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -128,37 +129,29 @@ export class CommitBrowseRepositoryFromHereCommandQuickPickItem extends CommandQ } export class CommitCompareWithHEADCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(compare-changes) Compare with HEAD'); + constructor(private readonly commit: GitCommit) { + super('Compare with HEAD'); + this.iconPath = new ThemeIcon('compare-changes'); } - override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { + override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { return Container.instance.searchAndCompareView.compare(this.commit.repoPath, this.commit.ref, 'HEAD'); } } export class CommitCompareWithWorkingCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(compare-changes) Compare with Working Tree'); + constructor(private readonly commit: GitCommit) { + super('Compare with Working Tree', new ThemeIcon('compare-changes')); } - override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { + override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { return Container.instance.searchAndCompareView.compare(this.commit.repoPath, this.commit.ref, ''); } } export class CommitCopyIdQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(copy) Copy SHA'); + constructor(private readonly commit: GitCommit) { + super('Copy SHA', new ThemeIcon('copy')); } override execute(): Promise { @@ -172,11 +165,8 @@ export class CommitCopyIdQuickPickItem extends CommandQuickPickItem { } export class CommitCopyMessageQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(copy) Copy Message'); + constructor(private readonly commit: GitCommit) { + super('Copy Message', new ThemeIcon('copy')); } override execute(): Promise { @@ -192,11 +182,8 @@ export class CommitCopyMessageQuickPickItem extends CommandQuickPickItem { } export class CommitOpenAllChangesCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(git-compare) Open All Changes'); + constructor(private readonly commit: GitCommit) { + super('Open All Changes', new ThemeIcon('git-compare')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -205,11 +192,8 @@ export class CommitOpenAllChangesCommandQuickPickItem extends CommandQuickPickIt } export class CommitOpenAllChangesWithDiffToolCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(git-compare) Open All Changes (difftool)'); + constructor(private readonly commit: GitCommit) { + super('Open All Changes (difftool)', new ThemeIcon('git-compare')); } override execute(): Promise { @@ -218,11 +202,8 @@ export class CommitOpenAllChangesWithDiffToolCommandQuickPickItem extends Comman } export class CommitOpenAllChangesWithWorkingCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(git-compare) Open All Changes with Working Tree'); + constructor(private readonly commit: GitCommit) { + super('Open All Changes with Working Tree', new ThemeIcon('git-compare')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -234,9 +215,8 @@ export class CommitOpenChangesCommandQuickPickItem extends CommandQuickPickItem constructor( private readonly commit: GitCommit, private readonly file: string | GitFile, - item?: QuickPickItem, ) { - super(item ?? '$(git-compare) Open Changes'); + super('Open Changes', new ThemeIcon('git-compare')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -248,9 +228,8 @@ export class CommitOpenChangesWithDiffToolCommandQuickPickItem extends CommandQu constructor( private readonly commit: GitCommit, private readonly file: string | GitFile, - item?: QuickPickItem, ) { - super(item ?? '$(git-compare) Open Changes (difftool)'); + super('Open Changes (difftool)', new ThemeIcon('git-compare')); } override execute(): Promise { @@ -262,9 +241,8 @@ export class CommitOpenChangesWithWorkingCommandQuickPickItem extends CommandQui constructor( private readonly commit: GitCommit, private readonly file: string | GitFile, - item?: QuickPickItem, ) { - super(item ?? '$(git-compare) Open Changes with Working File'); + super('Open Changes with Working File', new ThemeIcon('git-compare')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -273,11 +251,8 @@ export class CommitOpenChangesWithWorkingCommandQuickPickItem extends CommandQui } export class CommitOpenDirectoryCompareCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(git-compare) Open Directory Compare'); + constructor(private readonly commit: GitCommit) { + super('Open Directory Compare', new ThemeIcon('git-compare')); } override execute(): Promise { @@ -286,11 +261,8 @@ export class CommitOpenDirectoryCompareCommandQuickPickItem extends CommandQuick } export class CommitOpenDirectoryCompareWithWorkingCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(git-compare) Open Directory Compare with Working Tree'); + constructor(private readonly commit: GitCommit) { + super('Open Directory Compare with Working Tree', new ThemeIcon('git-compare')); } override execute(): Promise { @@ -299,11 +271,8 @@ export class CommitOpenDirectoryCompareWithWorkingCommandQuickPickItem extends C } export class CommitOpenDetailsCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(eye) Open Details'); + constructor(private readonly commit: GitCommit) { + super('Inspect Commit Details', new ThemeIcon('eye')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -312,11 +281,8 @@ export class CommitOpenDetailsCommandQuickPickItem extends CommandQuickPickItem } export class CommitOpenInGraphCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(gitlens-graph) Open in Commit Graph'); + constructor(private readonly commit: GitCommit) { + super('Open in Commit Graph', new ThemeIcon('gitlens-graph')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -325,11 +291,8 @@ export class CommitOpenInGraphCommandQuickPickItem extends CommandQuickPickItem } export class CommitOpenFilesCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(files) Open Files'); + constructor(private readonly commit: GitCommit) { + super('Open Files', new ThemeIcon('files')); } override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -341,9 +304,8 @@ export class CommitOpenFileCommandQuickPickItem extends CommandQuickPickItem { constructor( private readonly commit: GitCommit, private readonly file: string | GitFile, - item?: QuickPickItem, ) { - super(item ?? '$(file) Open File'); + super('Open File', new ThemeIcon('file')); } override execute(options?: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -352,11 +314,8 @@ export class CommitOpenFileCommandQuickPickItem extends CommandQuickPickItem { } export class CommitOpenRevisionsCommandQuickPickItem extends CommandQuickPickItem { - constructor( - private readonly commit: GitCommit, - item?: QuickPickItem, - ) { - super(item ?? '$(files) Open Files at Revision'); + constructor(private readonly commit: GitCommit) { + super('Open Files at Revision', new ThemeIcon('files')); } override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -368,9 +327,8 @@ export class CommitOpenRevisionCommandQuickPickItem extends CommandQuickPickItem constructor( private readonly commit: GitCommit, private readonly file: string | GitFile, - item?: QuickPickItem, ) { - super(item ?? '$(file) Open File at Revision'); + super('Open File at Revision', new ThemeIcon('file')); } override execute(options?: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -382,9 +340,8 @@ export class CommitApplyFileChangesCommandQuickPickItem extends CommandQuickPick constructor( private readonly commit: GitCommit, private readonly file: string | GitFile, - item?: QuickPickItem, ) { - super(item ?? 'Apply Changes'); + super('Apply Changes'); } override async execute(): Promise { @@ -396,14 +353,11 @@ export class CommitRestoreFileChangesCommandQuickPickItem extends CommandQuickPi constructor( private readonly commit: GitCommit, private readonly file: string | GitFile, - item?: QuickPickItem, ) { - super( - item ?? { - label: 'Restore', - description: 'aka checkout', - }, - ); + super({ + label: 'Restore', + description: 'aka checkout', + }); } override execute(): Promise { @@ -412,21 +366,23 @@ export class CommitRestoreFileChangesCommandQuickPickItem extends CommandQuickPi } export class OpenChangedFilesCommandQuickPickItem extends CommandQuickPickItem { - constructor(files: GitStatusFile[], item?: QuickPickItem) { + constructor(files: GitStatusFile[], label?: string) { const commandArgs: OpenChangedFilesCommandArgs = { uris: files.map(f => f.uri), }; - super(item ?? '$(files) Open All Changed Files', Commands.OpenChangedFiles, [commandArgs]); + super(label ?? 'Open All Changed Files', new ThemeIcon('files'), Commands.OpenChangedFiles, [commandArgs]); } } export class OpenOnlyChangedFilesCommandQuickPickItem extends CommandQuickPickItem { - constructor(files: GitStatusFile[], item?: QuickPickItem) { + constructor(files: GitStatusFile[], label?: string) { const commandArgs: OpenOnlyChangedFilesCommandArgs = { uris: files.map(f => f.uri), }; - super(item ?? '$(files) Open Changed & Close Unchanged Files', Commands.OpenOnlyChangedFiles, [commandArgs]); + super(label ?? 'Open Changed & Close Unchanged Files', new ThemeIcon('files'), Commands.OpenOnlyChangedFiles, [ + commandArgs, + ]); } } diff --git a/src/quickpicks/items/common.ts b/src/quickpicks/items/common.ts index a83966b09cba5..85798b9ea3519 100644 --- a/src/quickpicks/items/common.ts +++ b/src/quickpicks/items/common.ts @@ -1,4 +1,4 @@ -import type { QuickPickItem } from 'vscode'; +import type { QuickPickItem, ThemeIcon, Uri } from 'vscode'; import { commands, QuickPickItemKind } from 'vscode'; import type { Commands, Keys } from '../../constants'; @@ -13,22 +13,27 @@ export interface QuickPickSeparator extends QuickPickItem { kind: QuickPickItemKind.Separator; } -export function createQuickPickSeparator(label?: string): QuickPickSeparator { - return { kind: QuickPickItemKind.Separator, label: label ?? '' }; +export function createQuickPickSeparator(label?: string): T { + return { kind: QuickPickItemKind.Separator, label: label ?? '' } as unknown as T; } export interface QuickPickItemOfT extends QuickPickItem { readonly item: T; } +export function createQuickPickItemOfT(labelOrItem: string | QuickPickItem, item: T): QuickPickItemOfT { + return typeof labelOrItem === 'string' ? { label: labelOrItem, item: item } : { ...labelOrItem, item: item }; +} + export class CommandQuickPickItem implements QuickPickItem { static fromCommand(label: string, command: Commands, args?: T): CommandQuickPickItem; static fromCommand(item: QuickPickItem, command: Commands, args?: T): CommandQuickPickItem; static fromCommand(labelOrItem: string | QuickPickItem, command: Commands, args?: T): CommandQuickPickItem { return new CommandQuickPickItem( typeof labelOrItem === 'string' ? { label: labelOrItem } : labelOrItem, + undefined, command, - args == null ? [] : [args], + args == null ? [] : Array.isArray(args) ? args : [args], ); } @@ -39,9 +44,11 @@ export class CommandQuickPickItem implements Qu label!: string; description?: string; detail?: string | undefined; + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined; constructor( label: string, + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined, command?: Commands, args?: Arguments, options?: { @@ -51,6 +58,7 @@ export class CommandQuickPickItem implements Qu ); constructor( item: QuickPickItem, + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined, command?: Commands, args?: Arguments, options?: { @@ -60,6 +68,7 @@ export class CommandQuickPickItem implements Qu ); constructor( labelOrItem: string | QuickPickItem, + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined, command?: Commands, args?: Arguments, options?: { @@ -69,6 +78,7 @@ export class CommandQuickPickItem implements Qu ); constructor( labelOrItem: string | QuickPickItem, + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined, protected readonly command?: Commands, protected readonly args?: Arguments, protected readonly options?: { @@ -88,6 +98,10 @@ export class CommandQuickPickItem implements Qu } else { Object.assign(this, labelOrItem); } + + if (iconPath != null) { + this.iconPath = iconPath; + } } execute(_options?: { preserveFocus?: boolean; preview?: boolean }): Promise { diff --git a/src/quickpicks/items/directive.ts b/src/quickpicks/items/directive.ts index c3b942649d05b..e7b460e3fcd7a 100644 --- a/src/quickpicks/items/directive.ts +++ b/src/quickpicks/items/directive.ts @@ -1,16 +1,18 @@ -import type { QuickPickItem } from 'vscode'; -import type { Subscription } from '../../subscription'; +import type { QuickPickItem, ThemeIcon, Uri } from 'vscode'; +import type { Subscription } from '../../plus/gk/account/subscription'; export enum Directive { Back, Cancel, LoadMore, Noop, + Reload, RequiresVerification, - ExtendTrial, + SignIn, + StartPreview, + StartProTrial, RequiresPaidSubscription, - StartPreviewTrial, } export function isDirective(value: Directive | T): value is Directive { @@ -19,12 +21,20 @@ export function isDirective(value: Directive | T): value is Directive { export interface DirectiveQuickPickItem extends QuickPickItem { directive: Directive; + onDidSelect?: () => void | Promise; } export function createDirectiveQuickPickItem( directive: Directive, picked?: boolean, - options?: { label?: string; description?: string; detail?: string; subscription?: Subscription }, + options?: { + label?: string; + description?: string; + detail?: string; + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + subscription?: Subscription; + onDidSelect?: () => void | Promise; + }, ) { let label = options?.label; let detail = options?.detail; @@ -42,21 +52,27 @@ export function createDirectiveQuickPickItem( case Directive.Noop: label = 'Try again'; break; - case Directive.StartPreviewTrial: - label = 'Preview Pro'; - detail = 'Preview Pro for 3-days to use this on privately hosted repos'; + case Directive.Reload: + label = 'Refresh'; break; - case Directive.ExtendTrial: - label = 'Start Free Pro Trial'; - detail = 'Continue to use this on privately hosted repos, free for an additional 7 days'; + case Directive.SignIn: + label = 'Sign In'; + break; + case Directive.StartPreview: + label = 'Continue'; + detail = 'Continuing gives you 3 days to preview this and other local Pro features'; + break; + case Directive.StartProTrial: + label = 'Start Pro Trial'; + detail = 'Start your free 7-day Pro trial for full access to Pro features'; break; case Directive.RequiresVerification: - label = 'Resend Verification Email'; + label = 'Resend Email'; detail = 'You must verify your email before you can continue'; break; case Directive.RequiresPaidSubscription: label = 'Upgrade to Pro'; - detail = 'A paid plan is required to use this on privately hosted repos'; + detail = 'Upgrading to a paid plan is required to use this Pro feature'; break; } } @@ -65,9 +81,11 @@ export function createDirectiveQuickPickItem( label: label, description: options?.description, detail: detail, + iconPath: options?.iconPath, alwaysShow: true, picked: picked, directive: directive, + onDidSelect: options?.onDidSelect, }; return item; diff --git a/src/quickpicks/items/flags.ts b/src/quickpicks/items/flags.ts index 4b3943e897cbc..e5c771b59ca3e 100644 --- a/src/quickpicks/items/flags.ts +++ b/src/quickpicks/items/flags.ts @@ -1,9 +1,7 @@ import type { QuickPickItem } from 'vscode'; import type { QuickPickItemOfT } from './common'; -export type FlagsQuickPickItem = Context extends void - ? QuickPickItemOfT - : QuickPickItemOfT & { context: Context }; +export type FlagsQuickPickItem = QuickPickItemOfT & { context: Context }; export function createFlagsQuickPickItem(flags: T[], item: T[], options: QuickPickItem): FlagsQuickPickItem; export function createFlagsQuickPickItem( @@ -18,7 +16,7 @@ export function createFlagsQuickPickItem( options: QuickPickItem, context?: Context, ): any { - return { ...options, item: item, picked: hasFlags(flags, item), context: context }; + return { ...options, item: item, picked: options.picked ?? hasFlags(flags, item), context: context }; } function hasFlags(flags: T[], has?: T | T[]): boolean { diff --git a/src/quickpicks/items/gitCommands.ts b/src/quickpicks/items/gitCommands.ts index 4163b287b3001..b0eefcdf359ae 100644 --- a/src/quickpicks/items/gitCommands.ts +++ b/src/quickpicks/items/gitCommands.ts @@ -1,21 +1,20 @@ import type { QuickInputButton, QuickPickItem } from 'vscode'; +import { ThemeIcon } from 'vscode'; import type { GitCommandsCommandArgs } from '../../commands/gitCommands'; import { getSteps } from '../../commands/gitCommands.utils'; import { Commands, GlyphChars } from '../../constants'; import { Container } from '../../container'; import { emojify } from '../../emojis'; import type { GitBranch } from '../../git/models/branch'; -import type { GitCommit } from '../../git/models/commit'; +import type { GitCommit, GitStashCommit } from '../../git/models/commit'; import { isStash } from '../../git/models/commit'; -import type { GitContributor } from '../../git/models/contributor'; import type { GitReference } from '../../git/models/reference'; import { createReference, isRevisionRange, shortenRevision } from '../../git/models/reference'; import type { GitRemote } from '../../git/models/remote'; import { getRemoteUpstreamDescription } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; -import type { GitStatus } from '../../git/models/status'; import type { GitTag } from '../../git/models/tag'; -import type { GitWorktree } from '../../git/models/worktree'; +import { configuration } from '../../system/configuration'; import { fromNow } from '../../system/date'; import { pad } from '../../system/string'; import type { QuickPickItemOfT } from './common'; @@ -25,7 +24,7 @@ export class GitCommandQuickPickItem extends CommandQuickPickItem<[GitCommandsCo constructor(label: string, args: GitCommandsCommandArgs); constructor(item: QuickPickItem, args: GitCommandsCommandArgs); constructor(labelOrItem: string | QuickPickItem, args: GitCommandsCommandArgs) { - super(labelOrItem, Commands.GitCommands, [args], { suppressKeyPress: true }); + super(labelOrItem, undefined, Commands.GitCommands, [args], { suppressKeyPress: true }); } executeSteps(pickedVia: 'menu' | 'command') { @@ -102,7 +101,7 @@ export async function createBranchQuickPickItem( const status = `${branch.getTrackingStatus({ suffix: `${GlyphChars.Space} ` })}${arrows}${GlyphChars.Space} ${ branch.upstream.name }`; - description = `${description ? `${description}${GlyphChars.Space.repeat(2)}${status}` : status}`; + description = description ? `${description}${GlyphChars.Space.repeat(2)}${status}` : status; } if (options?.ref) { @@ -122,9 +121,7 @@ export async function createBranchQuickPickItem( const checked = options?.checked || (options?.checked == null && options?.current === 'checkmark' && branch.current); const item: BranchQuickPickItem = { - label: `$(git-branch)${GlyphChars.Space}${branch.starred ? `$(star-full)${GlyphChars.Space}` : ''}${ - branch.name - }${checked ? pad('$(check)', 2) : ''}`, + label: checked ? `${branch.name}${pad('$(check)', 2)}` : branch.name, description: description, alwaysShow: options?.alwaysShow, buttons: options?.buttons, @@ -133,6 +130,7 @@ export async function createBranchQuickPickItem( current: branch.current, ref: branch.name, remote: branch.remote, + iconPath: branch.starred ? new ThemeIcon('star-full') : new ThemeIcon('git-branch'), }; return item; @@ -145,49 +143,34 @@ export class CommitLoadMoreQuickPickItem implements QuickPickItem { export type CommitQuickPickItem = QuickPickItemOfT; -export function createCommitQuickPickItem( +export async function createCommitQuickPickItem( commit: T, picked?: boolean, - options?: { alwaysShow?: boolean; buttons?: QuickInputButton[]; compact?: boolean; icon?: boolean }, + options?: { alwaysShow?: boolean; buttons?: QuickInputButton[]; compact?: boolean; icon?: boolean | 'avatar' }, ) { if (isStash(commit)) { - const number = commit.number == null ? '' : `${commit.number}: `; - - if (options?.compact) { - const item: CommitQuickPickItem = { - label: `${options.icon ? `$(archive)${GlyphChars.Space}` : ''}${number}${commit.summary}`, - description: `${commit.formattedDate}${pad(GlyphChars.Dot, 2, 2)}${commit.formatStats({ - compact: true, - })}`, - alwaysShow: options.alwaysShow, - buttons: options.buttons, - picked: picked, - item: commit, - }; - - return item; - } + return createStashQuickPickItem(commit, picked, { + ...options, + icon: options?.icon === 'avatar' ? true : options?.icon, + }); + } - const item: CommitQuickPickItem = { - label: `${options?.icon ? `$(archive)${GlyphChars.Space}` : ''}${number}${commit.summary}`, - description: '', - detail: `${GlyphChars.Space.repeat(2)}${commit.formattedDate}${pad( - GlyphChars.Dot, - 2, - 2, - )}${commit.formatStats({ compact: true })}`, - alwaysShow: options?.alwaysShow, - buttons: options?.buttons, - picked: picked, - item: commit, - }; + let iconPath; + if (options?.icon === 'avatar') { + if (configuration.get('gitCommands.avatars')) { + iconPath = await commit.getAvatarUri(); + } else { + options.icon = true; + } + } - return item; + if (options?.icon === true) { + iconPath = new ThemeIcon('git-commit'); } if (options?.compact) { const item: CommitQuickPickItem = { - label: `${options.icon ? `$(git-commit)${GlyphChars.Space}` : ''}${commit.summary}`, + label: commit.summary, description: `${commit.author.name}, ${commit.formattedDate}${pad('$(git-commit)', 2, 1)}${ commit.shortSha }${pad(GlyphChars.Dot, 2, 2)}${commit.formatStats({ compact: true })}`, @@ -195,12 +178,13 @@ export function createCommitQuickPickItem( buttons: options.buttons, picked: picked, item: commit, + iconPath: iconPath, }; return item; } const item: CommitQuickPickItem = { - label: `${options?.icon ? `$(git-commit)${GlyphChars.Space}` : ''}${commit.summary}`, + label: commit.summary, description: '', detail: `${GlyphChars.Space.repeat(2)}${commit.author.name}, ${commit.formattedDate}${pad( '$(git-commit)', @@ -213,25 +197,47 @@ export function createCommitQuickPickItem( buttons: options?.buttons, picked: picked, item: commit, + iconPath: iconPath, }; return item; } -export type ContributorQuickPickItem = QuickPickItemOfT; - -export function createContributorQuickPickItem( - contributor: GitContributor, +export function createStashQuickPickItem( + commit: GitStashCommit, picked?: boolean, - options?: { alwaysShow?: boolean; buttons?: QuickInputButton[] }, -): ContributorQuickPickItem { - const item: ContributorQuickPickItem = { - label: contributor.label, - description: contributor.email, + options?: { alwaysShow?: boolean; buttons?: QuickInputButton[]; compact?: boolean; icon?: boolean }, +) { + const number = commit.number == null ? '' : `${commit.number}: `; + + if (options?.compact) { + const item: CommitQuickPickItem = { + label: `${number}${commit.summary}`, + description: `${commit.formattedDate}${pad(GlyphChars.Dot, 2, 2)}${commit.formatStats({ + compact: true, + })}`, + alwaysShow: options.alwaysShow, + buttons: options.buttons, + picked: picked, + item: commit, + iconPath: options.icon ? new ThemeIcon('archive') : undefined, + }; + + return item; + } + + const item: CommitQuickPickItem = { + label: `${number}${commit.summary}`, + description: '', + detail: `${GlyphChars.Space.repeat(2)}${commit.formattedDate}${pad(GlyphChars.Dot, 2, 2)}${commit.formatStats({ + compact: true, + })}`, alwaysShow: options?.alwaysShow, buttons: options?.buttons, picked: picked, - item: contributor, + item: commit, + iconPath: options?.icon ? new ThemeIcon('archive') : undefined, }; + return item; } @@ -249,7 +255,7 @@ export function createRefQuickPickItem( ): RefQuickPickItem { if (ref === '') { return { - label: `${options?.icon ? `$(file-directory)${GlyphChars.Space}` : ''}Working Tree`, + label: 'Working Tree', description: '', alwaysShow: options?.alwaysShow, buttons: options?.buttons, @@ -258,12 +264,13 @@ export function createRefQuickPickItem( current: false, ref: ref, remote: false, + iconPath: options?.icon ? new ThemeIcon('file-directory') : undefined, }; } if (ref === 'HEAD') { return { - label: `${options?.icon ? `$(git-branch)${GlyphChars.Space}` : ''}HEAD`, + label: 'HEAD', description: '', alwaysShow: options?.alwaysShow, buttons: options?.buttons, @@ -272,6 +279,7 @@ export function createRefQuickPickItem( current: false, ref: ref, remote: false, + iconPath: options?.icon ? new ThemeIcon('git-branch') : undefined, }; } @@ -336,12 +344,13 @@ export function createRemoteQuickPickItem( } const item: RemoteQuickPickItem = { - label: `$(cloud)${GlyphChars.Space}${remote.name}${options?.checked ? pad('$(check)', 2) : ''}`, + label: options?.checked ? `${remote.name}${pad('$(check)', 2)}` : remote.name, description: description, alwaysShow: options?.alwaysShow, buttons: options?.buttons, picked: picked, item: remote, + iconPath: new ThemeIcon('cloud'), }; return item; @@ -387,7 +396,7 @@ export async function createRepositoryQuickPickItem( const status = `${upstreamStatus}${workingStatus}`; if (status) { - description = `${description ? `${description}${status}` : status}`; + description = description ? `${description}${status}` : status; } } @@ -395,7 +404,7 @@ export async function createRepositoryQuickPickItem( const lastFetched = await repository.getLastFetched(); if (lastFetched !== 0) { const fetched = `Last fetched ${fromNow(new Date(lastFetched))}`; - description = `${description ? `${description}${pad(GlyphChars.Dot, 2, 2)}${fetched}` : fetched}`; + description = description ? `${description}${pad(GlyphChars.Dot, 2, 2)}${fetched}` : fetched; } } @@ -447,7 +456,7 @@ export function createTagQuickPickItem( } const item: TagQuickPickItem = { - label: `$(tag)${GlyphChars.Space}${tag.name}${options?.checked ? pad('$(check)', 2) : ''}`, + label: options?.checked ? `${tag.name}${pad('$(check)', 2)}` : tag.name, description: description, alwaysShow: options?.alwaysShow, buttons: options?.buttons, @@ -456,72 +465,7 @@ export function createTagQuickPickItem( current: false, ref: tag.name, remote: false, - }; - - return item; -} - -export interface WorktreeQuickPickItem extends QuickPickItemOfT { - readonly opened: boolean; - readonly hasChanges: boolean | undefined; -} - -export function createWorktreeQuickPickItem( - worktree: GitWorktree, - picked?: boolean, - missing?: boolean, - options?: { - alwaysShow?: boolean; - buttons?: QuickInputButton[]; - checked?: boolean; - message?: boolean; - path?: boolean; - type?: boolean; - status?: GitStatus; - }, -) { - let description = ''; - if (options?.type) { - description = 'worktree'; - } - - if (options?.status != null) { - description += options.status.hasChanges - ? pad(`Uncommited changes (${options.status.getFormattedDiffStatus()})`, description ? 2 : 0, 0) - : pad('No changes', description ? 2 : 0, 0); - } - - let icon; - let label; - switch (worktree.type) { - case 'bare': - label = '(bare)'; - icon = '$(folder)'; - break; - case 'branch': - label = worktree.branch!; - icon = '$(git-branch)'; - break; - case 'detached': - label = shortenRevision(worktree.sha); - icon = '$(git-commit)'; - break; - } - - const item: WorktreeQuickPickItem = { - label: `${icon}${GlyphChars.Space}${label}${options?.checked ? pad('$(check)', 2) : ''}`, - description: description, - detail: options?.path - ? missing - ? `${GlyphChars.Warning} Unable to locate $(folder) ${worktree.friendlyPath}` - : `In $(folder) ${worktree.friendlyPath}` - : undefined, - alwaysShow: options?.alwaysShow, - buttons: options?.buttons, - picked: picked, - item: worktree, - opened: worktree.opened, - hasChanges: options?.status?.hasChanges, + iconPath: new ThemeIcon('tag'), }; return item; diff --git a/src/quickpicks/organizationMembersPicker.ts b/src/quickpicks/organizationMembersPicker.ts new file mode 100644 index 0000000000000..dfe6a938110e8 --- /dev/null +++ b/src/quickpicks/organizationMembersPicker.ts @@ -0,0 +1,120 @@ +import type { Disposable } from 'vscode'; +import { window } from 'vscode'; +import { getAvatarUri } from '../avatars'; +import { ClearQuickInputButton } from '../commands/quickCommand.buttons'; +import type { OrganizationMember } from '../plus/gk/account/organization'; +import { debounce } from '../system/function'; +import { defer } from '../system/promise'; +import { sortCompare } from '../system/string'; +import type { QuickPickItemOfT } from './items/common'; + +export async function showOrganizationMembersPicker( + title: string, + placeholder: string, + members: OrganizationMember[] | Promise, + options?: { + clearButton?: boolean; + filter?: (member: OrganizationMember) => boolean; + multiselect?: boolean; + picked?: (member: OrganizationMember) => boolean; + }, +): Promise { + const deferred = defer(); + const disposables: Disposable[] = []; + + type OrganizationMemberQuickPickItem = QuickPickItemOfT; + + function sortItems(items: OrganizationMemberQuickPickItem[]) { + return items.sort((a, b) => (a.picked ? -1 : 1) - (b.picked ? -1 : 1) || sortCompare(a.label, b.label)); + } + + try { + const quickpick = window.createQuickPick(); + disposables.push( + quickpick, + quickpick.onDidHide(() => deferred.fulfill(undefined)), + quickpick.onDidAccept(() => + !quickpick.busy ? deferred.fulfill(quickpick.selectedItems.map(c => c.item)) : undefined, + ), + quickpick.onDidTriggerButton(e => { + if (e === ClearQuickInputButton) { + if (quickpick.canSelectMany) { + quickpick.selectedItems = []; + } else { + deferred.fulfill([]); + } + } + }), + ); + + quickpick.ignoreFocusOut = true; + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.canSelectMany = options?.multiselect ?? true; + + quickpick.buttons = options?.clearButton ? [ClearQuickInputButton] : []; + + quickpick.busy = true; + quickpick.show(); + + members = await members; + if (options?.filter != null) { + members = members.filter(options.filter); + } + + if (!deferred.pending) return; + + const items = members.map(member => { + const item: OrganizationMemberQuickPickItem = { + label: member.name ?? member.username, + description: member.email, + picked: options?.picked?.(member) ?? false, + item: member, + iconPath: getAvatarUri(member.email, undefined), + }; + + item.alwaysShow = item.picked; + return item; + }); + + if (!deferred.pending) return; + + quickpick.items = sortItems(items); + if (quickpick.canSelectMany) { + quickpick.selectedItems = items.filter(i => i.picked); + } else { + quickpick.activeItems = items.filter(i => i.picked); + } + + quickpick.busy = false; + + disposables.push( + quickpick.onDidChangeSelection( + debounce(e => { + if (!quickpick.canSelectMany || quickpick.busy) return; + + let update = false; + for (const item of quickpick.items) { + const picked = e.includes(item); + if (item.picked !== picked || item.alwaysShow !== picked) { + item.alwaysShow = item.picked = picked; + update = true; + } + } + + if (update) { + quickpick.items = sortItems([...quickpick.items]); + quickpick.selectedItems = e; + } + }, 10), + ), + ); + + const picks = await deferred.promise; + return picks; + } finally { + disposables.forEach(d => void d.dispose()); + } +} diff --git a/src/quickpicks/referencePicker.ts b/src/quickpicks/referencePicker.ts index df4d3586f6a8b..bbbed6d52c2b6 100644 --- a/src/quickpicks/referencePicker.ts +++ b/src/quickpicks/referencePicker.ts @@ -1,12 +1,8 @@ -import type { Disposable, QuickPick } from 'vscode'; +import type { Disposable } from 'vscode'; import { CancellationTokenSource, window } from 'vscode'; -import { - getBranchesAndOrTags, - getValidateGitReferenceFn, - RevealInSideBarQuickInputButton, -} from '../commands/quickCommand'; +import { RevealInSideBarQuickInputButton } from '../commands/quickCommand.buttons'; +import { getBranchesAndOrTags, getValidateGitReferenceFn } from '../commands/quickCommand.steps'; import type { Keys } from '../constants'; -import { GlyphChars } from '../constants'; import { Container } from '../container'; import { reveal as revealBranch } from '../git/actions/branch'; import { showDetailsView } from '../git/actions/commit'; @@ -28,49 +24,61 @@ export const enum ReferencesQuickPickIncludes { WorkingTree = 1 << 2, HEAD = 1 << 3, - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member BranchesAndTags = Branches | Tags, + // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member + All = Branches | Tags | WorkingTree | HEAD, } export interface ReferencesQuickPickOptions { - allowEnteringRefs?: boolean | { ranges?: boolean }; + allowRevisions?: boolean | { ranges?: boolean }; autoPick?: boolean; picked?: string; filter?: { branches?(b: GitBranch): boolean; tags?(t: GitTag): boolean }; include?: ReferencesQuickPickIncludes; - keys?: Keys[]; - onDidPressKey?(key: Keys, quickpick: QuickPick): void | Promise; + keyboard?: { + keys: Keys[]; + onDidPressKey(key: Keys, item: ReferencesQuickPickItem): void | Promise; + }; sort?: boolean | { branches?: BranchSortOptions; tags?: TagSortOptions }; } export async function showReferencePicker( repoPath: string, title: string, - placeHolder: string, - options: ReferencesQuickPickOptions = {}, + placeholder: string, + options?: ReferencesQuickPickOptions, ): Promise { const quickpick = window.createQuickPick(); quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); quickpick.title = title; quickpick.placeholder = - options.allowEnteringRefs != null - ? `${placeHolder}${GlyphChars.Space.repeat(3)}(or enter a reference using #)` - : placeHolder; + options?.allowRevisions != null && options.allowRevisions !== false + ? `${placeholder} (or enter a revision using #)` + : placeholder; quickpick.matchOnDescription = true; const disposables: Disposable[] = []; let scope: KeyboardScope | undefined; - if (options?.keys != null && options.keys.length !== 0 && options?.onDidPressKey !== null) { + if (options?.keyboard != null) { + const { keyboard } = options; scope = Container.instance.keyboard.createScope( Object.fromEntries( - options.keys.map(key => [ + keyboard.keys.map(key => [ key, { - onDidPressKey: key => { + onDidPressKey: async key => { if (quickpick.activeItems.length !== 0) { - void options.onDidPressKey!(key, quickpick); + const [item] = quickpick.activeItems; + if (item != null) { + const ignoreFocusOut = quickpick.ignoreFocusOut; + quickpick.ignoreFocusOut = true; + + await keyboard.onDidPressKey(key, item); + + quickpick.ignoreFocusOut = ignoreFocusOut; + } } }, }, @@ -85,7 +93,7 @@ export async function showReferencePicker( let autoPick; let items = getItems(repoPath, options); - if (options.autoPick) { + if (options?.autoPick) { items = items.then(itms => { if (itms.length <= 1) { autoPick = itms[0]; @@ -96,19 +104,17 @@ export async function showReferencePicker( } quickpick.busy = true; - quickpick.show(); const getValidateGitReference = getValidateGitReferenceFn(Container.instance.git.getRepository(repoPath), { buttons: [RevealInSideBarQuickInputButton], ranges: - options?.allowEnteringRefs && typeof options.allowEnteringRefs !== 'boolean' - ? options.allowEnteringRefs.ranges + options?.allowRevisions && typeof options.allowRevisions !== 'boolean' + ? options.allowRevisions.ranges : undefined, }); quickpick.items = await items; - quickpick.busy = false; try { @@ -122,19 +128,19 @@ export async function showReferencePicker( resolve(quickpick.activeItems[0]); }), quickpick.onDidChangeValue(async e => { - if (options.allowEnteringRefs) { - if (!(await getValidateGitReference(quickpick, e))) { - quickpick.items = await items; + if (scope != null) { + // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly + if (e.length !== 0) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } else { + void scope.resume(); } } - if (scope == null) return; - - // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly - if (e.length !== 0) { - await scope.pause(['left', 'right']); - } else { - await scope.resume(); + if (options?.allowRevisions) { + if (!(await getValidateGitReference(quickpick, e))) { + quickpick.items = await items; + } } }), quickpick.onDidTriggerItemButton(({ button, item: { item } }) => { @@ -165,30 +171,28 @@ export async function showReferencePicker( } } -async function getItems( - repoPath: string, - { picked, filter, include, sort }: ReferencesQuickPickOptions, -): Promise { - include = include ?? ReferencesQuickPickIncludes.BranchesAndTags; +async function getItems(repoPath: string, options?: ReferencesQuickPickOptions): Promise { + const include = options?.include ?? ReferencesQuickPickIncludes.BranchesAndTags; const items: ReferencesQuickPickItem[] = await getBranchesAndOrTags( Container.instance.git.getRepository(repoPath), include && ReferencesQuickPickIncludes.BranchesAndTags ? ['branches', 'tags'] : include && ReferencesQuickPickIncludes.Branches - ? ['branches'] - : include && ReferencesQuickPickIncludes.Tags - ? ['tags'] - : [], + ? ['branches'] + : include && ReferencesQuickPickIncludes.Tags + ? ['tags'] + : [], { buttons: [RevealInSideBarQuickInputButton], - filter: filter, - picked: picked, - sort: sort ?? { branches: { current: false }, tags: {} }, + filter: options?.filter, + picked: options?.picked, + sort: options?.sort ?? { branches: { current: false }, tags: {} }, }, ); // Move the picked item to the top + const picked = options?.picked; if (picked) { const index = items.findIndex(i => i.ref === picked); if (index !== -1) { diff --git a/src/quickpicks/remotePicker.ts b/src/quickpicks/remotePicker.ts index 1644433b21a17..2fbc318da1e5f 100644 --- a/src/quickpicks/remotePicker.ts +++ b/src/quickpicks/remotePicker.ts @@ -15,7 +15,7 @@ export async function showRemotePicker( picked?: string; setDefault?: boolean; }, -): Promise { +): Promise { const items: RemoteQuickPickItem[] = []; let picked: RemoteQuickPickItem | undefined; @@ -43,7 +43,7 @@ export async function showRemotePicker( } } - if (options?.autoPick && remotes.length === 1) return items[0]; + if (options?.autoPick && remotes.length === 1) return items[0].item; const quickpick = window.createQuickPick(); quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); @@ -77,9 +77,8 @@ export async function showRemotePicker( quickpick.show(); }); - if (pick == null) return undefined; - return pick; + return pick?.item; } finally { quickpick.dispose(); disposables.forEach(d => void d.dispose()); diff --git a/src/quickpicks/remoteProviderPicker.ts b/src/quickpicks/remoteProviderPicker.ts index bc1aaf9a66ec6..359d6e7698e42 100644 --- a/src/quickpicks/remoteProviderPicker.ts +++ b/src/quickpicks/remoteProviderPicker.ts @@ -1,15 +1,17 @@ import type { Disposable, QuickInputButton } from 'vscode'; -import { env, Uri, window } from 'vscode'; -import type { OpenOnRemoteCommandArgs } from '../commands'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { OpenOnRemoteCommandArgs } from '../commands/openOnRemote'; import { SetRemoteAsDefaultQuickInputButton } from '../commands/quickCommand.buttons'; import type { Keys } from '../constants'; import { Commands, GlyphChars } from '../constants'; import { Container } from '../container'; import { getBranchNameWithoutRemote, getRemoteNameFromBranchName } from '../git/models/branch'; -import { GitRemote } from '../git/models/remote'; +import type { GitRemote } from '../git/models/remote'; +import { getHighlanderProviders } from '../git/models/remote'; import type { RemoteResource } from '../git/models/remoteResource'; import { getNameFromRemoteResource, RemoteResourceType } from '../git/models/remoteResource'; import type { RemoteProvider } from '../git/remotes/remoteProvider'; +import { filterMap } from '../system/array'; import { getSettledValue } from '../system/promise'; import { getQuickPickIgnoreFocusOut } from '../system/utils'; import { CommandQuickPickItem } from './items/common'; @@ -29,7 +31,7 @@ export class ConfigureCustomRemoteProviderCommandQuickPickItem extends CommandQu export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { constructor( private readonly remote: GitRemote, - private readonly resource: RemoteResource, + private readonly resources: RemoteResource[], private readonly clipboard?: boolean, buttons?: QuickInputButton[], ) { @@ -41,52 +43,63 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { } override async execute(): Promise { - let resource = this.resource; - if (resource.type === RemoteResourceType.Comparison) { - if (getRemoteNameFromBranchName(resource.base) === this.remote.name) { - resource = { ...resource, base: getBranchNameWithoutRemote(resource.base) }; - } + const resourcesResults = await Promise.allSettled( + this.resources.map(async resource => { + if (resource.type === RemoteResourceType.Comparison) { + if (getRemoteNameFromBranchName(resource.base) === this.remote.name) { + resource = { ...resource, base: getBranchNameWithoutRemote(resource.base) }; + } - if (getRemoteNameFromBranchName(resource.compare) === this.remote.name) { - resource = { ...resource, compare: getBranchNameWithoutRemote(resource.compare) }; - } - } else if (resource.type === RemoteResourceType.CreatePullRequest) { - let branch = resource.base.branch; - if (branch == null) { - branch = await Container.instance.git.getDefaultBranchName(this.remote.repoPath, this.remote.name); - if (branch == null && this.remote.hasRichIntegration()) { - const defaultBranch = await this.remote.provider.getDefaultBranch?.(); - branch = defaultBranch?.name; + if (getRemoteNameFromBranchName(resource.compare) === this.remote.name) { + resource = { ...resource, compare: getBranchNameWithoutRemote(resource.compare) }; + } + } else if (resource.type === RemoteResourceType.CreatePullRequest) { + let branch = resource.base.branch; + if (branch == null) { + branch = await Container.instance.git.getDefaultBranchName( + this.remote.repoPath, + this.remote.name, + ); + if (branch == null && this.remote.hasIntegration()) { + const provider = await Container.instance.integrations.getByRemote(this.remote); + const defaultBranch = await provider?.getDefaultBranch?.(this.remote.provider.repoDesc); + branch = defaultBranch?.name; + } + } + + resource = { + ...resource, + base: { branch: branch, remote: { path: this.remote.path, url: this.remote.url } }, + }; + } else if ( + resource.type === RemoteResourceType.File && + resource.branchOrTag != null && + (this.remote.provider.id === 'bitbucket' || this.remote.provider.id === 'bitbucket-server') + ) { + // HACK ALERT + // Since Bitbucket can't support branch names in the url (other than with the default branch), + // turn this into a `Revision` request + const { branchOrTag } = resource; + const [branches, tags] = await Promise.allSettled([ + Container.instance.git.getBranches(this.remote.repoPath, { + filter: b => b.name === branchOrTag || b.getNameWithoutRemote() === branchOrTag, + }), + Container.instance.git.getTags(this.remote.repoPath, { filter: t => t.name === branchOrTag }), + ]); + + const sha = getSettledValue(branches)?.values[0]?.sha ?? getSettledValue(tags)?.values[0]?.sha; + if (sha) { + resource = { ...resource, type: RemoteResourceType.Revision, sha: sha }; + } } - } - resource = { - ...resource, - base: { branch: branch, remote: { path: this.remote.path, url: this.remote.url } }, - }; - } else if ( - resource.type === RemoteResourceType.File && - resource.branchOrTag != null && - (this.remote.provider.id === 'bitbucket' || this.remote.provider.id === 'bitbucket-server') - ) { - // HACK ALERT - // Since Bitbucket can't support branch names in the url (other than with the default branch), - // turn this into a `Revision` request - const { branchOrTag } = resource; - const [branches, tags] = await Promise.allSettled([ - Container.instance.git.getBranches(this.remote.repoPath, { - filter: b => b.name === branchOrTag || b.getNameWithoutRemote() === branchOrTag, - }), - Container.instance.git.getTags(this.remote.repoPath, { filter: t => t.name === branchOrTag }), - ]); + return resource; + }), + ); - const sha = getSettledValue(branches)?.values[0]?.sha ?? getSettledValue(tags)?.values[0]?.sha; - if (sha) { - resource = { ...resource, type: RemoteResourceType.Revision, sha: sha }; - } - } + const resources = filterMap(resourcesResults, r => getSettledValue(r)); - void (await (this.clipboard ? this.remote.provider.copy(resource) : this.remote.provider.open(resource))); + void (await (this.clipboard ? this.remote.provider.copy(resources) : this.remote.provider.open(resources))); } setAsDefault(): Promise { @@ -96,16 +109,16 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { export class CopyRemoteResourceCommandQuickPickItem extends CommandQuickPickItem { constructor(remotes: GitRemote[], resource: RemoteResource) { - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); const commandArgs: OpenOnRemoteCommandArgs = { resource: resource, remotes: remotes, clipboard: true, }; - const label = `$(copy) Copy Link to ${getNameFromRemoteResource(resource)} for ${ + const label = `Copy Link to ${getNameFromRemoteResource(resource)} for ${ providers?.length ? providers[0].name : 'Remote' }${providers?.length === 1 ? '' : GlyphChars.Ellipsis}`; - super(label, Commands.OpenOnRemote, [commandArgs]); + super(label, new ThemeIcon('copy'), Commands.OpenOnRemote, [commandArgs]); } override async onDidPressKey(key: Keys): Promise { @@ -116,18 +129,19 @@ export class CopyRemoteResourceCommandQuickPickItem extends CommandQuickPickItem export class OpenRemoteResourceCommandQuickPickItem extends CommandQuickPickItem { constructor(remotes: GitRemote[], resource: RemoteResource) { - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); const commandArgs: OpenOnRemoteCommandArgs = { resource: resource, remotes: remotes, clipboard: false, }; super( - `$(link-external) Open ${getNameFromRemoteResource(resource)} on ${ + `Open ${getNameFromRemoteResource(resource)} on ${ providers?.length === 1 ? providers[0].name : `${providers?.length ? providers[0].name : 'Remote'}${GlyphChars.Ellipsis}` }`, + new ThemeIcon('link-external'), Commands.OpenOnRemote, [commandArgs], ); @@ -137,7 +151,7 @@ export class OpenRemoteResourceCommandQuickPickItem extends CommandQuickPickItem export async function showRemoteProviderPicker( title: string, placeholder: string, - resource: RemoteResource, + resources: RemoteResource[], remotes: GitRemote[], options?: { autoPick?: 'default' | boolean; @@ -169,7 +183,7 @@ export async function showRemoteProviderPicker( r => new CopyOrOpenRemoteCommandQuickPickItem( r, - resource, + resources, clipboard, setDefault ? [SetRemoteAsDefaultQuickInputButton] : undefined, ), diff --git a/src/quickpicks/repositoryPicker.ts b/src/quickpicks/repositoryPicker.ts index ab3dbf8ec01e5..c1f9e76b01d0f 100644 --- a/src/quickpicks/repositoryPicker.ts +++ b/src/quickpicks/repositoryPicker.ts @@ -25,13 +25,13 @@ export async function getBestRepositoryOrShowPicker( } if (repository != null) return repository; - const pick = await showRepositoryPicker(title, placeholder, options); + const pick = await showRepositoryPicker(title, placeholder, undefined, options); if (pick instanceof CommandQuickPickItem) { await pick.execute(); return undefined; } - return pick?.item; + return pick; } export async function getRepositoryOrShowPicker( @@ -54,50 +54,35 @@ export async function getRepositoryOrShowPicker( } if (repository != null) return repository; - const pick = await showRepositoryPicker(title, placeholder, options); + const pick = await showRepositoryPicker(title, placeholder, undefined, options); if (pick instanceof CommandQuickPickItem) { void (await pick.execute()); return undefined; } - return pick?.item; + return pick; } export async function showRepositoryPicker( title: string | undefined, placeholder?: string, repositories?: Repository[], -): Promise; -export async function showRepositoryPicker( - title: string | undefined, - placeholder?: string, - options?: { filter?: (r: Repository) => Promise }, -): Promise; -export async function showRepositoryPicker( - title: string | undefined, - placeholder: string = 'Choose a repository', - repositoriesOrOptions?: Repository[] | { filter?: (r: Repository) => Promise }, -): Promise { - if ( - repositoriesOrOptions != null && - !Array.isArray(repositoriesOrOptions) && - repositoriesOrOptions.filter == null - ) { - repositoriesOrOptions = undefined; - } + options?: { filter?: (r: Repository) => Promise; picked?: Repository }, +): Promise { + repositories ??= Container.instance.git.openRepositories; let items: RepositoryQuickPickItem[]; - if (repositoriesOrOptions == null || Array.isArray(repositoriesOrOptions)) { - items = await Promise.all>([ - ...map(repositoriesOrOptions ?? Container.instance.git.openRepositories, r => - createRepositoryQuickPickItem(r, undefined, { branch: true, status: true }), + if (options?.filter == null) { + items = await Promise.all>( + map(repositories ?? Container.instance.git.openRepositories, r => + createRepositoryQuickPickItem(r, r === options?.picked, { branch: true, status: true }), ), - ]); + ); } else { - const { filter } = repositoriesOrOptions; + const { filter } = options; items = await filterMapAsync(Container.instance.git.openRepositories, async r => - (await filter!(r)) - ? createRepositoryQuickPickItem(r, undefined, { branch: true, status: true }) + (await filter(r)) + ? createRepositoryQuickPickItem(r, r === options?.picked, { branch: true, status: true }) : undefined, ); } @@ -128,9 +113,8 @@ export async function showRepositoryPicker( quickpick.show(); }); - if (pick == null) return undefined; - return pick; + return pick?.item; } finally { quickpick.dispose(); disposables.forEach(d => void d.dispose()); @@ -141,17 +125,17 @@ export async function showRepositoriesPicker( title: string | undefined, placeholder?: string, repositories?: Repository[], -): Promise; +): Promise; export async function showRepositoriesPicker( title: string | undefined, placeholder?: string, options?: { filter?: (r: Repository) => Promise }, -): Promise; +): Promise; export async function showRepositoriesPicker( title: string | undefined, placeholder: string = 'Choose a repository', repositoriesOrOptions?: Repository[] | { filter?: (r: Repository) => Promise }, -): Promise { +): Promise { if ( repositoriesOrOptions != null && !Array.isArray(repositoriesOrOptions) && @@ -162,11 +146,11 @@ export async function showRepositoriesPicker( let items: RepositoryQuickPickItem[]; if (repositoriesOrOptions == null || Array.isArray(repositoriesOrOptions)) { - items = await Promise.all>([ - ...map(repositoriesOrOptions ?? Container.instance.git.openRepositories, r => + items = await Promise.all>( + map(repositoriesOrOptions ?? Container.instance.git.openRepositories, r => createRepositoryQuickPickItem(r, undefined, { branch: true, status: true }), ), - ]); + ); } else { const { filter } = repositoriesOrOptions; items = await filterMapAsync(Container.instance.git.openRepositories, async r => @@ -204,7 +188,7 @@ export async function showRepositoriesPicker( }); if (picks == null) return []; - return picks; + return picks.map(p => p.item); } finally { quickpick.dispose(); disposables.forEach(d => void d.dispose()); diff --git a/src/quickpicks/revisionFilesPicker.ts b/src/quickpicks/revisionFilesPicker.ts new file mode 100644 index 0000000000000..35051e9d246ca --- /dev/null +++ b/src/quickpicks/revisionFilesPicker.ts @@ -0,0 +1,140 @@ +import type { Disposable, Uri } from 'vscode'; +import { window } from 'vscode'; +import type { Keys } from '../constants'; +import type { Container } from '../container'; +import type { GitRevisionReference } from '../git/models/reference'; +import type { GitTreeEntry } from '../git/models/tree'; +import { filterMap } from '../system/iterable'; +import type { KeyboardScope } from '../system/keyboard'; +import { splitPath } from '../system/path'; +import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import type { QuickPickItemOfT } from './items/common'; + +export type RevisionQuickPickItem = QuickPickItemOfT; + +export async function showRevisionFilesPicker( + container: Container, + revision: GitRevisionReference, + options: { + ignoreFocusOut?: boolean; + initialPath?: string; + keyboard?: { + keys: Keys[]; + onDidPressKey(key: Keys, uri: Uri): void | Promise; + }; + placeholder?: string; + title: string; + }, +): Promise { + const disposables: Disposable[] = []; + + const repoPath = revision.repoPath; + const ref = revision.ref; + + function getRevisionUri(item: RevisionQuickPickItem) { + return container.git.getRevisionUri(ref, `${repoPath}/${item.item.path}`, repoPath); + } + + try { + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = options?.ignoreFocusOut ?? getQuickPickIgnoreFocusOut(); + + const value = options.initialPath ?? ''; + + let scope: KeyboardScope | undefined; + if (options?.keyboard != null) { + const { keyboard } = options; + scope = container.keyboard.createScope( + Object.fromEntries( + keyboard.keys.map(key => [ + key, + { + onDidPressKey: async key => { + if (quickpick.activeItems.length !== 0) { + const [item] = quickpick.activeItems; + if (item.item != null) { + const ignoreFocusOut = quickpick.ignoreFocusOut; + quickpick.ignoreFocusOut = true; + + await keyboard.onDidPressKey(key, getRevisionUri(item)); + + quickpick.ignoreFocusOut = ignoreFocusOut; + } + } + }, + }, + ]), + ), + ); + void scope.start(); + if (value != null) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } + disposables.push(scope); + } + + quickpick.title = options.title; + quickpick.placeholder = options?.placeholder ?? 'Search files by name'; + quickpick.matchOnDescription = true; + + quickpick.value = value; + quickpick.busy = true; + quickpick.show(); + + const tree = await container.git.getTreeForRevision(repoPath, ref); + const items: RevisionQuickPickItem[] = [ + ...filterMap(tree, file => { + // Exclude directories + if (file.type !== 'blob') return undefined; + + const [label, description] = splitPath(file.path, undefined, true); + return { + label: label, + description: description === '.' ? '' : description, + item: file, + } satisfies RevisionQuickPickItem; + }), + ]; + quickpick.items = items; + quickpick.busy = false; + + const pick = await new Promise(resolve => { + disposables.push( + quickpick, + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length === 0) return; + + resolve(quickpick.activeItems[0]); + }), + quickpick.onDidChangeValue(value => { + if (scope == null) return; + + // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly + if (value.length !== 0) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } else { + void scope.resume(); + } + + for (const item of items) { + if ( + item.item.path.includes(value) && + !item.label.includes(value) && + !item.description!.includes(value) + ) { + item.alwaysShow = true; + } else { + item.alwaysShow = false; + } + } + quickpick.items = items; + }), + ); + }); + + return pick != null ? getRevisionUri(pick) : undefined; + } finally { + disposables.forEach(d => void d.dispose()); + } +} diff --git a/src/repositories.ts b/src/repositories.ts index 78cab92f526ad..cbedbfb59720f 100644 --- a/src/repositories.ts +++ b/src/repositories.ts @@ -1,6 +1,8 @@ import type { Uri } from 'vscode'; import { isLinux } from '@env/platform'; import { Schemes } from './constants'; +import type { RevisionUriData } from './git/gitProvider'; +import { decodeGitLensRevisionUriAuthority } from './git/gitUri'; import type { Repository } from './git/models/repository'; import { addVslsPrefixIfNeeded, normalizePath } from './system/path'; import { UriTrie } from './system/trie'; @@ -24,7 +26,6 @@ export function normalizeRepoUri(uri: Uri): { path: string; ignoreCase: boolean return { path: path, ignoreCase: !isLinux }; case Schemes.Git: - case Schemes.GitLens: path = uri.path; if (path.charCodeAt(path.length - 1) === slash) { path = path.slice(1, -1); @@ -33,6 +34,21 @@ export function normalizeRepoUri(uri: Uri): { path: string; ignoreCase: boolean } return { path: path, ignoreCase: !isLinux }; + case Schemes.GitLens: { + path = uri.path; + + const metadata = decodeGitLensRevisionUriAuthority(uri.authority); + if (metadata.uncPath != null && !path.startsWith(metadata.uncPath)) { + path = `${metadata.uncPath}${uri.path}`; + } + + if (path.charCodeAt(path.length - 1) === slash) { + path = path.slice(1, -1); + } else { + path = path.startsWith('//') ? path : path.slice(1); + } + return { path: path, ignoreCase: !isLinux }; + } case Schemes.Virtual: case Schemes.GitHub: { path = uri.path; diff --git a/src/statusbar/statusBarController.ts b/src/statusbar/statusBarController.ts index 217716a4cf3c0..cff753d684759 100644 --- a/src/statusbar/statusBarController.ts +++ b/src/statusbar/statusBarController.ts @@ -1,5 +1,6 @@ import type { ConfigurationChangeEvent, StatusBarItem, TextEditor, Uri } from 'vscode'; import { CancellationTokenSource, Disposable, MarkdownString, StatusBarAlignment, window } from 'vscode'; +import { Command } from '../commands/base'; import type { ToggleFileChangesAnnotationCommandArgs } from '../commands/toggleFileAnnotations'; import { StatusBarCommand } from '../config'; import { Commands, GlyphChars } from '../constants'; @@ -7,17 +8,16 @@ import type { Container } from '../container'; import { CommitFormatter } from '../git/formatters/commitFormatter'; import type { PullRequest } from '../git/models/pullRequest'; import { detailsMessage } from '../hovers/hovers'; -import type { MaybePausedResult } from '../system/cancellation'; -import { pauseOnCancelOrTimeout } from '../system/cancellation'; import { asCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { debug } from '../system/decorators/log'; import { once } from '../system/event'; import { Logger } from '../system/logger'; import { getLogScope } from '../system/logger.scope'; -import { getSettledValue } from '../system/promise'; +import type { MaybePausedResult } from '../system/promise'; +import { getSettledValue, pauseOnCancelOrTimeout } from '../system/promise'; import { isTextEditor } from '../system/utils'; -import type { GitLineState, LinesChangeEvent } from '../trackers/gitLineTracker'; +import type { LinesChangeEvent, LineState } from '../trackers/lineTracker'; export class StatusBarController implements Disposable { private _cancellation: CancellationTokenSource | undefined; @@ -154,6 +154,42 @@ export class StatusBarController implements Disposable { if (clear) { this.clearBlame(); + + if (e.suspended && e.editor?.document.isDirty && this._statusBarBlame != null) { + const statusBarItem = this._statusBarBlame; + const trackedDocumentPromise = this.container.documentTracker.get(e.editor.document); + queueMicrotask(async () => { + const doc = await trackedDocumentPromise; + if (!doc?.isBlameable) return; + + statusBarItem.tooltip = new MarkdownString(); + statusBarItem.tooltip.isTrusted = { enabledCommands: [Commands.ShowSettingsPage] }; + + if (doc.canDirtyIdle) { + statusBarItem.text = '$(watch) Blame Paused'; + statusBarItem.tooltip.appendMarkdown( + `Blame will resume after a [${configuration.get( + 'advanced.blame.delayAfterEdit', + )} ms delay](${Command.getMarkdownCommandArgsCore<[undefined, string]>( + Commands.ShowSettingsPage, + [undefined, 'advanced.blame.delayAfterEdit'], + )} 'Change the after edit delay') to limit the performance impact because there are unsaved changes`, + ); + } else { + statusBarItem.text = '$(debug-pause) Blame Paused'; + statusBarItem.tooltip.appendMarkdown( + `Blame will resume after saving because there are unsaved changes and the file is over the [${configuration.get( + 'advanced.blame.sizeThresholdAfterEdit', + )} line threshold](${Command.getMarkdownCommandArgsCore<[undefined, string]>( + Commands.ShowSettingsPage, + [undefined, 'advanced.blame.sizeThresholdAfterEdit'], + )} 'Change the after edit line threshold') to limit the performance impact`, + ); + } + + statusBarItem.show(); + }); + } } else if (this._statusBarBlame?.text.startsWith('$(git-commit)')) { this._statusBarBlame.text = `$(watch)${this._statusBarBlame.text.substring(13)}`; } @@ -171,7 +207,7 @@ export class StatusBarController implements Disposable { 1: s => s.commit?.sha, }, }) - private async updateBlame(editor: TextEditor, state: GitLineState) { + private async updateBlame(editor: TextEditor, state: LineState) { const cfg = configuration.get('statusBar'); if (!cfg.enabled || this._statusBarBlame == null || !isTextEditor(editor)) { this._cancellation?.cancel(); @@ -305,7 +341,7 @@ export class StatusBarController implements Disposable { const showPullRequests = !commit.isUncommitted && - remote?.hasRichIntegration() && + remote?.hasIntegration() && cfg.pullRequests.enabled && (CommitFormatter.has( cfg.format, @@ -348,7 +384,7 @@ export class StatusBarController implements Disposable { pr: Promise | PullRequest | undefined, timeout?: number, ) { - return detailsMessage(container, commit, commit.getGitUri(), commit.lines[0].line, { + return detailsMessage(container, commit, commit.getGitUri(), commit.lines[0].line - 1, { autolinks: true, cancellation: cancellation, dateFormat: defaultDateFormat, diff --git a/src/system/array.ts b/src/system/array.ts index f337df2217e6a..29c26aa40aeb0 100644 --- a/src/system/array.ts +++ b/src/system/array.ts @@ -33,11 +33,13 @@ export function ensure(source: T | T[] | undefined): T[] | undefined { } export async function filterAsync(source: T[], predicate: (item: T) => Promise): Promise { + const predicates = source.map>(i => predicate(i).then(r => [r, i])); + const filtered = []; - for (const item of source) { - if (await predicate(item)) { - filtered.push(item); - } + for await (const [include, item] of predicates) { + if (!include) continue; + + filtered.push(item); } return filtered; } @@ -60,12 +62,13 @@ export async function filterMapAsync( source: T[], predicateMapper: (item: T) => Promise, ): Promise { + const items = source.map(predicateMapper); + const filteredAndMapped = []; - for (const item of source) { - const mapped = await predicateMapper(item); - if (mapped != null) { - filteredAndMapped.push(mapped); - } + for await (const item of items) { + if (item == null) continue; + + filteredAndMapped.push(item); } return filteredAndMapped; } @@ -78,55 +81,6 @@ export function findLastIndex(source: T[], predicate: (value: T, index: numbe return -1; } -export function groupBy(source: readonly T[], groupingKey: (item: T) => string): Record { - return source.reduce>((groupings, current) => { - const value = groupingKey(current); - const group = groupings[value]; - if (group === undefined) { - groupings[value] = [current]; - } else { - group.push(current); - } - return groupings; - }, Object.create(null)); -} - -export function groupByMap( - source: readonly TValue[], - groupingKey: (item: TValue) => TKey, -): Map { - return source.reduce((groupings, current) => { - const value = groupingKey(current); - const group = groupings.get(value); - if (group === undefined) { - groupings.set(value, [current]); - } else { - group.push(current); - } - return groupings; - }, new Map()); -} - -export function groupByFilterMap( - source: readonly TValue[], - groupingKey: (item: TValue) => TKey, - predicateMapper: (item: TValue) => TMapped | null | undefined, -): Map { - return source.reduce((groupings, current) => { - const mapped = predicateMapper(current); - if (mapped != null) { - const value = groupingKey(current); - const group = groupings.get(value); - if (group === undefined) { - groupings.set(value, [mapped]); - } else { - group.push(mapped); - } - } - return groupings; - }, new Map()); -} - export function intersection(sources: T[][], comparator: (a: T, b: T) => boolean): T[] { const results: T[] = []; @@ -250,27 +204,22 @@ export function joinUnique(source: readonly T[], separator: string): string { return join(new Set(source), separator); } -export function splitAt(source: T[], index: number): [T[], T[]] { - return index < 0 ? [source, []] : [source.slice(0, index), source.slice(index)]; +export async function mapAsync( + source: T[], + mapper: (item: T) => TMapped | Promise, + predicate?: (item: TMapped) => boolean, +): Promise[]> { + const items = source.map(mapper); + + const mapped = []; + for await (const item of items) { + if (item == null || (predicate != null && !predicate(item))) continue; + + mapped.push(item); + } + return mapped; } -export function uniqueBy( - source: readonly TValue[], - uniqueKey: (item: TValue) => TKey, - onDuplicate: (original: TValue, current: TValue) => TValue | void, -): TValue[] { - const map = source.reduce((uniques, current) => { - const value = uniqueKey(current); - const original = uniques.get(value); - if (original === undefined) { - uniques.set(value, current); - } else { - const updated = onDuplicate(original, current); - if (updated !== undefined) { - uniques.set(value, updated); - } - } - return uniques; - }, new Map()); - return [...map.values()]; +export function splitAt(source: T[], index: number): [T[], T[]] { + return index < 0 ? [source, []] : [source.slice(0, index), source.slice(index)]; } diff --git a/src/system/brand.ts b/src/system/brand.ts new file mode 100644 index 0000000000000..0c6f0c8f4d7cd --- /dev/null +++ b/src/system/brand.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +declare const __brand: unique symbol; +// eslint-disable-next-line @typescript-eslint/naming-convention +declare const __base: unique symbol; + +type _Brand = { [__brand]: B; [__base]: Base }; +export type Branded = Base & _Brand; +export type Brand> = B extends Branded ? B : never; +export type Unbrand = T extends _Brand ? Base : never; diff --git a/src/system/cancellation.ts b/src/system/cancellation.ts index b4f15aa053fba..dfe3402d2bfd7 100644 --- a/src/system/cancellation.ts +++ b/src/system/cancellation.ts @@ -1,7 +1,5 @@ import type { CancellationToken, Disposable } from 'vscode'; import { CancellationTokenSource } from 'vscode'; -import { map } from './iterable'; -import { isPromise } from './promise'; export class TimedCancellationSource implements CancellationTokenSource, Disposable { private readonly cancellation = new CancellationTokenSource(); @@ -25,441 +23,3 @@ export class TimedCancellationSource implements CancellationTokenSource, Disposa return this.cancellation.token; } } - -type PausedResult = { - value: Promise; - paused: true; - reason: 'cancelled' | 'timedout'; -}; - -export type CompletedResult = { - value: T; - paused: false; -}; - -export type MaybePausedResult = PausedResult | CompletedResult; - -export function pauseOnCancelOrTimeout( - promise: T | Promise, - cancellation?: undefined, - timeout?: undefined, -): Promise>; -export function pauseOnCancelOrTimeout( - promise: T | Promise, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: (result: PausedResult) => void | Promise, -): Promise>; -export function pauseOnCancelOrTimeout( - promise: T | Promise, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: (result: PausedResult) => void | Promise, -): Promise> { - if (!isPromise(promise)) { - return Promise.resolve({ value: promise, paused: false } satisfies MaybePausedResult); - } - - if (cancellation == null && timeout == null) { - return promise.then(value => ({ value: value, paused: false }) satisfies CompletedResult); - } - - let disposeCancellation: Disposable | undefined; - let disposeTimeout: Disposable | undefined; - - const result = Promise.race([ - promise.then(value => { - disposeCancellation?.dispose(); - disposeTimeout?.dispose(); - - if (cancellation?.isCancellationRequested) { - return { - value: Promise.resolve(value), - paused: true, - reason: 'cancelled', - } satisfies MaybePausedResult; - } - - return { value: value, paused: false } satisfies MaybePausedResult; - }), - new Promise>(resolve => { - const resolver = (reason: 'cancelled' | 'timedout') => { - disposeCancellation?.dispose(); - disposeTimeout?.dispose(); - - resolve({ - value: promise, - paused: true, - reason: reason, - } satisfies MaybePausedResult); - }; - - disposeCancellation = cancellation?.onCancellationRequested(() => resolver('cancelled')); - if (timeout != null) { - if (typeof timeout === 'number') { - const timer = setTimeout(() => resolver('timedout'), timeout); - disposeTimeout = { dispose: () => clearTimeout(timer) }; - } else { - disposeTimeout = timeout.onCancellationRequested(() => resolver('timedout')); - } - } - }), - ]); - - return continuation == null - ? result - : result.then(r => { - if (r.paused) { - setTimeout(() => continuation(r), 0); - } - return r; - }); -} - -export async function pauseOnCancelOrTimeoutMap( - source: Map>, - ignoreErrors: true, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: (result: PausedResult>>) => void | Promise, -): Promise>>; -export async function pauseOnCancelOrTimeoutMap( - source: Map>, - ignoreErrors?: boolean, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: (result: PausedResult>>) => void | Promise, -): Promise>>; -export async function pauseOnCancelOrTimeoutMap( - source: Map>, - ignoreErrors?: boolean, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: (result: PausedResult>>) => void | Promise, -): Promise>> { - if (source.size === 0) return source as unknown as Map>; - - // Change the timeout to a cancellation token if it is a number to avoid creating lots of timers - let timeoutCancellation: CancellationTokenSource | undefined; - if (timeout != null && typeof timeout === 'number') { - timeoutCancellation = new TimedCancellationSource(timeout); - timeout = timeoutCancellation.token; - } - - const results = await Promise.all( - map(source, ([id, promise]) => - pauseOnCancelOrTimeout( - promise.catch(ex => (ignoreErrors || !(ex instanceof Error) ? undefined : ex)), - cancellation, - timeout, - ).then(result => [id, result] as const), - ), - ); - - timeoutCancellation?.dispose(); - - if (continuation != null) { - if (results.some(([, r]) => r.paused)) { - async function getContinuationValue() { - const completed = new Map>(); - - for (const [id, result] of results) { - completed.set(id, { value: result.paused ? await result.value : result.value, paused: false }); - } - - return completed; - } - - const cancelled = results.some(([, r]) => r.paused && r.reason === 'cancelled'); - - void continuation({ - value: getContinuationValue(), - paused: true, - reason: cancelled ? 'cancelled' : 'timedout', - }); - } - } - - return new Map>(results); -} - -export async function pauseOnCancelOrTimeoutMapPromise( - source: Promise> | undefined>, - ignoreErrors: true, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: (result: PausedResult>>) => void | Promise, -): Promise> | undefined>>; -export async function pauseOnCancelOrTimeoutMapPromise( - source: Promise> | undefined>, - ignoreErrors?: boolean, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: (result: PausedResult>>) => void | Promise, -): Promise> | undefined>>; -export async function pauseOnCancelOrTimeoutMapPromise( - source: Promise> | undefined>, - ignoreErrors?: boolean, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: (result: PausedResult>>) => void | Promise, -): Promise> | undefined>> { - // Change the timeout to a cancellation token if it is a number to avoid creating lots of timers - let timeoutCancellation: CancellationTokenSource | undefined; - if (timeout != null && typeof timeout === 'number') { - timeoutCancellation = new TimedCancellationSource(timeout); - timeout = timeoutCancellation.token; - } - - const mapPromise = source.then(m => - m == null ? m : pauseOnCancelOrTimeoutMap(m, ignoreErrors, cancellation, timeout, continuation), - ); - - void mapPromise.then(() => timeoutCancellation?.dispose()); - - const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); - return result.paused - ? { value: mapPromise, paused: result.paused, reason: result.reason } - : { value: await mapPromise, paused: false }; -} - -export async function pauseOnCancelOrTimeoutMapTuple( - source: Map | undefined, ...U]>, - cancellation?: undefined, - timeout?: undefined, -): Promise | undefined, ...U]>>; -export async function pauseOnCancelOrTimeoutMapTuple( - source: Map | undefined, ...U]>, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: ( - result: PausedResult | undefined, ...U]>>, - ) => void | Promise, -): Promise | undefined, ...U]>>; -export async function pauseOnCancelOrTimeoutMapTuple( - source: Map | undefined, ...U]>, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: ( - result: PausedResult | undefined, ...U]>>, - ) => void | Promise, -): Promise | undefined, ...U]>> { - if (source.size === 0) { - return source as unknown as Map | undefined, ...U]>; - } - - // Change the timeout to a cancellation token if it is a number to avoid creating lots of timers - let timeoutCancellation: CancellationTokenSource | undefined; - if (timeout != null && typeof timeout === 'number') { - timeoutCancellation = new TimedCancellationSource(timeout); - timeout = timeoutCancellation.token; - } - - const results = await Promise.all( - map(source, ([id, [promise, ...rest]]) => - promise == null - ? ([id, [undefined, ...rest]] as const) - : pauseOnCancelOrTimeout( - promise.catch(() => undefined), - cancellation, - timeout, - ).then(result => [id, [result as MaybePausedResult | undefined, ...rest]] as const), - ), - ); - - timeoutCancellation?.dispose(); - - if (continuation != null) { - if (results.some(([, [r]]) => r?.paused ?? false)) { - async function getContinuationValue() { - const completed = new Map | undefined, ...U]>(); - - for (const [id, [r, ...rest]] of results) { - completed.set(id, [ - { value: r?.paused ? await r.value : r?.value, paused: false }, - ...rest, - ] as const); - } - - return completed; - } - - const cancelled = results.some(([, [r]]) => r?.paused && r.reason === 'cancelled'); - - void continuation({ - value: getContinuationValue(), - paused: true, - reason: cancelled ? 'cancelled' : 'timedout', - }); - } - } - - return new Map | undefined, ...U]>(results); -} - -export async function pauseOnCancelOrTimeoutMapTuplePromise( - source: Promise | undefined, ...U]> | undefined>, - cancellation?: undefined, - timeout?: undefined, -): Promise | undefined, ...U]> | undefined>>; -export async function pauseOnCancelOrTimeoutMapTuplePromise( - source: Promise | undefined, ...U]> | undefined>, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: ( - result: PausedResult | undefined, ...U]>>, - ) => void | Promise, -): Promise | undefined, ...U]> | undefined>>; -export async function pauseOnCancelOrTimeoutMapTuplePromise( - source: Promise | undefined, ...U]> | undefined>, - cancellation?: CancellationToken, - timeout?: number | CancellationToken, - continuation?: ( - result: PausedResult | undefined, ...U]>>, - ) => void | Promise, -): Promise | undefined, ...U]> | undefined>> { - // Change the timeout to a cancellation token if it is a number to avoid creating lots of timers - let timeoutCancellation: CancellationTokenSource | undefined; - if (timeout != null && typeof timeout === 'number') { - timeoutCancellation = new TimedCancellationSource(timeout); - timeout = timeoutCancellation.token; - } - - const mapPromise = source.then(m => - m == null ? m : pauseOnCancelOrTimeoutMapTuple(m, cancellation, timeout, continuation), - ); - - void mapPromise.then(() => timeoutCancellation?.dispose()); - - const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); - return result.paused - ? { value: mapPromise, paused: result.paused, reason: result.reason } - : { value: await mapPromise, paused: false }; -} - -// type PromiseKeys = { -// [K in keyof T]: T[K] extends Promise | undefined ? K : never; -// }[keyof T]; -// type WithCompletedResult> = Omit & { -// [K in U]: CompletedResult | undefined> | undefined; -// }; -// type WithMaybePausedResult> = Omit & { -// [K in U]: MaybePausedResult | undefined> | undefined; -// }; - -// export async function pauseOnCancelOrTimeoutMapOnProp>( -// source: Map, -// prop: U, -// cancellation?: undefined, -// timeout?: undefined, -// ): Promise>>; -// export async function pauseOnCancelOrTimeoutMapOnProp>( -// source: Map, -// prop: U, -// cancellation?: CancellationToken, -// timeout?: number | CancellationToken, -// continuation?: (result: PausedResult>>) => void | Promise, -// ): Promise>>; -// export async function pauseOnCancelOrTimeoutMapOnProp>( -// source: Map, -// prop: U, -// cancellation?: CancellationToken, -// timeout?: number | CancellationToken, -// continuation?: (result: PausedResult>>) => void | Promise, -// ): Promise>> { -// if (source.size === 0) { -// return source as unknown as Map>; -// } - -// // Change the timeout to a cancellation token if it is a number to avoid creating lots of timers -// let timeoutCancellation: CancellationTokenSource | undefined; -// if (timeout != null && typeof timeout === 'number') { -// timeoutCancellation = new TimedCancellationSource(timeout); -// timeout = timeoutCancellation.token; -// } - -// const results = await Promise.all( -// map(source, ([id, item]) => -// item[prop] == null -// ? ([id, item as WithMaybePausedResult] as const) -// : pauseOnCancelOrTimeout( -// (item[prop] as Promise).catch(() => undefined), -// cancellation, -// timeout, -// ).then(result => { -// (item as any)[prop] = result; -// return [id, item as WithMaybePausedResult] as const; -// }), -// ), -// ); - -// timeoutCancellation?.dispose(); - -// if (continuation != null) { -// if (results.some(([, r]) => (r as any)[prop]?.paused ?? false)) { -// async function getContinuationValue() { -// const completed = new Map>(); - -// for (const [id, result] of results) { -// const r = result[prop]; // as MaybePausedResult> | undefined; -// (result as /*WithCompletedResult*/ any)[prop] = r?.paused ? await r.value : r?.value; -// completed.set(id, result as WithCompletedResult); -// } - -// return completed; -// } - -// const cancelled = results.some(([, result]) => { -// const r = result[prop]; -// return r?.paused && r.reason === 'cancelled'; -// }); - -// void continuation({ -// value: getContinuationValue(), -// paused: true, -// reason: cancelled ? 'cancelled' : 'timedout', -// }); -// } -// } - -// return new Map>(results); -// } - -// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( -// source: Promise | undefined>, -// prop: U, -// cancellation?: undefined, -// timeout?: undefined, -// ): Promise> | undefined>>; -// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( -// source: Promise | undefined>, -// prop: U, -// cancellation?: CancellationToken, -// timeout?: number | CancellationToken, -// continuation?: (result: PausedResult>>) => void | Promise, -// ): Promise> | undefined>>; -// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( -// source: Promise | undefined>, -// prop: U, -// cancellation?: CancellationToken, -// timeout?: number | CancellationToken, -// continuation?: (result: PausedResult>>) => void | Promise, -// ): Promise> | undefined>> { -// // Change the timeout to a cancellation token if it is a number to avoid creating lots of timers -// let timeoutCancellation: CancellationTokenSource | undefined; -// if (timeout != null && typeof timeout === 'number') { -// timeoutCancellation = new TimedCancellationSource(timeout); -// timeout = timeoutCancellation.token; -// } - -// const mapPromise = source.then(m => -// m == null ? m : pauseOnCancelOrTimeoutMapOnProp(m, prop, cancellation, timeout, continuation), -// ); - -// void mapPromise.then(() => timeoutCancellation?.dispose()); - -// const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); -// return result.paused -// ? { value: mapPromise, paused: result.paused, reason: result.reason } -// : { value: await mapPromise, paused: false }; -// } diff --git a/src/system/color.ts b/src/system/color.ts index e70a5cb9b7cf3..80bb6d530eecb 100644 --- a/src/system/color.ts +++ b/src/system/color.ts @@ -1,27 +1,5 @@ import { CharCode } from '../constants'; -function adjustLight(color: number, amount: number) { - const cc = color + amount; - const c = amount < 0 ? (cc < 0 ? 0 : cc) : cc > 255 ? 255 : cc; - - return Math.round(c); -} - -// TODO@d13 leaving as is for now, updating to the color library breaks our existing darkened colors -export function darken(color: string, percentage: number) { - return lighten(color, -percentage); -} - -// TODO@d13 leaving as is for now, updating to the color library breaks our existing lightened colors -export function lighten(color: string, percentage: number) { - const rgba = toRgba(color); - if (rgba == null) return color; - - const [r, g, b, a] = rgba; - const amount = (255 * percentage) / 100; - return `rgba(${adjustLight(r, amount)}, ${adjustLight(g, amount)}, ${adjustLight(b, amount)}, ${a})`; -} - export function opacity(color: string, percentage: number) { const rgba = Color.from(color); if (rgba == null) return color; diff --git a/src/system/command.ts b/src/system/command.ts index f78669aad91a4..38005e7a7106a 100644 --- a/src/system/command.ts +++ b/src/system/command.ts @@ -9,7 +9,7 @@ import { isWebviewContext } from './webview'; export type CommandCallback = Parameters[1]; -type CommandConstructor = new (container: Container) => Command; +type CommandConstructor = new (container: Container, ...args: any[]) => Command; const registrableCommands: CommandConstructor[] = []; export function command(): ClassDecorator { @@ -22,7 +22,17 @@ export function registerCommand(command: string, callback: CommandCallback, this return commands.registerCommand( command, function (this: any, ...args) { - Container.instance.telemetry.sendEvent('command', { command: command }); + let context: any; + if (command === Commands.GitCommands) { + const arg = args?.[0]; + if (arg?.command != null) { + context = { mode: args[0].command }; + if (arg?.state?.subcommand != null) { + context.submode = arg.state.subcommand; + } + } + } + Container.instance.telemetry.sendEvent('command', { command: command, context: context }); callback.call(this, ...args); }, thisArg, @@ -93,7 +103,13 @@ export function executeCoreCommand( command: CoreCommands, ...args: T ): Thenable { - if (command != 'setContext' && command !== 'vscode.executeDocumentSymbolProvider') { + if ( + command != 'setContext' && + command !== 'vscode.executeDocumentSymbolProvider' && + command !== 'vscode.changes' && + command !== 'vscode.diff' && + command !== 'vscode.open' + ) { Container.instance.telemetry.sendEvent('command/core', { command: command }); } return commands.executeCommand(command, ...args); diff --git a/src/system/configuration.ts b/src/system/configuration.ts index 9212061bcd0f7..f4e9d3942c20e 100644 --- a/src/system/configuration.ts +++ b/src/system/configuration.ts @@ -1,6 +1,6 @@ import type { ConfigurationChangeEvent, ConfigurationScope, Event, ExtensionContext } from 'vscode'; import { ConfigurationTarget, EventEmitter, workspace } from 'vscode'; -import type { Config } from '../config'; +import type { Config, CoreConfig } from '../config'; import { extensionPrefix } from '../constants'; import { areEqual } from './object'; @@ -87,6 +87,25 @@ export class Configuration { : workspace.getConfiguration(undefined, scope).get(section, defaultValue); } + getCore( + section: S, + scope?: ConfigurationScope | null, + ): CoreConfigPathValue | undefined; + getCore( + section: S, + scope: ConfigurationScope | null | undefined, + defaultValue: CoreConfigPathValue, + ): CoreConfigPathValue; + getCore( + section: S, + scope?: ConfigurationScope | null, + defaultValue?: CoreConfigPathValue, + ): CoreConfigPathValue | undefined { + return defaultValue === undefined + ? workspace.getConfiguration(undefined, scope).get>(section) + : workspace.getConfiguration(undefined, scope).get>(section, defaultValue); + } + changed( e: ConfigurationChangeEvent | undefined, section: S | S[], @@ -111,6 +130,18 @@ export class Configuration { : e.affectsConfiguration(section, scope!); } + changedCore( + e: ConfigurationChangeEvent | undefined, + section: S | S[], + scope?: ConfigurationScope | null | undefined, + ): boolean { + if (e == null) return true; + + return Array.isArray(section) + ? section.some(s => e.affectsConfiguration(s, scope!)) + : e.affectsConfiguration(section, scope!); + } + inspect>(section: S, scope?: ConfigurationScope | null) { return workspace .getConfiguration(extensionPrefix, scope) @@ -121,6 +152,13 @@ export class Configuration { return workspace.getConfiguration(undefined, scope).inspect(section); } + inspectCore>( + section: S, + scope?: ConfigurationScope | null, + ) { + return workspace.getConfiguration(undefined, scope).inspect(section); + } + isUnset(section: S, scope?: ConfigurationScope | null): boolean { const inspect = this.inspect(section, scope)!; if (inspect.workspaceFolderValue !== undefined) return false; @@ -338,8 +376,11 @@ export type PathValue> = P extends `${infer Key}.${infer Re : never : never : P extends keyof T - ? T[P] - : never; + ? T[P] + : never; export type ConfigPath = Path; export type ConfigPathValue

= PathValue; + +export type CoreConfigPath = Path; +export type CoreConfigPathValue

= PathValue; diff --git a/src/system/context.ts b/src/system/context.ts index e217cc4ab83c3..2818d4f825074 100644 --- a/src/system/context.ts +++ b/src/system/context.ts @@ -2,19 +2,31 @@ import { EventEmitter } from 'vscode'; import type { ContextKeys } from '../constants'; import { executeCoreCommand } from './command'; -const contextStorage = new Map(); +const contextStorage = new Map(); -const _onDidChangeContext = new EventEmitter(); +const _onDidChangeContext = new EventEmitter(); export const onDidChangeContext = _onDidChangeContext.event; -export function getContext(key: ContextKeys): T | undefined; -export function getContext(key: ContextKeys, defaultValue: T): T; -export function getContext(key: ContextKeys, defaultValue?: T): T | undefined { - return (contextStorage.get(key) as T | undefined) ?? defaultValue; +export function getContext(key: T): ContextKeys[T] | undefined; +export function getContext(key: T, defaultValue: ContextKeys[T]): ContextKeys[T]; +export function getContext( + key: T, + defaultValue?: ContextKeys[T], +): ContextKeys[T] | undefined { + return (contextStorage.get(key) as ContextKeys[T] | undefined) ?? defaultValue; } -export async function setContext(key: ContextKeys, value: unknown): Promise { - contextStorage.set(key, value); - void (await executeCoreCommand('setContext', key, value)); +export async function setContext( + key: T, + value: ContextKeys[T] | undefined, +): Promise { + if (contextStorage.get(key) === value) return; + + if (value == null) { + contextStorage.delete(key); + } else { + contextStorage.set(key, value); + } + void (await executeCoreCommand('setContext', key, value ?? undefined)); _onDidChangeContext.fire(key); } diff --git a/src/system/counter.ts b/src/system/counter.ts new file mode 100644 index 0000000000000..7338fe80f6911 --- /dev/null +++ b/src/system/counter.ts @@ -0,0 +1,18 @@ +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) + +export type Counter = { readonly current: number; next(): number }; + +export function getScopedCounter(): Counter { + let counter = 0; + return { + get current() { + return counter; + }, + next: function () { + if (counter === maxSmallIntegerV8) { + counter = 0; + } + return ++counter; + }, + }; +} diff --git a/src/system/date.ts b/src/system/date.ts index 729b3f696c608..f5bb490a4781b 100644 --- a/src/system/date.ts +++ b/src/system/date.ts @@ -210,8 +210,7 @@ export function formatDate( ) => { if (literal != null) return (literal as string).substring(1, literal.length - 1); - for (const key in groups) { - const value = groups[key]; + for (const [key, value] of Object.entries(groups)) { if (value == null) continue; const part = parts.find(p => p.type === key); @@ -237,19 +236,21 @@ export function getDateDifference( first: Date | number, second: Date | number, unit?: 'days' | 'hours' | 'minutes' | 'seconds', + roundFn?: (value: number) => number, ): number { const diff = (typeof second === 'number' ? second : second.getTime()) - (typeof first === 'number' ? first : first.getTime()); + const round = roundFn ?? Math.floor; switch (unit) { case 'days': - return Math.floor(diff / (1000 * 60 * 60 * 24)); + return round(diff / (1000 * 60 * 60 * 24)); case 'hours': - return Math.floor(diff / (1000 * 60 * 60)); + return round(diff / (1000 * 60 * 60)); case 'minutes': - return Math.floor(diff / (1000 * 60)); + return round(diff / (1000 * 60)); case 'seconds': - return Math.floor(diff / 1000); + return round(diff / 1000); default: return diff; } @@ -275,8 +276,7 @@ function getDateTimeFormatOptionsFromFormatString( for (const { groups } of format.matchAll(customDateTimeFormatParserRegex)) { if (groups == null) continue; - for (const key in groups) { - const value = groups[key]; + for (const [key, value] of Object.entries(groups)) { if (value == null) continue; switch (key) { diff --git a/src/system/decorators/gate.ts b/src/system/decorators/gate.ts index 3701bfc71113e..bb157ea0ca9f4 100644 --- a/src/system/decorators/gate.ts +++ b/src/system/decorators/gate.ts @@ -29,7 +29,7 @@ export function gate any>(resolver?: (...args: Parame if (promise === undefined) { let result; try { - result = fn!.apply(this, args); + result = fn.apply(this, args); if (result == null || !isPromise(result)) { return result; } @@ -39,7 +39,7 @@ export function gate any>(resolver?: (...args: Parame this[prop] = undefined; return r; }) - .catch(ex => { + .catch((ex: unknown) => { this[prop] = undefined; throw ex; }); diff --git a/src/system/decorators/log.ts b/src/system/decorators/log.ts index 7ea2823a293a1..0226aa7715d55 100644 --- a/src/system/decorators/log.ts +++ b/src/system/decorators/log.ts @@ -4,12 +4,10 @@ import { getParameters } from '../function'; import { getLoggableName, Logger } from '../logger'; import { slowCallWarningThreshold } from '../logger.constants'; import type { LogScope } from '../logger.scope'; -import { clearLogScope, getNextLogScopeId, setLogScope } from '../logger.scope'; +import { clearLogScope, logScopeIdGenerator, setLogScope } from '../logger.scope'; import { isPromise } from '../promise'; import { getDurationMilliseconds } from '../string'; -const emptyStr = ''; - export interface LogContext { id: number; instance: any; @@ -29,11 +27,10 @@ interface LogOptions any> { 4?: ((arg: Parameters[4]) => unknown) | string | false; [key: number]: (((arg: any) => unknown) | string | false) | undefined; }; - condition?(...args: Parameters): boolean; + if?(this: any, ...args: Parameters): boolean; enter?(...args: Parameters): string; exit?: ((result: PromiseType>) => string) | boolean; prefix?(context: LogContext, ...args: Parameters): string; - sanitize?(key: string, value: any): any; logThreshold?: number; scoped?: boolean; singleLine?: boolean; @@ -56,11 +53,10 @@ type PromiseType = T extends Promise ? U : T; export function log any>(options?: LogOptions, debug = false) { let overrides: LogOptions['args'] | undefined; - let conditionFn: LogOptions['condition'] | undefined; + let ifFn: LogOptions['if'] | undefined; let enterFn: LogOptions['enter'] | undefined; let exitFn: LogOptions['exit'] | undefined; let prefixFn: LogOptions['prefix'] | undefined; - let sanitizeFn: LogOptions['sanitize'] | undefined; let logThreshold: NonNullable['logThreshold']> = 0; let scoped: NonNullable['scoped']> = false; let singleLine: NonNullable['singleLine']> = false; @@ -68,11 +64,10 @@ export function log any>(options?: LogOptions, deb if (options != null) { ({ args: overrides, - condition: conditionFn, + if: ifFn, enter: enterFn, exit: exitFn, prefix: prefixFn, - sanitize: sanitizeFn, logThreshold = 0, scoped = true, singleLine = false, @@ -89,8 +84,9 @@ export function log any>(options?: LogOptions, deb scoped = true; } - const logFn = debug ? Logger.debug.bind(Logger) : Logger.log.bind(Logger); - const warnFn = Logger.warn.bind(Logger); + const debugging = Logger.isDebugging; + const logFn: (message: string, ...params: any[]) => void = debug ? Logger.debug : Logger.log; + const logLevel = debugging ? 'debug' : 'info'; return (target: any, key: string, descriptor: PropertyDescriptor & Record) => { let fn: Function | undefined; @@ -104,38 +100,29 @@ export function log any>(options?: LogOptions, deb } if (fn == null || fnKey == null) throw new Error('Not supported'); - const parameters = getParameters(fn); + const parameters = overrides !== false ? getParameters(fn) : []; descriptor[fnKey] = function (this: any, ...args: Parameters) { - const scopeId = getNextLogScopeId(); - - if ( - (!Logger.isDebugging && !Logger.enabled('debug') && !(Logger.enabled('info') && !debug)) || - (conditionFn != null && !conditionFn(...args)) - ) { - return fn!.apply(this, args); + if ((!debugging && !Logger.enabled(logLevel)) || (ifFn != null && !ifFn.apply(this, args))) { + return fn.apply(this, args); } - let instanceName: string; - if (this != null) { - instanceName = getLoggableName(this); - if (this.constructor?.[LogInstanceNameFn]) { - instanceName = target.constructor[LogInstanceNameFn](this, instanceName); - } - } else { - instanceName = emptyStr; - } + const scopeId = logScopeIdGenerator.next(); - let prefix = `${scoped ? `[${scopeId.toString(16).padStart(5)}] ` : emptyStr}${ - instanceName ? `${instanceName}.` : emptyStr - }${key}`; + const instanceName = this != null ? getLoggableName(this) : undefined; + + let prefix = instanceName + ? scoped + ? `[${scopeId.toString(16).padStart(5)}] ${instanceName}.${key}` + : `${instanceName}.${key}` + : key; if (prefixFn != null) { prefix = prefixFn( { id: scopeId, instance: this, - instanceName: instanceName, + instanceName: instanceName ?? '', name: key, prefix: prefix, }, @@ -149,14 +136,14 @@ export function log any>(options?: LogOptions, deb setLogScope(scopeId, scope); } - const enter = enterFn != null ? enterFn(...args) : emptyStr; + const enter = enterFn != null ? enterFn(...args) : ''; let loggableParams: string; if (overrides === false || args.length === 0) { - loggableParams = emptyStr; + loggableParams = ''; if (!singleLine) { - logFn(`${prefix}${enter}`); + logFn.call(Logger, `${prefix}${enter}`); } } else { loggableParams = ''; @@ -189,20 +176,14 @@ export function log any>(options?: LogOptions, deb loggableParams += ', '; } - paramLogValue = Logger.toLoggable(paramValue, sanitizeFn); + paramLogValue = Logger.toLoggable(paramValue); } loggableParams += paramName ? `${paramName}=${paramLogValue}` : paramLogValue; } if (!singleLine) { - logFn( - `${prefix}${enter}${ - loggableParams && (debug || Logger.enabled('debug') || Logger.isDebugging) - ? `(${loggableParams})` - : emptyStr - }`, - ); + logFn.call(Logger, loggableParams ? `${prefix}${enter}(${loggableParams})` : `${prefix}${enter}`); } } @@ -210,15 +191,19 @@ export function log any>(options?: LogOptions, deb const start = timed ? hrtime() : undefined; const logError = (ex: Error) => { - const timing = start !== undefined ? ` [${getDurationMilliseconds(start)}ms]` : emptyStr; + const timing = start !== undefined ? ` [${getDurationMilliseconds(start)}ms]` : ''; if (singleLine) { Logger.error( ex, - `${prefix}${enter}${loggableParams ? `(${loggableParams})` : emptyStr}`, - `failed${scope?.exitDetails ? scope.exitDetails : emptyStr}${timing}`, + loggableParams ? `${prefix}${enter}(${loggableParams})` : `${prefix}${enter}`, + scope?.exitDetails ? `failed${scope.exitDetails}${timing}` : `failed${timing}`, ); } else { - Logger.error(ex, prefix, `failed${scope?.exitDetails ? scope.exitDetails : emptyStr}${timing}`); + Logger.error( + ex, + prefix, + scope?.exitDetails ? `failed${scope.exitDetails}${timing}` : `failed${timing}`, + ); } if (scoped) { @@ -228,7 +213,7 @@ export function log any>(options?: LogOptions, deb let result; try { - result = fn!.apply(this, args); + result = fn.apply(this, args); } catch (ex) { logError(ex); throw ex; @@ -241,14 +226,14 @@ export function log any>(options?: LogOptions, deb if (start != null) { duration = getDurationMilliseconds(start); if (duration > slowCallWarningThreshold) { - exitLogFn = warnFn; + exitLogFn = Logger.warn; timing = ` [*${duration}ms] (slow)`; } else { exitLogFn = logFn; timing = ` [${duration}ms]`; } } else { - timing = emptyStr; + timing = ''; exitLogFn = logFn; } @@ -263,27 +248,28 @@ export function log any>(options?: LogOptions, deb } else if (exitFn === true) { exit = `returned ${Logger.toLoggable(r)}`; } + } else if (scope?.exitFailed) { + exit = scope.exitFailed; + exitLogFn = Logger.error; } else { exit = 'completed'; } if (singleLine) { if (logThreshold === 0 || duration! > logThreshold) { - exitLogFn( - `${prefix}${enter}${ - loggableParams && (debug || Logger.enabled('debug') || Logger.isDebugging) - ? `(${loggableParams})` - : emptyStr - } ${exit}${scope?.exitDetails ? scope.exitDetails : emptyStr}${timing}`, + exitLogFn.call( + Logger, + loggableParams + ? `${prefix}${enter}(${loggableParams}) ${exit}${scope?.exitDetails || ''}${timing}` + : `${prefix}${enter} ${exit}${scope?.exitDetails || ''}${timing}`, ); } } else { - exitLogFn( - `${prefix}${ - loggableParams && (debug || Logger.enabled('debug') || Logger.isDebugging) - ? `(${loggableParams})` - : emptyStr - } ${exit}${scope?.exitDetails ? scope.exitDetails : emptyStr}${timing}`, + exitLogFn.call( + Logger, + loggableParams + ? `${prefix}(${loggableParams}) ${exit}${scope?.exitDetails || ''}${timing}` + : `${prefix} ${exit}${scope?.exitDetails || ''}${timing}`, ); } @@ -293,8 +279,7 @@ export function log any>(options?: LogOptions, deb }; if (result != null && isPromise(result)) { - const promise = result.then(logResult); - promise.catch(logError); + result.then(logResult, logError); } else { logResult(result); } @@ -302,7 +287,7 @@ export function log any>(options?: LogOptions, deb return result; } - return fn!.apply(this, args); + return fn.apply(this, args); }; }; } diff --git a/src/system/decorators/memoize.ts b/src/system/decorators/memoize.ts index fec7e56d0ebfa..bd9a3ed2c34dc 100644 --- a/src/system/decorators/memoize.ts +++ b/src/system/decorators/memoize.ts @@ -29,7 +29,7 @@ export function memoize any>(resolver?: (...args: Par return result; } - result = fn!.apply(this, args); + result = fn.apply(this, args); Object.defineProperty(this, prop, { configurable: false, enumerable: false, diff --git a/src/system/decorators/resolver.ts b/src/system/decorators/resolver.ts index 21969e851992e..fcf2a95794d56 100644 --- a/src/system/decorators/resolver.ts +++ b/src/system/decorators/resolver.ts @@ -3,18 +3,15 @@ import { isContainer } from '../../container'; import { isBranch } from '../../git/models/branch'; import { isCommit } from '../../git/models/commit'; import { isTag } from '../../git/models/tag'; -import { isViewNode } from '../../views/nodes/viewNode'; +import { isViewNode } from '../../views/nodes/abstract/viewNode'; function replacer(key: string, value: any): any { - if (key === '') return value; - - if (value == null) return value; - if (typeof value !== 'object') return value; + if (key === '' || value == null || typeof value !== 'object') return value; if (value instanceof Error) return String(value); if (value instanceof Uri) { - if ('sha' in (value as any) && (value as any).sha) { - return `${(value as any).sha}:${value.toString()}`; + if ('sha' in value && typeof value.sha === 'string' && value.sha) { + return `${value.sha}:${value.toString()}`; } return value.toString(); } @@ -28,37 +25,36 @@ function replacer(key: string, value: any): any { export function defaultResolver(...args: any[]): string { if (args.length === 0) return ''; - if (args.length !== 1) { - return JSON.stringify(args, replacer); - } + if (args.length > 1) return JSON.stringify(args, replacer); + + const [arg] = args; + if (arg == null) return ''; - const arg0 = args[0]; - if (arg0 == null) return ''; - switch (typeof arg0) { + switch (typeof arg) { case 'string': - return arg0; + return arg; case 'number': case 'boolean': case 'undefined': case 'symbol': case 'bigint': - return String(arg0); + return String(arg); default: - if (arg0 instanceof Error) return String(arg0); - if (arg0 instanceof Uri) { - if ('sha' in arg0 && typeof arg0.sha === 'string' && arg0.sha) { - return `${arg0.sha}:${arg0.toString()}`; + if (arg instanceof Error) return String(arg); + if (arg instanceof Uri) { + if ('sha' in arg && typeof arg.sha === 'string' && arg.sha) { + return `${arg.sha}:${arg.toString()}`; } - return arg0.toString(); + return arg.toString(); } - if (isBranch(arg0) || isCommit(arg0) || isTag(arg0) || isViewNode(arg0)) { - return arg0.toString(); + if (isBranch(arg) || isCommit(arg) || isTag(arg) || isViewNode(arg)) { + return arg.toString(); } - if (isContainer(arg0)) return ''; + if (isContainer(arg)) return ''; - return JSON.stringify(arg0, replacer); + return JSON.stringify(arg, replacer); } } diff --git a/src/system/decorators/serialize.ts b/src/system/decorators/serialize.ts index 43e2f8127faa1..8d375957bd662 100644 --- a/src/system/decorators/serialize.ts +++ b/src/system/decorators/serialize.ts @@ -22,7 +22,7 @@ export function serialize(): (target: any, key: string, descriptor: PropertyDesc let promise: Promise | undefined = this[serializeKey]; // eslint-disable-next-line no-return-await, @typescript-eslint/no-unsafe-return - const run = async () => await fn!.apply(this, args); + const run = async () => await fn.apply(this, args); if (promise == null) { promise = run(); } else { diff --git a/src/system/decorators/timeout.ts b/src/system/decorators/timeout.ts deleted file mode 100644 index 08123a16ec9ba..0000000000000 --- a/src/system/decorators/timeout.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -import { cancellable, isPromise } from '../promise'; - -export function timeout(timeout: number): any; -export function timeout(timeoutFromLastArg: true, defaultTimeout?: number): any; -export function timeout(timeoutOrTimeoutFromLastArg: number | boolean, defaultTimeout?: number): any { - let timeout: number | undefined; - let timeoutFromLastArg = false; - if (typeof timeoutOrTimeoutFromLastArg === 'boolean') { - timeoutFromLastArg = timeoutOrTimeoutFromLastArg; - } else { - timeout = timeoutOrTimeoutFromLastArg; - } - - return (target: any, key: string, descriptor: PropertyDescriptor) => { - let fn: Function | undefined; - if (typeof descriptor.value === 'function') { - fn = descriptor.value; - } - if (fn == null) throw new Error('Not supported'); - - descriptor.value = function (this: any, ...args: any[]) { - if (timeoutFromLastArg) { - const lastArg = args[args.length - 1]; - if (lastArg != null && typeof lastArg === 'number') { - timeout = lastArg; - } else { - timeout = defaultTimeout; - } - } - - const result = fn?.apply(this, args); - if (timeout == null || timeout < 1 || !isPromise(result)) return result; - - return cancellable(result, timeout, { onDidCancel: resolve => resolve(undefined) }); - }; - }; -} diff --git a/src/system/event.ts b/src/system/event.ts index e3d9120f74161..a206afd90e84e 100644 --- a/src/system/event.ts +++ b/src/system/event.ts @@ -1,16 +1,20 @@ -import type { Disposable, Event } from 'vscode'; +import type { Event } from 'vscode'; +import { Disposable } from 'vscode'; import type { Deferred } from './promise'; export function once(event: Event): Event { - return (listener: (e: T) => unknown, thisArgs?: unknown, disposables?: Disposable[]) => { - const result = event( - e => { + return take(event, 1); +} + +export function take(event: Event, count: number): Event { + return (listener: (e: T) => unknown, thisArgs?: unknown) => { + let i = 0; + const result = event(e => { + if (++i >= count) { result.dispose(); - return listener.call(thisArgs, e); - }, - null, - disposables, - ); + } + return listener.call(thisArgs, e); + }); return result; }; @@ -20,18 +24,14 @@ export function promisify(event: Event): Promise { return new Promise(resolve => once(event)(resolve)); } -export function until(event: Event, predicate: (e: T) => boolean): Event { - return (listener: (e: T) => unknown, thisArgs?: unknown, disposables?: Disposable[]) => { - const result = event( - e => { - if (predicate(e)) { - result.dispose(); - } - return listener.call(thisArgs, e); - }, - null, - disposables, - ); +export function takeUntil(event: Event, predicate: (e: T) => boolean): Event { + return (listener: (e: T) => unknown, thisArgs?: unknown) => { + const result = event(e => { + if (predicate(e)) { + result.dispose(); + } + return listener.call(thisArgs, e); + }); return result; }; @@ -81,6 +81,7 @@ export function promisifyDeferred( pending = false; } catch (ex) { pending = false; + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(ex); } }); @@ -103,3 +104,30 @@ export function promisifyDeferred( cancel: () => cancel?.(), }; } + +export function weakEvent( + event: Event, + listener: (e: T) => any, + thisArg: U, + alsoDisposeOnReleaseOrDispose?: Disposable[], +): Disposable { + const ref = new WeakRef(thisArg); + + let disposable: Disposable; + + const d = event((e: T) => { + const obj = ref.deref(); + if (obj != null) { + listener.call(obj, e); + } else { + disposable.dispose(); + } + }); + + if (alsoDisposeOnReleaseOrDispose == null) { + disposable = d; + } else { + disposable = Disposable.from(d, ...alsoDisposeOnReleaseOrDispose); + } + return disposable; +} diff --git a/src/system/function.ts b/src/system/function.ts index 107e975729077..1d3de1cce2c19 100644 --- a/src/system/function.ts +++ b/src/system/function.ts @@ -4,7 +4,7 @@ export interface Deferrable any> { (...args: Parameters): ReturnType | undefined; cancel(): void; flush(): ReturnType | undefined; - pending?(): boolean; + pending(): boolean; } interface PropOfValue { @@ -12,7 +12,11 @@ interface PropOfValue { value: string | undefined; } -export function debounce ReturnType>(fn: T, wait: number): Deferrable { +export function debounce ReturnType>( + fn: T, + wait: number, + aggregator?: (prevArgs: Parameters, nextArgs: Parameters) => Parameters, +): Deferrable { let lastArgs: Parameters; let lastCallTime: number | undefined; let lastThis: ThisType; @@ -53,7 +57,8 @@ export function debounce ReturnType>(fn: T, wai // Only invoke if we have `lastArgs` which means `fn` has been debounced at least once if (lastArgs) return invoke(); - lastArgs = lastThis = undefined!; + lastArgs = undefined!; + lastThis = undefined!; return result; } @@ -62,14 +67,20 @@ export function debounce ReturnType>(fn: T, wai if (timer != null) { clearTimeout(timer); } - lastArgs = lastCallTime = lastThis = timer = undefined!; + lastArgs = undefined!; + lastCallTime = undefined!; + lastThis = undefined!; + timer = undefined!; } function flush() { - return timer != null ? trailingEdge() : result; + if (timer == null) return result; + + clearTimeout(timer); + return trailingEdge(); } - function pending() { + function pending(): boolean { return timer != null; } @@ -77,7 +88,12 @@ export function debounce ReturnType>(fn: T, wai const time = Date.now(); const isInvoking = shouldInvoke(time); - lastArgs = args; + if (aggregator != null && lastArgs) { + lastArgs = aggregator(lastArgs, args); + } else { + lastArgs = args; + } + // eslint-disable-next-line @typescript-eslint/no-this-alias lastThis = this; lastCallTime = time; @@ -103,7 +119,6 @@ export function debounce ReturnType>(fn: T, wai } const comma = ','; -const emptyStr = ''; const equals = '='; const openBrace = '{'; const openParen = '('; @@ -119,7 +134,7 @@ export function getParameters(fn: Function): string[] { if (fn.length === 0) return []; let fnBody: string = Function.prototype.toString.call(fn); - fnBody = fnBody.replace(fnBodyStripCommentsRegex, emptyStr) || fnBody; + fnBody = fnBody.replace(fnBodyStripCommentsRegex, '') || fnBody; fnBody = fnBody.slice(0, fnBody.indexOf(openBrace)); let open = fnBody.indexOf(openParen); @@ -133,7 +148,7 @@ export function getParameters(fn: Function): string[] { const match = fnBodyRegex.exec(fnBody); return match != null - ? match[1].split(comma).map(param => param.trim().replace(fnBodyStripParamDefaultValueRegex, emptyStr)) + ? match[1].split(comma).map(param => param.trim().replace(fnBodyStripParamDefaultValueRegex, '')) : []; } @@ -162,6 +177,23 @@ export function once any>(fn: T): T { } as T; } +type PartialArgs = { + [K in keyof P]: K extends keyof T ? T[K] : never; +}; + +type DropFirstN = { + 0: T; + 1: T extends [infer _, ...infer R] ? DropFirstN : T; +}[I['length'] extends N ? 0 : 1]; + +export function partial any, P extends any[]>( + fn: T, + ...partialArgs: PartialArgs, P> +): (...rest: DropFirstN, P['length']>) => ReturnType { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return (...rest) => fn(...partialArgs, ...rest); +} + export function propOf>(o: T, key: K) { const propOfCore = >(o: T, key: K) => { const value: string = diff --git a/src/system/iterable.ts b/src/system/iterable.ts index c9bb07195e636..445ecec7e01af 100644 --- a/src/system/iterable.ts +++ b/src/system/iterable.ts @@ -38,19 +38,18 @@ export function* chunkByStringLength(source: string[], maxLength: number): Itera } } -export function count(source: IterableIterator, predicate?: (item: T) => boolean): number { - let count = 0; - let next: IteratorResult; - - while (true) { - next = source.next(); - if (next.done) break; +export function count( + source: Iterable | IterableIterator | undefined, + predicate?: (item: T) => boolean, +): number { + if (source == null) return 0; - if (predicate === undefined || predicate(next.value)) { + let count = 0; + for (const item of source) { + if (predicate == null || predicate(item)) { count++; } } - return count; } @@ -100,11 +99,11 @@ export function forEach(source: Iterable | IterableIterator, fn: (item: } } -export function find(source: Iterable | IterableIterator, predicate: (item: T) => boolean): T | null { +export function find(source: Iterable | IterableIterator, predicate: (item: T) => boolean): T | undefined { for (const item of source) { if (predicate(item)) return item; } - return null; + return undefined; } export function findIndex(source: Iterable | IterableIterator, predicate: (item: T) => boolean): number { @@ -120,6 +119,19 @@ export function first(source: Iterable | IterableIterator): T | undefin return source[Symbol.iterator]().next().value as T | undefined; } +export function flatCount( + source: Iterable | IterableIterator | undefined, + accumulator: (item: T) => number, +): number { + if (source == null) return 0; + + let count = 0; + for (const item of source) { + count += accumulator(item); + } + return count; +} + export function* flatMap( source: Iterable | IterableIterator, mapper: (item: T) => Iterable, @@ -129,6 +141,76 @@ export function* flatMap( } } +export function flatten(source: Iterable> | IterableIterator>): IterableIterator { + return flatMap(source, i => i); +} + +export function groupBy( + source: Iterable | IterableIterator, + groupingKey: (item: T) => string, +): Record { + const groupings: Record = Object.create(null); + + for (const current of source) { + const value = groupingKey(current); + const group = groupings[value]; + if (group === undefined) { + groupings[value] = [current]; + } else { + group.push(current); + } + } + + return groupings; +} + +export function groupByMap( + source: Iterable | IterableIterator, + groupingKey: (item: TValue) => TKey, + options?: { filterNullGroups?: boolean }, +): Map { + const groupings = new Map(); + + const filterNullGroups = options?.filterNullGroups ?? false; + + for (const current of source) { + const value = groupingKey(current); + if (value == null && filterNullGroups) continue; + + const group = groupings.get(value); + if (group === undefined) { + groupings.set(value, [current]); + } else { + group.push(current); + } + } + + return groupings; +} + +export function groupByFilterMap( + source: Iterable | IterableIterator, + groupingKey: (item: TValue) => TKey, + predicateMapper: (item: TValue) => TMapped | null | undefined, +): Map { + const groupings = new Map(); + + for (const current of source) { + const mapped = predicateMapper(current); + if (mapped == null) continue; + + const value = groupingKey(current); + const group = groupings.get(value); + if (group === undefined) { + groupings.set(value, [mapped]); + } else { + group.push(mapped); + } + } + + return groupings; +} + export function has(source: Iterable | IterableIterator, item: T): boolean { return some(source, i => i === item); } @@ -170,6 +252,48 @@ export function* map( } } +export function max(source: Iterable | IterableIterator): number; +export function max(source: Iterable | IterableIterator, selector: (item: T) => number): number; +export function max(source: Iterable | IterableIterator, selector?: (item: T) => number): number { + let max = Number.NEGATIVE_INFINITY; + if (selector == null) { + for (const item of source as Iterable | IterableIterator) { + if (item > max) { + max = item; + } + } + } else { + for (const item of source) { + const value = selector(item); + if (value > max) { + max = value; + } + } + } + return max; +} + +export function min(source: Iterable | IterableIterator): number; +export function min(source: Iterable | IterableIterator, selector: (item: T) => number): number; +export function min(source: Iterable | IterableIterator, selector?: (item: T) => number): number { + let min = Number.POSITIVE_INFINITY; + if (selector == null) { + for (const item of source as Iterable | IterableIterator) { + if (item < min) { + min = item; + } + } + } else { + for (const item of source) { + const value = selector(item); + if (value < min) { + min = value; + } + } + } + return min; +} + export function next(source: IterableIterator): T { return source.next().value as T; } diff --git a/src/system/logger.scope.ts b/src/system/logger.scope.ts index 17f2e440f126e..5348d79f4cbc9 100644 --- a/src/system/logger.scope.ts +++ b/src/system/logger.scope.ts @@ -1,12 +1,14 @@ -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +import { getScopedCounter } from './counter'; + +export const logScopeIdGenerator = getScopedCounter(); const scopes = new Map(); -let scopeCounter = 0; export interface LogScope { readonly scopeId?: number; readonly prefix: string; exitDetails?: string; + exitFailed?: string; } export function clearLogScope(scopeId: number) { @@ -14,34 +16,28 @@ export function clearLogScope(scopeId: number) { } export function getLogScope(): LogScope | undefined { - return scopes.get(scopeCounter); + return scopes.get(logScopeIdGenerator.current); } -export function getNewLogScope(prefix: string): LogScope { - const scopeId = getNextLogScopeId(); +export function getNewLogScope(prefix: string, scope?: LogScope | undefined): LogScope { + if (scope != null) return { scopeId: scope.scopeId, prefix: `${scope.prefix}${prefix}` }; + + const scopeId = logScopeIdGenerator.next(); return { scopeId: scopeId, prefix: `[${String(scopeId).padStart(5)}] ${prefix}`, }; } -export function getLogScopeId(): number { - return scopeCounter; -} - -export function getNextLogScopeId(): number { - if (scopeCounter === maxSmallIntegerV8) { - scopeCounter = 0; - } - return ++scopeCounter; -} - export function setLogScope(scopeId: number, scope: LogScope) { scopes.set(scopeId, scope); } -export function setLogScopeExit(scope: LogScope | undefined, details: string): void { +export function setLogScopeExit(scope: LogScope | undefined, details: string | undefined, failed?: string): void { if (scope == null) return; scope.exitDetails = details; + if (failed != null) { + scope.exitFailed = failed; + } } diff --git a/src/system/logger.ts b/src/system/logger.ts index 82297efe6228d..e95f62c6d53d1 100644 --- a/src/system/logger.ts +++ b/src/system/logger.ts @@ -1,8 +1,7 @@ +import { LogInstanceNameFn } from './decorators/log'; import type { LogLevel } from './logger.constants'; import type { LogScope } from './logger.scope'; -const emptyStr = ''; - const enum OrderedLevel { Off = 0, Error = 1, @@ -57,7 +56,7 @@ export const Logger = new (class Logger { this.output?.dispose?.(); this.output = undefined; } else { - this.output = this.output ?? this.provider!.createChannel(this.provider!.name); + this.output ??= this.provider!.createChannel(this.provider!.name); } } @@ -77,16 +76,16 @@ export const Logger = new (class Logger { message = params.shift(); if (scopeOrMessage != null) { - message = `${scopeOrMessage.prefix} ${message ?? emptyStr}`; + message = `${scopeOrMessage.prefix} ${message ?? ''}`; } } if (this.isDebugging) { - console.log(this.timestamp, `[${this.provider!.name}]`, message ?? emptyStr, ...params); + console.log(this.timestamp, `[${this.provider!.name}]`, message ?? '', ...params); } if (this.output == null || this.level < OrderedLevel.Debug) return; - this.output.appendLine(`${this.timestamp} ${message ?? emptyStr}${this.toLoggableParams(true, params)}`); + this.output.appendLine(`${this.timestamp} ${message ?? ''}${this.toLoggableParams(true, params)}`); } error(ex: Error | unknown, message?: string, ...params: any[]): void; @@ -98,7 +97,7 @@ export const Logger = new (class Logger { if (scopeOrMessage == null || typeof scopeOrMessage === 'string') { message = scopeOrMessage; } else { - message = `${scopeOrMessage.prefix} ${params.shift() ?? emptyStr}`; + message = `${scopeOrMessage.prefix} ${params.shift() ?? ''}`; } if (message == null) { @@ -112,12 +111,18 @@ export const Logger = new (class Logger { } if (this.isDebugging) { - console.error(this.timestamp, `[${this.provider!.name}]`, message ?? emptyStr, ...params, ex); + if (ex != null) { + console.error(this.timestamp, `[${this.provider!.name}]`, message ?? '', ...params, ex); + } else { + console.error(this.timestamp, `[${this.provider!.name}]`, message ?? '', ...params); + } } if (this.output == null || this.level < OrderedLevel.Error) return; this.output.appendLine( - `${this.timestamp} ${message ?? emptyStr}${this.toLoggableParams(false, params)}\n${String(ex)}`, + `${this.timestamp} ${message ?? ''}${this.toLoggableParams(false, params)}${ + ex != null ? `\n${String(ex)}` : '' + }`, ); } @@ -133,16 +138,16 @@ export const Logger = new (class Logger { message = params.shift(); if (scopeOrMessage != null) { - message = `${scopeOrMessage.prefix} ${message ?? emptyStr}`; + message = `${scopeOrMessage.prefix} ${message ?? ''}`; } } if (this.isDebugging) { - console.log(this.timestamp, `[${this.provider!.name}]`, message ?? emptyStr, ...params); + console.log(this.timestamp, `[${this.provider!.name}]`, message ?? '', ...params); } if (this.output == null || this.level < OrderedLevel.Info) return; - this.output.appendLine(`${this.timestamp} ${message ?? emptyStr}${this.toLoggableParams(false, params)}`); + this.output.appendLine(`${this.timestamp} ${message ?? ''}${this.toLoggableParams(false, params)}`); } warn(message: string, ...params: any[]): void; @@ -157,16 +162,16 @@ export const Logger = new (class Logger { message = params.shift(); if (scopeOrMessage != null) { - message = `${scopeOrMessage.prefix} ${message ?? emptyStr}`; + message = `${scopeOrMessage.prefix} ${message ?? ''}`; } } if (this.isDebugging) { - console.warn(this.timestamp, `[${this.provider!.name}]`, message ?? emptyStr, ...params); + console.warn(this.timestamp, `[${this.provider!.name}]`, message ?? '', ...params); } if (this.output == null || this.level < OrderedLevel.Warn) return; - this.output.appendLine(`${this.timestamp} ${message ?? emptyStr}${this.toLoggableParams(false, params)}`); + this.output.appendLine(`${this.timestamp} ${message ?? ''}${this.toLoggableParams(false, params)}`); } showOutputChannel(preserveFocus?: boolean): void { @@ -192,14 +197,64 @@ export const Logger = new (class Logger { private toLoggableParams(debugOnly: boolean, params: any[]) { if (params.length === 0 || (debugOnly && this.level < OrderedLevel.Debug && !this.isDebugging)) { - return emptyStr; + return ''; } const loggableParams = params.map(p => this.toLoggable(p)).join(', '); - return loggableParams.length !== 0 ? ` \u2014 ${loggableParams}` : emptyStr; + return loggableParams.length !== 0 ? ` \u2014 ${loggableParams}` : ''; } })(); +export class BufferedLogChannel implements LogChannel { + private readonly buffer: string[] = []; + private bufferTimer: ReturnType | undefined; + + constructor( + private readonly channel: RequireSome & { append(value: string): void }, + private readonly interval: number = 500, + ) {} + + dispose(): void { + clearInterval(this.bufferTimer); + this.bufferTimer = undefined; + + this.channel.dispose(); + } + + get name(): string { + return this.channel.name; + } + + appendLine(value: string) { + this.buffer.push(value); + this.bufferTimer ??= setInterval(() => this.flush(), this.interval); + } + + show(preserveFocus?: boolean): void { + this.channel.show?.(preserveFocus); + } + + private _emptyCounter = 0; + + private flush() { + if (this.buffer.length) { + this._emptyCounter = 0; + + const value = this.buffer.join('\n'); + this.buffer.length = 0; + + this.channel.append(value); + } else { + this._emptyCounter++; + if (this._emptyCounter > 10) { + clearInterval(this.bufferTimer); + this.bufferTimer = undefined; + this._emptyCounter = 0; + } + } + } +} + function toOrderedLevel(logLevel: LogLevel): OrderedLevel { switch (logLevel) { case 'off': @@ -218,18 +273,26 @@ function toOrderedLevel(logLevel: LogLevel): OrderedLevel { } export function getLoggableName(instance: Function | object) { - let name: string; + let ctor; if (typeof instance === 'function') { if (instance.prototype?.constructor == null) return instance.name; - name = instance.prototype.constructor.name ?? emptyStr; + ctor = instance.prototype.constructor; } else { - name = instance.constructor?.name ?? emptyStr; + ctor = instance.constructor; } + let name: string = ctor?.name ?? ''; + // Strip webpack module name (since I never name classes with an _) const index = name.indexOf('_'); - return index === -1 ? name : name.substr(index + 1); + name = index === -1 ? name : name.substr(index + 1); + + if (ctor?.[LogInstanceNameFn] != null) { + name = ctor[LogInstanceNameFn](instance, name); + } + + return name; } export interface LogProvider { diff --git a/src/system/object.ts b/src/system/object.ts index 4428119619674..e519e3ed55fc2 100644 --- a/src/system/object.ts +++ b/src/system/object.ts @@ -8,73 +8,87 @@ export function areEqual(a: any, b: any): boolean { return JSON.stringify(a) === JSON.stringify(b); } -export function flatten( - o: any, - options: { arrays?: 'join' | 'spread'; prefix?: string; skipPaths?: string[]; skipNulls: true; stringify: true }, -): Record; -export function flatten( - o: any, - options: { arrays?: 'join' | 'spread'; prefix?: string; skipPaths?: string[]; skipNulls: true; stringify?: false }, -): Record>; -export function flatten( - o: any, - options: { - arrays?: 'join' | 'spread'; - prefix?: string; - skipPaths?: string[]; - skipNulls?: false; - stringify: true | 'all'; - }, -): Record; -export function flatten( - o: any, - options?: { - arrays?: 'join' | 'spread'; - prefix?: string; - skipPaths?: string[]; - skipNulls?: boolean; - stringify?: boolean; - }, -): Record; -export function flatten( - o: any, - options?: { - arrays?: 'join' | 'spread'; - prefix?: string; - skipPaths?: string[]; - skipNulls?: boolean; - stringify?: boolean | 'all'; - }, -): Record { +type AddPrefix

= P extends '' | undefined ? K : `${P}.${K}`; +type AddArrayIndex

= P extends '' | undefined ? `[${I}]` : `${P}[${I}]`; + +type Merge = MergeUnion; +type MergeUnion = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void + ? { [K in keyof I]: I[K] } + : never; + +type FlattenArray = T extends (infer U)[] + ? U extends object + ? { [Key in `${AddArrayIndex}.${string}`]: string | number | boolean } + : { [Key in AddArrayIndex]: string | number | boolean } + : T extends object + ? { [Key in `${AddArrayIndex}.${string}`]: string | number | boolean } + : { [Key in AddArrayIndex]: string | number | boolean }; + +type FlattenSpread = T extends ReadonlyArray + ? FlattenArray + : { + [K in keyof T]: T[K] extends ReadonlyArray + ? FlattenArray>> + : T[K] extends object + ? FlattenSpread>> + : { + [Key in AddPrefix>]: T[K] extends string | number | boolean + ? T[K] + : string; + }; + }[keyof T]; + +type FlattenJoin = { + [K in keyof T]: T[K] extends ReadonlyArray + ? { [Key in AddPrefix>]: string } + : T[K] extends object + ? FlattenJoin>> + : { + [Key in AddPrefix>]: T[K] extends string | number | boolean ? T[K] : string; + }; +}[keyof T]; + +type Flatten< + T extends object | null | undefined, + P extends string | undefined, + JoinArrays extends boolean, +> = T extends object ? Merge : FlattenSpread> : object; + +type FlattenOptions = { + joinArrays?: boolean; + skipPaths?: string[]; +}; + +export function flatten( + o: T, + prefix?: P, + options?: O, +): Flatten extends true ? true : false> { + const joinArrays = options?.joinArrays ?? false; + const skipPaths = options?.skipPaths?.length - ? options?.prefix - ? options.skipPaths.map(p => `${options.prefix}.${p}`) + ? prefix + ? options.skipPaths.map(p => `${prefix}.${p}`) : options.skipPaths : undefined; - const skipNulls = options?.skipNulls ?? false; - const stringify = options?.stringify ?? false; function flattenCore(flattened: Record, key: string, value: any) { if (skipPaths?.includes(key)) return; if (Object(value) !== value) { - if (value == null) { - if (skipNulls) return; - - flattened[key] = stringify ? (stringify == 'all' ? JSON.stringify(value) : value ?? null) : value; - } else if (typeof value === 'string') { - flattened[key] = value; - } else if (stringify) { - flattened[key] = - typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value); - } else { - flattened[key] = value; - } + if (value == null) return; + + flattened[key] = + typeof value === 'string' + ? value + : typeof value === 'number' || typeof value === 'boolean' + ? value + : JSON.stringify(value); } else if (Array.isArray(value)) { const len = value.length; if (len === 0) return; - if (options?.arrays === 'join') { + if (joinArrays) { flattened[key] = value.join(','); } else { for (let i = 0; i < len; i++) { @@ -92,8 +106,8 @@ export function flatten( } const flattened: Record = Object.create(null); - flattenCore(flattened, options?.prefix ?? '', o); - return flattened; + flattenCore(flattened, prefix ?? '', o); + return flattened as Flatten extends true ? true : false>; } export function paths(o: Record, path?: string): string[] { diff --git a/src/system/paging.ts b/src/system/paging.ts new file mode 100644 index 0000000000000..c7a448c2cdbb9 --- /dev/null +++ b/src/system/paging.ts @@ -0,0 +1,31 @@ +import type { PagedResult } from '../git/gitProvider'; + +export class PageableResult { + private cached: Mutable> | undefined; + + constructor(private readonly fetch: (paging: PagedResult['paging']) => Promise>) {} + + async *values(): AsyncIterable> { + if (this.cached != null) { + for (const value of this.cached.values) { + yield value; + } + } + + let results = this.cached; + while (results == null || results.paging?.more) { + results = await this.fetch(results?.paging); + + if (this.cached == null) { + this.cached = results; + } else { + this.cached.values.push(...results.values); + this.cached.paging = results.paging; + } + + for (const value of results.values) { + yield value; + } + } + } +} diff --git a/src/system/path.ts b/src/system/path.ts index c20fa7e4848ea..3ecca549f67aa 100644 --- a/src/system/path.ts +++ b/src/system/path.ts @@ -101,7 +101,7 @@ export function isChild(pathOrUri: string | Uri, base: string | Uri): boolean { } return ( - isDescendent(pathOrUri, base) && + isDescendant(pathOrUri, base) && (typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.path) .substr(base.length + (base.charCodeAt(base.length - 1) === slash ? 0 : 1)) .split('/').length === 1 @@ -109,17 +109,17 @@ export function isChild(pathOrUri: string | Uri, base: string | Uri): boolean { } return ( - isDescendent(pathOrUri, base) && + isDescendant(pathOrUri, base) && (typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.path) .substr(base.path.length + (base.path.charCodeAt(base.path.length - 1) === slash ? 0 : 1)) .split('/').length === 1 ); } -export function isDescendent(path: string, base: string | Uri): boolean; -export function isDescendent(uri: Uri, base: string | Uri): boolean; -export function isDescendent(pathOrUri: string | Uri, base: string | Uri): boolean; -export function isDescendent(pathOrUri: string | Uri, base: string | Uri): boolean { +export function isDescendant(path: string, base: string | Uri): boolean; +export function isDescendant(uri: Uri, base: string | Uri): boolean; +export function isDescendant(pathOrUri: string | Uri, base: string | Uri): boolean; +export function isDescendant(pathOrUri: string | Uri, base: string | Uri): boolean { if (typeof base === 'string') { base = normalizePath(base); if (base.charCodeAt(0) !== slash) { @@ -191,6 +191,14 @@ export function normalizePath(path: string): string { return path; } +export function pathEquals(a: string, b: string, ignoreCase?: boolean): boolean { + if (ignoreCase || (ignoreCase == null && !isLinux)) { + a = a.toLowerCase(); + b = b.toLowerCase(); + } + return normalizePath(a) === normalizePath(b); +} + export function relative(from: string, to: string, ignoreCase?: boolean): string { from = hasSchemeRegex.test(from) ? Uri.parse(from, true).path : normalizePath(from); to = hasSchemeRegex.test(to) ? Uri.parse(to, true).path : normalizePath(to); @@ -213,7 +221,7 @@ export function splitPath( root: string | undefined, splitOnBaseIfMissing: boolean = false, ignoreCase?: boolean, -): [string, string] { +): [path: string, root: string] { pathOrUri = getBestPath(pathOrUri); if (root) { diff --git a/src/system/promise.ts b/src/system/promise.ts index 09d1ac779f7ca..b4f1bc2f7d609 100644 --- a/src/system/promise.ts +++ b/src/system/promise.ts @@ -1,32 +1,33 @@ import type { CancellationToken, Disposable } from 'vscode'; +import { map } from './iterable'; export type PromiseOrValue = Promise | T; export function any(...promises: Promise[]): Promise { return new Promise((resolve, reject) => { - const errors: Error[] = []; let settled = false; + const onFullfilled = (r: T) => { + settled = true; + resolve(r); + }; + + let errors: Error[]; + const onRejected = (ex: Error) => { + if (settled) return; + + if (errors == null) { + errors = [ex]; + } else { + errors.push(ex); + } + + if (promises.length - errors.length < 1) { + reject(new AggregateError(errors)); + } + }; for (const promise of promises) { - // eslint-disable-next-line no-loop-func - void (async () => { - try { - const result = await promise; - if (settled) return; - - resolve(result); - settled = true; - } catch (ex) { - errors.push(ex); - } finally { - if (!settled) { - if (promises.length - errors.length < 1) { - reject(new AggregateError(errors)); - settled = true; - } - } - } - })(); + promise.then(onFullfilled, onRejected); } }); } @@ -69,55 +70,66 @@ export class PromiseCancelledError = Promise> extend export function cancellable( promise: Promise, - timeoutOrToken?: number | CancellationToken, - options: { + timeout?: number | CancellationToken, + cancellation?: CancellationToken, + options?: { cancelMessage?: string; - onDidCancel?(resolve: (value: T | PromiseLike) => void, reject: (reason?: any) => void): void; - } = {}, + onDidCancel?( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + reason: 'cancelled' | 'timedout', + ): void; + }, ): Promise { - if (timeoutOrToken == null || (typeof timeoutOrToken === 'number' && timeoutOrToken <= 0)) return promise; + if (timeout == null && cancellation == null) return promise; return new Promise((resolve, reject) => { let fulfilled = false; - let timer: ReturnType | undefined; - let disposable: Disposable | undefined; - - if (typeof timeoutOrToken === 'number') { - timer = setTimeout(() => { - if (typeof options.onDidCancel === 'function') { - options.onDidCancel(resolve, reject); - } else { - reject(new PromiseCancelledError(promise, options.cancelMessage ?? 'TIMED OUT')); - } - }, timeoutOrToken); - } else { - disposable = timeoutOrToken.onCancellationRequested(() => { - disposable?.dispose(); - if (fulfilled) return; - - if (typeof options.onDidCancel === 'function') { - options.onDidCancel(resolve, reject); - } else { - reject(new PromiseCancelledError(promise, options.cancelMessage ?? 'CANCELLED')); - } - }); + let disposeCancellation: Disposable | undefined; + let disposeTimeout: Disposable | undefined; + + const resolver = (reason: 'cancelled' | 'timedout') => { + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + + if (fulfilled) return; + + if (options?.onDidCancel != null) { + options.onDidCancel(resolve, reject, reason); + } else { + reject( + new PromiseCancelledError( + promise, + options?.cancelMessage ?? (reason === 'cancelled' ? 'CANCELLED' : 'TIMED OUT'), + ), + ); + } + }; + + disposeCancellation = cancellation?.onCancellationRequested(() => resolver('cancelled')); + if (timeout != null) { + if (typeof timeout === 'number') { + const timer = setTimeout(() => resolver('timedout'), timeout); + disposeTimeout = { dispose: () => clearTimeout(timer) }; + } else { + disposeTimeout = timeout.onCancellationRequested(() => resolver('timedout')); + } } promise.then( () => { fulfilled = true; - if (timer != null) { - clearTimeout(timer); - } - disposable?.dispose(); + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + resolve(promise); }, ex => { fulfilled = true; - if (timer != null) { - clearTimeout(timer); - } - disposable?.dispose(); + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(ex); }, ); @@ -128,7 +140,7 @@ export interface Deferred { readonly pending: boolean; readonly promise: Promise; fulfill: (value: T) => void; - cancel(): void; + cancel(e?: Error): void; } export function defer(): Deferred { @@ -143,14 +155,22 @@ export function defer(): Deferred { deferred.pending = false; resolve(value); }; - deferred.cancel = function () { + deferred.cancel = function (e?: Error) { deferred.pending = false; - reject(); + if (e != null) { + reject(e); + } else { + reject(); + } }; }); return deferred; } +export function getDeferredPromiseIfPending(deferred: Deferred | undefined): Promise | undefined { + return deferred?.pending ? deferred.promise : undefined; +} + export function getSettledValue(promise: PromiseSettledResult | undefined): T | undefined; export function getSettledValue( promise: PromiseSettledResult | undefined, @@ -167,6 +187,419 @@ export function isPromise(obj: PromiseLike | T): obj is Promise { return obj != null && (obj instanceof Promise || typeof (obj as PromiseLike)?.then === 'function'); } +type PausedResult = { + value: Promise; + paused: true; + reason: 'cancelled' | 'timedout'; +}; + +export type CompletedResult = { + value: T; + paused: false; +}; + +export type MaybePausedResult = PausedResult | CompletedResult; + +export function pauseOnCancelOrTimeout( + promise: T | Promise, + cancellation?: undefined, + timeout?: undefined, +): Promise>; +export function pauseOnCancelOrTimeout( + promise: T | Promise, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult) => void | Promise, +): Promise>; +export function pauseOnCancelOrTimeout( + promise: T | Promise, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult) => void | Promise, +): Promise> { + if (!isPromise(promise)) { + return Promise.resolve({ value: promise, paused: false } satisfies MaybePausedResult); + } + + if (cancellation == null && timeout == null) { + return promise.then(value => ({ value: value, paused: false }) satisfies CompletedResult); + } + + let disposeCancellation: Disposable | undefined; + let disposeTimeout: Disposable | undefined; + + const result = Promise.race([ + promise.then(value => { + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + + if (cancellation?.isCancellationRequested) { + return { + value: Promise.resolve(value), + paused: true, + reason: 'cancelled', + } satisfies MaybePausedResult; + } + + return { value: value, paused: false } satisfies MaybePausedResult; + }), + new Promise>(resolve => { + const resolver = (reason: 'cancelled' | 'timedout') => { + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + + resolve({ + value: promise, + paused: true, + reason: reason, + } satisfies MaybePausedResult); + }; + + disposeCancellation = cancellation?.onCancellationRequested(() => resolver('cancelled')); + if (timeout != null) { + const signal = typeof timeout === 'number' ? AbortSignal.timeout(timeout) : timeout; + + const handler = () => resolver('timedout'); + signal.addEventListener('abort', handler); + disposeTimeout = { dispose: () => signal.removeEventListener('abort', handler) }; + } + }), + ]); + + return continuation == null + ? result + : result.then(r => { + if (r.paused) { + setTimeout(() => continuation(r), 0); + } + return r; + }); +} + +export async function pauseOnCancelOrTimeoutMap( + source: Map>, + ignoreErrors: true, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise>>; +export async function pauseOnCancelOrTimeoutMap( + source: Map>, + ignoreErrors?: boolean, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise>>; +export async function pauseOnCancelOrTimeoutMap( + source: Map>, + ignoreErrors?: boolean, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise>> { + if (source.size === 0) return source as unknown as Map>; + + // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers + if (timeout != null && typeof timeout === 'number') { + timeout = AbortSignal.timeout(timeout); + } + + const results = await Promise.all( + map(source, ([id, promise]) => + pauseOnCancelOrTimeout( + promise.catch((ex: unknown) => (ignoreErrors || !(ex instanceof Error) ? undefined : ex)), + cancellation, + timeout, + ).then(result => [id, result] as const), + ), + ); + + if (continuation != null) { + if (results.some(([, r]) => r.paused)) { + async function getContinuationValue() { + const completed = new Map>(); + + for (const [id, result] of results) { + completed.set(id, { value: result.paused ? await result.value : result.value, paused: false }); + } + + return completed; + } + + const cancelled = results.some(([, r]) => r.paused && r.reason === 'cancelled'); + + void continuation({ + value: getContinuationValue(), + paused: true, + reason: cancelled ? 'cancelled' : 'timedout', + }); + } + } + + return new Map>(results); +} + +export async function pauseOnCancelOrTimeoutMapPromise( + source: Promise> | undefined>, + ignoreErrors: true, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise> | undefined>>; +export async function pauseOnCancelOrTimeoutMapPromise( + source: Promise> | undefined>, + ignoreErrors?: boolean, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise> | undefined>>; +export async function pauseOnCancelOrTimeoutMapPromise( + source: Promise> | undefined>, + ignoreErrors?: boolean, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise> | undefined>> { + // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers + if (timeout != null && typeof timeout === 'number') { + timeout = AbortSignal.timeout(timeout); + } + + const mapPromise = source.then(m => + m == null ? m : pauseOnCancelOrTimeoutMap(m, ignoreErrors, cancellation, timeout, continuation), + ); + + const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); + return result.paused + ? { value: mapPromise, paused: result.paused, reason: result.reason } + : { value: await mapPromise, paused: false }; +} + +export async function pauseOnCancelOrTimeoutMapTuple( + source: Map | undefined, ...U]>, + cancellation?: undefined, + timeout?: undefined, +): Promise | undefined, ...U]>>; +export async function pauseOnCancelOrTimeoutMapTuple( + source: Map | undefined, ...U]>, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: ( + result: PausedResult | undefined, ...U]>>, + ) => void | Promise, +): Promise | undefined, ...U]>>; +export async function pauseOnCancelOrTimeoutMapTuple( + source: Map | undefined, ...U]>, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: ( + result: PausedResult | undefined, ...U]>>, + ) => void | Promise, +): Promise | undefined, ...U]>> { + if (source.size === 0) { + return source as unknown as Map | undefined, ...U]>; + } + + // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers + if (timeout != null && typeof timeout === 'number') { + timeout = AbortSignal.timeout(timeout); + } + + const results = await Promise.all( + map(source, ([id, [promise, ...rest]]) => + promise == null + ? ([id, [undefined, ...rest]] as const) + : pauseOnCancelOrTimeout( + promise.catch(() => undefined), + cancellation, + timeout, + ).then(result => [id, [result as MaybePausedResult | undefined, ...rest]] as const), + ), + ); + + if (continuation != null) { + if (results.some(([, [r]]) => r?.paused ?? false)) { + async function getContinuationValue() { + const completed = new Map | undefined, ...U]>(); + + for (const [id, [r, ...rest]] of results) { + completed.set(id, [ + { value: r?.paused ? await r.value : r?.value, paused: false }, + ...rest, + ] as const); + } + + return completed; + } + + const cancelled = results.some(([, [r]]) => r?.paused && r.reason === 'cancelled'); + + void continuation({ + value: getContinuationValue(), + paused: true, + reason: cancelled ? 'cancelled' : 'timedout', + }); + } + } + + return new Map | undefined, ...U]>(results); +} + +export async function pauseOnCancelOrTimeoutMapTuplePromise( + source: Promise | undefined, ...U]> | undefined>, + cancellation?: undefined, + timeout?: undefined, +): Promise | undefined, ...U]> | undefined>>; +export async function pauseOnCancelOrTimeoutMapTuplePromise( + source: Promise | undefined, ...U]> | undefined>, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: ( + result: PausedResult | undefined, ...U]>>, + ) => void | Promise, +): Promise | undefined, ...U]> | undefined>>; +export async function pauseOnCancelOrTimeoutMapTuplePromise( + source: Promise | undefined, ...U]> | undefined>, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: ( + result: PausedResult | undefined, ...U]>>, + ) => void | Promise, +): Promise | undefined, ...U]> | undefined>> { + // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers + if (timeout != null && typeof timeout === 'number') { + timeout = AbortSignal.timeout(timeout); + } + + const mapPromise = source.then(m => + m == null ? m : pauseOnCancelOrTimeoutMapTuple(m, cancellation, timeout, continuation), + ); + + const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); + return result.paused + ? { value: mapPromise, paused: result.paused, reason: result.reason } + : { value: await mapPromise, paused: false }; +} + +// type PromiseKeys = { +// [K in keyof T]: T[K] extends Promise | undefined ? K : never; +// }[keyof T]; +// type WithCompletedResult> = Omit & { +// [K in U]: CompletedResult | undefined> | undefined; +// }; +// type WithMaybePausedResult> = Omit & { +// [K in U]: MaybePausedResult | undefined> | undefined; +// }; + +// export async function pauseOnCancelOrTimeoutMapOnProp>( +// source: Map, +// prop: U, +// cancellation?: undefined, +// timeout?: undefined, +// ): Promise>>; +// export async function pauseOnCancelOrTimeoutMapOnProp>( +// source: Map, +// prop: U, +// cancellation?: CancellationToken, +// timeout?: number | AbortSignal, +// continuation?: (result: PausedResult>>) => void | Promise, +// ): Promise>>; +// export async function pauseOnCancelOrTimeoutMapOnProp>( +// source: Map, +// prop: U, +// cancellation?: CancellationToken, +// timeout?: number | AbortSignal, +// continuation?: (result: PausedResult>>) => void | Promise, +// ): Promise>> { +// if (source.size === 0) { +// return source as unknown as Map>; +// } + +// // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers +// if (timeout != null && typeof timeout === 'number') { +// timeout = AbortSignal.timeout(timeout); +// } + +// const results = await Promise.all( +// map(source, ([id, item]) => +// item[prop] == null +// ? ([id, item as WithMaybePausedResult] as const) +// : pauseOnCancelOrTimeout( +// (item[prop] as Promise).catch(() => undefined), +// cancellation, +// timeout, +// ).then(result => { +// (item as any)[prop] = result; +// return [id, item as WithMaybePausedResult] as const; +// }), +// ), +// ); + +// if (continuation != null) { +// if (results.some(([, r]) => (r as any)[prop]?.paused ?? false)) { +// async function getContinuationValue() { +// const completed = new Map>(); + +// for (const [id, result] of results) { +// const r = result[prop]; // as MaybePausedResult> | undefined; +// (result as /*WithCompletedResult*/ any)[prop] = r?.paused ? await r.value : r?.value; +// completed.set(id, result as WithCompletedResult); +// } + +// return completed; +// } + +// const cancelled = results.some(([, result]) => { +// const r = result[prop]; +// return r?.paused && r.reason === 'cancelled'; +// }); + +// void continuation({ +// value: getContinuationValue(), +// paused: true, +// reason: cancelled ? 'cancelled' : 'timedout', +// }); +// } +// } + +// return new Map>(results); +// } + +// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( +// source: Promise | undefined>, +// prop: U, +// cancellation?: undefined, +// timeout?: undefined, +// ): Promise> | undefined>>; +// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( +// source: Promise | undefined>, +// prop: U, +// cancellation?: CancellationToken, +// timeout?: number | AbortSignal, +// continuation?: (result: PausedResult>>) => void | Promise, +// ): Promise> | undefined>>; +// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( +// source: Promise | undefined>, +// prop: U, +// cancellation?: CancellationToken, +// timeout?: number | AbortSignal, +// continuation?: (result: PausedResult>>) => void | Promise, +// ): Promise> | undefined>> { +// // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers +// if (timeout != null && typeof timeout === 'number') { +// timeout = AbortSignal.timeout(timeout); +// } + +// const mapPromise = source.then(m => +// m == null ? m : pauseOnCancelOrTimeoutMapOnProp(m, prop, cancellation, timeout, continuation), +// ); + +// const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); +// return result.paused +// ? { value: mapPromise, paused: result.paused, reason: result.reason } +// : { value: await mapPromise, paused: false }; +// } + // export function progress(promise: Promise, intervalMs: number, onProgress: () => boolean): Promise { // return new Promise((resolve, reject) => { // let timer: ReturnType | undefined; @@ -236,6 +669,28 @@ export function isPromise(obj: PromiseLike | T): obj is Promise { // return new Map(await Promise.all(promises)); // } +export type TimedResult = { readonly value: T; readonly duration: number }; +export async function timed(promise: Promise): Promise> { + const start = Date.now(); + const value = await promise; + return { value: value, duration: Date.now() - start }; +} + +export async function timedWithSlowThreshold( + promise: Promise, + slowThreshold: { timeout: number; onSlow: (duration: number) => void }, +): Promise> { + const start = Date.now(); + + const result = await pauseOnCancelOrTimeout(promise, undefined, slowThreshold.timeout); + + const value = result.paused + ? await result.value.finally(() => slowThreshold.onSlow(Date.now() - start)) + : result.value; + + return { value: value, duration: Date.now() - start }; +} + export function wait(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/src/system/serialize.ts b/src/system/serialize.ts index d40cac14bf2b7..d700ffe6cc312 100644 --- a/src/system/serialize.ts +++ b/src/system/serialize.ts @@ -1,23 +1,41 @@ +import { Uri } from 'vscode'; +import type { Branded } from './brand'; + export type Serialized = T extends Function ? never : T extends Date - ? number - : T extends object - ? { - [K in keyof T]: T[K] extends Date ? number : Serialized; - } - : T; + ? number + : T extends Uri + ? string + : T extends Branded + ? U + : T extends any[] + ? Serialized[] + : T extends object + ? { + [K in keyof T]: T[K] extends Date ? number : Serialized; + } + : T; + +export function serialize(obj: T): Serialized; +export function serialize(obj: T | undefined): Serialized | undefined; +export function serialize(obj: T | undefined): Serialized | undefined { + if (obj == null) return undefined; -export function serialize(obj: T): Serialized { try { function replacer(this: any, key: string, value: unknown) { if (value instanceof Date) return value.getTime(); if (value instanceof Map || value instanceof Set) return [...value.entries()]; if (value instanceof Function || value instanceof Error) return undefined; if (value instanceof RegExp) return value.toString(); + if (value instanceof Uri) return value.toString(); const original = this[key]; - return original instanceof Date ? original.getTime() : value; + return original instanceof Date + ? original.getTime() + : original instanceof Uri + ? original.toString() + : value; } return JSON.parse(JSON.stringify(obj, replacer)) as Serialized; } catch (ex) { diff --git a/src/system/stopwatch.ts b/src/system/stopwatch.ts index 73c30a87e2e27..9e4351e90c99d 100644 --- a/src/system/stopwatch.ts +++ b/src/system/stopwatch.ts @@ -3,7 +3,10 @@ import type { LogProvider } from './logger'; import { defaultLogProvider } from './logger'; import type { LogLevel } from './logger.constants'; import type { LogScope } from './logger.scope'; -import { getNextLogScopeId } from './logger.scope'; +import { getNewLogScope } from './logger.scope'; + +(Symbol as any).dispose ??= Symbol('Symbol.dispose'); +(Symbol as any).asyncDispose ??= Symbol('Symbol.asyncDispose'); type StopwatchLogOptions = { message?: string; suffix?: string }; type StopwatchOptions = { @@ -13,8 +16,8 @@ type StopwatchOptions = { }; type StopwatchLogLevel = Exclude; -export class Stopwatch { - private readonly instance = `[${String(getNextLogScopeId()).padStart(5)}] `; +export class Stopwatch implements Disposable { + private readonly logScope: LogScope; private readonly logLevel: StopwatchLogLevel; private readonly logProvider: LogProvider; @@ -23,17 +26,10 @@ export class Stopwatch { return this._time; } - constructor( - private readonly scope: string | LogScope | undefined, - options?: StopwatchOptions, - ...params: any[] - ) { - let logScope; - if (typeof scope !== 'string') { - logScope = scope; - scope = ''; - this.instance = ''; - } + private _stopped = false; + + constructor(scope: string | LogScope | undefined, options?: StopwatchOptions, ...params: any[]) { + this.logScope = scope != null && typeof scope !== 'string' ? scope : getNewLogScope(scope ?? ''); let logOptions: StopwatchLogOptions | undefined; if (typeof options?.log === 'boolean') { @@ -52,57 +48,51 @@ export class Stopwatch { if (params.length) { this.logProvider.log( this.logLevel, - logScope, - `${this.instance}${scope}${logOptions.message ?? ''}${logOptions.suffix ?? ''}`, + this.logScope, + `${logOptions.message ?? ''}${logOptions.suffix ?? ''}`, ...params, ); } else { this.logProvider.log( this.logLevel, - logScope, - `${this.instance}${scope}${logOptions.message ?? ''}${logOptions.suffix ?? ''}`, + this.logScope, + `${logOptions.message ?? ''}${logOptions.suffix ?? ''}`, ); } } } + [Symbol.dispose](): void { + this.stop(); + } + elapsed(): number { const [secs, nanosecs] = hrtime(this._time); return secs * 1000 + Math.floor(nanosecs / 1000000); } log(options?: StopwatchLogOptions): void { - this.logCore(this.scope, options, false); + this.logCore(options, false); } restart(options?: StopwatchLogOptions): void { - this.logCore(this.scope, options, true); + this.logCore(options, true); this._time = hrtime(); + this._stopped = false; } stop(options?: StopwatchLogOptions): void { + if (this._stopped) return; + this.restart(options); + this._stopped = true; } - private logCore( - scope: string | LogScope | undefined, - options: StopwatchLogOptions | undefined, - logTotalElapsed: boolean, - ): void { + private logCore(options: StopwatchLogOptions | undefined, logTotalElapsed: boolean): void { if (!this.logProvider.enabled(this.logLevel)) return; - let logScope; - if (typeof scope !== 'string') { - logScope = scope; - scope = ''; - } - if (!logTotalElapsed) { - this.logProvider.log( - this.logLevel, - logScope, - `${this.instance}${scope}${options?.message ?? ''}${options?.suffix ?? ''}`, - ); + this.logProvider.log(this.logLevel, this.logScope, `${options?.message ?? ''}${options?.suffix ?? ''}`); return; } @@ -110,10 +100,10 @@ export class Stopwatch { const [secs, nanosecs] = hrtime(this._time); const ms = secs * 1000 + Math.floor(nanosecs / 1000000); - const prefix = `${this.instance}${scope}${options?.message ?? ''}`; + const prefix = options?.message ?? ''; this.logProvider.log( ms > 250 ? 'warn' : this.logLevel, - logScope, + this.logScope, `${prefix ? `${prefix} ` : ''}[${ms}ms]${options?.suffix ?? ''}`, ); } diff --git a/src/system/storage.ts b/src/system/storage.ts index 05315443482ad..1f30d7362d056 100644 --- a/src/system/storage.ts +++ b/src/system/storage.ts @@ -66,17 +66,33 @@ export class Storage implements Disposable { @debug({ logThreshold: 250 }) async deleteWithPrefix(prefix: ExtractPrefixes): Promise { - const qualifiedKey = `${extensionPrefix}:${prefix}`; - const qualifiedPrefix = `${qualifiedKey}:`; + return this.deleteWithPrefixCore(prefix); + } + + async deleteWithPrefixCore( + prefix?: ExtractPrefixes, + exclude?: GlobalStorageKeys[], + ): Promise { + const qualifiedKeyPrefix = `${extensionPrefix}:`; + + for (const qualifiedKey of this.context.globalState.keys() as `${typeof extensionPrefix}:${GlobalStorageKeys}`[]) { + if (!qualifiedKey.startsWith(qualifiedKeyPrefix)) continue; + + const key = qualifiedKey.substring(qualifiedKeyPrefix.length) as GlobalStorageKeys; + if (prefix == null || key === prefix || key.startsWith(`${prefix}:`)) { + if (exclude?.includes(key)) continue; - for (const key of this.context.globalState.keys() as GlobalStorageKeys[]) { - if (key === qualifiedKey || key.startsWith(qualifiedPrefix)) { await this.context.globalState.update(key, undefined); this._onDidChange.fire({ key: key, workspace: false }); } } } + @debug({ logThreshold: 250 }) + async reset(): Promise { + return this.deleteWithPrefixCore(undefined, ['premium:subscription']); + } + @debug({ args: { 1: false }, logThreshold: 250 }) async store(key: T, value: GlobalStorage[T] | undefined): Promise { await this.context.globalState.update(`${extensionPrefix}:${key}`, value); @@ -115,17 +131,33 @@ export class Storage implements Disposable { @debug({ logThreshold: 250 }) async deleteWorkspaceWithPrefix(prefix: ExtractPrefixes): Promise { - const qualifiedKey = `${extensionPrefix}:${prefix}`; - const qualifiedPrefix = `${qualifiedKey}:`; + return this.deleteWorkspaceWithPrefixCore(prefix); + } + + async deleteWorkspaceWithPrefixCore( + prefix?: ExtractPrefixes, + exclude?: WorkspaceStorageKeys[], + ): Promise { + const qualifiedKeyPrefix = `${extensionPrefix}:`; + + for (const qualifiedKey of this.context.workspaceState.keys() as `${typeof extensionPrefix}:${WorkspaceStorageKeys}`[]) { + if (!qualifiedKey.startsWith(qualifiedKeyPrefix)) continue; + + const key = qualifiedKey.substring(qualifiedKeyPrefix.length) as WorkspaceStorageKeys; + if (prefix == null || key === prefix || key.startsWith(`${prefix}:`)) { + if (exclude?.includes(key)) continue; - for (const key of this.context.workspaceState.keys() as WorkspaceStorageKeys[]) { - if (key === qualifiedKey || key.startsWith(qualifiedPrefix)) { await this.context.workspaceState.update(key, undefined); this._onDidChange.fire({ key: key, workspace: true }); } } } + @debug({ logThreshold: 250 }) + async resetWorkspace(): Promise { + return this.deleteWorkspaceWithPrefixCore(); + } + @debug({ args: { 1: false }, logThreshold: 250 }) async storeWorkspace( key: T, diff --git a/src/system/string.ts b/src/system/string.ts index 6129fa19a3280..2cad1741679b7 100644 --- a/src/system/string.ts +++ b/src/system/string.ts @@ -1,4 +1,9 @@ -import ansiRegex from 'ansi-regex'; +import type { + WidthOptions as StringWidthOptions, + TruncationOptions as StringWidthTruncationOptions, + Result as TruncatedStringWidthResult, +} from 'fast-string-truncated-width'; +import getTruncatedStringWidth from 'fast-string-truncated-width'; import { hrtime } from '@env/hrtime'; import { CharCode } from '../constants'; @@ -126,7 +131,11 @@ export function encodeHtmlWeak(s: string | undefined): string | undefined { } const escapeMarkdownRegex = /[\\`*_{}[\]()#+\-.!]/g; +const unescapeMarkdownRegex = /\\([\\`*_{}[\]()#+\-.!])/g; + const escapeMarkdownHeaderRegex = /^===/gm; +const unescapeMarkdownHeaderRegex = /^\u200b===/gm; + // const sampleMarkdown = '## message `not code` *not important* _no underline_ \n> don\'t quote me \n- don\'t list me \n+ don\'t list me \n1. don\'t list me \nnot h1 \n=== \nnot h2 \n---\n***\n---\n___'; const markdownQuotedRegex = /\r?\n/g; @@ -143,6 +152,16 @@ export function escapeMarkdown(s: string, options: { quoted?: boolean } = {}): s return s.trim().replace(markdownQuotedRegex, '\t\\\n> '); } +export function unescapeMarkdown(s: string): string { + return ( + s + // Unescape markdown + .replace(unescapeMarkdownRegex, '$1') + // Unescape markdown header + .replace(unescapeMarkdownHeaderRegex, '===') + ); +} + export function escapeRegex(s: string) { return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); } @@ -195,6 +214,41 @@ export function* getLines(data: string | string[], char: string = '\n'): Iterabl } } +const defaultTruncationOptions: StringWidthTruncationOptions = { + ellipsisWidth: 0, + limit: 2 ** 30 - 1, // Max number that can be stored in V8's smis (small integers) +}; + +const defaultWidthOptions: StringWidthOptions = { + ansiWidth: 0, + controlWidth: 0, + ambiguousWidth: 1, + emojiWidth: 2, + fullWidthWidth: 2, + regularWidth: 1, + wideWidth: 2, +}; + +export function getTruncatedWidth(s: string, limit: number, ellipsisWidth: number): TruncatedStringWidthResult { + if (s == null || s.length === 0) { + return { + truncated: false, + ellipsed: false, + width: 0, + index: 0, + }; + } + + return getTruncatedStringWidth(s, { limit: limit, ellipsisWidth: ellipsisWidth ?? 0 }, defaultWidthOptions); +} + +export function getWidth(s: string): number { + if (s == null || s.length === 0) return 0; + + const result = getTruncatedStringWidth(s, defaultTruncationOptions, defaultWidthOptions); + return result.width; +} + const superscripts = ['\u00B9', '\u00B2', '\u00B3', '\u2074', '\u2075', '\u2076', '\u2077', '\u2078', '\u2079']; export function getSuperscript(num: number) { @@ -421,6 +475,7 @@ export async function interpolateAsync(template: string, context: object | undef value = await value; } + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands result += template.slice(position, token.start) + (value ?? ''); position = token.end; } @@ -446,40 +501,6 @@ export function pad(s: string, before: number = 0, after: number = 0, padding: s return `${before === 0 ? '' : padding.repeat(before)}${s}${after === 0 ? '' : padding.repeat(after)}`; } -export function padLeft(s: string, padTo: number, padding: string = '\u00a0', width?: number) { - const diff = padTo - (width ?? getWidth(s)); - return diff <= 0 ? s : padding.repeat(diff) + s; -} - -export function padLeftOrTruncate(s: string, max: number, padding?: string, width?: number) { - width = width ?? getWidth(s); - if (width < max) return padLeft(s, max, padding, width); - if (width > max) return truncate(s, max, undefined, width); - return s; -} - -export function padRight(s: string, padTo: number, padding: string = '\u00a0', width?: number) { - const diff = padTo - (width ?? getWidth(s)); - return diff <= 0 ? s : s + padding.repeat(diff); -} - -export function padOrTruncate(s: string, max: number, padding?: string, width?: number) { - const left = max < 0; - max = Math.abs(max); - - width = width ?? getWidth(s); - if (width < max) return left ? padLeft(s, max, padding, width) : padRight(s, max, padding, width); - if (width > max) return truncate(s, max, undefined, width); - return s; -} - -export function padRightOrTruncate(s: string, max: number, padding?: string, width?: number) { - width = width ?? getWidth(s); - if (width < max) return padRight(s, max, padding, width); - if (width > max) return truncate(s, max); - return s; -} - export function pluralize( s: string, count: number, @@ -583,118 +604,6 @@ export function truncateMiddle(s: string, truncateTo: number, ellipsis: string = return `${s.slice(0, Math.floor(truncateTo / 2) - 1)}${ellipsis}${s.slice(width - Math.ceil(truncateTo / 2))}`; } -let cachedAnsiRegex: RegExp | undefined; -const containsNonAsciiRegex = /[^\x20-\x7F\u00a0\u2026]/; - -// See sindresorhus/string-width -export function getWidth(s: string): number { - if (s == null || s.length === 0) return 0; - - // Shortcut to avoid needless string `RegExp`s, replacements, and allocations - if (!containsNonAsciiRegex.test(s)) return s.length; - - if (cachedAnsiRegex == null) { - cachedAnsiRegex = ansiRegex(); - } - s = s.replace(cachedAnsiRegex, ''); - - if (s.length === 0) return 0; - - let count = 0; - let emoji = 0; - let joiners = 0; - - const graphemes = [...s]; - for (let i = 0; i < graphemes.length; i++) { - const code = graphemes[i].codePointAt(0)!; - - // Ignore control characters - if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; - - // Ignore combining characters - if (code >= 0x300 && code <= 0x36f) continue; - - if ( - (code >= 0x1f600 && code <= 0x1f64f) || // Emoticons - (code >= 0x1f300 && code <= 0x1f5ff) || // Misc Symbols and Pictographs - (code >= 0x1f680 && code <= 0x1f6ff) || // Transport and Map - (code >= 0x2600 && code <= 0x26ff) || // Misc symbols - (code >= 0x2700 && code <= 0x27bf) || // Dingbats - (code >= 0xfe00 && code <= 0xfe0f) || // Variation Selectors - (code >= 0x1f900 && code <= 0x1f9ff) || // Supplemental Symbols and Pictographs - (code >= 65024 && code <= 65039) || // Variation selector - (code >= 8400 && code <= 8447) // Combining Diacritical Marks for Symbols - ) { - if (code >= 0x1f3fb && code <= 0x1f3ff) continue; // emoji modifier fitzpatrick type - - emoji++; - count += 2; - continue; - } - - // Ignore zero-width joiners '\u200d' - if (code === 8205) { - joiners++; - count -= 2; - continue; - } - - // Surrogates - if (code > 0xffff) { - i++; - } - - count += isFullwidthCodePoint(code) ? 2 : 1; - } - - const offset = emoji - joiners; - if (offset > 1) { - count += offset - 1; - } - return count; -} - -// See sindresorhus/is-fullwidth-code-point -function isFullwidthCodePoint(cp: number) { - // code points are derived from: - // http://www.unix.org/Public/UNIDATA/EastAsianWidth.txt - if ( - cp >= 0x1100 && - (cp <= 0x115f || // Hangul Jamo - cp === 0x2329 || // LEFT-POINTING ANGLE BRACKET - cp === 0x232a || // RIGHT-POINTING ANGLE BRACKET - // CJK Radicals Supplement .. Enclosed CJK Letters and Months - (cp >= 0x2e80 && cp <= 0x3247 && cp !== 0x303f) || - // Enclosed CJK Letters and Months .. CJK Unified Ideographs Extension A - (cp >= 0x3250 && cp <= 0x4dbf) || - // CJK Unified Ideographs .. Yi Radicals - (cp >= 0x4e00 && cp <= 0xa4c6) || - // Hangul Jamo Extended-A - (cp >= 0xa960 && cp <= 0xa97c) || - // Hangul Syllables - (cp >= 0xac00 && cp <= 0xd7a3) || - // CJK Compatibility Ideographs - (cp >= 0xf900 && cp <= 0xfaff) || - // Vertical Forms - (cp >= 0xfe10 && cp <= 0xfe19) || - // CJK Compatibility Forms .. Small Form Variants - (cp >= 0xfe30 && cp <= 0xfe6b) || - // Halfwidth and Fullwidth Forms - (cp >= 0xff01 && cp <= 0xff60) || - (cp >= 0xffe0 && cp <= 0xffe6) || - // Kana Supplement - (cp >= 0x1b000 && cp <= 0x1b001) || - // Enclosed Ideographic Supplement - (cp >= 0x1f200 && cp <= 0x1f251) || - // CJK Unified Ideographs Extension B .. Tertiary Ideographic Plane - (cp >= 0x20000 && cp <= 0x3fffd)) - ) { - return true; - } - - return false; -} - // Below adapted from https://github.com/pieroxy/lz-string const keyStrBase64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; @@ -862,6 +771,7 @@ function _decompressLZString(length: number, resetValue: any, getNextValue: (ind if (dictionary[c]) { entry = dictionary[c]!; } else if (c === dictSize) { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands entry = w + w.charAt(0); } else { return undefined; @@ -870,6 +780,7 @@ function _decompressLZString(length: number, resetValue: any, getNextValue: (ind // Add w+entry[0] to the dictionary. + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands dictionary[dictSize++] = w + entry.charAt(0); enlargeIn--; diff --git a/src/system/utils.ts b/src/system/utils.ts index b24069e286ab3..100d690f09611 100644 --- a/src/system/utils.ts +++ b/src/system/utils.ts @@ -5,7 +5,7 @@ import { isGitUri } from '../git/gitUri'; import { executeCoreCommand } from './command'; import { configuration } from './configuration'; import { Logger } from './logger'; -import { extname } from './path'; +import { extname, normalizePath, relative } from './path'; import { satisfies } from './version'; export function findTextDocument(uri: Uri): TextDocument | undefined { @@ -29,7 +29,7 @@ export function findEditor(uri: Uri): TextEditor | undefined { export async function findOrOpenEditor( uri: Uri, - options?: TextDocumentShowOptions & { throwOnError?: boolean }, + options?: TextDocumentShowOptions & { background?: boolean; throwOnError?: boolean }, ): Promise { const e = findEditor(uri); if (e != null) { @@ -43,7 +43,7 @@ export async function findOrOpenEditor( return openEditor(uri, { viewColumn: window.activeTextEditor?.viewColumn, ...options }); } -export function findOrOpenEditors(uris: Uri[]): void { +export function findOrOpenEditors(uris: Uri[], options?: TextDocumentShowOptions & { background?: boolean }): void { const normalizedUris = new Map(uris.map(uri => [uri.toString(), uri])); for (const e of window.visibleTextEditors) { @@ -53,11 +53,31 @@ export function findOrOpenEditors(uris: Uri[]): void { } } + options = { background: true, preview: false, ...options }; for (const uri of normalizedUris.values()) { - void executeCoreCommand('vscode.open', uri, { background: true, preview: false }); + void executeCoreCommand('vscode.open', uri, options); } } +export function getEditorCommand() { + let editor; + switch (env.appName) { + case 'Visual Studio Code - Insiders': + editor = 'code-insiders --wait --reuse-window'; + break; + case 'Visual Studio Code - Exploration': + editor = 'code-exploration --wait --reuse-window'; + break; + case 'VSCodium': + editor = 'codium --wait --reuse-window'; + break; + default: + editor = 'code --wait --reuse-window'; + break; + } + return editor; +} + export function getEditorIfActive(document: TextDocument): TextEditor | undefined { const editor = window.activeTextEditor; return editor != null && editor.document === document ? editor : undefined; @@ -67,6 +87,14 @@ export function getQuickPickIgnoreFocusOut() { return !configuration.get('advanced.quickPick.closeOnFocusOut'); } +export function getWorkspaceFriendlyPath(uri: Uri): string { + const folder = workspace.getWorkspaceFolder(uri); + if (folder == null) return normalizePath(uri.fsPath); + + const relativePath = normalizePath(relative(folder.uri.fsPath, uri.fsPath)); + return relativePath || folder.name; +} + export function hasVisibleTextEditor(uri?: Uri): boolean { if (window.visibleTextEditors.length === 0) return false; @@ -106,16 +134,21 @@ export function isTextEditor(editor: TextEditor): boolean { export async function openEditor( uri: Uri, - options: TextDocumentShowOptions & { rethrow?: boolean } = {}, + options?: TextDocumentShowOptions & { background?: boolean; throwOnError?: boolean }, ): Promise { - const { rethrow, ...opts } = options; + let background; + let throwOnError; + if (options != null) { + ({ background, throwOnError, ...options } = options); + } + try { if (isGitUri(uri)) { uri = uri.documentUri(); } - if (uri.scheme === Schemes.GitLens && ImageMimetypes[extname(uri.fsPath)]) { - await executeCoreCommand('vscode.open', uri); + if (background || (uri.scheme === Schemes.GitLens && ImageMimetypes[extname(uri.fsPath)])) { + await executeCoreCommand('vscode.open', uri, { background: background, ...options }); return undefined; } @@ -125,7 +158,7 @@ export async function openEditor( preserveFocus: false, preview: true, viewColumn: ViewColumn.Active, - ...opts, + ...options, }); } catch (ex) { const msg: string = ex?.toString() ?? ''; @@ -135,13 +168,42 @@ export async function openEditor( return undefined; } - if (rethrow) throw ex; + if (throwOnError) throw ex; Logger.error(ex, 'openEditor'); return undefined; } } +export async function openChangesEditor( + resources: { uri: Uri; lhs: Uri | undefined; rhs: Uri | undefined }[], + title: string, + _options?: TextDocumentShowOptions, +): Promise { + try { + await executeCoreCommand( + 'vscode.changes', + title, + resources.map(r => [r.uri, r.lhs, r.rhs]), + ); + } catch (ex) { + Logger.error(ex, 'openChangesEditor'); + } +} + +export async function openDiffEditor( + lhs: Uri, + rhs: Uri, + title: string, + options?: TextDocumentShowOptions, +): Promise { + try { + await executeCoreCommand('vscode.diff', lhs, rhs, title, options); + } catch (ex) { + Logger.error(ex, 'openDiffEditor'); + } +} + export async function openWalkthrough( extensionId: string, walkthroughId: string, @@ -158,7 +220,7 @@ export async function openWalkthrough( 'workbench.action.openWalkthrough', { category: `${extensionId}#${walkthroughId}`, - step: stepId ? `${extensionId}#${walkthroughId}#${stepId}` : undefined, + step: stepId, }, openToSide, )); @@ -180,30 +242,25 @@ export function openWorkspace( }); } -export function getEditorCommand() { - let editor; - switch (env.appName) { - case 'Visual Studio Code - Insiders': - editor = 'code-insiders --wait --reuse-window'; - break; - case 'Visual Studio Code - Exploration': - editor = 'code-exploration --wait --reuse-window'; - break; - case 'VSCodium': - editor = 'codium --wait --reuse-window'; - break; - default: - editor = 'code --wait --reuse-window'; - break; - } - return editor; +export async function revealInFileExplorer(uri: Uri) { + void (await executeCoreCommand('revealFileInOS', uri)); } -export function supportedInVSCodeVersion(feature: 'input-prompt-links') { +export function supportedInVSCodeVersion(feature: 'language-models') { switch (feature) { - case 'input-prompt-links': - return satisfies(codeVersion, '>= 1.76'); + case 'language-models': + return satisfies(codeVersion, '>= 1.90-insider'); default: return false; } } + +export async function openUrl(url: string): Promise; +export async function openUrl(url?: string): Promise; +export async function openUrl(url?: string): Promise { + if (url == null) return undefined; + + // Pass a string to openExternal to avoid double encoding issues: https://github.com/microsoft/vscode/issues/85930 + // vscode.d.ts currently says it only supports a Uri, but it actually accepts a string too + return (env.openExternal as unknown as (target: string) => Thenable)(url); +} diff --git a/src/system/webview.ts b/src/system/webview.ts index 4848ce96968c1..85834fe623662 100644 --- a/src/system/webview.ts +++ b/src/system/webview.ts @@ -3,12 +3,16 @@ import type { WebviewIds, WebviewViewIds } from '../constants'; export function createWebviewCommandLink( command: `${WebviewIds | WebviewViewIds}.${string}`, webviewId: WebviewIds | WebviewViewIds, + webviewInstanceId: string | undefined, ): string { - return `command:${command}?${encodeURIComponent(JSON.stringify({ webview: webviewId } satisfies WebviewContext))}`; + return `command:${command}?${encodeURIComponent( + JSON.stringify({ webview: webviewId, webviewInstance: webviewInstanceId } satisfies WebviewContext), + )}`; } export interface WebviewContext { webview: WebviewIds | WebviewViewIds; + webviewInstance: string | undefined; } export function isWebviewContext(item: object | null | undefined): item is WebviewContext { @@ -20,6 +24,7 @@ export function isWebviewContext(item: object | null | undefined): item is Webvi export interface WebviewItemContext extends Partial { webviewItem: string; webviewItemValue: TValue; + webviewItemsValues?: { webviewItem: string; webviewItemValue: TValue }[]; } export function isWebviewItemContext( diff --git a/src/telemetry/openTelemetryProvider.ts b/src/telemetry/openTelemetryProvider.ts index 37d58a9ae7d42..3c2678f0b741c 100644 --- a/src/telemetry/openTelemetryProvider.ts +++ b/src/telemetry/openTelemetryProvider.ts @@ -10,7 +10,13 @@ import { // ConsoleSpanExporter, SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base'; -import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; +import { + SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, + SEMRESATTRS_DEVICE_ID, + SEMRESATTRS_OS_TYPE, + SEMRESATTRS_SERVICE_NAME, + SEMRESATTRS_SERVICE_VERSION, +} from '@opentelemetry/semantic-conventions'; import type { HttpsProxyAgent } from 'https-proxy-agent'; import type { TelemetryContext, TelemetryProvider } from './telemetry'; @@ -23,11 +29,11 @@ export class OpenTelemetryProvider implements TelemetryProvider { constructor(context: TelemetryContext, agent?: HttpsProxyAgent, debugging?: boolean) { this.provider = new BasicTracerProvider({ resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: 'gitlens', - [SemanticResourceAttributes.SERVICE_VERSION]: context.extensionVersion, - [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: context.env, - [SemanticResourceAttributes.DEVICE_ID]: context.machineId, - [SemanticResourceAttributes.OS_TYPE]: context.platform, + [SEMRESATTRS_SERVICE_NAME]: 'gitlens', + [SEMRESATTRS_SERVICE_VERSION]: context.extensionVersion, + [SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: context.env, + [SEMRESATTRS_DEVICE_ID]: context.machineId, + [SEMRESATTRS_OS_TYPE]: context.platform, 'extension.id': context.extensionId, 'session.id': context.sessionId, language: context.language, @@ -46,9 +52,7 @@ export class OpenTelemetryProvider implements TelemetryProvider { // } const exporter = new OTLPTraceExporter({ - url: debugging - ? 'https://otel-dev.gitkraken.com:4318/v1/traces' - : 'https://otel.gitkraken.com:4318/v1/traces', + url: debugging ? 'https://otel-dev.gitkraken.com/v1/traces' : 'https://otel.gitkraken.com/v1/traces', compression: 'gzip' as any, httpAgentOptions: agent?.options, }); diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts index 92347ffa8d4f5..abda32c01741b 100644 --- a/src/telemetry/telemetry.ts +++ b/src/telemetry/telemetry.ts @@ -3,7 +3,7 @@ import type { Disposable } from 'vscode'; import { version as codeVersion, env } from 'vscode'; import { getProxyAgent } from '@env/fetch'; import { getPlatform } from '@env/platform'; -import type { TelemetryEvents } from '../constants'; +import type { Source, TelemetryEvents, TelemetryGlobalContext } from '../constants'; import type { Container } from '../container'; import { configuration } from '../system/configuration'; @@ -23,16 +23,18 @@ export interface TelemetryContext { vscodeVersion: string; } +export type TelemetryEventData = Record; + export interface TelemetryProvider extends Disposable { sendEvent(name: string, data?: Record, startTime?: TimeInput, endTime?: TimeInput): void; startEvent(name: string, data?: Record, startTime?: TimeInput): Span; setGlobalAttributes(attributes: Map): void; } -interface QueuedEvent { +interface QueuedEvent { type: 'sendEvent'; - name: TelemetryEvents; - data?: Record; + name: T; + data?: TelemetryEvents[T]; global: Map; startTime: TimeInput; endTime: TimeInput; @@ -121,6 +123,7 @@ export class TelemetryService implements Disposable { for (const { type, name, data, global } of queue) { if (type === 'sendEvent') { this.provider.setGlobalAttributes(global); + assertsTelemetryEventData(data); this.provider.sendEvent(name, stripNullOrUndefinedAttributes(data)); } } @@ -129,20 +132,24 @@ export class TelemetryService implements Disposable { this.provider.setGlobalAttributes(this.globalAttributes); } - sendEvent( - name: TelemetryEvents, - data?: Record, + sendEvent( + name: T, + data?: TelemetryEvents[T], + source?: Source, startTime?: TimeInput, endTime?: TimeInput, ): void { if (!this._enabled) return; + assertsTelemetryEventData(data); + addSourceAttributes(source, data); + if (this.provider == null) { this.eventQueue.push({ type: 'sendEvent', name: name, data: data, - global: new Map([...this.globalAttributes]), + global: new Map(this.globalAttributes), startTime: startTime ?? Date.now(), endTime: endTime ?? Date.now(), }); @@ -152,13 +159,17 @@ export class TelemetryService implements Disposable { this.provider.sendEvent(name, stripNullOrUndefinedAttributes(data), startTime, endTime); } - startEvent( - name: TelemetryEvents, - data?: Record, + startEvent( + name: T, + data?: TelemetryEvents[T], + source?: Source, startTime?: TimeInput, ): Disposable | undefined { if (!this._enabled) return undefined; + assertsTelemetryEventData(data); + addSourceAttributes(source, data); + if (this.provider != null) { const span = this.provider.startEvent(name, stripNullOrUndefinedAttributes(data), startTime); return { @@ -168,7 +179,7 @@ export class TelemetryService implements Disposable { startTime = startTime ?? Date.now(); return { - dispose: () => this.sendEvent(name, data, startTime, Date.now()), + dispose: () => this.sendEvent(name, data, source, startTime, Date.now()), }; } @@ -184,32 +195,54 @@ export class TelemetryService implements Disposable { // ): void { // } - setGlobalAttribute(key: string, value: AttributeValue | null | undefined): void { + setGlobalAttribute( + key: T, + value: TelemetryGlobalContext[T] | null | undefined, + ): void { if (value == null) { - this.globalAttributes.delete(key); + this.globalAttributes.delete(`global.${key}`); } else { - this.globalAttributes.set(key, value); + this.globalAttributes.set(`global.${key}`, value); } this.provider?.setGlobalAttributes(this.globalAttributes); } - setGlobalAttributes(attributes: Record): void { + setGlobalAttributes(attributes: Partial): void { for (const [key, value] of Object.entries(attributes)) { if (value == null) { - this.globalAttributes.delete(key); + this.globalAttributes.delete(`global.${key}`); } else { - this.globalAttributes.set(key, value); + this.globalAttributes.set(`global.${key}`, value); } } this.provider?.setGlobalAttributes(this.globalAttributes); } - deleteGlobalAttribute(key: string): void { - this.globalAttributes.delete(key); + deleteGlobalAttribute(key: keyof TelemetryGlobalContext): void { + this.globalAttributes.delete(`global.${key}`); this.provider?.setGlobalAttributes(this.globalAttributes); } } +function addSourceAttributes( + source: Source | undefined, + data: Record | undefined, +) { + if (source == null) return; + + data ??= {}; + data['source.name'] = source.source; + if (source.detail != null) { + if (typeof source.detail === 'string') { + data['source.detail'] = source.detail; + } else if (typeof source.detail === 'object') { + for (const [key, value] of Object.entries(source.detail)) { + data[`source.detail.${key}`] = value; + } + } + } +} + function stripNullOrUndefinedAttributes(data: Record | undefined) { if (data == null) return undefined; @@ -221,3 +254,9 @@ function stripNullOrUndefinedAttributes(data: Record { - if (e.name === extensionTerminalName) { + if (e === _terminal) { _terminal = undefined; _disposable?.dispose(); _disposable = undefined; diff --git a/src/terminal/linkProvider.ts b/src/terminal/linkProvider.ts index 1e061fe09f39d..c7496357d2756 100644 --- a/src/terminal/linkProvider.ts +++ b/src/terminal/linkProvider.ts @@ -1,11 +1,9 @@ import type { Disposable, TerminalLink, TerminalLinkContext, TerminalLinkProvider } from 'vscode'; import { commands, window } from 'vscode'; -import type { - GitCommandsCommandArgs, - ShowCommitsInViewCommandArgs, - ShowQuickBranchHistoryCommandArgs, - ShowQuickCommitCommandArgs, -} from '../commands'; +import type { GitCommandsCommandArgs } from '../commands/gitCommands'; +import type { InspectCommandArgs } from '../commands/inspect'; +import type { ShowQuickBranchHistoryCommandArgs } from '../commands/showQuickBranchHistory'; +import type { ShowQuickCommitCommandArgs } from '../commands/showQuickCommit'; import { Commands } from '../constants'; import type { Container } from '../container'; import type { PagedResult } from '../git/gitProvider'; @@ -18,7 +16,8 @@ import { configuration } from '../system/configuration'; const commandsRegexShared = /\b(g(?:it)?\b\s*)\b(branch|checkout|cherry-pick|fetch|grep|log|merge|pull|push|rebase|reset|revert|show|stash|status|tag)\b/gi; // Since negative lookbehind isn't supported in all browsers, leave out the negative lookbehind condition `(? = { + const link: GitTerminalLink = { startIndex: match.index, length: ref.length, tooltip: 'Show Commit', @@ -177,8 +176,7 @@ export class GitTerminalLinkProvider implements Disposable, TerminalLinkProvider ? { command: Commands.ShowInDetailsView, args: { - repoPath: repoPath, - refs: [ref], + ref: createReference(ref, repoPath, { refType: 'revision' }), }, } : { diff --git a/src/test/suite/system/trie.test.ts b/src/test/suite/system/trie.test.ts index 70867e0f662da..73e95b903419f 100644 --- a/src/test/suite/system/trie.test.ts +++ b/src/test/suite/system/trie.test.ts @@ -5,7 +5,6 @@ import { isLinux } from '../../../env/node/platform'; import { normalizeRepoUri } from '../../../repositories'; import type { UriEntry } from '../../../system/trie'; import { PathEntryTrie, UriEntryTrie, UriTrie } from '../../../system/trie'; -// eslint-disable-next-line import/extensions import paths from './paths.json'; describe('PathEntryTrie Test Suite', () => { diff --git a/src/trackers/documentTracker.ts b/src/trackers/documentTracker.ts index e35f851cc399c..ec9efa32d3f89 100644 --- a/src/trackers/documentTracker.ts +++ b/src/trackers/documentTracker.ts @@ -18,66 +18,68 @@ import type { RepositoryChangeEvent } from '../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; -import { debug } from '../system/decorators/log'; import { once } from '../system/event'; import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; -import { filter, join, map } from '../system/iterable'; import { findTextDocument, isActiveDocument, isTextEditor } from '../system/utils'; -import type { DocumentBlameStateChangeEvent } from './trackedDocument'; -import { TrackedDocument } from './trackedDocument'; +import { TrackedGitDocument } from './trackedDocument'; -export * from './trackedDocument'; - -export interface DocumentContentChangeEvent { +export interface DocumentContentChangeEvent { readonly editor: TextEditor; - readonly document: TrackedDocument; + readonly document: TrackedGitDocument; readonly contentChanges: readonly TextDocumentContentChangeEvent[]; } -export interface DocumentDirtyStateChangeEvent { +export interface DocumentBlameStateChangeEvent { + readonly editor: TextEditor; + readonly document: TrackedGitDocument; + readonly blameable: boolean; +} + +export interface DocumentDirtyStateChangeEvent { readonly editor: TextEditor; - readonly document: TrackedDocument; + readonly document: TrackedGitDocument; readonly dirty: boolean; } -export interface DocumentDirtyIdleTriggerEvent { +export interface DocumentDirtyIdleTriggerEvent { readonly editor: TextEditor; - readonly document: TrackedDocument; + readonly document: TrackedGitDocument; } -export class DocumentTracker implements Disposable { - private _onDidChangeBlameState = new EventEmitter>(); - get onDidChangeBlameState(): Event> { +export class GitDocumentTracker implements Disposable { + private _onDidChangeBlameState = new EventEmitter(); + get onDidChangeBlameState(): Event { return this._onDidChangeBlameState.event; } - private _onDidChangeContent = new EventEmitter>(); - get onDidChangeContent(): Event> { + private _onDidChangeContent = new EventEmitter(); + get onDidChangeContent(): Event { return this._onDidChangeContent.event; } - private _onDidChangeDirtyState = new EventEmitter>(); - get onDidChangeDirtyState(): Event> { + private _onDidChangeDirtyState = new EventEmitter(); + get onDidChangeDirtyState(): Event { return this._onDidChangeDirtyState.event; } - private _onDidTriggerDirtyIdle = new EventEmitter>(); - get onDidTriggerDirtyIdle(): Event> { + private _onDidTriggerDirtyIdle = new EventEmitter(); + get onDidTriggerDirtyIdle(): Event { return this._onDidTriggerDirtyIdle.event; } private _dirtyIdleTriggerDelay: number; + private _dirtyIdleTriggeredDebounced: Deferrable<(e: DocumentDirtyIdleTriggerEvent) => void> | undefined; + private _dirtyStateChangedDebounced: Deferrable<(e: DocumentDirtyStateChangeEvent) => void> | undefined; private readonly _disposable: Disposable; - protected readonly _documentMap = new Map>>(); + private readonly _documentMap = new Map>(); - constructor(protected readonly container: Container) { + constructor(private readonly container: Container) { this._disposable = Disposable.from( once(container.onReady)(this.onReady, this), configuration.onDidChange(this.onConfigurationChanged, this), window.onDidChangeActiveTextEditor(this.onActiveTextEditorChanged, this), - // window.onDidChangeVisibleTextEditors(debounce(this.onVisibleEditorsChanged, 5000), this), - workspace.onDidChangeTextDocument(debounce(this.onTextDocumentChanged, 50), this), + workspace.onDidChangeTextDocument(this.onTextDocumentChanged, this), workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this), workspace.onDidSaveTextDocument(this.onTextDocumentSaved, this), this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), @@ -102,6 +104,14 @@ export class DocumentTracker implements Disposable { private onActiveTextEditorChanged(editor: TextEditor | undefined) { if (editor != null && !isTextEditor(editor)) return; + this._dirtyIdleTriggeredDebounced?.flush(); + this._dirtyIdleTriggeredDebounced?.cancel(); + this._dirtyIdleTriggeredDebounced = undefined; + + this._dirtyStateChangedDebounced?.flush(); + this._dirtyStateChangedDebounced?.cancel(); + this._dirtyStateChangedDebounced = undefined; + if (this._timer != null) { clearTimeout(this._timer); this._timer = undefined; @@ -137,21 +147,24 @@ export class DocumentTracker implements Disposable { e != null && (configuration.changed(e, 'blame.ignoreWhitespace') || configuration.changed(e, 'advanced.caching.enabled')) ) { - this.reset('config'); + void this.refreshDocuments(); } if (configuration.changed(e, 'advanced.blame.delayAfterEdit')) { this._dirtyIdleTriggerDelay = configuration.get('advanced.blame.delayAfterEdit'); + this._dirtyIdleTriggeredDebounced?.flush(); + this._dirtyIdleTriggeredDebounced?.cancel(); this._dirtyIdleTriggeredDebounced = undefined; } } private onRepositoriesChanged(e: RepositoriesChangeEvent) { - this.reset( - 'repository', - e.added.length ? new Set(e.added.map(r => r.path)) : undefined, - e.removed.length ? new Set(e.removed.map(r => r.path)) : undefined, - ); + void this.refreshDocuments({ + addedOrChangedRepoPaths: e.added.length + ? new Set(e.added.map(r => r.path.toLowerCase())) + : undefined, + removedRepoPaths: e.removed.length ? new Set(e.removed.map(r => r.path.toLowerCase())) : undefined, + }); } private onRepositoryChanged(e: RepositoryChangeEvent) { @@ -164,16 +177,45 @@ export class DocumentTracker implements Disposable { RepositoryChangeComparisonMode.Any, ) ) { - this.reset('repository', new Set([e.repository.path])); + void this.refreshDocuments({ addedOrChangedRepoPaths: new Set([e.repository.path]) }); } } - private async onTextDocumentChanged(e: TextDocumentChangeEvent) { + private debouncedTextDocumentChanges = new WeakMap< + TextDocument, + Deferrable[0]> + >(); + + private onTextDocumentChanged(e: TextDocumentChangeEvent) { const { scheme } = e.document.uri; if (!this.container.git.supportedSchemes.has(scheme)) return; + let debouncedChange = this.debouncedTextDocumentChanges.get(e.document); + if (debouncedChange == null) { + debouncedChange = debounce( + e => this.onTextDocumentChangedCore(e), + 50, + ([prev]: [TextDocumentChangeEvent], [next]: [TextDocumentChangeEvent]) => { + return [ + { + ...next, + // Aggregate content changes + contentChanges: [...prev.contentChanges, ...next.contentChanges], + } satisfies TextDocumentChangeEvent, + ]; + }, + ); + this.debouncedTextDocumentChanges.set(e.document, debouncedChange); + } + + debouncedChange(e); + } + + private async onTextDocumentChangedCore(e: TextDocumentChangeEvent) { + this.debouncedTextDocumentChanges.delete(e.document); + const doc = await (this._documentMap.get(e.document) ?? this.addCore(e.document)); - doc.reset('document'); + doc.refresh('doc-changed'); const dirty = e.document.isDirty; const editor = window.activeTextEditor; @@ -230,10 +272,10 @@ export class DocumentTracker implements Disposable { // } // } - add(document: TextDocument): Promise>; - add(uri: Uri): Promise>; - add(documentOrUri: TextDocument | Uri): Promise>; - async add(documentOrUri: TextDocument | Uri): Promise> { + add(document: TextDocument): Promise; + add(uri: Uri): Promise; + add(documentOrUri: TextDocument | Uri): Promise; + async add(documentOrUri: TextDocument | Uri): Promise { let document; if (isGitUri(documentOrUri)) { try { @@ -275,13 +317,13 @@ export class DocumentTracker implements Disposable { return doc; } - private async addCore(document: TextDocument): Promise> { - const doc = TrackedDocument.create( + private async addCore(document: TextDocument): Promise { + const doc = TrackedGitDocument.create( document, // Always start out false, so we will fire the event if needed false, { - onDidBlameStateChange: (e: DocumentBlameStateChangeEvent) => this._onDidChangeBlameState.fire(e), + onDidBlameStateChange: (e: DocumentBlameStateChangeEvent) => this._onDidChangeBlameState.fire(e), }, this.container, ); @@ -299,10 +341,10 @@ export class DocumentTracker implements Disposable { this._documentMap.clear(); } - get(document: TextDocument): Promise> | undefined; - get(uri: Uri): Promise> | undefined; - get(documentOrUri: TextDocument | Uri): Promise> | undefined; - get(documentOrUri: TextDocument | Uri): Promise> | undefined { + get(document: TextDocument): Promise | undefined; + get(uri: Uri): Promise | undefined; + get(documentOrUri: TextDocument | Uri): Promise | undefined; + get(documentOrUri: TextDocument | Uri): Promise | undefined { if (documentOrUri instanceof Uri) { const document = findTextDocument(documentOrUri); if (document == null) return undefined; @@ -314,7 +356,7 @@ export class DocumentTracker implements Disposable { return doc; } - async getOrAdd(documentOrUri: TextDocument | Uri): Promise> { + async getOrAdd(documentOrUri: TextDocument | Uri): Promise { if (documentOrUri instanceof Uri) { documentOrUri = findTextDocument(documentOrUri) ?? documentOrUri; } @@ -336,20 +378,37 @@ export class DocumentTracker implements Disposable { return this._documentMap.has(documentOrUri); } - private async remove(document: TextDocument, tracked?: TrackedDocument): Promise { - let promise; + resetCache(document: TextDocument, affects: 'blame' | 'diff' | 'log'): Promise; + resetCache(uri: Uri, affects: 'blame' | 'diff' | 'log'): Promise; + async resetCache(documentOrUri: TextDocument | Uri, affects: 'blame' | 'diff' | 'log'): Promise { + const doc = this.get(documentOrUri); + if (doc == null) return; + + switch (affects) { + case 'blame': + (await doc).state?.clearBlame(); + break; + case 'diff': + (await doc).state?.clearDiff(); + break; + case 'log': + (await doc).state?.clearLog(); + break; + } + } + + private async remove(document: TextDocument, tracked?: TrackedGitDocument): Promise { + let docPromise; if (tracked != null) { - promise = this._documentMap.get(document); + docPromise = this._documentMap.get(document); } this._documentMap.delete(document); - (tracked ?? (await promise))?.dispose(); + (tracked ?? (await docPromise))?.dispose(); } - private _dirtyIdleTriggeredDebounced: Deferrable<(e: DocumentDirtyIdleTriggerEvent) => void> | undefined; - private _dirtyStateChangedDebounced: Deferrable<(e: DocumentDirtyStateChangeEvent) => void> | undefined; - private fireDocumentDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { + private fireDocumentDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { if (e.dirty) { queueMicrotask(() => { this._dirtyStateChangedDebounced?.cancel(); @@ -359,14 +418,13 @@ export class DocumentTracker implements Disposable { }); if (this._dirtyIdleTriggerDelay > 0) { - if (this._dirtyIdleTriggeredDebounced == null) { - this._dirtyIdleTriggeredDebounced = debounce((e: DocumentDirtyIdleTriggerEvent) => { - if (this._dirtyIdleTriggeredDebounced?.pending!()) return; + this._dirtyIdleTriggeredDebounced ??= debounce((e: DocumentDirtyIdleTriggerEvent) => { + if (this._dirtyIdleTriggeredDebounced?.pending()) return; - e.document.isDirtyIdle = true; + if (e.document.setIsDirtyIdle()) { this._onDidTriggerDirtyIdle.fire(e); - }, this._dirtyIdleTriggerDelay); - } + } + }, this._dirtyIdleTriggerDelay); this._dirtyIdleTriggeredDebounced({ editor: e.editor, document: e.document }); } @@ -374,41 +432,31 @@ export class DocumentTracker implements Disposable { return; } - if (this._dirtyStateChangedDebounced == null) { - this._dirtyStateChangedDebounced = debounce((e: DocumentDirtyStateChangeEvent) => { - if (window.activeTextEditor !== e.editor) return; + this._dirtyStateChangedDebounced ??= debounce((e: DocumentDirtyStateChangeEvent) => { + if (window.activeTextEditor !== e.editor) return; - this._onDidChangeDirtyState.fire(e); - }, 250); - } + this._onDidChangeDirtyState.fire(e); + }, 250); this._dirtyStateChangedDebounced(e); } - @debug['reset']>({ - args: { - 1: c => (c != null ? join(c, ',') : ''), - 2: r => (r != null ? join(r, ',') : ''), - }, - }) - private reset(reason: 'config' | 'repository', changedRepoPaths?: Set, removedRepoPaths?: Set) { - void Promise.allSettled( - map( - filter(this._documentMap, ([key]) => typeof key === 'string'), - async ([, promise]) => { - const doc = await promise; - - if (removedRepoPaths?.has(doc.uri.repoPath!)) { - void this.remove(doc.document, doc); - return; - } + private async refreshDocuments(changed?: { + addedOrChangedRepoPaths?: Set; + removedRepoPaths?: Set; + }) { + if (this._documentMap.size === 0) return; - if (changedRepoPaths == null || changedRepoPaths.has(doc.uri.repoPath!)) { - doc.reset(reason); - } - }, - ), - ); + for await (const doc of this._documentMap.values()) { + const repoPath = doc.uri.repoPath?.toLocaleLowerCase(); + if (repoPath == null) continue; + + if (changed?.removedRepoPaths?.has(repoPath)) { + void this.remove(doc.document, doc); + } else if (changed == null || changed?.addedOrChangedRepoPaths?.has(repoPath)) { + doc.refresh('repo-changed'); + } + } } } diff --git a/src/trackers/gitDocumentTracker.ts b/src/trackers/gitDocumentTracker.ts deleted file mode 100644 index 4f76867b3ddba..0000000000000 --- a/src/trackers/gitDocumentTracker.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { TextDocument, Uri } from 'vscode'; -import type { GitBlame } from '../git/models/blame'; -import type { GitDiffFile } from '../git/models/diff'; -import type { GitLog } from '../git/models/log'; -import { DocumentTracker } from './documentTracker'; - -export * from './documentTracker'; - -interface CachedItem { - item: Promise; - errorMessage?: string; -} - -export type CachedBlame = CachedItem; -export type CachedDiff = CachedItem; -export type CachedLog = CachedItem; - -export class GitDocumentState { - private readonly blameCache = new Map(); - private readonly diffCache = new Map(); - private readonly logCache = new Map(); - - clearBlame(key?: string): void { - if (key == null) { - this.blameCache.clear(); - return; - } - this.blameCache.delete(key); - } - - clearDiff(key?: string): void { - if (key == null) { - this.diffCache.clear(); - return; - } - this.diffCache.delete(key); - } - - clearLog(key?: string): void { - if (key == null) { - this.logCache.clear(); - return; - } - this.logCache.delete(key); - } - - getBlame(key: string): CachedBlame | undefined { - return this.blameCache.get(key); - } - - getDiff(key: string): CachedDiff | undefined { - return this.diffCache.get(key); - } - - getLog(key: string): CachedLog | undefined { - return this.logCache.get(key); - } - - setBlame(key: string, value: CachedBlame | undefined) { - if (value == null) { - this.blameCache.delete(key); - return; - } - this.blameCache.set(key, value); - } - - setDiff(key: string, value: CachedDiff | undefined) { - if (value == null) { - this.diffCache.delete(key); - return; - } - this.diffCache.set(key, value); - } - - setLog(key: string, value: CachedLog | undefined) { - if (value == null) { - this.logCache.delete(key); - return; - } - this.logCache.set(key, value); - } -} - -export class GitDocumentTracker extends DocumentTracker { - resetCache(document: TextDocument, affects: 'blame' | 'diff' | 'log'): Promise; - resetCache(uri: Uri, affects: 'blame' | 'diff' | 'log'): Promise; - async resetCache(documentOrUri: TextDocument | Uri, affects: 'blame' | 'diff' | 'log'): Promise { - const doc = this.get(documentOrUri); - if (doc == null) return; - - switch (affects) { - case 'blame': - (await doc).state?.clearBlame(); - break; - case 'diff': - (await doc).state?.clearDiff(); - break; - case 'log': - (await doc).state?.clearLog(); - break; - } - } -} diff --git a/src/trackers/gitLineTracker.ts b/src/trackers/gitLineTracker.ts deleted file mode 100644 index ab28fd00911af..0000000000000 --- a/src/trackers/gitLineTracker.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { TextEditor } from 'vscode'; -import { Disposable } from 'vscode'; -import { GlyphChars } from '../constants'; -import type { Container } from '../container'; -import type { GitCommit } from '../git/models/commit'; -import { configuration } from '../system/configuration'; -import { debug } from '../system/decorators/log'; -import { getLogScope, setLogScopeExit } from '../system/logger.scope'; -import type { - DocumentBlameStateChangeEvent, - DocumentContentChangeEvent, - DocumentDirtyIdleTriggerEvent, - DocumentDirtyStateChangeEvent, - GitDocumentState, -} from './gitDocumentTracker'; -import type { LinesChangeEvent, LineSelection } from './lineTracker'; -import { LineTracker } from './lineTracker'; - -export * from './lineTracker'; - -export interface GitLineState { - commit: GitCommit; -} - -export class GitLineTracker extends LineTracker { - constructor(private readonly container: Container) { - super(); - } - - protected override async fireLinesChanged(e: LinesChangeEvent) { - let updated = false; - if (!this.suspended && !e.pending && e.selections != null && e.editor != null) { - updated = await this.updateState(e.selections, e.editor); - } - - super.fireLinesChanged(updated ? e : { ...e, selections: undefined }); - } - - private _subscriptionOnlyWhenActive: Disposable | undefined; - - protected override onStart(): Disposable | undefined { - this.onResume(); - - return Disposable.from( - { dispose: () => this.onSuspend() }, - this.container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), - this.container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), - this.container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), - ); - } - - protected override onResume(): void { - if (this._subscriptionOnlyWhenActive == null) { - this._subscriptionOnlyWhenActive = this.container.tracker.onDidChangeContent(this.onContentChanged, this); - } - } - - protected override onSuspend(): void { - this._subscriptionOnlyWhenActive?.dispose(); - this._subscriptionOnlyWhenActive = undefined; - } - - @debug({ - args: { - 0: e => `editor/doc=${e.editor.document.uri.toString(true)}, blameable=${e.blameable}`, - }, - }) - private onBlameStateChanged(_e: DocumentBlameStateChangeEvent) { - this.notifyLinesChanged('editor'); - } - - @debug({ - args: { - 0: e => `editor/doc=${e.editor.document.uri.toString(true)}`, - }, - }) - private onContentChanged(e: DocumentContentChangeEvent) { - if ( - e.contentChanges.some( - scope => - this.selections?.some( - selection => - (scope.range.end.line >= selection.active && selection.active >= scope.range.start.line) || - (scope.range.start.line >= selection.active && selection.active >= scope.range.end.line), - ), - ) - ) { - this.notifyLinesChanged('editor'); - } - } - - @debug({ - args: { - 0: e => `editor/doc=${e.editor.document.uri.toString(true)}`, - }, - }) - private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { - const maxLines = configuration.get('advanced.blame.sizeThresholdAfterEdit'); - if (maxLines > 0 && e.document.lineCount > maxLines) return; - - this.resume(); - } - - @debug({ - args: { - 0: e => `editor/doc=${e.editor.document.uri.toString(true)}, dirty=${e.dirty}`, - }, - }) - private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { - if (e.dirty) { - this.suspend(); - } else { - this.resume({ force: true }); - } - } - - @debug({ - args: { 0: selections => selections?.map(s => s.active).join(','), 1: e => e.document.uri.toString(true) }, - exit: true, - }) - private async updateState(selections: LineSelection[], editor: TextEditor): Promise { - const scope = getLogScope(); - - if (!this.includes(selections)) { - setLogScopeExit(scope, ` ${GlyphChars.Dot} lines no longer match`); - - return false; - } - - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); - if (!trackedDocument.isBlameable) { - setLogScopeExit(scope, ` ${GlyphChars.Dot} document is not blameable`); - - return false; - } - - if (selections.length === 1) { - const blameLine = await this.container.git.getBlameForLine( - trackedDocument.uri, - selections[0].active, - editor?.document, - ); - if (blameLine == null) { - setLogScopeExit(scope, ` ${GlyphChars.Dot} blame failed`); - - return false; - } - - if (blameLine.commit != null && blameLine.commit.file == null) { - debugger; - } - - this.setState(blameLine.line.line - 1, { commit: blameLine.commit }); - } else { - const blame = await this.container.git.getBlame(trackedDocument.uri, editor.document); - if (blame == null) { - setLogScopeExit(scope, ` ${GlyphChars.Dot} blame failed`); - - return false; - } - - for (const selection of selections) { - const commitLine = blame.lines[selection.active]; - const commit = blame.commits.get(commitLine.sha); - if (commit != null && commit.file == null) { - debugger; - } - - if (commit == null) { - debugger; - this.resetState(selection.active); - } else { - this.setState(selection.active, { commit: commit }); - } - } - } - - // Check again because of the awaits above - - if (!this.includes(selections)) { - setLogScopeExit(scope, ` ${GlyphChars.Dot} lines no longer match`); - - return false; - } - - if (!trackedDocument.isBlameable) { - setLogScopeExit(scope, ` ${GlyphChars.Dot} document is not blameable`); - - return false; - } - - if (editor.document.isDirty) { - trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); - } - - return true; - } -} diff --git a/src/trackers/lineTracker.ts b/src/trackers/lineTracker.ts index 1490e5476580d..84d0643f4430a 100644 --- a/src/trackers/lineTracker.ts +++ b/src/trackers/lineTracker.ts @@ -1,11 +1,19 @@ import type { Event, Selection, TextEditor, TextEditorSelectionChangeEvent } from 'vscode'; import { Disposable, EventEmitter, window } from 'vscode'; +import type { Container } from '../container'; +import type { GitCommit } from '../git/models/commit'; import { debug } from '../system/decorators/log'; import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; -import { Logger } from '../system/logger'; -import { getLogScope } from '../system/logger.scope'; +import { getLogScope, setLogScopeExit } from '../system/logger.scope'; import { isTextEditor } from '../system/utils'; +import type { + DocumentBlameStateChangeEvent, + DocumentContentChangeEvent, + DocumentDirtyIdleTriggerEvent, + DocumentDirtyStateChangeEvent, + GitDocumentTracker, +} from './documentTracker'; export interface LinesChangeEvent { readonly editor: TextEditor | undefined; @@ -13,6 +21,7 @@ export interface LinesChangeEvent { readonly reason: 'editor' | 'selection'; readonly pending?: boolean; + readonly suspended?: boolean; } export interface LineSelection { @@ -20,7 +29,11 @@ export interface LineSelection { active: number; } -export class LineTracker implements Disposable { +export interface LineState { + commit: GitCommit; +} + +export class LineTracker { private _onDidChangeActiveLines = new EventEmitter(); get onDidChangeActiveLines(): Event { return this._onDidChangeActiveLines.event; @@ -28,7 +41,14 @@ export class LineTracker implements Disposable { protected _disposable: Disposable | undefined; private _editor: TextEditor | undefined; - private readonly _state = new Map(); + private readonly _state = new Map(); + private _subscriptions = new Map(); + private _subscriptionOnlyWhenTracking: Disposable | undefined; + + constructor( + private readonly container: Container, + private readonly documentTracker: GitDocumentTracker, + ) {} dispose() { for (const subscriber of this._subscriptions.keys()) { @@ -43,9 +63,64 @@ export class LineTracker implements Disposable { this._editor = editor; this._selections = toLineSelections(editor?.selections); + if (this._suspended) { + this.resume({ force: true }); + } else { + this.notifyLinesChanged('editor'); + } + } + + @debug({ + args: { + 0: e => `editor/doc=${e.editor.document.uri.toString(true)}, blameable=${e.blameable}`, + }, + }) + private onBlameStateChanged(_e: DocumentBlameStateChangeEvent) { this.notifyLinesChanged('editor'); } + @debug({ + args: { + 0: e => `editor/doc=${e.editor.document.uri.toString(true)}`, + }, + }) + private onContentChanged(e: DocumentContentChangeEvent) { + if ( + this.selections?.length && + e.contentChanges.some(c => + this.selections!.some( + selection => + (c.range.end.line >= selection.active && selection.active >= c.range.start.line) || + (c.range.start.line >= selection.active && selection.active >= c.range.end.line), + ), + ) + ) { + this.notifyLinesChanged('editor'); + } + } + + @debug({ + args: { + 0: e => `editor/doc=${e.editor.document.uri.toString(true)}`, + }, + }) + private onDirtyIdleTriggered(_e: DocumentDirtyIdleTriggerEvent) { + this.resume(); + } + + @debug({ + args: { + 0: e => `editor/doc=${e.editor.document.uri.toString(true)}, dirty=${e.dirty}`, + }, + }) + private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { + if (e.dirty) { + this.suspend(); + } else { + this.resume({ force: true }); + } + } + private onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent) { // If this isn't for our cached editor and its not a real editor -- kick out if (this._editor !== e.textEditor && !isTextEditor(e.textEditor)) return; @@ -59,7 +134,17 @@ export class LineTracker implements Disposable { this.notifyLinesChanged(this._editor === e.textEditor ? 'selection' : 'editor'); } - getState(line: number): T | undefined { + private _selections: LineSelection[] | undefined; + get selections(): LineSelection[] | undefined { + return this._selections; + } + + private _suspended = false; + get suspended() { + return this._suspended; + } + + getState(line: number): LineState | undefined { return this._state.get(line); } @@ -72,15 +157,10 @@ export class LineTracker implements Disposable { this._state.clear(); } - setState(line: number, state: T | undefined) { + setState(line: number, state: LineState | undefined) { this._state.set(line, state); } - private _selections: LineSelection[] | undefined; - get selections(): LineSelection[] | undefined { - return this._selections; - } - includes(selections: LineSelection[]): boolean; includes(line: number, options?: { activeOnly: boolean }): boolean; includes(lineOrSelections: number | LineSelection[], options?: { activeOnly: boolean }): boolean { @@ -110,15 +190,36 @@ export class LineTracker implements Disposable { this.notifyLinesChanged('editor'); } - private _subscriptions = new Map(); + @debug() + resume(options?: { force?: boolean; silent?: boolean }) { + if (!options?.force && !this._suspended) return; + + this._suspended = false; + this._subscriptionOnlyWhenTracking ??= this.documentTracker.onDidChangeContent(this.onContentChanged, this); + + if (!options?.silent) { + this.notifyLinesChanged('editor'); + } + } + + @debug() + suspend(options?: { force?: boolean; silent?: boolean }) { + if (!options?.force && this._suspended) return; + + this._suspended = true; + this._subscriptionOnlyWhenTracking?.dispose(); + this._subscriptionOnlyWhenTracking = undefined; + + if (!options?.silent) { + this.notifyLinesChanged('editor'); + } + } subscribed(subscriber: unknown) { return this._subscriptions.has(subscriber); } - protected onStart?(): Disposable | undefined; - - @debug({ args: false }) + @debug({ args: false, singleLine: true }) subscribe(subscriber: unknown, subscription: Disposable): Disposable { const scope = getLogScope(); @@ -137,21 +238,28 @@ export class LineTracker implements Disposable { } if (first) { - Logger.debug(scope, 'Starting line tracker...'); + setLogScopeExit(scope, ' \u2022 starting line tracker...'); + + this.resume({ force: true, silent: true }); this._disposable = Disposable.from( + { dispose: () => this.suspend({ force: true, silent: true }) }, window.onDidChangeActiveTextEditor(debounce(this.onActiveTextEditorChanged, 0), this), window.onDidChangeTextEditorSelection(this.onTextEditorSelectionChanged, this), - this.onStart?.() ?? { dispose: () => {} }, + this.documentTracker.onDidChangeBlameState(this.onBlameStateChanged, this), + this.documentTracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), + this.documentTracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), ); queueMicrotask(() => this.onActiveTextEditorChanged(window.activeTextEditor)); + } else { + setLogScopeExit(scope, ' \u2022 already started...'); } return disposable; } - @debug({ args: false }) + @debug({ args: false, singleLine: true }) unsubscribe(subscriber: unknown) { const subs = this._subscriptions.get(subscriber); if (subs == null) return; @@ -168,39 +276,17 @@ export class LineTracker implements Disposable { this._disposable = undefined; } - private _suspended = false; - get suspended() { - return this._suspended; - } - - protected onResume?(): void; - - @debug() - resume(options?: { force?: boolean }) { - if (!options?.force && !this._suspended) return; - - this._suspended = false; - this.onResume?.(); - this.notifyLinesChanged('editor'); - } - - protected onSuspend?(): void; - - @debug() - suspend(options?: { force?: boolean }) { - if (!options?.force && this._suspended) return; - - this._suspended = true; - this.onSuspend?.(); - this.notifyLinesChanged('editor'); - } + private async fireLinesChanged(e: LinesChangeEvent) { + let updated = false; + if (!this.suspended && !e.pending && e.selections != null && e.editor != null) { + updated = await this.updateState(e.selections, e.editor); + } - protected fireLinesChanged(e: LinesChangeEvent) { - this._onDidChangeActiveLines.fire(e); + this._onDidChangeActiveLines.fire(updated ? e : { ...e, selections: undefined, suspended: this.suspended }); } private _fireLinesChangedDebounced: Deferrable<(e: LinesChangeEvent) => void> | undefined; - protected notifyLinesChanged(reason: 'editor' | 'selection') { + private notifyLinesChanged(reason: 'editor' | 'selection') { if (reason === 'editor') { this.resetState(); } @@ -212,7 +298,7 @@ export class LineTracker implements Disposable { this._fireLinesChangedDebounced?.cancel(); - this.fireLinesChanged(e); + void this.fireLinesChanged(e); }); return; @@ -227,26 +313,107 @@ export class LineTracker implements Disposable { return; } - this.fireLinesChanged(e); + void this.fireLinesChanged(e); }, 250); } // If we have no pending moves, then fire an immediate pending event, and defer the real event - if (!this._fireLinesChangedDebounced.pending?.()) { - this.fireLinesChanged({ ...e, pending: true }); + if (!this._fireLinesChangedDebounced.pending()) { + void this.fireLinesChanged({ ...e, pending: true }); } this._fireLinesChangedDebounced(e); } + + @debug({ + args: { 0: selections => selections?.map(s => s.active).join(','), 1: e => e.document.uri.toString(true) }, + exit: true, + }) + private async updateState(selections: LineSelection[], editor: TextEditor): Promise { + const scope = getLogScope(); + + if (!this.includes(selections)) { + setLogScopeExit(scope, ` \u2022 lines no longer match`); + + return false; + } + + const document = await this.documentTracker.getOrAdd(editor.document); + if (!document.isBlameable) { + setLogScopeExit(scope, ` \u2022 document is not blameable`); + + return false; + } + + if (selections.length === 1) { + const blameLine = await this.container.git.getBlameForLine( + document.uri, + selections[0].active, + editor?.document, + ); + if (blameLine == null) { + setLogScopeExit(scope, ` \u2022 blame failed`); + + return false; + } + + if (blameLine.commit != null && blameLine.commit.file == null) { + debugger; + } + + this.setState(blameLine.line.line - 1, { commit: blameLine.commit }); + } else { + const blame = await this.container.git.getBlame(document.uri, editor.document); + if (blame == null) { + setLogScopeExit(scope, ` \u2022 blame failed`); + + return false; + } + + for (const selection of selections) { + const commitLine = blame.lines[selection.active]; + const commit = blame.commits.get(commitLine.sha); + if (commit != null && commit.file == null) { + debugger; + } + + if (commit == null) { + debugger; + this.resetState(selection.active); + } else { + this.setState(selection.active, { commit: commit }); + } + } + } + + // Check again because of the awaits above + + if (!this.includes(selections)) { + setLogScopeExit(scope, ` \u2022 lines no longer match`); + + return false; + } + + if (!document.isBlameable) { + setLogScopeExit(scope, ` \u2022 document is not blameable`); + + return false; + } + + if (editor.document.isDirty) { + document.setForceDirtyStateChangeOnNextDocumentChange(); + } + + return true; + } } function isIncluded(selections: LineSelection[] | undefined, within: LineSelection[] | undefined): boolean { if (selections == null && within == null) return true; if (selections == null || within == null || selections.length !== within.length) return false; - let match; return selections.every((s, i) => { - match = within[i]; + const match = within[i]; return s.active === match.active && s.anchor === match.anchor; }); } diff --git a/src/trackers/trackedDocument.ts b/src/trackers/trackedDocument.ts index 3a990528ef8f4..f7b3b73b57cf4 100644 --- a/src/trackers/trackedDocument.ts +++ b/src/trackers/trackedDocument.ts @@ -1,38 +1,111 @@ -import type { Disposable, Event, TextDocument, TextEditor } from 'vscode'; +import type { Disposable, Event, TextDocument } from 'vscode'; import { EventEmitter } from 'vscode'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { deletedOrMissing } from '../git/models/constants'; +import type { GitBlame } from '../git/models/blame'; +import type { GitDiffFile } from '../git/models/diff'; +import type { GitLog } from '../git/models/log'; +import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; import { Logger } from '../system/logger'; import { getEditorIfActive, isActiveDocument } from '../system/utils'; +import type { DocumentBlameStateChangeEvent } from './documentTracker'; -export interface DocumentBlameStateChangeEvent { - readonly editor: TextEditor; - readonly document: TrackedDocument; - readonly blameable: boolean; +interface CachedItem { + item: Promise; + errorMessage?: string; } -export class TrackedDocument implements Disposable { - static async create( +export type CachedBlame = CachedItem; +export type CachedDiff = CachedItem; +export type CachedLog = CachedItem; + +export class GitDocumentState { + private readonly blameCache = new Map(); + private readonly diffCache = new Map(); + private readonly logCache = new Map(); + + clearBlame(key?: string): void { + if (key == null) { + this.blameCache.clear(); + return; + } + this.blameCache.delete(key); + } + + clearDiff(key?: string): void { + if (key == null) { + this.diffCache.clear(); + return; + } + this.diffCache.delete(key); + } + + clearLog(key?: string): void { + if (key == null) { + this.logCache.clear(); + return; + } + this.logCache.delete(key); + } + + getBlame(key: string): CachedBlame | undefined { + return this.blameCache.get(key); + } + + getDiff(key: string): CachedDiff | undefined { + return this.diffCache.get(key); + } + + getLog(key: string): CachedLog | undefined { + return this.logCache.get(key); + } + + setBlame(key: string, value: CachedBlame | undefined) { + if (value == null) { + this.blameCache.delete(key); + return; + } + this.blameCache.set(key, value); + } + + setDiff(key: string, value: CachedDiff | undefined) { + if (value == null) { + this.diffCache.delete(key); + return; + } + this.diffCache.set(key, value); + } + + setLog(key: string, value: CachedLog | undefined) { + if (value == null) { + this.logCache.delete(key); + return; + } + this.logCache.set(key, value); + } +} + +export class TrackedGitDocument implements Disposable { + static async create( document: TextDocument, dirty: boolean, - eventDelegates: { onDidBlameStateChange(e: DocumentBlameStateChangeEvent): void }, + eventDelegates: { onDidBlameStateChange(e: DocumentBlameStateChangeEvent): void }, container: Container, ) { - const doc = new TrackedDocument(document, dirty, eventDelegates, container); + const doc = new TrackedGitDocument(document, dirty, eventDelegates, container); await doc.initialize(); return doc; } - private _onDidBlameStateChange = new EventEmitter>(); - get onDidBlameStateChange(): Event> { + private _onDidBlameStateChange = new EventEmitter(); + get onDidBlameStateChange(): Event { return this._onDidBlameStateChange.event; } - state: T | undefined; + state: GitDocumentState | undefined; private _disposable: Disposable | undefined; private _disposed: boolean = false; @@ -41,7 +114,7 @@ export class TrackedDocument implements Disposable { private constructor( readonly document: TextDocument, public dirty: boolean, - private _eventDelegates: { onDidBlameStateChange(e: DocumentBlameStateChangeEvent): void }, + private _eventDelegates: { onDidBlameStateChange(e: DocumentBlameStateChangeEvent): void }, private readonly container: Container, ) {} @@ -58,7 +131,7 @@ export class TrackedDocument implements Disposable { this._uri = await GitUri.fromUri(uri); if (!this._disposed) { - await this.update(); + await this.update({ forceDirtyIdle: true }); } this.initializing = false; @@ -75,19 +148,26 @@ export class TrackedDocument implements Disposable { } get isBlameable() { - return this._blameFailed ? false : this._isTracked; + return this._blameFailed != null ? false : this._isTracked; + } + + get canDirtyIdle(): boolean { + if (!this.document.isDirty) return false; + + const maxLines = configuration.get('advanced.blame.sizeThresholdAfterEdit'); + return !(maxLines > 0 && this.document.lineCount > maxLines); } private _isDirtyIdle: boolean = false; get isDirtyIdle() { return this._isDirtyIdle; } - set isDirtyIdle(value: boolean) { - this._isDirtyIdle = value; - } - get isRevision() { - return this._uri != null ? Boolean(this._uri.sha) && this._uri.sha !== deletedOrMissing : false; + setIsDirtyIdle(): boolean { + if (!this.canDirtyIdle) return false; + + this._isDirtyIdle = true; + return true; } private _isTracked: boolean = false; @@ -105,7 +185,7 @@ export class TrackedDocument implements Disposable { async activate(): Promise { if (this._requiresUpdate) { - await this.update(); + await this.update({ forceDirtyIdle: true }); } void setContext('gitlens:activeFileStatus', this.getStatus()); } @@ -115,12 +195,12 @@ export class TrackedDocument implements Disposable { } private _updateDebounced: - | Deferrable<({ forceBlameChange }?: { forceBlameChange?: boolean | undefined }) => Promise> + | Deferrable<(options?: { forceBlameChange?: boolean | undefined }) => Promise> | undefined; - reset(reason: 'config' | 'document' | 'repository') { + refresh(reason: 'doc-changed' | 'repo-changed') { this._requiresUpdate = true; - this._blameFailed = false; + this._blameFailed = undefined; this._isDirtyIdle = false; if (this.state != null) { @@ -128,7 +208,7 @@ export class TrackedDocument implements Disposable { Logger.log(`Reset state for '${this.document.uri.toString(true)}', reason=${reason}`); } - if (reason === 'repository' && isActiveDocument(this.document)) { + if (reason === 'repo-changed' && isActiveDocument(this.document)) { if (this._updateDebounced == null) { this._updateDebounced = debounce(this.update.bind(this), 250); } @@ -137,11 +217,11 @@ export class TrackedDocument implements Disposable { } } - private _blameFailed: boolean = false; - setBlameFailure() { + private _blameFailed: Error | undefined; + setBlameFailure(ex: Error) { const wasBlameable = this.isBlameable; - this._blameFailed = true; + this._blameFailed = ex; if (wasBlameable && isActiveDocument(this.document)) { void this.update({ forceBlameChange: true }); @@ -157,7 +237,7 @@ export class TrackedDocument implements Disposable { } private _requiresUpdate: boolean = true; - async update({ forceBlameChange }: { forceBlameChange?: boolean } = {}) { + async update(options?: { forceBlameChange?: boolean; forceDirtyIdle?: boolean }): Promise { this._requiresUpdate = false; if (this._disposed || this._uri == null) { @@ -167,11 +247,15 @@ export class TrackedDocument implements Disposable { return; } - this._isDirtyIdle = false; + if (this.document.isDirty && options?.forceDirtyIdle && this.canDirtyIdle) { + this._isDirtyIdle = true; + } else { + this._isDirtyIdle = false; + } // Caches these before the awaits const active = getEditorIfActive(this.document); - const wasBlameable = forceBlameChange ? undefined : this.isBlameable; + const wasBlameable = options?.forceBlameChange ? undefined : this.isBlameable; const repo = this.container.git.getRepository(this._uri); if (repo == null) { @@ -190,7 +274,7 @@ export class TrackedDocument implements Disposable { void setContext('gitlens:activeFileStatus', this.getStatus()); if (!this.initializing && wasBlameable !== blameable) { - const e: DocumentBlameStateChangeEvent = { editor: active, document: this, blameable: blameable }; + const e: DocumentBlameStateChangeEvent = { editor: active, document: this, blameable: blameable }; this._onDidBlameStateChange.fire(e); this._eventDelegates.onDidBlameStateChange(e); } @@ -205,9 +289,6 @@ export class TrackedDocument implements Disposable { if (this.isBlameable) { status += 'blameable|'; } - if (this.isRevision) { - status += 'revision|'; - } if (this.hasRemotes) { status += 'remotes|'; } diff --git a/src/uris/deepLinks/deepLink.ts b/src/uris/deepLinks/deepLink.ts index eac1e8ee3ae20..90131c444a3e7 100644 --- a/src/uris/deepLinks/deepLink.ts +++ b/src/uris/deepLinks/deepLink.ts @@ -2,6 +2,7 @@ import type { Uri } from 'vscode'; import type { GitReference } from '../../git/models/reference'; import type { GitRemote } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; +import type { OpenWorkspaceLocation } from '../../system/utils'; export type UriTypes = 'link'; @@ -9,10 +10,23 @@ export enum DeepLinkType { Branch = 'b', Commit = 'c', Comparison = 'compare', + Draft = 'drafts', + File = 'f', Repository = 'r', Tag = 't', + Workspace = 'workspace', } +export enum DeepLinkActionType { + Switch = 'switch', + SwitchToPullRequest = 'switch-to-pr', + SwitchToPullRequestWorktree = 'switch-to-pr-worktree', + SwitchToAndSuggestPullRequest = 'switch-to-and-suggest-pr', +} + +export const AccountDeepLinkTypes: DeepLinkType[] = [DeepLinkType.Draft, DeepLinkType.Workspace]; +export const PaidDeepLinkTypes: DeepLinkType[] = []; + export function deepLinkTypeToString(type: DeepLinkType): string { switch (type) { case DeepLinkType.Branch: @@ -21,10 +35,16 @@ export function deepLinkTypeToString(type: DeepLinkType): string { return 'Commit'; case DeepLinkType.Comparison: return 'Comparison'; + case DeepLinkType.Draft: + return 'Cloud Patch'; + case DeepLinkType.File: + return 'File'; case DeepLinkType.Repository: return 'Repository'; case DeepLinkType.Tag: return 'Tag'; + case DeepLinkType.Workspace: + return 'Workspace'; default: debugger; return 'Unknown'; @@ -46,107 +66,175 @@ export function refTypeToDeepLinkType(refType: GitReference['refType']): DeepLin export interface DeepLink { type: DeepLinkType; - repoId: string; + mainId?: string; remoteUrl?: string; repoPath?: string; + filePath?: string; targetId?: string; secondaryTargetId?: string; secondaryRemoteUrl?: string; + action?: string; + params?: URLSearchParams; } export function parseDeepLinkUri(uri: Uri): DeepLink | undefined { // The link target id is everything after the link target. // For example, if the uri is /link/r/{repoId}/b/{branchName}?url={remoteUrl}, // the link target id is {branchName} - const [, type, prefix, repoId, target, ...rest] = uri.path.split('/'); - if (type !== 'link' || prefix !== DeepLinkType.Repository) return undefined; + const [, type, prefix, mainId, target, ...rest] = uri.path.split('/'); + if (type !== 'link') return undefined; const urlParams = new URLSearchParams(uri.query); - let remoteUrl = urlParams.get('url') ?? undefined; - if (remoteUrl != null) { - remoteUrl = decodeURIComponent(remoteUrl); - } - let repoPath = urlParams.get('path') ?? undefined; - if (repoPath != null) { - repoPath = decodeURIComponent(repoPath); - } - if (!remoteUrl && !repoPath) return undefined; - - if (target == null) { - return { - type: DeepLinkType.Repository, - repoId: repoId, - remoteUrl: remoteUrl, - repoPath: repoPath, - }; - } + switch (prefix) { + case DeepLinkType.Repository: { + let remoteUrl = urlParams.get('url') ?? undefined; + if (remoteUrl != null) { + remoteUrl = decodeURIComponent(remoteUrl); + } + let repoPath = urlParams.get('path') ?? undefined; + if (repoPath != null) { + repoPath = decodeURIComponent(repoPath); + } + if (!remoteUrl && !repoPath) return undefined; + + const action = urlParams.get('action') ?? undefined; + + if (target == null) { + return { + type: DeepLinkType.Repository, + mainId: mainId, + remoteUrl: remoteUrl, + repoPath: repoPath, + }; + } - if (rest == null || rest.length === 0) return undefined; - - let targetId: string; - let secondaryTargetId: string | undefined; - let secondaryRemoteUrl: string | undefined; - const joined = rest.join('/'); - - if (target === DeepLinkType.Comparison) { - const split = joined.split(/(\.\.\.|\.\.)/); - if (split.length !== 3) return undefined; - targetId = split[0]; - secondaryTargetId = split[2]; - secondaryRemoteUrl = urlParams.get('prRepoUrl') ?? undefined; - if (secondaryRemoteUrl != null) { - secondaryRemoteUrl = decodeURIComponent(secondaryRemoteUrl); + if (rest == null || rest.length === 0) return undefined; + + let targetId: string | undefined; + let secondaryTargetId: string | undefined; + let secondaryRemoteUrl: string | undefined; + let filePath: string | undefined; + const joined = rest.join('/'); + + if (target === DeepLinkType.Comparison) { + const split = joined.split(/(\.\.\.|\.\.)/); + if (split.length !== 3) return undefined; + targetId = split[0]; + secondaryTargetId = split[2]; + secondaryRemoteUrl = urlParams.get('prRepoUrl') ?? undefined; + if (secondaryRemoteUrl != null) { + secondaryRemoteUrl = decodeURIComponent(secondaryRemoteUrl); + } + } else if (target === DeepLinkType.File) { + filePath = joined; + let ref = urlParams.get('ref') ?? undefined; + if (ref != null) { + ref = decodeURIComponent(ref); + } + targetId = ref; + let lines = urlParams.get('lines') ?? undefined; + if (lines != null) { + lines = decodeURIComponent(lines); + } + secondaryTargetId = lines; + } else { + targetId = joined; + } + + return { + type: target as DeepLinkType, + mainId: mainId, + remoteUrl: remoteUrl, + repoPath: repoPath, + filePath: filePath, + targetId: targetId, + secondaryTargetId: secondaryTargetId, + secondaryRemoteUrl: secondaryRemoteUrl, + action: action, + params: urlParams, + }; } - } else { - targetId = joined; - } + case DeepLinkType.Draft: { + if (mainId == null || mainId.match(/^v\d+$/)) return undefined; + + let patchId = urlParams.get('patch') ?? undefined; + if (patchId != null) { + patchId = decodeURIComponent(patchId); + } + + return { + type: DeepLinkType.Draft, + targetId: mainId, + secondaryTargetId: patchId, + params: urlParams, + }; + } + + case DeepLinkType.Workspace: + return { + type: DeepLinkType.Workspace, + mainId: mainId, + params: urlParams, + }; - return { - type: target as DeepLinkType, - repoId: repoId, - remoteUrl: remoteUrl, - repoPath: repoPath, - targetId: targetId, - secondaryTargetId: secondaryTargetId, - secondaryRemoteUrl: secondaryRemoteUrl, - }; + default: + return undefined; + } } export const enum DeepLinkServiceState { Idle, + AccountCheck, + PlanCheck, + TypeMatch, RepoMatch, CloneOrAddRepo, - OpeningRepo, AddedRepoMatch, RemoteMatch, AddRemote, TargetMatch, Fetch, FetchedTargetMatch, + MaybeOpenRepo, + RepoOpening, + EnsureRemoteMatch, + GoToTarget, OpenGraph, OpenComparison, + OpenDraft, + OpenWorkspace, + OpenFile, + SwitchToRef, } export const enum DeepLinkServiceAction { + AccountCheckPassed, DeepLinkEventFired, DeepLinkCancelled, DeepLinkResolved, DeepLinkStored, DeepLinkErrored, - OpenRepo, + LinkIsRepoType, + LinkIsDraftType, + LinkIsWorkspaceType, + PlanCheckPassed, RepoMatched, RepoMatchedInLocalMapping, RepoMatchFailed, RepoAdded, - RepoOpened, RemoteMatched, RemoteMatchFailed, RemoteMatchUnneeded, RemoteAdded, TargetMatched, - TargetsMatched, TargetMatchFailed, TargetFetched, + RepoOpened, + RepoOpening, + OpenGraph, + OpenComparison, + OpenFile, + OpenSwitch, } export type DeepLinkRepoOpenType = 'clone' | 'folder' | 'workspace' | 'current'; @@ -154,23 +242,46 @@ export type DeepLinkRepoOpenType = 'clone' | 'folder' | 'workspace' | 'current'; export interface DeepLinkServiceContext { state: DeepLinkServiceState; url?: string | undefined; - repoId?: string | undefined; + mainId?: string | undefined; repo?: Repository | undefined; remoteUrl?: string | undefined; remote?: GitRemote | undefined; secondaryRemote?: GitRemote | undefined; repoPath?: string | undefined; + filePath?: string | undefined; targetId?: string | undefined; secondaryTargetId?: string | undefined; secondaryRemoteUrl?: string | undefined; targetType?: DeepLinkType | undefined; targetSha?: string | undefined; secondaryTargetSha?: string | undefined; + action?: string | undefined; + repoOpenLocation?: OpenWorkspaceLocation | undefined; + repoOpenUri?: Uri | undefined; + params?: URLSearchParams | undefined; } export const deepLinkStateTransitionTable: Record> = { [DeepLinkServiceState.Idle]: { - [DeepLinkServiceAction.DeepLinkEventFired]: DeepLinkServiceState.RepoMatch, + [DeepLinkServiceAction.DeepLinkEventFired]: DeepLinkServiceState.AccountCheck, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.AccountCheck]: { + [DeepLinkServiceAction.AccountCheckPassed]: DeepLinkServiceState.PlanCheck, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.PlanCheck]: { + [DeepLinkServiceAction.PlanCheckPassed]: DeepLinkServiceState.TypeMatch, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.TypeMatch]: { + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.LinkIsRepoType]: DeepLinkServiceState.RepoMatch, + [DeepLinkServiceAction.LinkIsDraftType]: DeepLinkServiceState.OpenDraft, + [DeepLinkServiceAction.LinkIsWorkspaceType]: DeepLinkServiceState.OpenWorkspace, }, [DeepLinkServiceState.RepoMatch]: { [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, @@ -179,13 +290,6 @@ export const deepLinkStateTransitionTable: Record = { [DeepLinkServiceState.Idle]: { message: 'Done.', increment: 100 }, + [DeepLinkServiceState.AccountCheck]: { message: 'Checking account...', increment: 1 }, + [DeepLinkServiceState.PlanCheck]: { message: 'Checking plan...', increment: 2 }, + [DeepLinkServiceState.TypeMatch]: { message: 'Matching link type...', increment: 5 }, [DeepLinkServiceState.RepoMatch]: { message: 'Finding a matching repository...', increment: 10 }, [DeepLinkServiceState.CloneOrAddRepo]: { message: 'Adding repository...', increment: 20 }, - [DeepLinkServiceState.OpeningRepo]: { message: 'Opening repository...', increment: 30 }, - [DeepLinkServiceState.AddedRepoMatch]: { message: 'Finding a matching repository...', increment: 40 }, - [DeepLinkServiceState.RemoteMatch]: { message: 'Finding a matching remote...', increment: 50 }, - [DeepLinkServiceState.AddRemote]: { message: 'Adding remote...', increment: 60 }, - [DeepLinkServiceState.TargetMatch]: { message: 'finding a matching target...', increment: 70 }, - [DeepLinkServiceState.Fetch]: { message: 'Fetching...', increment: 80 }, - [DeepLinkServiceState.FetchedTargetMatch]: { message: 'Finding a matching target...', increment: 90 }, - [DeepLinkServiceState.OpenGraph]: { message: 'Opening graph...', increment: 95 }, - [DeepLinkServiceState.OpenComparison]: { message: 'Opening comparison...', increment: 95 }, + [DeepLinkServiceState.AddedRepoMatch]: { message: 'Finding a matching repository...', increment: 25 }, + [DeepLinkServiceState.RemoteMatch]: { message: 'Finding a matching remote...', increment: 30 }, + [DeepLinkServiceState.AddRemote]: { message: 'Adding remote...', increment: 40 }, + [DeepLinkServiceState.TargetMatch]: { message: 'finding a matching target...', increment: 50 }, + [DeepLinkServiceState.Fetch]: { message: 'Fetching...', increment: 60 }, + [DeepLinkServiceState.FetchedTargetMatch]: { message: 'Finding a matching target...', increment: 65 }, + [DeepLinkServiceState.MaybeOpenRepo]: { message: 'Opening repository...', increment: 70 }, + [DeepLinkServiceState.RepoOpening]: { message: 'Opening repository...', increment: 75 }, + [DeepLinkServiceState.GoToTarget]: { message: 'Opening target...', increment: 80 }, + [DeepLinkServiceState.OpenGraph]: { message: 'Opening graph...', increment: 90 }, + [DeepLinkServiceState.OpenComparison]: { message: 'Opening comparison...', increment: 90 }, + [DeepLinkServiceState.OpenDraft]: { message: 'Opening cloud patch...', increment: 90 }, + [DeepLinkServiceState.OpenWorkspace]: { message: 'Opening workspace...', increment: 90 }, + [DeepLinkServiceState.OpenFile]: { message: 'Opening file...', increment: 90 }, + [DeepLinkServiceState.SwitchToRef]: { message: 'Switching to ref...', increment: 90 }, }; diff --git a/src/uris/deepLinks/deepLinkService.ts b/src/uris/deepLinks/deepLinkService.ts index 22b4481780d45..9806308263045 100644 --- a/src/uris/deepLinks/deepLinkService.ts +++ b/src/uris/deepLinks/deepLinkService.ts @@ -1,81 +1,67 @@ -import { Disposable, env, EventEmitter, ProgressLocation, Uri, window, workspace } from 'vscode'; +import type { QuickPickItem } from 'vscode'; +import { Disposable, env, EventEmitter, ProgressLocation, Range, Uri, window, workspace } from 'vscode'; import type { StoredDeepLinkContext, StoredNamedRef } from '../../constants'; import { Commands } from '../../constants'; import type { Container } from '../../container'; +import { executeGitCommand } from '../../git/actions'; +import { openFileAtRevision } from '../../git/actions/commit'; +import type { GitBranch } from '../../git/models/branch'; import { getBranchNameWithoutRemote } from '../../git/models/branch'; +import type { GitCommit } from '../../git/models/commit'; import type { GitReference } from '../../git/models/reference'; import { createReference, isSha } from '../../git/models/reference'; +import type { GitTag } from '../../git/models/tag'; import { parseGitRemoteUrl } from '../../git/parsers/remoteParser'; +import type { RepositoryIdentity } from '../../gk/models/repositoryIdentities'; +import { missingRepositoryId } from '../../gk/models/repositoryIdentities'; +import { ensureAccount, ensurePaidPlan } from '../../plus/utils'; import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol'; +import { createQuickPickSeparator } from '../../quickpicks/items/common'; import { executeCommand } from '../../system/command'; import { configuration } from '../../system/configuration'; import { once } from '../../system/event'; import { Logger } from '../../system/logger'; import { normalizePath } from '../../system/path'; +import { fromBase64 } from '../../system/string'; import type { OpenWorkspaceLocation } from '../../system/utils'; -import { openWorkspace } from '../../system/utils'; +import { findOrOpenEditor, openWorkspace } from '../../system/utils'; +import { showInspectView } from '../../webviews/commitDetails/actions'; +import type { ShowWipArgs } from '../../webviews/commitDetails/protocol'; import type { DeepLink, DeepLinkProgress, DeepLinkRepoOpenType, DeepLinkServiceContext, UriTypes } from './deepLink'; import { + AccountDeepLinkTypes, + DeepLinkActionType, DeepLinkServiceAction, DeepLinkServiceState, deepLinkStateToProgress, deepLinkStateTransitionTable, DeepLinkType, + deepLinkTypeToString, + PaidDeepLinkTypes, parseDeepLinkUri, } from './deepLink'; +type OpenQuickPickItem = { + label: string; + action?: DeepLinkRepoOpenType; +}; + +type OpenLocationQuickPickItem = { + label: string; + action?: OpenWorkspaceLocation; +}; + export class DeepLinkService implements Disposable { private readonly _disposables: Disposable[] = []; private _context: DeepLinkServiceContext; - private readonly _onDeepLinkProgressUpdated: EventEmitter; + private readonly _onDeepLinkProgressUpdated = new EventEmitter(); constructor(private readonly container: Container) { this._context = { state: DeepLinkServiceState.Idle, }; - this._onDeepLinkProgressUpdated = new EventEmitter(); - - this._disposables.push( - container.uri.onDidReceiveUri(async (uri: Uri) => { - const link = parseDeepLinkUri(uri); - if (link == null) return; - - if (this._context.state === DeepLinkServiceState.Idle) { - if (this.container.git.isDiscoveringRepositories) { - await this.container.git.isDiscoveringRepositories; - } - - if (!link.type || (!link.repoId && !link.remoteUrl && !link.repoPath)) { - void window.showErrorMessage('Unable to resolve link'); - Logger.warn(`Unable to resolve link - missing basic properties: ${uri.toString()}`); - return; - } - - if (!Object.values(DeepLinkType).includes(link.type)) { - void window.showErrorMessage('Unable to resolve link'); - Logger.warn(`Unable to resolve link - unknown link type: ${uri.toString()}`); - return; - } - - if (link.type !== DeepLinkType.Repository && link.targetId == null) { - void window.showErrorMessage('Unable to resolve link'); - Logger.warn(`Unable to resolve link - no target id provided: ${uri.toString()}`); - return; - } - - if (link.type === DeepLinkType.Comparison && link.secondaryTargetId == null) { - void window.showErrorMessage('Unable to resolve link'); - Logger.warn(`Unable to resolve link - no secondary target id provided: ${uri.toString()}`); - return; - } - - this.setContextFromDeepLink(link, uri.toString()); - - await this.processDeepLink(); - } - }), - ); + this._disposables.push(container.uri.onDidReceiveUri(async (uri: Uri) => this.processDeepLinkUri(uri))); const pendingDeepLink = this.container.storage.get('deepLinks:pending'); if (pendingDeepLink != null) { @@ -92,44 +78,94 @@ export class DeepLinkService implements Disposable { this._context = { state: DeepLinkServiceState.Idle, url: undefined, - repoId: undefined, + mainId: undefined, repo: undefined, remoteUrl: undefined, remote: undefined, secondaryRemote: undefined, repoPath: undefined, + filePath: undefined, targetId: undefined, secondaryTargetId: undefined, secondaryRemoteUrl: undefined, targetType: undefined, targetSha: undefined, + action: undefined, + repoOpenLocation: undefined, + repoOpenUri: undefined, + params: undefined, }; } private setContextFromDeepLink(link: DeepLink, url: string) { this._context = { ...this._context, - repoId: link.repoId, + mainId: link.mainId, targetType: link.type, url: url, remoteUrl: link.remoteUrl, repoPath: link.repoPath, + filePath: link.filePath, targetId: link.targetId, secondaryTargetId: link.secondaryTargetId, secondaryRemoteUrl: link.secondaryRemoteUrl, + action: link.action, + params: link.params, }; } + async processDeepLinkUri(uri: Uri, useProgress: boolean = true) { + const link = parseDeepLinkUri(uri); + if (link == null) return; + + if (this._context.state === DeepLinkServiceState.Idle) { + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; + } + + if (!link.type || (!link.mainId && !link.remoteUrl && !link.repoPath && !link.targetId)) { + void window.showErrorMessage('Unable to resolve link'); + Logger.warn(`Unable to resolve link - missing basic properties: ${uri.toString()}`); + return; + } + + if (!Object.values(DeepLinkType).includes(link.type)) { + void window.showErrorMessage('Unable to resolve link'); + Logger.warn(`Unable to resolve link - unknown link type: ${uri.toString()}`); + return; + } + + if (link.type !== DeepLinkType.Repository && link.targetId == null && link.mainId == null) { + void window.showErrorMessage('Unable to resolve link'); + Logger.warn(`Unable to resolve link - no main/target id provided: ${uri.toString()}`); + return; + } + + if (link.type === DeepLinkType.Comparison && link.secondaryTargetId == null) { + void window.showErrorMessage('Unable to resolve link'); + Logger.warn(`Unable to resolve link - no secondary target id provided: ${uri.toString()}`); + return; + } + + this.setContextFromDeepLink(link, uri.toString()); + + await this.processDeepLink(undefined, useProgress); + } + } + private async processPendingDeepLink(pendingDeepLink: StoredDeepLinkContext) { if (pendingDeepLink.url == null) return; const link = parseDeepLinkUri(Uri.parse(pendingDeepLink.url)); if (link == null) return; - this._context = { state: DeepLinkServiceState.CloneOrAddRepo }; + this._context = { state: DeepLinkServiceState.MaybeOpenRepo }; this.setContextFromDeepLink(link, pendingDeepLink.url); + this._context.targetSha = pendingDeepLink.targetSha; + this._context.secondaryTargetSha = pendingDeepLink.secondaryTargetSha; + this._context.repoPath = pendingDeepLink.repoPath; - let action = DeepLinkServiceAction.OpenRepo; + let action = DeepLinkServiceAction.RepoOpening; if (this.container.git.isDiscoveringRepositories) { await this.container.git.isDiscoveringRepositories; @@ -147,11 +183,11 @@ export class DeepLinkService implements Disposable { } queueMicrotask(() => { - void this.processDeepLink(action); + void this.processDeepLink(action, pendingDeepLink.useProgress); }); } - private async getShaForBranch(targetId: string): Promise { + private async getBranch(targetId: string): Promise { const { repo, remote, secondaryRemote } = this._context; if (!repo) return undefined; @@ -165,8 +201,8 @@ export class DeepLinkService implements Disposable { } let branch = await repo.getBranch(branchName); - if (branch?.sha != null) { - return branch.sha; + if (branch != null) { + return branch; } // If that fails, try matching to any existing remote using its path. @@ -180,8 +216,8 @@ export class DeepLinkService implements Disposable { if (remote.provider?.owner === owner) { branchName = `${remote.name}/${branchBaseName}`; branch = await repo.getBranch(branchName); - if (branch?.sha != null) { - return branch.sha; + if (branch != null) { + return branch; } } } @@ -190,25 +226,32 @@ export class DeepLinkService implements Disposable { } // If the above don't work, it may still exist locally. - branch = await repo.getBranch(targetId); - if (branch?.sha != null) { - return branch.sha; - } - - return undefined; + return repo.getBranch(targetId); } - private async getShaForTag(targetId: string): Promise { + private async getCommit(targetId: string): Promise { const { repo } = this._context; if (!repo) return undefined; - const tag = await repo.getTag(targetId); - if (tag?.sha != null) { - return tag.sha; + if (await this.container.git.validateReference(repo.path, targetId)) { + return repo.getCommit(targetId); } return undefined; } + private async getTag(targetId: string): Promise { + const { repo } = this._context; + return repo?.getTag(targetId); + } + + private async getShaForBranch(targetId: string): Promise { + return (await this.getBranch(targetId))?.sha; + } + + private async getShaForTag(targetId: string): Promise { + return (await this.getTag(targetId))?.sha; + } + private async getShaForCommit(targetId: string): Promise { const { repo } = this._context; if (!repo) return undefined; @@ -223,14 +266,14 @@ export class DeepLinkService implements Disposable { targetId: string, secondaryTargetId: string, ): Promise<[string, string] | undefined> { - const sha1 = await this.getComparisonRefSha(targetId); + const sha1 = await this.getRefSha(targetId); if (sha1 == null) return undefined; - const sha2 = await this.getComparisonRefSha(secondaryTargetId); + const sha2 = await this.getRefSha(secondaryTargetId); if (sha2 == null) return undefined; return [sha1, sha2]; } - private async getComparisonRefSha(ref: string) { + private async getRefSha(ref: string) { // try treating each id as a commit sha first, then a branch if that fails, then a tag if that fails. // Note: a blank target id will be treated as 'Working Tree' and will resolve to a blank Sha. @@ -250,6 +293,22 @@ export class DeepLinkService implements Disposable { return this.getShaForCommit(ref); } + private async getTargetRef(ref: string): Promise { + if (ref === '') return undefined; + if (isSha(ref)) return this.getCommit(ref); + + const normalized = ref.toLocaleLowerCase(); + if (!normalized.startsWith('refs/tags/') && !normalized.startsWith('tags/')) { + const branch = await this.getBranch(ref); + if (branch != null) return branch; + } + + const tag = await this.getTag(ref); + if (tag != null) return tag; + + return this.getCommit(ref); + } + private async getShasForTargets(): Promise { const { repo, targetType, targetId, secondaryTargetId } = this._context; if (repo == null || targetType === DeepLinkType.Repository || targetId == null) return undefined; @@ -265,6 +324,10 @@ export class DeepLinkService implements Disposable { return this.getShaForCommit(targetId); } + if (targetType === DeepLinkType.File) { + return this.getRefSha(targetId); + } + if (targetType === DeepLinkType.Comparison) { if (secondaryTargetId == null) return undefined; return this.getShasForComparison(targetId, secondaryTargetId); @@ -277,73 +340,90 @@ export class DeepLinkService implements Disposable { includeCurrent?: boolean; customMessage?: string; }): Promise { - const openOptions: { title: string; action?: DeepLinkRepoOpenType; isCloseAffordance?: boolean }[] = [ - { title: 'Open Folder', action: 'folder' }, - { title: 'Open Workspace', action: 'workspace' }, + const openOptions: OpenQuickPickItem[] = [ + { label: 'Choose a Local Folder...', action: 'folder' }, + { label: 'Choose a Workspace File...', action: 'workspace' }, ]; if (this._context.remoteUrl != null) { - openOptions.push({ title: 'Clone', action: 'clone' }); + openOptions.push({ label: 'Clone Repository...', action: 'clone' }); } if (options?.includeCurrent) { - openOptions.push({ title: 'Use Current Window', action: 'current' }); + openOptions.push(createQuickPickSeparator(), { label: 'Use Current Window', action: 'current' }); } - openOptions.push({ title: 'Cancel', isCloseAffordance: true }); - const openTypeResult = await window.showInformationMessage( - options?.customMessage ?? 'No matching repository found. Please choose an option.', - { modal: true }, - ...openOptions, - ); + openOptions.push(createQuickPickSeparator(), { label: 'Cancel' }); + const openTypeResult = await window.showQuickPick(openOptions, { + title: 'Locating Repository', + placeHolder: + options?.customMessage ?? 'Unable to locate a matching repository, please choose how to locate it', + }); return openTypeResult?.action; } private async showOpenLocationPrompt(openType: DeepLinkRepoOpenType): Promise { // Only add the "add to workspace" option if openType is 'folder' - const openOptions: { title: string; action?: OpenWorkspaceLocation; isCloseAffordance?: boolean }[] = [ - { title: 'Open', action: 'currentWindow' }, - { title: 'Open in New Window', action: 'newWindow' }, + const openOptions: OpenLocationQuickPickItem[] = [ + { label: 'Open in Current Window', action: 'currentWindow' }, + { label: 'Open in New Window', action: 'newWindow' }, ]; if (openType !== 'workspace') { - openOptions.push({ title: 'Add to Workspace', action: 'addToWorkspace' }); + openOptions.push({ label: 'Add Folder to Workspace', action: 'addToWorkspace' }); } - openOptions.push({ title: 'Cancel', isCloseAffordance: true }); - const openLocationResult = await window.showInformationMessage( - `Please choose an option to open the repository ${openType === 'clone' ? 'after cloning' : openType}.`, - { modal: true }, - ...openOptions, - ); + let suffix; + switch (openType) { + case 'clone': + suffix = ' \u00a0\u2022\u00a0 Clone'; + break; + case 'folder': + suffix = ' \u00a0\u2022\u00a0 Folder'; + break; + case 'workspace': + suffix = ' \u00a0\u2022\u00a0 Workspace from File'; + break; + case 'current': + suffix = ''; + break; + } + + openOptions.push(createQuickPickSeparator(), { label: 'Cancel' }); + const openLocationResult = await window.showQuickPick(openOptions, { + title: `Locating Repository${suffix}`, + placeHolder: `Please choose where to open the repository ${ + openType === 'clone' ? 'after cloning' : openType + }`, + }); return openLocationResult?.action; } private async showFetchPrompt(): Promise { - const fetchResult = await window.showInformationMessage( - "The link target(s) couldn't be found. Would you like to fetch from the remote?", - { modal: true }, - { title: 'Fetch', action: true }, - { title: 'Cancel', isCloseAffordance: true }, - ); + const fetch: QuickPickItem = { label: 'Fetch' }; + const cancel: QuickPickItem = { label: 'Cancel' }; + const result = await window.showQuickPick([fetch, createQuickPickSeparator(), cancel], { + title: 'Locating Link Target', + placeHolder: 'Unable to find the link target(s), would you like to fetch from the remote?', + }); - return fetchResult?.action || false; + return result === fetch; } private async showAddRemotePrompt(remoteUrl: string, existingRemoteNames: string[]): Promise { - let remoteName = undefined; - const result = await window.showInformationMessage( - `Unable to find a remote for '${remoteUrl}'. Would you like to add a new remote?`, - { modal: true }, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result?.title !== 'Yes') return remoteName; + const add: QuickPickItem = { label: 'Add Remote' }; + const cancel: QuickPickItem = { label: 'Cancel' }; + const result = await window.showQuickPick([add, cancel], { + title: `Locating Remote`, + placeHolder: `Unable to find remote for '${remoteUrl}', would you like to add a new remote?`, + }); + if (result !== add) return undefined; - remoteName = await window.showInputBox({ + const remoteName = await window.showInputBox({ prompt: 'Enter a name for the remote', + value: getMaybeRemoteNameFromRemoteUrl(remoteUrl), validateInput: value => { if (!value) return 'A name is required'; if (existingRemoteNames.includes(value)) return 'A remote with that name already exists'; @@ -356,47 +436,52 @@ export class DeepLinkService implements Disposable { private async processDeepLink( initialAction: DeepLinkServiceAction = DeepLinkServiceAction.DeepLinkEventFired, + useProgress: boolean = true, ): Promise { let message = ''; let action = initialAction; + if (action === DeepLinkServiceAction.DeepLinkCancelled && this._context.state === DeepLinkServiceState.Idle) { + return; + } //Repo match let matchingLocalRepoPaths: string[] = []; + const { targetType } = this._context; + + if (useProgress) { + queueMicrotask( + () => + void window.withProgress( + { + cancellable: true, + location: ProgressLocation.Notification, + title: `Opening ${deepLinkTypeToString(targetType ?? DeepLinkType.Repository)} link...`, + }, + (progress, token) => { + progress.report({ increment: 0 }); + return new Promise(resolve => { + token.onCancellationRequested(() => { + queueMicrotask(() => this.processDeepLink(DeepLinkServiceAction.DeepLinkCancelled)); + resolve(); + }); - queueMicrotask( - () => - void window.withProgress( - { - cancellable: true, - location: ProgressLocation.Notification, - title: `Opening repository for link: ${this._context.url}}`, - }, - (progress, token) => { - progress.report({ increment: 0 }); - return new Promise(resolve => { - token.onCancellationRequested(() => { - queueMicrotask(() => this.processDeepLink(DeepLinkServiceAction.DeepLinkCancelled)); - resolve(); - }); - - this._disposables.push( this._onDeepLinkProgressUpdated.event(({ message, increment }) => { progress.report({ message: message, increment: increment }); if (increment === 100) { resolve(); } - }), - ); - }); - }, - ), - ); + }); + }); + }, + ), + ); + } while (true) { this._context.state = deepLinkStateTransitionTable[this._context.state][action]; const { state, - repoId, + mainId, repo, url, remoteUrl, @@ -404,11 +489,14 @@ export class DeepLinkService implements Disposable { remote, secondaryRemote, repoPath, + filePath, targetId, secondaryTargetId, targetSha, secondaryTargetSha, targetType, + repoOpenLocation, + repoOpenUri, } = this._context; this._onDeepLinkProgressUpdated.fire(deepLinkStateToProgress[state]); switch (state) { @@ -422,18 +510,118 @@ export class DeepLinkService implements Disposable { this.resetContext(); return; } + case DeepLinkServiceState.AccountCheck: { + if (targetType == null) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'No link type provided.'; + break; + } + if (!AccountDeepLinkTypes.includes(targetType)) { + action = DeepLinkServiceAction.AccountCheckPassed; + break; + } + + if ( + !(await ensureAccount( + this.container, + `Opening ${deepLinkTypeToString( + targetType, + )} links is a Preview feature and requires an account.`, + { + source: 'deeplink', + detail: { + action: 'open', + type: targetType, + friendlyType: deepLinkTypeToString(targetType), + }, + }, + )) + ) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Account required to open link'; + break; + } + + action = DeepLinkServiceAction.AccountCheckPassed; + break; + } + case DeepLinkServiceState.PlanCheck: { + if (targetType == null) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'No link type provided.'; + break; + } + if (!PaidDeepLinkTypes.includes(targetType)) { + action = DeepLinkServiceAction.PlanCheckPassed; + break; + } + + if ( + !(await ensurePaidPlan( + this.container, + `Opening ${deepLinkTypeToString(targetType)} links is a Pro feature.`, + { + source: 'deeplink', + detail: { + action: 'open', + type: targetType, + friendlyType: deepLinkTypeToString(targetType), + }, + }, + )) + ) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Paid plan required to open link'; + break; + } + + action = DeepLinkServiceAction.PlanCheckPassed; + break; + } + case DeepLinkServiceState.TypeMatch: { + switch (targetType) { + case DeepLinkType.Draft: + action = DeepLinkServiceAction.LinkIsDraftType; + break; + case DeepLinkType.Workspace: + action = DeepLinkServiceAction.LinkIsWorkspaceType; + break; + default: + action = DeepLinkServiceAction.LinkIsRepoType; + break; + } + + break; + } case DeepLinkServiceState.RepoMatch: case DeepLinkServiceState.AddedRepoMatch: { - if (!repoId && !remoteUrl && !repoPath) { + if (repo != null && repoOpenUri != null && repoOpenLocation != null) { + action = DeepLinkServiceAction.RepoMatched; + break; + } + + if (!mainId && !remoteUrl && !repoPath) { action = DeepLinkServiceAction.DeepLinkErrored; message = 'No repository id, remote url or path was provided.'; break; } + let repoIdentity: RepositoryIdentity | undefined; + + let mainIdToSearch = mainId; + let remoteUrlToSearch = remoteUrl; + + if (repoIdentity != null) { + this._context.remoteUrl = repoIdentity.remote?.url ?? undefined; + remoteUrlToSearch = repoIdentity.remote?.url; + this._context.mainId = repoIdentity.initialCommitSha ?? undefined; + mainIdToSearch = repoIdentity.initialCommitSha; + } + let remoteDomain = ''; let remotePath = ''; - if (remoteUrl != null) { - [, remoteDomain, remotePath] = parseGitRemoteUrl(remoteUrl); + if (remoteUrlToSearch != null) { + [, remoteDomain, remotePath] = parseGitRemoteUrl(remoteUrlToSearch); } // Try to match a repo using the remote URL first, since that saves us some steps. // As a fallback, try to match using the repo id. @@ -459,10 +647,10 @@ export class DeepLinkService implements Disposable { } } - if (repoId != null && repoId !== '-') { + if (mainIdToSearch != null && mainIdToSearch !== missingRepositoryId) { // Repo ID can be any valid SHA in the repo, though standard practice is to use the // first commit SHA. - if (await this.container.git.validateReference(repo.path, repoId)) { + if (await this.container.git.validateReference(repo.path, mainIdToSearch)) { this._context.repo = repo; action = DeepLinkServiceAction.RepoMatched; break; @@ -472,7 +660,7 @@ export class DeepLinkService implements Disposable { if (!this._context.repo && state === DeepLinkServiceState.RepoMatch) { matchingLocalRepoPaths = await this.container.repositoryPathMapping.getLocalRepoPaths({ - remoteUrl: remoteUrl, + remoteUrl: remoteUrlToSearch, }); if (matchingLocalRepoPaths.length > 0) { for (const repo of this.container.git.repositories) { @@ -506,7 +694,7 @@ export class DeepLinkService implements Disposable { break; } case DeepLinkServiceState.CloneOrAddRepo: { - if (!repoId && !remoteUrl && !repoPath) { + if (!mainId && !remoteUrl && !repoPath) { action = DeepLinkServiceAction.DeepLinkErrored; message = 'Missing repository id, remote url and path.'; break; @@ -514,7 +702,6 @@ export class DeepLinkService implements Disposable { let chosenRepoPath: string | undefined; let repoOpenType: DeepLinkRepoOpenType | undefined; - let repoOpenUri: Uri | undefined; if (matchingLocalRepoPaths.length > 0) { chosenRepoPath = await window.showQuickPick( @@ -526,7 +713,7 @@ export class DeepLinkService implements Disposable { action = DeepLinkServiceAction.DeepLinkCancelled; break; } else if (chosenRepoPath !== 'Choose a different location') { - repoOpenUri = Uri.file(chosenRepoPath); + this._context.repoOpenUri = Uri.file(chosenRepoPath); repoOpenType = 'folder'; } } @@ -550,9 +737,10 @@ export class DeepLinkService implements Disposable { action = DeepLinkServiceAction.DeepLinkCancelled; break; } + this._context.repoOpenLocation = repoOpenLocation; - if (repoOpenUri == null) { - repoOpenUri = ( + if (this._context.repoOpenUri == null) { + this._context.repoOpenUri = ( await window.showOpenDialog({ title: `Choose a ${repoOpenType === 'workspace' ? 'workspace' : 'folder'} to ${ repoOpenType === 'clone' ? 'clone the repository to' : 'open the repository' @@ -567,12 +755,12 @@ export class DeepLinkService implements Disposable { )?.[0]; } - if (!repoOpenUri) { + if (!this._context.repoOpenUri) { action = DeepLinkServiceAction.DeepLinkCancelled; break; } - if (repoOpenUri != null && remoteUrl != null && repoOpenType === 'clone') { + if (this._context.repoOpenUri != null && remoteUrl != null && repoOpenType === 'clone') { // clone the repository, then set repoOpenUri to the repo path let repoClonePath; try { @@ -582,7 +770,8 @@ export class DeepLinkService implements Disposable { title: `Cloning repository for link: ${this._context.url}}`, }, - async () => this.container.git.clone(remoteUrl, repoOpenUri?.fsPath ?? ''), + async () => + this.container.git.clone(remoteUrl, this._context.repoOpenUri?.fsPath ?? ''), ); } catch { action = DeepLinkServiceAction.DeepLinkErrored; @@ -596,20 +785,22 @@ export class DeepLinkService implements Disposable { break; } - repoOpenUri = Uri.file(repoClonePath); + this._context.repoOpenUri = Uri.file(repoClonePath); } - // Add the repo to the repo path mapping if it exists - if ( - repoOpenType !== 'current' && - repoOpenType !== 'workspace' && - !matchingLocalRepoPaths.includes(repoOpenUri.fsPath) - ) { - const chosenRepo = await this.container.git.getOrOpenRepository(repoOpenUri, { - closeOnOpen: true, - detectNested: false, - }); - if (chosenRepo != null) { + // Add the chosen repo as closed + const chosenRepo = await this.container.git.getOrOpenRepository(this._context.repoOpenUri, { + closeOnOpen: true, + detectNested: false, + }); + if (chosenRepo != null) { + this._context.repo = chosenRepo; + // Add the repo to the repo path mapping if it exists + if ( + repoOpenType !== 'current' && + repoOpenType !== 'workspace' && + !matchingLocalRepoPaths.includes(this._context.repoOpenUri.fsPath) + ) { await this.container.repositoryPathMapping.writeLocalRepoPath( { remoteUrl: remoteUrl }, chosenRepo.uri.fsPath, @@ -617,29 +808,11 @@ export class DeepLinkService implements Disposable { } } - if (repoOpenLocation === 'addToWorkspace' && (workspace.workspaceFolders?.length || 0) > 1) { - action = DeepLinkServiceAction.OpenRepo; - } else { - // Deep link will resolve in a different service instance - await this.container.storage.store('deepLinks:pending', { - url: this._context.url, - repoPath: repoOpenUri.toString(), - }); - action = DeepLinkServiceAction.DeepLinkStored; - } - - openWorkspace(repoOpenUri, { location: repoOpenLocation }); + action = DeepLinkServiceAction.RepoAdded; break; } - case DeepLinkServiceState.OpeningRepo: { - this._disposables.push( - once(this.container.git.onDidChangeRepositories)(() => { - queueMicrotask(() => this.processDeepLink(DeepLinkServiceAction.RepoAdded)); - }), - ); - return; - } - case DeepLinkServiceState.RemoteMatch: { + case DeepLinkServiceState.RemoteMatch: + case DeepLinkServiceState.EnsureRemoteMatch: { if (repoPath && repo && !remoteUrl && !secondaryRemoteUrl) { action = DeepLinkServiceAction.RemoteMatchUnneeded; break; @@ -669,7 +842,12 @@ export class DeepLinkService implements Disposable { (remoteUrl && !this._context.remote) || (secondaryRemoteUrl && !this._context.secondaryRemote) ) { - action = DeepLinkServiceAction.RemoteMatchFailed; + if (state === DeepLinkServiceState.RemoteMatch) { + action = DeepLinkServiceAction.RemoteMatchFailed; + } else { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'No matching remote found.'; + } } else { action = DeepLinkServiceAction.RemoteMatched; } @@ -707,6 +885,9 @@ export class DeepLinkService implements Disposable { message = 'Failed to add remote.'; break; } + } else { + action = DeepLinkServiceAction.DeepLinkCancelled; + break; } } @@ -733,6 +914,9 @@ export class DeepLinkService implements Disposable { message = 'Failed to add remote.'; break; } + } else { + action = DeepLinkServiceAction.DeepLinkCancelled; + break; } } @@ -742,9 +926,11 @@ export class DeepLinkService implements Disposable { if (!remoteName && !secondaryRemoteName) { action = DeepLinkServiceAction.DeepLinkCancelled; + break; } else if (!this._context.remote) { action = DeepLinkServiceAction.DeepLinkErrored; message = 'Failed to add remote.'; + break; } action = DeepLinkServiceAction.RemoteAdded; @@ -766,6 +952,9 @@ export class DeepLinkService implements Disposable { if (targetType === DeepLinkType.Comparison) { [this._context.targetSha, this._context.secondaryTargetSha] = (await this.getShasForTargets()) ?? []; + } else if (targetType === DeepLinkType.File && targetId == null) { + action = DeepLinkServiceAction.TargetMatched; + break; } else { this._context.targetSha = (await this.getShasForTargets()) as string | undefined; } @@ -783,10 +972,7 @@ export class DeepLinkService implements Disposable { break; } - action = - targetType === DeepLinkType.Comparison - ? DeepLinkServiceAction.TargetsMatched - : DeepLinkServiceAction.TargetMatched; + action = DeepLinkServiceAction.TargetMatched; break; } case DeepLinkServiceState.Fetch: { @@ -818,6 +1004,74 @@ export class DeepLinkService implements Disposable { action = DeepLinkServiceAction.TargetFetched; break; } + case DeepLinkServiceState.MaybeOpenRepo: { + if (repoOpenLocation != null && repoOpenUri != null) { + action = DeepLinkServiceAction.RepoOpening; + if (!(repoOpenLocation === 'addToWorkspace' && (workspace.workspaceFolders?.length || 0) > 1)) { + // Deep link will resolve in a different service instance + await this.container.storage.store('deepLinks:pending', { + url: this._context.url, + repoPath: repoOpenUri.toString(), + targetSha: this._context.targetSha, + secondaryTargetSha: this._context.secondaryTargetSha, + useProgress: useProgress, + }); + action = DeepLinkServiceAction.DeepLinkStored; + } + + openWorkspace(repoOpenUri, { location: repoOpenLocation }); + } else { + action = DeepLinkServiceAction.RepoOpened; + } + break; + } + case DeepLinkServiceState.RepoOpening: { + this._disposables.push( + once(this.container.git.onDidChangeRepositories)(() => { + queueMicrotask(() => this.processDeepLink(DeepLinkServiceAction.RepoOpened)); + }), + ); + return; + } + case DeepLinkServiceState.GoToTarget: { + // Need to re-fetch the remotes in case we opened in a new window + + if (targetType === DeepLinkType.Repository) { + if ( + this._context.action === DeepLinkActionType.Switch || + this._context.action === DeepLinkActionType.SwitchToPullRequest || + this._context.action === DeepLinkActionType.SwitchToPullRequestWorktree || + this._context.action === DeepLinkActionType.SwitchToAndSuggestPullRequest + ) { + action = DeepLinkServiceAction.OpenSwitch; + } else { + action = DeepLinkServiceAction.OpenGraph; + } + break; + } + + switch (targetType) { + case DeepLinkType.File: + action = DeepLinkServiceAction.OpenFile; + break; + case DeepLinkType.Comparison: + action = DeepLinkServiceAction.OpenComparison; + break; + default: + if ( + this._context.action === DeepLinkActionType.Switch || + this._context.action === DeepLinkActionType.SwitchToPullRequest || + this._context.action === DeepLinkActionType.SwitchToPullRequestWorktree || + this._context.action === DeepLinkActionType.SwitchToAndSuggestPullRequest + ) { + action = DeepLinkServiceAction.OpenSwitch; + } else { + action = DeepLinkServiceAction.OpenGraph; + } + break; + } + break; + } case DeepLinkServiceState.OpenGraph: { if (!repo || !targetType) { action = DeepLinkServiceAction.DeepLinkErrored; @@ -872,6 +1126,175 @@ export class DeepLinkService implements Disposable { action = DeepLinkServiceAction.DeepLinkResolved; break; } + case DeepLinkServiceState.OpenDraft: { + if (!targetId) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing cloud patch id.'; + break; + } + + const type = this._context.params?.get('type'); + let prEntityId = this._context.params?.get('prEntityId'); + if (prEntityId != null) { + prEntityId = fromBase64(prEntityId).toString(); + } + + void (await executeCommand(Commands.OpenCloudPatch, { + type: type === 'suggested_pr_change' ? 'code_suggestion' : 'patch', + id: targetId, + patchId: secondaryTargetId, + prEntityId: prEntityId, + })); + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } + case DeepLinkServiceState.OpenWorkspace: { + if (!mainId) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing workspace id.'; + break; + } + + await this.container.workspacesView.revealWorkspaceNode(mainId, { + select: true, + focus: true, + expand: true, + }); + + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } + case DeepLinkServiceState.OpenFile: { + if (filePath == null || !repo) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing file path.'; + break; + } + + let selection: Range | undefined; + if (secondaryTargetId != null) { + // secondary target id can be a single number or a range separated with a dash. If it's a single number, form a range from it. If it's a range, parse it. + const range = secondaryTargetId.split('-'); + if (range.length === 1) { + const lineNumber = parseInt(range[0]); + if (!isNaN(lineNumber)) { + selection = new Range(lineNumber < 1 ? 0 : lineNumber - 1, 0, lineNumber, 0); + } + } else if (range.length === 2) { + const startLineNumber = parseInt(range[0]); + const endLineNumber = parseInt(range[1]); + if (!isNaN(startLineNumber) && !isNaN(endLineNumber)) { + selection = new Range( + startLineNumber < 1 ? 0 : startLineNumber - 1, + 0, + endLineNumber, + 0, + ); + } + } + } + + if (targetSha == null) { + try { + await findOrOpenEditor(Uri.file(`${repo.path}/${filePath}`), { + preview: false, + selection: selection, + throwOnError: true, + }); + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } catch (ex) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = `Unable to open file${ex?.message ? `: ${ex.message}` : ''}`; + break; + } + } + + let revisionUri: Uri | undefined; + try { + revisionUri = this.container.git.getRevisionUri( + targetSha, + filePath, + repoPath ?? repo.uri.fsPath, + ); + } catch {} + if (revisionUri == null) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Unable to get revision uri.'; + break; + } + + try { + await openFileAtRevision(revisionUri, { + preview: false, + selection: selection, + }); + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } catch { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Unable to open file.'; + break; + } + } + case DeepLinkServiceState.SwitchToRef: { + if (!repo || !targetType || !targetId) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing repository or target type.'; + break; + } + + let skipSwitch = false; + if (targetType === DeepLinkType.Branch) { + // Check if the branch is already checked out. If so, we are done. + const currentBranch = await repo.getBranch(); + const targetBranch = await this.getBranch(targetId); + if ( + currentBranch != null && + targetBranch != null && + // TODO: When we create a new local branch during switch, it should set its upstream to the original remote branch target. + // Then this can be updated to just check the upstream of `currentBranch`. + currentBranch.getNameWithoutRemote() === targetBranch.getNameWithoutRemote() + ) { + skipSwitch = true; + } + } + + if (!skipSwitch) { + const ref = await this.getTargetRef(targetId); + if (ref == null) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Unable to find link target in the repository.'; + break; + } + + await executeGitCommand({ + command: 'switch', + state: { + repos: repo, + reference: ref, + skipWorktreeConfirmations: + this._context.action === DeepLinkActionType.SwitchToPullRequestWorktree, + }, + }); + } + + if ( + this._context.action === DeepLinkActionType.SwitchToPullRequest || + this._context.action === DeepLinkActionType.SwitchToPullRequestWorktree || + this._context.action === DeepLinkActionType.SwitchToAndSuggestPullRequest + ) { + await showInspectView({ + type: 'wip', + inReview: this._context.action === DeepLinkActionType.SwitchToAndSuggestPullRequest, + repository: repo, + source: 'launchpad', + } satisfies ShowWipArgs); + } + + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } default: { action = DeepLinkServiceAction.DeepLinkErrored; message = 'Unknown state.'; @@ -881,6 +1304,7 @@ export class DeepLinkService implements Disposable { } } + async copyDeepLinkUrl(workspaceId: string): Promise; async copyDeepLinkUrl(ref: GitReference, remoteUrl: string): Promise; async copyDeepLinkUrl( repoPath: string, @@ -889,17 +1313,31 @@ export class DeepLinkService implements Disposable { compareWithRef?: StoredNamedRef, ): Promise; async copyDeepLinkUrl( - refOrRepoPath: string | GitReference, - remoteUrl: string, + refOrIdOrRepoPath: string | GitReference, + remoteUrl?: string, compareRef?: StoredNamedRef, compareWithRef?: StoredNamedRef, ): Promise { - const url = await (typeof refOrRepoPath === 'string' - ? this.generateDeepLinkUrl(refOrRepoPath, remoteUrl, compareRef, compareWithRef) - : this.generateDeepLinkUrl(refOrRepoPath, remoteUrl)); + const url = await (typeof refOrIdOrRepoPath === 'string' + ? remoteUrl != null + ? this.generateDeepLinkUrl(refOrIdOrRepoPath, remoteUrl, compareRef, compareWithRef) + : this.generateDeepLinkUrl(refOrIdOrRepoPath) + : this.generateDeepLinkUrl(refOrIdOrRepoPath, remoteUrl!)); + await env.clipboard.writeText(url.toString()); + } + + async copyFileDeepLinkUrl( + repoPath: string, + filePath: string, + remoteUrl: string, + lines?: number[], + ref?: GitReference, + ): Promise { + const url = await this.generateFileDeepLinkUr(repoPath, filePath, remoteUrl, lines, ref); await env.clipboard.writeText(url.toString()); } + async generateDeepLinkUrl(workspaceId: string): Promise; async generateDeepLinkUrl(ref: GitReference, remoteUrl: string): Promise; async generateDeepLinkUrl( repoPath: string, @@ -908,37 +1346,49 @@ export class DeepLinkService implements Disposable { compareWithRef?: StoredNamedRef, ): Promise; async generateDeepLinkUrl( - refOrRepoPath: string | GitReference, - remoteUrl: string, + refOrIdOrRepoPath: string | GitReference, + remoteUrl?: string, compareRef?: StoredNamedRef, compareWithRef?: StoredNamedRef, ): Promise { - const repoPath = typeof refOrRepoPath !== 'string' ? refOrRepoPath.repoPath : refOrRepoPath; - let repoId; - try { - repoId = await this.container.git.getUniqueRepositoryId(repoPath); - } catch { - repoId = '-'; - } - let targetType: DeepLinkType | undefined; let targetId: string | undefined; let compareWithTargetId: string | undefined; - if (typeof refOrRepoPath !== 'string') { - switch (refOrRepoPath.refType) { + const schemeOverride = configuration.get('deepLinks.schemeOverride'); + const scheme = !schemeOverride ? 'vscode' : schemeOverride === true ? env.uriScheme : schemeOverride; + let modePrefixString = ''; + if (this.container.env === 'dev') { + modePrefixString = 'dev.'; + } else if (this.container.env === 'staging') { + modePrefixString = 'staging.'; + } + + if (remoteUrl == null && typeof refOrIdOrRepoPath === 'string') { + const deepLinkRedirectUrl = new URL( + `https://${modePrefixString}gitkraken.dev/link/workspaces/${refOrIdOrRepoPath}`, + ); + deepLinkRedirectUrl.searchParams.set('origin', 'gitlens'); + return deepLinkRedirectUrl; + } + + const repoPath = typeof refOrIdOrRepoPath !== 'string' ? refOrIdOrRepoPath.repoPath : refOrIdOrRepoPath; + const repoId = (await this.container.git.getUniqueRepositoryId(repoPath)) ?? missingRepositoryId; + + if (typeof refOrIdOrRepoPath !== 'string') { + switch (refOrIdOrRepoPath.refType) { case 'branch': targetType = DeepLinkType.Branch; - targetId = refOrRepoPath.remote - ? getBranchNameWithoutRemote(refOrRepoPath.name) - : refOrRepoPath.name; + targetId = refOrIdOrRepoPath.remote + ? getBranchNameWithoutRemote(refOrIdOrRepoPath.name) + : refOrIdOrRepoPath.name; break; case 'revision': targetType = DeepLinkType.Commit; - targetId = refOrRepoPath.ref; + targetId = refOrIdOrRepoPath.ref; break; case 'tag': targetType = DeepLinkType.Tag; - targetId = refOrRepoPath.name; + targetId = refOrIdOrRepoPath.name; break; } } @@ -949,9 +1399,6 @@ export class DeepLinkService implements Disposable { compareWithTargetId = compareWithRef.label ?? compareWithRef.ref; } - const schemeOverride = configuration.get('deepLinks.schemeOverride'); - - const scheme = !schemeOverride ? 'vscode' : schemeOverride === true ? env.uriScheme : schemeOverride; let target; if (targetType === DeepLinkType.Comparison) { target = `/${targetType}/${compareWithTargetId}...${targetId}`; @@ -968,11 +1415,32 @@ export class DeepLinkService implements Disposable { }/${repoId}${target}`, ); - // Add the remote URL as a query parameter - deepLink.searchParams.set('url', remoteUrl); - const params = new URLSearchParams(); - params.set('url', remoteUrl); + if (remoteUrl != null) { + // Add the remote URL as a query parameter + deepLink.searchParams.set('url', remoteUrl); + } + + const deepLinkRedirectUrl = new URL( + `https://${modePrefixString}gitkraken.dev/link/${encodeURIComponent( + Buffer.from(deepLink.href).toString('base64'), + )}`, + ); + + deepLinkRedirectUrl.searchParams.set('origin', 'gitlens'); + return deepLinkRedirectUrl; + } + async generateFileDeepLinkUr( + repoPath: string, + filePath: string, + remoteUrl: string, + lines?: number[], + ref?: GitReference, + ): Promise { + const targetType = DeepLinkType.File; + const targetId = filePath; + const schemeOverride = configuration.get('deepLinks.schemeOverride'); + const scheme = !schemeOverride ? 'vscode' : schemeOverride === true ? env.uriScheme : schemeOverride; let modePrefixString = ''; if (this.container.env === 'dev') { modePrefixString = 'dev.'; @@ -980,11 +1448,58 @@ export class DeepLinkService implements Disposable { modePrefixString = 'staging.'; } + const repoId = (await this.container.git.getUniqueRepositoryId(repoPath)) ?? missingRepositoryId; + let linesString = ''; + if (lines != null) { + if (lines.length === 1) { + linesString = `${lines[0]}`; + } else if (lines.length === 2) { + if (lines[0] === lines[1]) { + linesString = `${lines[0]}`; + } else if (lines[0] < lines[1]) { + linesString = `${lines[0]}-${lines[1]}`; + } + } + } + + const deepLink = new URL( + `${scheme}://${this.container.context.extension.id}/${'link' satisfies UriTypes}/${ + DeepLinkType.Repository + }/${repoId}/${targetType}/${targetId}`, + ); + + deepLink.searchParams.set('url', remoteUrl); + if (linesString !== '') { + deepLink.searchParams.set('lines', linesString); + } + + if (ref != null) { + switch (ref.refType) { + case 'branch': + deepLink.searchParams.set('ref', ref.name); + break; + case 'revision': + deepLink.searchParams.set('ref', ref.ref); + break; + case 'tag': + deepLink.searchParams.set('ref', ref.name); + break; + } + } + const deepLinkRedirectUrl = new URL( `https://${modePrefixString}gitkraken.dev/link/${encodeURIComponent( Buffer.from(deepLink.href).toString('base64'), )}`, ); + + deepLinkRedirectUrl.searchParams.set('origin', 'gitlens'); return deepLinkRedirectUrl; } } + +export function getMaybeRemoteNameFromRemoteUrl(remoteUrl: string): string | undefined { + const remoteUrlParts = remoteUrl.split('/'); + if (remoteUrlParts.length < 3) return undefined; + return remoteUrlParts[remoteUrlParts.length - 2]; +} diff --git a/src/uris/uriService.ts b/src/uris/uriService.ts index a4aec82f8e5a1..cce82ec1fd530 100644 --- a/src/uris/uriService.ts +++ b/src/uris/uriService.ts @@ -1,7 +1,9 @@ import type { Disposable, Event, Uri, UriHandler } from 'vscode'; import { EventEmitter, window } from 'vscode'; import type { Container } from '../container'; -import { AuthenticationUriPathPrefix } from '../plus/gk/authenticationConnection'; +import { AuthenticationUriPathPrefix } from '../plus/gk/account/authenticationConnection'; +import { SubscriptionUpdatedUriPathPrefix } from '../plus/gk/account/subscription'; +import { CloudIntegrationAuthenticationUriPathPrefix } from '../plus/integrations/authentication/models'; import { log } from '../system/decorators/log'; // This service is in charge of registering a URI handler and handling/emitting URI events received by GitLens. @@ -11,10 +13,21 @@ export class UriService implements Disposable, UriHandler { private _disposable: Disposable; private _onDidReceiveAuthenticationUri: EventEmitter = new EventEmitter(); + private _onDidReceiveCloudIntegrationAuthenticationUri: EventEmitter = new EventEmitter(); + private _onDidReceiveSubscriptionUpdatedUri: EventEmitter = new EventEmitter(); + get onDidReceiveAuthenticationUri(): Event { return this._onDidReceiveAuthenticationUri.event; } + get onDidReceiveCloudIntegrationAuthenticationUri(): Event { + return this._onDidReceiveCloudIntegrationAuthenticationUri.event; + } + + get onDidReceiveSubscriptionUpdatedUri(): Event { + return this._onDidReceiveSubscriptionUpdatedUri.event; + } + private _onDidReceiveUri: EventEmitter = new EventEmitter(); get onDidReceiveUri(): Event { return this._onDidReceiveUri.event; @@ -34,6 +47,12 @@ export class UriService implements Disposable, UriHandler { if (type === AuthenticationUriPathPrefix) { this._onDidReceiveAuthenticationUri.fire(uri); return; + } else if (type === CloudIntegrationAuthenticationUriPathPrefix) { + this._onDidReceiveCloudIntegrationAuthenticationUri.fire(uri); + return; + } else if (type === SubscriptionUpdatedUriPathPrefix) { + this._onDidReceiveSubscriptionUpdatedUri.fire(uri); + return; } this._onDidReceiveUri.fire(uri); diff --git a/src/views/branchesView.ts b/src/views/branchesView.ts index fa1170f0b099f..ddfe67a5f1a42 100644 --- a/src/views/branchesView.ts +++ b/src/views/branchesView.ts @@ -8,16 +8,17 @@ import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; import type { GitBranchReference, GitRevisionReference } from '../git/models/reference'; import { getReferenceLabel } from '../git/models/reference'; -import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import type { Repository, RepositoryChangeEvent } from '../git/models/repository'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { gate } from '../system/decorators/gate'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { BranchesNode } from './nodes/branchesNode'; import { BranchNode } from './nodes/branchNode'; import { BranchOrTagFolderNode } from './nodes/branchOrTagFolderNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -47,15 +48,46 @@ export class BranchesRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let grouped: Map> | undefined; + + let repositories = this.view.container.git.openRepositories; + if (configuration.get('views.collapseWorktreesWhenPossible')) { + grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No branches could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading branches...' + : 'No branches could be found.'; return []; } this.view.message = undefined; + let openWorktreeBranches: Set | undefined; + if (grouped?.size) { + // Get all the opened worktree branches to pass along downstream, e.g. in the BranchNode to display an indicator + openWorktreeBranches = new Set(); + + await Promise.allSettled( + [...grouped].map(async ([r, nested]) => { + if (!nested.size) return; + + const worktrees = await r.getWorktrees(); + for (const wt of worktrees) { + if (wt.branch == null || nested.has(wt.repoPath)) return; + + openWorktreeBranches!.add(wt.branch?.name); + } + }), + ); + } + this.updateContext({ + openWorktreeBranches: openWorktreeBranches?.size ? openWorktreeBranches : undefined, + }); + const splat = repositories.length === 1; this.children = repositories.map( r => new BranchesRepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r, splat), @@ -103,6 +135,10 @@ export class BranchesView extends ViewBase<'branches', BranchesViewNode, Branche return this.config.reveal || !configuration.get('views.repositories.showBranches'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new BranchesViewNode(this); } @@ -177,7 +213,9 @@ export class BranchesView extends ViewBase<'branches', BranchesViewNode, Branche !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && - !configuration.changed(e, 'sortBranchesBy') + !configuration.changed(e, 'sortBranchesBy') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') ) { return false; } @@ -213,6 +251,7 @@ export class BranchesView extends ViewBase<'branches', BranchesViewNode, Branche const branches = await this.container.git.getCommitBranches( commit.repoPath, commit.ref, + undefined, isCommit(commit) ? { commitDate: commit.committer.date } : undefined, ); if (branches.length === 0) return undefined; diff --git a/src/views/commitsView.ts b/src/views/commitsView.ts index 95a10f683513e..fcd0574ff4227 100644 --- a/src/views/commitsView.ts +++ b/src/views/commitsView.ts @@ -1,9 +1,4 @@ -import type { - CancellationToken, - ConfigurationChangeEvent, - TreeViewSelectionChangeEvent, - TreeViewVisibilityChangeEvent, -} from 'vscode'; +import type { CancellationToken, ConfigurationChangeEvent } from 'vscode'; import { Disposable, ProgressLocation, ThemeIcon, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; import type { CommitsViewConfig, ViewFilesLayout } from '../config'; import { Commands, GlyphChars } from '../constants'; @@ -11,10 +6,14 @@ import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; +import { matchContributor } from '../git/models/contributor'; import type { GitRevisionReference } from '../git/models/reference'; import { getReferenceLabel } from '../git/models/reference'; import type { RepositoryChangeEvent } from '../git/models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import type { GitUser } from '../git/models/user'; +import { showContributorsPicker } from '../quickpicks/contributorsPicker'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; import { createCommand, executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; @@ -22,14 +21,12 @@ import { gate } from '../system/decorators/gate'; import { debug } from '../system/decorators/log'; import { disposableInterval } from '../system/function'; import type { UsageChangeEvent } from '../telemetry/usageTracker'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { BranchNode } from './nodes/branchNode'; import { BranchTrackingStatusNode } from './nodes/branchTrackingStatusNode'; -import { CommitFileNode } from './nodes/commitFileNode'; -import { CommitNode } from './nodes/commitNode'; import { CommandMessageNode } from './nodes/common'; -import { FileRevisionAsCommitNode } from './nodes/fileRevisionAsCommitNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -45,14 +42,7 @@ export class CommitsRepositoryNode extends RepositoryFolderNode; + hideMergeCommits?: boolean; } export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsViewConfig> { @@ -210,7 +204,11 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie return this.config.reveal || !configuration.get('views.repositories.showCommits'); } - private readonly _state: CommitsViewState = {}; + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + + private readonly _state: CommitsViewState = { filterCommits: new Map() }; get state(): CommitsViewState { return this._state; } @@ -252,15 +250,26 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie this, ), registerViewCommand( - this.getQualifiedCommand('setMyCommitsOnlyOn'), - () => this.setMyCommitsOnly(true), + this.getQualifiedCommand('setCommitsFilterAuthors'), + n => this.setCommitsFilter(n, true), this, ), registerViewCommand( - this.getQualifiedCommand('setMyCommitsOnlyOff'), - () => this.setMyCommitsOnly(false), + this.getQualifiedCommand('setCommitsFilterOff'), + n => this.setCommitsFilter(n, false), this, ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOn'), + () => this.setShowMergeCommits(true), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOff'), + () => this.setShowMergeCommits(false), + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), registerViewCommand( @@ -297,7 +306,8 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && - !configuration.changed(e, 'plusFeatures.enabled') + !configuration.changed(e, 'plusFeatures.enabled') && + !configuration.changed(e, 'sortRepositoriesBy') ) { return false; } @@ -305,49 +315,6 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie return true; } - protected override onSelectionChanged(e: TreeViewSelectionChangeEvent) { - super.onSelectionChanged(e); - this.notifySelections(); - } - - protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { - super.onVisibilityChanged(e); - - if (e.visible) { - this.notifySelections(); - } - } - - private notifySelections() { - const node = this.selection?.[0]; - if (node == null) return; - - if (node instanceof CommitNode || node instanceof FileRevisionAsCommitNode || node instanceof CommitFileNode) { - this.container.events.fire( - 'commit:selected', - { - commit: node.commit, - interaction: 'passive', - preserveFocus: true, - preserveVisibility: true, - }, - { source: this.id }, - ); - } - - if (node instanceof FileRevisionAsCommitNode || node instanceof CommitFileNode) { - this.container.events.fire( - 'file:selected', - { - uri: node.uri, - preserveFocus: true, - preserveVisibility: true, - }, - { source: this.id }, - ); - } - } - async findCommit(commit: GitCommit | { repoPath: string; ref: string }, token?: CancellationToken) { const { repoPath } = commit; @@ -355,8 +322,7 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie if (branch == null) return undefined; // Check if the commit exists on the current branch - const branches = await this.container.git.getCommitBranches(commit.repoPath, commit.ref, { - branch: branch.name, + const branches = await this.container.git.getCommitBranches(commit.repoPath, commit.ref, branch.name, { commitDate: isCommit(commit) ? commit.committer.date : undefined, }); if (!branches.length) return undefined; @@ -447,9 +413,66 @@ export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsVie return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); } - private setMyCommitsOnly(enabled: boolean) { - void setContext('gitlens:views:commits:myCommitsOnly', enabled); - this.state.myCommitsOnly = enabled; + private async setCommitsFilter(node: ViewNode, filter: boolean) { + let repo; + if (node != null) { + if (node.is('repo-folder')) { + repo = node.repo; + } else { + let parent: ViewNode | undefined = node; + do { + parent = parent.getParent(); + if (parent?.is('repo-folder')) { + repo = parent.repo; + break; + } + } while (parent != null); + } + } + + if (filter) { + repo ??= await getRepositoryOrShowPicker('Filter Commits', 'Choose a repository'); + if (repo == null) return; + + let authors = this.state.filterCommits.get(repo.id); + if (authors == null) { + const current = await this.container.git.getCurrentUser(repo.uri); + authors = current != null ? [current] : undefined; + } + + const result = await showContributorsPicker( + this.container, + repo, + 'Filter Commits', + repo.virtual ? 'Choose a contributor to show commits from' : 'Choose contributors to show commits from', + { + appendReposToTitle: true, + clearButton: true, + multiselect: !repo.virtual, + picked: c => authors?.some(u => matchContributor(c, u)) ?? false, + }, + ); + if (result == null) return; + + if (result.length === 0) { + filter = false; + this.state.filterCommits.delete(repo.id); + } else { + this.state.filterCommits.set(repo.id, result); + } + } else if (repo != null) { + this.state.filterCommits.delete(repo.id); + } else { + this.state.filterCommits.clear(); + } + + void setContext('gitlens:views:commits:filtered', this.state.filterCommits.size !== 0); + void this.refresh(true); + } + + private setShowMergeCommits(on: boolean) { + void setContext('gitlens:views:commits:hideMergeCommits', !on); + this.state.hideMergeCommits = !on; void this.refresh(true); } diff --git a/src/views/contributorsView.ts b/src/views/contributorsView.ts index 9a906f65842b4..a0e78c2bf331b 100644 --- a/src/views/contributorsView.ts +++ b/src/views/contributorsView.ts @@ -7,22 +7,26 @@ import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitContributor } from '../git/models/contributor'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; +import { setContext } from '../system/context'; import { gate } from '../system/decorators/gate'; import { debug } from '../system/decorators/log'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { ContributorNode } from './nodes/contributorNode'; import { ContributorsNode } from './nodes/contributorsNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; export class ContributorsRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.child == null) { - this.child = new ContributorsNode(this.uri, this.view, this, this.repo); + this.child = new ContributorsNode(this.uri, this.view, this, this.repo, { + showMergeCommits: !this.view.state.hideMergeCommits, + }); } return this.child.getChildren(); @@ -50,9 +54,19 @@ export class ContributorsRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let repositories = this.view.container.git.openRepositories; + if ( + configuration.get('views.collapseWorktreesWhenPossible') && + configuration.get('views.contributors.showAllBranches') + ) { + const grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No contributors could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading contributors...' + : 'No contributors could be found.'; return []; } @@ -110,17 +124,32 @@ export class ContributorsViewNode extends RepositoriesSubscribeableNode { protected readonly configKey = 'contributors'; constructor(container: Container) { super(container, 'contributors', 'Contributors', 'contributorsView'); + + void setContext('gitlens:views:contributors:hideMergeCommits', true); } override get canReveal(): boolean { return this.config.reveal || !configuration.get('views.repositories.showContributors'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + + private readonly _state: ContributorsViewState = { hideMergeCommits: true }; + get state(): ContributorsViewState { + return this._state; + } + protected getRoot() { return new ContributorsViewNode(this); } @@ -169,6 +198,17 @@ export class ContributorsView extends ViewBase<'contributors', ContributorsViewN this, ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOn'), + () => this.setShowMergeCommits(true), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOff'), + () => this.setShowMergeCommits(false), + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), @@ -196,7 +236,9 @@ export class ContributorsView extends ViewBase<'contributors', ContributorsViewN !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && - !configuration.changed(e, 'sortContributorsBy') + !configuration.changed(e, 'sortContributorsBy') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') ) { return false; } @@ -280,6 +322,12 @@ export class ContributorsView extends ViewBase<'contributors', ContributorsViewN return configuration.updateEffective(`views.${this.configKey}.showAllBranches` as const, enabled); } + private setShowMergeCommits(on: boolean) { + void setContext('gitlens:views:contributors:hideMergeCommits', !on); + this.state.hideMergeCommits = !on; + void this.refresh(true); + } + private setShowAvatars(enabled: boolean) { return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); } diff --git a/src/views/draftsView.ts b/src/views/draftsView.ts new file mode 100644 index 0000000000000..34bf184bde936 --- /dev/null +++ b/src/views/draftsView.ts @@ -0,0 +1,201 @@ +import type { CancellationToken, TreeViewVisibilityChangeEvent } from 'vscode'; +import { Disposable, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; +import type { OpenWalkthroughCommandArgs } from '../commands/walkthroughs'; +import type { DraftsViewConfig } from '../config'; +import { Commands, previewBadge } from '../constants'; +import type { Container } from '../container'; +import { AuthenticationRequiredError } from '../errors'; +import { unknownGitUri } from '../git/gitUri'; +import type { Draft } from '../gk/models/drafts'; +import { ensurePlusFeaturesEnabled } from '../plus/gk/utils'; +import { executeCommand } from '../system/command'; +import { configuration } from '../system/configuration'; +import { gate } from '../system/decorators/gate'; +import { groupByFilterMap } from '../system/iterable'; +import { CacheableChildrenViewNode } from './nodes/abstract/cacheableChildrenViewNode'; +import { DraftNode } from './nodes/draftNode'; +import { GroupingNode } from './nodes/groupingNode'; +import { ViewBase } from './viewBase'; +import { registerViewCommand } from './viewCommands'; + +export class DraftsViewNode extends CacheableChildrenViewNode<'drafts', DraftsView, GroupingNode | DraftNode> { + constructor(view: DraftsView) { + super('drafts', unknownGitUri, view); + } + + async getChildren(): Promise<(GroupingNode | DraftNode)[]> { + if (this.children == null) { + const children: (GroupingNode | DraftNode)[] = []; + + try { + const drafts = await this.view.container.drafts.getDrafts(); + drafts?.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + + const groups = groupByFilterMap( + drafts, + this.calcDraftGroupKey.bind(this), + d => new DraftNode(this.uri, this.view, this, d), + ); + + const mine = groups.get('mine'); + const shared = groups.get('shared'); + const isFlat = mine?.length && !shared?.length; + + if (!isFlat) { + if (mine?.length) { + children.push(new GroupingNode(this.view, 'Created by Me', mine)); + } + if (shared?.length) { + children.push(new GroupingNode(this.view, 'Shared with Me', shared)); + } + } else { + children.push(...mine); + } + } catch (ex) { + if (!(ex instanceof AuthenticationRequiredError)) throw ex; + } + + this.children = children; + } + + return this.children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Drafts', TreeItemCollapsibleState.Expanded); + return item; + } + + private calcDraftGroupKey(d: Draft): DraftGroupKey { + if (d.type === 'suggested_pr_change') { + return 'pr_suggestion'; + } + return d.isMine ? 'mine' : 'shared'; + } +} + +type DraftGroupKey = 'pr_suggestion' | 'mine' | 'shared'; + +export class DraftsView extends ViewBase<'drafts', DraftsViewNode, DraftsViewConfig> { + protected readonly configKey = 'drafts'; + private _disposable: Disposable | undefined; + + constructor(container: Container) { + super(container, 'drafts', 'Cloud Patches', 'draftsView'); + + this.description = previewBadge; + } + + override dispose() { + this._disposable?.dispose(); + super.dispose(); + } + + protected getRoot() { + return new DraftsViewNode(this); + } + + protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent): void { + if (this._disposable == null) { + this._disposable = Disposable.from(this.container.subscription.onDidChange(() => this.refresh(true), this)); + } + + super.onVisibilityChanged(e); + } + + override async show(options?: { preserveFocus?: boolean | undefined }): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + + return super.show(options); + } + + override get canReveal(): boolean { + return false; + } + + protected registerCommands(): Disposable[] { + void this.container.viewCommands; + + return [ + registerViewCommand( + this.getQualifiedCommand('info'), + () => + executeCommand(Commands.OpenWalkthrough, { + step: 'code-collab', + source: 'cloud-patches', + detail: 'info', + }), + this, + ), + registerViewCommand( + this.getQualifiedCommand('copy'), + () => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), + this, + ), + registerViewCommand(this.getQualifiedCommand('refresh'), () => this.refresh(true), this), + registerViewCommand( + this.getQualifiedCommand('create'), + async () => { + await executeCommand(Commands.CreateCloudPatch); + void this.ensureRoot().triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('delete'), + async (node: DraftNode) => { + const confirm = { title: 'Delete' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `Are you sure you want to delete Cloud Patch '${node.draft.title}'?`, + { modal: true }, + confirm, + cancel, + ); + + if (result === confirm) { + await this.container.drafts.deleteDraft(node.draft.id); + void node.getParent()?.triggerChange(true); + } + }, + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), + ]; + } + + async findDraft(draft: Draft, cancellation?: CancellationToken) { + return this.findNode((n: any) => n.draft?.id === draft.id, { + allowPaging: false, + maxDepth: 2, + canTraverse: n => { + if (n instanceof DraftsViewNode || n instanceof GroupingNode) return true; + + return false; + }, + token: cancellation, + }); + } + + @gate(() => '') + async revealDraft( + draft: Draft, + options?: { + select?: boolean; + focus?: boolean; + expand?: boolean | number; + }, + ) { + const node = await this.findDraft(draft); + if (node == null) return undefined; + + await this.ensureRevealNode(node, options); + + return node; + } + + private setShowAvatars(enabled: boolean) { + return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); + } +} diff --git a/src/views/fileHistoryView.ts b/src/views/fileHistoryView.ts index a6b97a089f1f6..3658879bf1a4c 100644 --- a/src/views/fileHistoryView.ts +++ b/src/views/fileHistoryView.ts @@ -30,6 +30,10 @@ export class FileHistoryView extends ViewBase< void setContext('gitlens:views:fileHistory:editorFollowing', this._followEditor); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected override get showCollapseAll(): boolean { return false; } @@ -89,6 +93,16 @@ export class FileHistoryView extends ViewBase< () => this.setShowAllBranches(false), this, ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOn'), + () => this.setShowMergeCommits(true), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOff'), + () => this.setShowMergeCommits(false), + this, + ), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), ]; @@ -106,7 +120,8 @@ export class FileHistoryView extends ViewBase< !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && !configuration.changed(e, 'advanced.fileHistoryFollowsRenames') && - !configuration.changed(e, 'advanced.fileHistoryShowAllBranches') + !configuration.changed(e, 'advanced.fileHistoryShowAllBranches') && + !configuration.changed(e, 'advanced.fileHistoryShowMergeCommits') ) { return false; } @@ -180,6 +195,10 @@ export class FileHistoryView extends ViewBase< return configuration.updateEffective('advanced.fileHistoryShowAllBranches', enabled); } + private setShowMergeCommits(enabled: boolean) { + return configuration.updateEffective('advanced.fileHistoryShowMergeCommits', enabled); + } + private setShowAvatars(enabled: boolean) { return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); } diff --git a/src/views/lineHistoryView.ts b/src/views/lineHistoryView.ts index 206daecbfeaec..f3c849d348708 100644 --- a/src/views/lineHistoryView.ts +++ b/src/views/lineHistoryView.ts @@ -20,6 +20,10 @@ export class LineHistoryView extends ViewBase<'lineHistory', LineHistoryTrackerN void setContext('gitlens:views:lineHistory:editorFollowing', true); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected override get showCollapseAll(): boolean { return false; } diff --git a/src/views/nodes/UncommittedFileNode.ts b/src/views/nodes/UncommittedFileNode.ts index 2cf23d743e7c4..2e534ccce5568 100644 --- a/src/views/nodes/UncommittedFileNode.ts +++ b/src/views/nodes/UncommittedFileNode.ts @@ -1,6 +1,6 @@ import type { Command } from 'vscode'; import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithPreviousCommandArgs } from '../../commands'; +import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; import { Commands } from '../../constants'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; import { GitUri } from '../../git/gitUri'; @@ -8,13 +8,14 @@ import type { GitFile } from '../../git/models/file'; import { getGitFileStatusIcon } from '../../git/models/file'; import { dirname, joinPaths } from '../../system/path'; import type { ViewsWithCommits } from '../viewBase'; +import { ViewFileNode } from './abstract/viewFileNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import type { FileNode } from './folderNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewFileNode } from './viewNode'; -export class UncommittedFileNode extends ViewFileNode implements FileNode { +export class UncommittedFileNode extends ViewFileNode<'uncommitted-file', ViewsWithCommits> implements FileNode { constructor(view: ViewsWithCommits, parent: ViewNode, repoPath: string, file: GitFile) { - super(GitUri.fromFile(file, repoPath), view, parent, file); + super('uncommitted-file', GitUri.fromFile(file, repoPath), view, parent, file); } override toClipboard(): string { diff --git a/src/views/nodes/UncommittedFilesNode.ts b/src/views/nodes/UncommittedFilesNode.ts index 0dc982bc4e859..b660e5a412224 100644 --- a/src/views/nodes/UncommittedFilesNode.ts +++ b/src/views/nodes/UncommittedFilesNode.ts @@ -3,16 +3,16 @@ import { GitUri } from '../../git/gitUri'; import type { GitTrackingState } from '../../git/models/branch'; import type { GitFileWithCommit } from '../../git/models/file'; import type { GitStatus, GitStatusFile } from '../../git/models/status'; -import { groupBy, makeHierarchical } from '../../system/array'; -import { flatMap } from '../../system/iterable'; +import { makeHierarchical } from '../../system/array'; +import { flatMap, groupBy } from '../../system/iterable'; import { joinPaths, normalizePath } from '../../system/path'; import type { ViewsWithWorkingTree } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { UncommittedFileNode } from './UncommittedFileNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class UncommittedFilesNode extends ViewNode { +export class UncommittedFilesNode extends ViewNode<'uncommitted-files', ViewsWithWorkingTree> { constructor( view: ViewsWithWorkingTree, protected override readonly parent: ViewNode, @@ -26,9 +26,9 @@ export class UncommittedFilesNode extends ViewNode { }, public readonly range: string | undefined, ) { - super(GitUri.fromRepoPath(status.repoPath), view, parent); + super('uncommitted-files', GitUri.fromRepoPath(status.repoPath), view, parent); - this._uniqueId = getViewNodeId('uncommitted-files', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { diff --git a/src/views/nodes/abstract/cacheableChildrenViewNode.ts b/src/views/nodes/abstract/cacheableChildrenViewNode.ts new file mode 100644 index 0000000000000..4aacc58692a79 --- /dev/null +++ b/src/views/nodes/abstract/cacheableChildrenViewNode.ts @@ -0,0 +1,35 @@ +import type { TreeViewNodeTypes } from '../../../constants'; +import { debug } from '../../../system/decorators/log'; +import type { View } from '../../viewBase'; +import { disposeChildren } from '../../viewBase'; +import { ViewNode } from './viewNode'; + +export abstract class CacheableChildrenViewNode< + Type extends TreeViewNodeTypes = TreeViewNodeTypes, + TView extends View = View, + TChild extends ViewNode = ViewNode, + State extends object = any, +> extends ViewNode { + private _children: TChild[] | undefined; + protected get children(): TChild[] | undefined { + return this._children; + } + protected set children(value: TChild[] | undefined) { + if (this._children === value) return; + + disposeChildren(this._children, value); + this._children = value; + } + + override dispose() { + super.dispose(); + this.children = undefined; + } + + @debug() + override refresh(reset: boolean = false) { + if (reset) { + this.children = undefined; + } + } +} diff --git a/src/views/nodes/abstract/repositoriesSubscribeableNode.ts b/src/views/nodes/abstract/repositoriesSubscribeableNode.ts new file mode 100644 index 0000000000000..eaa9164670a93 --- /dev/null +++ b/src/views/nodes/abstract/repositoriesSubscribeableNode.ts @@ -0,0 +1,51 @@ +import { Disposable } from 'vscode'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; +import { unknownGitUri } from '../../../git/gitUri'; +import type { SubscriptionChangeEvent } from '../../../plus/gk/account/subscriptionService'; +import { debug } from '../../../system/decorators/log'; +import { weakEvent } from '../../../system/event'; +import { szudzikPairing } from '../../../system/function'; +import type { View } from '../../viewBase'; +import { SubscribeableViewNode } from './subscribeableViewNode'; +import type { ViewNode } from './viewNode'; + +export abstract class RepositoriesSubscribeableNode< + TView extends View = View, + TChild extends ViewNode = ViewNode, +> extends SubscribeableViewNode<'repositories', TView, TChild> { + protected override splatted = true; + + constructor(view: TView) { + super('repositories', unknownGitUri, view); + } + + override async getSplattedChild() { + if (this.children == null) { + await this.getChildren(); + } + + return this.children?.length === 1 ? this.children[0] : undefined; + } + + protected override etag(): number { + return szudzikPairing(this.view.container.git.etag, this.view.container.subscription.etag); + } + + @debug() + protected subscribe(): Disposable | Promise { + return Disposable.from( + weakEvent(this.view.container.git.onDidChangeRepositories, this.onRepositoriesChanged, this), + weakEvent(this.view.container.subscription.onDidChange, this.onSubscriptionChanged, this), + ); + } + + private onRepositoriesChanged(_e: RepositoriesChangeEvent) { + void this.triggerChange(true); + } + + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + if (e.current.plan !== e.previous.plan) { + void this.triggerChange(true); + } + } +} diff --git a/src/views/nodes/abstract/repositoryFolderNode.ts b/src/views/nodes/abstract/repositoryFolderNode.ts new file mode 100644 index 0000000000000..0b39f4c9473c6 --- /dev/null +++ b/src/views/nodes/abstract/repositoryFolderNode.ts @@ -0,0 +1,230 @@ +import type { Disposable } from 'vscode'; +import { MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GlyphChars } from '../../../constants'; +import type { GitUri } from '../../../git/gitUri'; +import { getHighlanderProviders } from '../../../git/models/remote'; +import type { RepositoryChangeEvent } from '../../../git/models/repository'; +import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import { gate } from '../../../system/decorators/gate'; +import { debug, log } from '../../../system/decorators/log'; +import { weakEvent } from '../../../system/event'; +import { basename } from '../../../system/path'; +import { pad } from '../../../system/string'; +import type { View } from '../../viewBase'; +import { SubscribeableViewNode } from './subscribeableViewNode'; +import type { ViewNode } from './viewNode'; +import { ContextValues, getViewNodeId } from './viewNode'; + +export abstract class RepositoryFolderNode< + TView extends View = View, + TChild extends ViewNode = ViewNode, +> extends SubscribeableViewNode<'repo-folder', TView> { + protected override splatted = true; + + constructor( + uri: GitUri, + view: TView, + protected override readonly parent: ViewNode, + public readonly repo: Repository, + splatted: boolean, + private readonly options?: { showBranchAndLastFetched?: boolean }, + ) { + super('repo-folder', uri, view, parent); + + this.updateContext({ repository: this.repo }); + this._uniqueId = getViewNodeId(this.type, this.context); + + this.splatted = splatted; + } + + private _child: TChild | undefined; + protected get child(): TChild | undefined { + return this._child; + } + protected set child(value: TChild | undefined) { + if (this._child === value) return; + + this._child?.dispose(); + this._child = value; + } + + override dispose() { + super.dispose(); + this.child = undefined; + } + + override get id(): string { + return this._uniqueId; + } + + override toClipboard(): string { + return this.repo.path; + } + + get repoPath(): string { + return this.repo.path; + } + + async getTreeItem(): Promise { + this.splatted = false; + + const branch = await this.repo.getBranch(); + const ahead = (branch?.state.ahead ?? 0) > 0; + const behind = (branch?.state.behind ?? 0) > 0; + + const expand = ahead || behind || this.repo.starred || this.view.container.git.isRepositoryForEditor(this.repo); + + let label = this.repo.formattedName ?? this.uri.repoPath ?? ''; + if (this.options?.showBranchAndLastFetched && branch != null) { + const remove = `: ${basename(branch.name)}`; + const suffix = `: ${branch.name}`; + if (label.endsWith(remove)) { + label = label.substring(0, label.length - remove.length) + suffix; + } else if (!label.endsWith(suffix)) { + label += suffix; + } + } + + const item = new TreeItem( + label, + expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, + ); + item.contextValue = `${ContextValues.RepositoryFolder}${this.repo.starred ? '+starred' : ''}`; + if (ahead) { + item.contextValue += '+ahead'; + } + if (behind) { + item.contextValue += '+behind'; + } + if (this.view.type === 'commits' && this.view.state.filterCommits.get(this.repo.id)?.length) { + item.contextValue += '+filtered'; + } + + if (branch != null && this.options?.showBranchAndLastFetched) { + const lastFetched = (await this.repo.getLastFetched()) ?? 0; + + const status = branch.getTrackingStatus(); + if (status) { + item.description = status; + if (lastFetched) { + item.description += pad(GlyphChars.Dot, 1, 1); + } + } + if (lastFetched) { + item.description = `${item.description ?? ''}Last fetched ${Repository.formatLastFetched(lastFetched)}`; + } + + let providerName; + if (branch.upstream != null) { + const providers = getHighlanderProviders( + await this.view.container.git.getRemotesWithProviders(branch.repoPath), + ); + providerName = providers?.length ? providers[0].name : undefined; + } else { + const remote = await branch.getRemote(); + providerName = remote?.provider?.name; + } + + item.tooltip = new MarkdownString( + `${this.repo.formattedName ?? this.uri.repoPath ?? ''}${ + lastFetched + ? `${pad(GlyphChars.Dash, 2, 2)}Last fetched ${Repository.formatLastFetched( + lastFetched, + false, + )}` + : '' + }${this.repo.formattedName ? `\n${this.uri.repoPath}` : ''}\n\nCurrent branch $(git-branch) ${ + branch.name + }${ + branch.upstream != null + ? ` is ${branch.getTrackingStatus({ + empty: branch.upstream.missing + ? `missing upstream $(git-branch) ${branch.upstream.name}` + : `up to date with $(git-branch) ${branch.upstream.name}${ + providerName ? ` on ${providerName}` : '' + }`, + expand: true, + icons: true, + separator: ', ', + suffix: ` $(git-branch) ${branch.upstream.name}${ + providerName ? ` on ${providerName}` : '' + }`, + })}` + : `hasn't been published to ${providerName ?? 'a remote'}` + }`, + true, + ); + } else { + item.tooltip = this.repo.formattedName + ? `${this.repo.formattedName}\n${this.uri.repoPath}` + : this.uri.repoPath ?? ''; + } + + return item; + } + + override async getSplattedChild() { + if (this.child == null) { + await this.getChildren(); + } + + return this.child; + } + + @gate() + @debug() + override async refresh(reset: boolean = false) { + super.refresh(reset); + await this.child?.triggerChange(reset, false, this); + + await this.ensureSubscription(); + } + + @log() + async star() { + await this.repo.star(); + // void this.parent!.triggerChange(); + } + + @log() + async unstar() { + await this.repo.unstar(); + // void this.parent!.triggerChange(); + } + + @debug() + protected subscribe(): Disposable | Promise { + return weakEvent(this.repo.onDidChange, this.onRepositoryChanged, this); + } + + protected override etag(): number { + return this.repo.etag; + } + + protected abstract changed(e: RepositoryChangeEvent): boolean; + + @debug({ args: { 0: e => e.toString() } }) + private onRepositoryChanged(e: RepositoryChangeEvent) { + if (e.changed(RepositoryChange.Closed, RepositoryChangeComparisonMode.Any)) { + this.dispose(); + void this.parent?.triggerChange(true); + + return; + } + + if ( + e.changed(RepositoryChange.Opened, RepositoryChangeComparisonMode.Any) || + e.changed(RepositoryChange.Starred, RepositoryChangeComparisonMode.Any) + ) { + void this.parent?.triggerChange(true); + + return; + } + + if (this.changed(e)) { + // If we are sorting by last fetched, then we need to trigger the parent to resort + const node = !this.loaded || this.repo.orderByLastFetched ? this.parent ?? this : this; + void node.triggerChange(true); + } + } +} diff --git a/src/views/nodes/abstract/subscribeableViewNode.ts b/src/views/nodes/abstract/subscribeableViewNode.ts new file mode 100644 index 0000000000000..171685c9f58cf --- /dev/null +++ b/src/views/nodes/abstract/subscribeableViewNode.ts @@ -0,0 +1,167 @@ +import type { TreeViewVisibilityChangeEvent } from 'vscode'; +import { Disposable } from 'vscode'; +import type { TreeViewSubscribableNodeTypes } from '../../../constants'; +import type { GitUri } from '../../../git/gitUri'; +import { gate } from '../../../system/decorators/gate'; +import { debug } from '../../../system/decorators/log'; +import { weakEvent } from '../../../system/event'; +import type { View } from '../../viewBase'; +import { CacheableChildrenViewNode } from './cacheableChildrenViewNode'; +import type { ViewNode } from './viewNode'; +import { canAutoRefreshView } from './viewNode'; + +export abstract class SubscribeableViewNode< + Type extends TreeViewSubscribableNodeTypes = TreeViewSubscribableNodeTypes, + TView extends View = View, + TChild extends ViewNode = ViewNode, + State extends object = any, +> extends CacheableChildrenViewNode { + protected disposable: Disposable; + protected subscription: Promise | undefined; + + protected loaded: boolean = false; + + constructor(type: Type, uri: GitUri, view: TView, parent?: ViewNode) { + super(type, uri, view, parent); + + const disposables = [ + weakEvent(this.view.onDidChangeVisibility, this.onVisibilityChanged, this), + // weak(this.view.onDidChangeNodeCollapsibleState, this.onNodeCollapsibleStateChanged, this), + ]; + + if (canAutoRefreshView(this.view)) { + disposables.push(weakEvent(this.view.onDidChangeAutoRefresh, this.onAutoRefreshChanged, this)); + } + + const getTreeItem = this.getTreeItem; + this.getTreeItem = function (this: SubscribeableViewNode) { + this.loaded = true; + void this.ensureSubscription(); + return getTreeItem.apply(this); + }; + + const getChildren = this.getChildren; + this.getChildren = function (this: SubscribeableViewNode) { + this.loaded = true; + void this.ensureSubscription(); + return getChildren.apply(this); + }; + + this.disposable = Disposable.from(...disposables); + } + + override dispose() { + super.dispose(); + void this.unsubscribe(); + this.disposable?.dispose(); + } + + @debug() + override async triggerChange(reset: boolean = false, force: boolean = false): Promise { + if (!this.loaded || this._disposed) return; + + if (reset && !this.view.visible) { + this._pendingReset = reset; + } + await super.triggerChange(reset, force); + } + + private _canSubscribe: boolean = true; + protected get canSubscribe(): boolean { + return this._canSubscribe && !this._disposed; + } + protected set canSubscribe(value: boolean) { + if (this._canSubscribe === value) return; + + this._canSubscribe = value; + + void this.ensureSubscription(); + if (value) { + void this.triggerChange(); + } + } + + private _etag: number | undefined; + protected abstract etag(): number; + + private _pendingReset: boolean = false; + private get requiresResetOnVisible(): boolean { + let reset = this._pendingReset; + this._pendingReset = false; + + const etag = this.etag(); + if (etag !== this._etag) { + this._etag = etag; + reset = true; + } + + return reset; + } + + protected abstract subscribe(): Disposable | undefined | Promise; + + @debug() + protected async unsubscribe(): Promise { + this._etag = this.etag(); + + if (this.subscription != null) { + const subscriptionPromise = this.subscription; + this.subscription = undefined; + + (await subscriptionPromise)?.dispose(); + } + } + + @debug() + protected onAutoRefreshChanged() { + this.onVisibilityChanged({ visible: this.view.visible }); + } + + // protected onParentCollapsibleStateChanged?(state: TreeItemCollapsibleState): void; + // protected onCollapsibleStateChanged?(state: TreeItemCollapsibleState): void; + // protected collapsibleState: TreeItemCollapsibleState | undefined; + // protected onNodeCollapsibleStateChanged(e: TreeViewNodeCollapsibleStateChangeEvent) { + // if (e.element === this) { + // this.collapsibleState = e.state; + // if (this.onCollapsibleStateChanged !== undefined) { + // this.onCollapsibleStateChanged(e.state); + // } + // } else if (e.element === this.parent) { + // if (this.onParentCollapsibleStateChanged !== undefined) { + // this.onParentCollapsibleStateChanged(e.state); + // } + // } + // } + @debug() + protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { + void this.ensureSubscription(); + + if (e.visible) { + void this.triggerChange(this.requiresResetOnVisible); + } + } + + @gate() + @debug() + async ensureSubscription() { + // We only need to subscribe if we are visible and if auto-refresh enabled (when supported) + if (!this.canSubscribe || !this.view.visible || (canAutoRefreshView(this.view) && !this.view.autoRefresh)) { + await this.unsubscribe(); + + return; + } + + // If we already have a subscription, just kick out + if (this.subscription != null) return; + + this.subscription = Promise.resolve(this.subscribe()); + void (await this.subscription); + } + + @gate() + @debug() + async resetSubscription() { + await this.unsubscribe(); + await this.ensureSubscription(); + } +} diff --git a/src/views/nodes/abstract/viewFileNode.ts b/src/views/nodes/abstract/viewFileNode.ts new file mode 100644 index 0000000000000..8ad36597a7f0a --- /dev/null +++ b/src/views/nodes/abstract/viewFileNode.ts @@ -0,0 +1,33 @@ +import type { TreeViewFileNodeTypes } from '../../../constants'; +import type { GitUri } from '../../../git/gitUri'; +import type { GitFile } from '../../../git/models/file'; +import type { View } from '../../viewBase'; +import { ViewNode } from './viewNode'; + +export abstract class ViewFileNode< + Type extends TreeViewFileNodeTypes = TreeViewFileNodeTypes, + TView extends View = View, + State extends object = any, +> extends ViewNode { + constructor( + type: Type, + uri: GitUri, + view: TView, + public override parent: ViewNode, + public readonly file: GitFile, + ) { + super(type, uri, view, parent); + } + + get repoPath(): string { + return this.uri.repoPath!; + } + + override toString(): string { + return `${super.toString()}:${this.file.path}`; + } +} + +export function isViewFileNode(node: unknown): node is ViewFileNode { + return node instanceof ViewFileNode; +} diff --git a/src/views/nodes/abstract/viewNode.ts b/src/views/nodes/abstract/viewNode.ts new file mode 100644 index 0000000000000..a255e1aa41486 --- /dev/null +++ b/src/views/nodes/abstract/viewNode.ts @@ -0,0 +1,443 @@ +import type { CancellationToken, Command, Disposable, Event, TreeItem } from 'vscode'; +import type { TreeViewNodeTypes } from '../../../constants'; +import type { GitUri } from '../../../git/gitUri'; +import type { GitBranch } from '../../../git/models/branch'; +import type { GitCommit } from '../../../git/models/commit'; +import type { GitContributor } from '../../../git/models/contributor'; +import type { GitFile } from '../../../git/models/file'; +import type { PullRequest } from '../../../git/models/pullRequest'; +import type { GitReflogRecord } from '../../../git/models/reflog'; +import type { GitRemote } from '../../../git/models/remote'; +import type { Repository } from '../../../git/models/repository'; +import type { GitTag } from '../../../git/models/tag'; +import type { GitWorktree } from '../../../git/models/worktree'; +import type { Draft } from '../../../gk/models/drafts'; +import type { + CloudWorkspace, + CloudWorkspaceRepositoryDescriptor, + LocalWorkspace, + LocalWorkspaceRepositoryDescriptor, +} from '../../../plus/workspaces/models'; +import { gate } from '../../../system/decorators/gate'; +import { debug, logName } from '../../../system/decorators/log'; +import { is as isA } from '../../../system/function'; +import { getLoggableName } from '../../../system/logger'; +import type { View } from '../../viewBase'; +import type { BranchNode } from '../branchNode'; +import type { BranchTrackingStatusFilesNode } from '../branchTrackingStatusFilesNode'; +import type { BranchTrackingStatus, BranchTrackingStatusNode } from '../branchTrackingStatusNode'; +import type { CodeSuggestionsNode } from '../codeSuggestionsNode'; +import type { CommitFileNode } from '../commitFileNode'; +import type { CommitNode } from '../commitNode'; +import type { CompareBranchNode } from '../compareBranchNode'; +import type { CompareResultsNode } from '../compareResultsNode'; +import type { FileRevisionAsCommitNode } from '../fileRevisionAsCommitNode'; +import type { FolderNode } from '../folderNode'; +import type { LineHistoryTrackerNode } from '../lineHistoryTrackerNode'; +import type { MergeConflictFileNode } from '../mergeConflictFileNode'; +import type { RepositoryNode } from '../repositoryNode'; +import type { ResultsCommitsNode } from '../resultsCommitsNode'; +import type { ResultsFileNode } from '../resultsFileNode'; +import type { ResultsFilesNode } from '../resultsFilesNode'; +import type { StashFileNode } from '../stashFileNode'; +import type { StashNode } from '../stashNode'; +import type { StatusFileNode } from '../statusFileNode'; +import type { TagNode } from '../tagNode'; +import type { UncommittedFileNode } from '../UncommittedFileNode'; +import type { RepositoryFolderNode } from './repositoryFolderNode'; + +export const enum ContextValues { + ActiveFileHistory = 'gitlens:history:active:file', + ActiveLineHistory = 'gitlens:history:active:line', + AutolinkedItems = 'gitlens:autolinked:items', + AutolinkedItem = 'gitlens:autolinked:item', + Branch = 'gitlens:branch', + Branches = 'gitlens:branches', + BranchStatusAheadOfUpstream = 'gitlens:status-branch:upstream:ahead', + BranchStatusBehindUpstream = 'gitlens:status-branch:upstream:behind', + BranchStatusMissingUpstream = 'gitlens:status-branch:upstream:missing', + BranchStatusNoUpstream = 'gitlens:status-branch:upstream:none', + BranchStatusSameAsUpstream = 'gitlens:status-branch:upstream:same', + BranchStatusFiles = 'gitlens:status-branch:files', + CodeSuggestions = 'gitlens:drafts:code-suggestions', + Commit = 'gitlens:commit', + Commits = 'gitlens:commits', + Compare = 'gitlens:compare', + CompareBranch = 'gitlens:compare:branch', + ComparePicker = 'gitlens:compare:picker', + ComparePickerWithRef = 'gitlens:compare:picker:ref', + CompareResults = 'gitlens:compare:results', + CompareResultsCommits = 'gitlens:compare:results:commits', + Contributor = 'gitlens:contributor', + Contributors = 'gitlens:contributors', + DateMarker = 'gitlens:date-marker', + Draft = 'gitlens:draft', + File = 'gitlens:file', + FileHistory = 'gitlens:history:file', + Folder = 'gitlens:folder', + Grouping = 'gitlens:grouping', + LineHistory = 'gitlens:history:line', + Merge = 'gitlens:merge', + MergeConflictCurrentChanges = 'gitlens:merge-conflict:current', + MergeConflictIncomingChanges = 'gitlens:merge-conflict:incoming', + Message = 'gitlens:message', + MessageSignIn = 'gitlens:message:signin', + Pager = 'gitlens:pager', + PullRequest = 'gitlens:pullrequest', + Rebase = 'gitlens:rebase', + Reflog = 'gitlens:reflog', + ReflogRecord = 'gitlens:reflog-record', + Remote = 'gitlens:remote', + Remotes = 'gitlens:remotes', + Repositories = 'gitlens:repositories', + Repository = 'gitlens:repository', + RepositoryFolder = 'gitlens:repo-folder', + ResultsFile = 'gitlens:file:results', + ResultsFiles = 'gitlens:results:files', + SearchAndCompare = 'gitlens:searchAndCompare', + SearchResults = 'gitlens:search:results', + SearchResultsCommits = 'gitlens:search:results:commits', + Stash = 'gitlens:stash', + Stashes = 'gitlens:stashes', + StatusFileCommits = 'gitlens:status:file:commits', + StatusFiles = 'gitlens:status:files', + StatusAheadOfUpstream = 'gitlens:status:upstream:ahead', + StatusBehindUpstream = 'gitlens:status:upstream:behind', + StatusMissingUpstream = 'gitlens:status:upstream:missing', + StatusNoUpstream = 'gitlens:status:upstream:none', + StatusSameAsUpstream = 'gitlens:status:upstream:same', + Tag = 'gitlens:tag', + Tags = 'gitlens:tags', + UncommittedFiles = 'gitlens:uncommitted:files', + Workspace = 'gitlens:workspace', + WorkspaceMissingRepository = 'gitlens:workspaceMissingRepository', + Workspaces = 'gitlens:workspaces', + Worktree = 'gitlens:worktree', + Worktrees = 'gitlens:worktrees', +} + +export interface AmbientContext { + readonly autolinksId?: string; + readonly branch?: GitBranch; + readonly branchStatus?: BranchTrackingStatus; + readonly branchStatusUpstreamType?: 'ahead' | 'behind' | 'same' | 'missing' | 'none'; + readonly commit?: GitCommit; + readonly comparisonId?: string; + readonly comparisonFiltered?: boolean; + readonly contributor?: GitContributor; + readonly draft?: Draft; + readonly file?: GitFile; + readonly pullRequest?: PullRequest; + readonly reflog?: GitReflogRecord; + readonly remote?: GitRemote; + readonly repository?: Repository; + readonly root?: boolean; + readonly searchId?: string; + readonly status?: 'merging' | 'rebasing'; + readonly storedComparisonId?: string; + readonly tag?: GitTag; + readonly workspace?: CloudWorkspace | LocalWorkspace; + readonly wsRepositoryDescriptor?: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor; + readonly worktree?: GitWorktree; + + readonly openWorktreeBranches?: Set; +} + +export function getViewNodeId(type: string, context: AmbientContext): string { + let uniqueness = ''; + if (context.root) { + uniqueness += '/root'; + } + if (context.workspace != null) { + uniqueness += `/ws/${context.workspace.id}`; + } + if (context.wsRepositoryDescriptor != null) { + uniqueness += `/wsrepo/${context.wsRepositoryDescriptor.id}`; + } + if (context.repository != null) { + uniqueness += `/repo/${context.repository.id}`; + } + if (context.worktree != null) { + uniqueness += `/worktree/${context.worktree.uri.path}`; + } + if (context.remote != null) { + uniqueness += `/remote/${context.remote.name}`; + } + if (context.tag != null) { + uniqueness += `/tag/${context.tag.id}`; + } + if (context.branch != null) { + uniqueness += `/branch/${context.branch.id}`; + } + if (context.branchStatus != null) { + uniqueness += `/branch-status/${context.branchStatus.upstream?.name ?? '-'}`; + } + if (context.branchStatusUpstreamType != null) { + uniqueness += `/branch-status-direction/${context.branchStatusUpstreamType}`; + } + if (context.pullRequest != null) { + uniqueness += `/pr/${context.pullRequest.id}`; + } + if (context.status != null) { + uniqueness += `/status/${context.status}`; + } + if (context.reflog != null) { + uniqueness += `/reflog/${context.reflog.sha}+${context.reflog.selector}+${context.reflog.command}+${ + context.reflog.commandArgs ?? '' + }+${context.reflog.date.getTime()}`; + } + if (context.contributor != null) { + uniqueness += `/contributor/${ + context.contributor.id ?? + `${context.contributor.username}+${context.contributor.email}+${context.contributor.name}` + }`; + } + if (context.autolinksId != null) { + uniqueness += `/autolinks/${context.autolinksId}`; + } + if (context.comparisonId != null) { + uniqueness += `/comparison/${context.comparisonId}`; + } + if (context.searchId != null) { + uniqueness += `/search/${context.searchId}`; + } + if (context.commit != null) { + uniqueness += `/commit/${context.commit.sha}`; + } + if (context.file != null) { + uniqueness += `/file/${context.file.path}+${context.file.status}`; + } + if (context.draft != null) { + uniqueness += `/draft/${context.draft.id}`; + } + + return `gitlens://viewnode/${type}${uniqueness}`; +} + +export type ClipboardType = 'text' | 'markdown'; + +@logName((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`) +export abstract class ViewNode< + Type extends TreeViewNodeTypes = TreeViewNodeTypes, + TView extends View = View, + State extends object = any, +> implements Disposable +{ + is(type: T): this is TreeViewNodesByType[T] { + return this.type === (type as unknown as Type); + } + + isAny(...types: T): this is TreeViewNodesByType[T[number]] { + return types.includes(this.type as unknown as T[number]); + } + + protected _uniqueId!: string; + protected splatted = false; + // NOTE: @eamodio uncomment to track node leaks + // readonly uuid = uuid(); + + constructor( + public readonly type: Type, + // public readonly id: string | undefined, + uri: GitUri, + public readonly view: TView, + protected parent?: ViewNode, + ) { + // NOTE: @eamodio uncomment to track node leaks + // queueMicrotask(() => this.view.registerNode(this)); + this._uri = uri; + } + + protected _disposed = false; + // NOTE: @eamodio uncomment to track node leaks + // @debug() + dispose() { + this._disposed = true; + // NOTE: @eamodio uncomment to track node leaks + // this.view.unregisterNode(this); + } + + get id(): string | undefined { + return this._uniqueId; + } + + private _context: AmbientContext | undefined; + protected get context(): AmbientContext { + return this._context ?? this.parent?.context ?? {}; + } + + protected updateContext(context: AmbientContext, reset: boolean = false) { + this._context = this.getNewContext(context, reset); + } + + protected getNewContext(context: AmbientContext, reset: boolean = false) { + return { ...(reset ? this.parent?.context : this.context), ...context }; + } + + getUrl?(): string | Promise | undefined; + toClipboard?(type?: ClipboardType): string | Promise; + + toString(): string { + return getLoggableName(this); + } + + protected _uri: GitUri; + get uri(): GitUri { + return this._uri; + } + + abstract getChildren(): ViewNode[] | Promise; + + getParent(): ViewNode | undefined { + // If this node's parent has been splatted (e.g. not shown itself, but its children are), then return its grandparent + return this.parent?.splatted ? this.parent?.getParent() : this.parent; + } + + abstract getTreeItem(): TreeItem | Promise; + + resolveTreeItem?(item: TreeItem, token: CancellationToken): TreeItem | Promise; + + getCommand(): Command | undefined { + return undefined; + } + + refresh?(reset?: boolean): boolean | void | Promise | Promise; + + @gate() + @debug() + triggerChange(reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode): Promise { + if (this._disposed) return Promise.resolve(); + + // If this node has been splatted (e.g. not shown itself, but its children are), then delegate the change to its parent + if (this.splatted && this.parent != null && this.parent !== avoidSelf) { + return this.parent.triggerChange(reset, force); + } + + return this.view.refreshNode(this, reset, force); + } + + getSplattedChild?(): Promise; + + deleteState = StateKey>(key?: T): void { + if (this.id == null) { + debugger; + throw new Error('Id is required to delete state'); + } + this.view.nodeState.deleteState(this.id, key as string); + } + + getState = StateKey>(key: T): StateValue | undefined { + if (this.id == null) { + debugger; + throw new Error('Id is required to get state'); + } + return this.view.nodeState.getState(this.id, key as string); + } + + storeState = StateKey>( + key: T, + value: StateValue, + sticky?: boolean, + ): void { + if (this.id == null) { + debugger; + throw new Error('Id is required to store state'); + } + this.view.nodeState.storeState(this.id, key as string, value, sticky); + } +} + +type StateKey = keyof T; +type StateValue> = P extends keyof T ? T[P] : never; + +export interface PageableViewNode extends ViewNode { + readonly id: string; + limit?: number; + readonly hasMore: boolean; + loadMore(limit?: number | { until?: string | undefined }, context?: Record): Promise; +} + +export function isPageableViewNode(node: ViewNode): node is ViewNode & PageableViewNode { + return isA(node, 'loadMore'); +} + +interface AutoRefreshableView { + autoRefresh: boolean; + onDidChangeAutoRefresh: Event; +} + +export function canAutoRefreshView(view: View): view is View & AutoRefreshableView { + return isA(view, 'onDidChangeAutoRefresh'); +} + +export function canEditNode(node: ViewNode): node is ViewNode & { edit(): void | Promise } { + return typeof (node as ViewNode & { edit(): void | Promise }).edit === 'function'; +} + +export function canGetNodeRepoPath(node?: ViewNode): node is ViewNode & { repoPath: string | undefined } { + return node != null && 'repoPath' in node && typeof node.repoPath === 'string'; +} + +export function canViewDismissNode(view: View): view is View & { dismissNode(node: ViewNode): void } { + return typeof (view as View & { dismissNode(node: ViewNode): void }).dismissNode === 'function'; +} + +export function getNodeRepoPath(node?: ViewNode): string | undefined { + return canGetNodeRepoPath(node) ? node.repoPath : undefined; +} + +// prettier-ignore +type TreeViewNodesByType = { + [T in TreeViewNodeTypes]: T extends 'branch' + ? BranchNode + : T extends 'commit' + ? CommitNode + : T extends 'commit-file' + ? CommitFileNode + : T extends 'compare-branch' + ? CompareBranchNode + : T extends 'compare-results' + ? CompareResultsNode + : T extends 'conflict-file' + ? MergeConflictFileNode + : T extends 'drafts-code-suggestions' + ? CodeSuggestionsNode + : T extends 'file-commit' + ? FileRevisionAsCommitNode + : T extends 'folder' + ? FolderNode + : T extends 'line-history-tracker' + ? LineHistoryTrackerNode + : T extends 'repository' + ? RepositoryNode + : T extends 'repo-folder' + ? RepositoryFolderNode + : T extends 'results-commits' + ? ResultsCommitsNode + : T extends 'results-file' + ? ResultsFileNode + : T extends 'results-files' + ? ResultsFilesNode + : T extends 'stash' + ? StashNode + : T extends 'stash-file' + ? StashFileNode + : T extends 'status-file' + ? StatusFileNode + : T extends 'tag' + ? TagNode + : T extends 'tracking-status' + ? BranchTrackingStatusNode + : T extends 'tracking-status-files' + ? BranchTrackingStatusFilesNode + : T extends 'uncommitted-file' + ? UncommittedFileNode + : ViewNode; +}; + +export function isViewNode(node: unknown): node is ViewNode; +export function isViewNode(node: unknown, type: T): node is TreeViewNodesByType[T]; +export function isViewNode(node: unknown, type?: T): node is ViewNode { + if (node == null) return false; + return node instanceof ViewNode ? type == null || node.type === type : false; +} diff --git a/src/views/nodes/abstract/viewRefNode.ts b/src/views/nodes/abstract/viewRefNode.ts new file mode 100644 index 0000000000000..8c7a7f369cc14 --- /dev/null +++ b/src/views/nodes/abstract/viewRefNode.ts @@ -0,0 +1,45 @@ +import type { TreeViewRefFileNodeTypes, TreeViewRefNodeTypes } from '../../../constants'; +import type { GitUri } from '../../../git/gitUri'; +import type { GitReference, GitRevisionReference } from '../../../git/models/reference'; +import { getReferenceLabel } from '../../../git/models/reference'; +import type { View } from '../../viewBase'; +import { ViewFileNode } from './viewFileNode'; +import { ViewNode } from './viewNode'; + +export abstract class ViewRefNode< + Type extends TreeViewRefNodeTypes = TreeViewRefNodeTypes, + TView extends View = View, + TReference extends GitReference = GitReference, + State extends object = any, +> extends ViewNode { + constructor( + type: Type, + uri: GitUri, + view: TView, + protected override readonly parent: ViewNode, + ) { + super(type, uri, view, parent); + } + + abstract get ref(): TReference; + + get repoPath(): string { + return this.uri.repoPath!; + } + + override toString(): string { + return `${super.toString()}:${getReferenceLabel(this.ref, false)}`; + } +} + +export abstract class ViewRefFileNode< + Type extends TreeViewRefFileNodeTypes = TreeViewRefFileNodeTypes, + TView extends View = View, + State extends object = any, +> extends ViewFileNode { + abstract get ref(): GitRevisionReference; + + override toString(): string { + return `${super.toString()}:${this.file.path}`; + } +} diff --git a/src/views/nodes/autolinkedItemNode.ts b/src/views/nodes/autolinkedItemNode.ts index 2da9817e4c211..d9d3161caed85 100644 --- a/src/views/nodes/autolinkedItemNode.ts +++ b/src/views/nodes/autolinkedItemNode.ts @@ -6,26 +6,40 @@ import { getIssueOrPullRequestMarkdownIcon, getIssueOrPullRequestThemeIcon } fro import { fromNow } from '../../system/date'; import { isPromise } from '../../system/promise'; import type { ViewsWithCommits } from '../viewBase'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; +import type { ClipboardType } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; -export class AutolinkedItemNode extends ViewNode { +export class AutolinkedItemNode extends ViewNode<'autolink', ViewsWithCommits> { constructor( view: ViewsWithCommits, protected override readonly parent: ViewNode, public readonly repoPath: string, public readonly item: Autolink, - private enrichedItem: Promise | IssueOrPullRequest | undefined, + private maybeEnriched: Promise | IssueOrPullRequest | undefined, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('autolink', GitUri.fromRepoPath(repoPath), view, parent); - this._uniqueId = getViewNodeId(`autolink+${item.id}`, this.context); + this._uniqueId = getViewNodeId(`${this.type}+${item.id}`, this.context); } override get id(): string { return this._uniqueId; } - override toClipboard(): string { + override async toClipboard(type?: ClipboardType): Promise { + const enriched = await this.maybeEnriched; + switch (type) { + case 'markdown': { + return `[${this.item.prefix ?? ''}${this.item.id}](${this.item.url})${ + enriched?.title ? ` - ${enriched?.title}` : '' + }`; + } + default: + return `${this.item.id}: ${enriched?.title ?? this.item.url}`; + } + } + + override getUrl(): string { return this.item.url; } @@ -34,11 +48,11 @@ export class AutolinkedItemNode extends ViewNode { } getTreeItem(): TreeItem { - const enriched = this.enrichedItem; + const enriched = this.maybeEnriched; const pending = isPromise(enriched); if (pending) { void enriched.then(item => { - this.enrichedItem = item; + this.maybeEnriched = item; this.view.triggerNodeChange(this); }); } @@ -56,10 +70,10 @@ export class AutolinkedItemNode extends ViewNode { pending ? 'loading~spin' : autolink.type == null - ? 'link' - : autolink.type === 'pullrequest' - ? 'git-pull-request' - : 'issues', + ? 'link' + : autolink.type === 'pullrequest' + ? 'git-pull-request' + : 'issues', ); item.contextValue = ContextValues.AutolinkedItem; item.tooltip = new MarkdownString( @@ -70,20 +84,20 @@ export class AutolinkedItemNode extends ViewNode { autolink.type == null ? 'Autolinked' : autolink.type === 'pullrequest' - ? 'Autolinked Pull Request' - : 'Autolinked Issue' + ? 'Autolinked Pull Request' + : 'Autolinked Issue' } ${autolink.prefix}${autolink.id}` } \\\n[${autolink.url}](${autolink.url}${autolink.title != null ? ` "${autolink.title}"` : ''})`, ); return item; } - const relativeTime = fromNow(enriched.closedDate ?? enriched.date); + const relativeTime = fromNow(enriched.closedDate ?? enriched.updatedDate ?? enriched.createdDate); const item = new TreeItem(`${enriched.id}: ${enriched.title}`, TreeItemCollapsibleState.None); item.description = relativeTime; item.iconPath = getIssueOrPullRequestThemeIcon(enriched); - item.contextValue = enriched.type === 'pullrequest' ? ContextValues.PullRequest : ContextValues.AutolinkedIssue; + item.contextValue = `${ContextValues.AutolinkedItem}+${enriched.type === 'pullrequest' ? 'pr' : 'issue'}`; const linkTitle = ` "Open ${enriched.type === 'pullrequest' ? 'Pull Request' : 'Issue'} \\#${enriched.id} on ${ enriched.provider.name @@ -92,7 +106,7 @@ export class AutolinkedItemNode extends ViewNode { `${getIssueOrPullRequestMarkdownIcon(enriched)} [**${enriched.title.trim()}**](${ enriched.url }${linkTitle}) \\\n[#${enriched.id}](${enriched.url}${linkTitle}) was ${ - enriched.closed ? 'closed' : 'opened' + enriched.closed ? (enriched.state === 'merged' ? 'merged' : 'closed') : 'opened' } ${relativeTime}`, true, ); diff --git a/src/views/nodes/autolinkedItemsNode.ts b/src/views/nodes/autolinkedItemsNode.ts index 37f2ade27c337..f44c1b162296c 100644 --- a/src/views/nodes/autolinkedItemsNode.ts +++ b/src/views/nodes/autolinkedItemsNode.ts @@ -1,44 +1,41 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; import type { GitLog } from '../../git/models/log'; -import { PullRequest } from '../../git/models/pullRequest'; -import { pauseOnCancelOrTimeoutMapTuple } from '../../system/cancellation'; -import { gate } from '../../system/decorators/gate'; -import { debug } from '../../system/decorators/log'; -import { getSettledValue } from '../../system/promise'; +import { isPullRequest } from '../../git/models/pullRequest'; +import { getSettledValue, pauseOnCancelOrTimeoutMapTuple } from '../../system/promise'; import type { ViewsWithCommits } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { AutolinkedItemNode } from './autolinkedItemNode'; import { LoadMoreNode, MessageNode } from './common'; import { PullRequestNode } from './pullRequestNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; let instanceId = 0; -export class AutolinkedItemsNode extends ViewNode { +export class AutolinkedItemsNode extends CacheableChildrenViewNode<'autolinks', ViewsWithCommits> { private _instanceId: number; constructor( view: ViewsWithCommits, - protected override readonly parent: ViewNode, + protected override readonly parent: PageableViewNode, public readonly repoPath: string, public readonly log: GitLog, private expand: boolean, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('autolinks', GitUri.fromRepoPath(repoPath), view, parent); this._instanceId = instanceId++; this.updateContext({ autolinksId: String(this._instanceId) }); - this._uniqueId = getViewNodeId('autolinks', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { return this._uniqueId; } - private _children: ViewNode[] | undefined; - async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const commits = [...this.log.commits.values()]; let children: ViewNode[] | undefined; @@ -66,7 +63,7 @@ export class AutolinkedItemsNode extends ViewNode { if (enrichedAutolinks?.size) { children = [...enrichedAutolinks.values()].map(([issueOrPullRequest, autolink]) => - issueOrPullRequest != null && PullRequest.is(issueOrPullRequest?.value) + issueOrPullRequest != null && isPullRequest(issueOrPullRequest?.value) ? new PullRequestNode(this.view, this, issueOrPullRequest.value, this.log.repoPath) : new AutolinkedItemNode( this.view, @@ -85,16 +82,16 @@ export class AutolinkedItemsNode extends ViewNode { if (this.log.hasMore) { children.push( - new LoadMoreNode(this.view, this.parent as any, children[children.length - 1], { + new LoadMoreNode(this.view, this.parent, children[children.length - 1], { context: { expandAutolinks: true }, message: 'Load more commits to search for autolinks', }), ); } - this._children = children; + this.children = children; } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -107,12 +104,4 @@ export class AutolinkedItemsNode extends ViewNode { return item; } - - @gate() - @debug() - override refresh(reset: boolean = false) { - if (!reset) return; - - this._children = undefined; - } } diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index c28d8d14b3ce6..c2cbdc1e531f4 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -1,24 +1,28 @@ import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window } from 'vscode'; import type { ViewShowBranchComparison } from '../../config'; -import type { Colors } from '../../constants'; +import type { Colors, StoredBranchComparison } from '../../constants'; import { GlyphChars } from '../../constants'; import type { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; import type { PullRequest, PullRequestState } from '../../git/models/pullRequest'; import type { GitBranchReference } from '../../git/models/reference'; -import { GitRemote } from '../../git/models/remote'; +import { shortenRevision } from '../../git/models/reference'; +import { getHighlanderProviders } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; import type { GitUser } from '../../git/models/user'; import { getContext } from '../../system/context'; import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; +import { log } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import type { Deferred } from '../../system/promise'; import { defer, getSettledValue } from '../../system/promise'; import { pad } from '../../system/string'; -import { RemotesView } from '../remotesView'; import type { ViewsWithBranches } from '../viewBase'; +import { disposeChildren } from '../viewBase'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefNode } from './abstract/viewRefNode'; import { BranchTrackingStatusNode } from './branchTrackingStatusNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; @@ -27,27 +31,31 @@ import { insertDateMarkers } from './helpers'; import { MergeStatusNode } from './mergeStatusNode'; import { PullRequestNode } from './pullRequestNode'; import { RebaseStatusNode } from './rebaseStatusNode'; -import type { PageableViewNode, ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewRefNode } from './viewNode'; type State = { pullRequest: PullRequest | null | undefined; pendingPullRequest: Promise | undefined; }; -export class BranchNode extends ViewRefNode implements PageableViewNode { +type Options = { + expand: boolean; + limitCommits: boolean; + showAsCommits: boolean; + showComparison: false | ViewShowBranchComparison; + showCurrentOrOpened: boolean; + showMergeCommits?: boolean; + showStatus: boolean; + showTracking: boolean; + authors?: GitUser[]; +}; + +export class BranchNode + extends ViewRefNode<'branch', ViewsWithBranches, GitBranchReference, State> + implements PageableViewNode +{ limit: number | undefined; - private readonly options: { - expanded: boolean; - limitCommits: boolean; - showAsCommits: boolean; - showComparison: false | ViewShowBranchComparison; - showCurrent: boolean; - showStatus: boolean; - showTracking: boolean; - authors?: GitUser[]; - }; + private readonly options: Options; protected override splatted = true; constructor( @@ -58,30 +66,21 @@ export class BranchNode extends ViewRefNode, ) { - super(uri, view, parent); + super('branch', uri, view, parent); this.updateContext({ repository: repo, branch: branch, root: root }); - this._uniqueId = getViewNodeId('branch', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this.limit = this.view.getNodeLastKnownLimit(this); this.options = { - expanded: false, + expand: false, limitCommits: false, showAsCommits: false, showComparison: false, // Hide the current branch checkmark when the node is displayed as a root - showCurrent: !this.root, + showCurrentOrOpened: !this.root, // Don't show merge/rebase status info the node is displayed as a root showStatus: true, //!this.root, // Don't show tracking info the node is displayed as a root @@ -90,6 +89,11 @@ export class BranchNode extends ViewRefNode { - if (this._children == null) { + if (this.children == null) { const branch = this.branch; let onCompleted: Deferred | undefined; let pullRequest; + let pullRequestInsertIndex = 0; + + function getPullRequestComparison(pr: PullRequest | null | undefined): StoredBranchComparison | undefined { + if (pr?.refs?.base == null && pr?.refs?.head == null) return undefined; + + return { + ref: pr.refs.base.sha, + label: `${pr.refs.base.branch} (${shortenRevision(pr.refs.base.sha)})`, + notation: '...', + type: 'branch', + checkedFiles: [], + }; + } if ( this.view.config.pullRequests.enabled && this.view.config.pullRequests.showForBranches && (branch.upstream != null || branch.remote) && - getContext('gitlens:hasConnectedRemotes') + getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(branch.repoPath) ) { pullRequest = this.getState('pullRequest'); if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { @@ -167,12 +198,24 @@ export class BranchNode extends ViewRefNode + n.is('compare-branch'), + ); + if (comparisonNode != null) { + const comparison = getPullRequestComparison(pr); + if (comparison != null) { + await comparisonNode.setDefaultCompareWith(comparison); + } + } + } } // Refresh this node to add the pull request node or remove the spinner @@ -206,6 +249,7 @@ export class BranchNode extends ViewRefNode onCompleted?.fulfill(), 1); } - return this._children; + return this.children; } async getTreeItem(): Promise { this.splatted = false; let tooltip: string | MarkdownString = `${ - this.current ? 'Current branch' : 'Branch' - } $(git-branch) ${this.branch.getNameWithoutRemote()}${this.branch.rebasing ? ' (Rebasing)' : ''}`; + this.current ? 'Current branch\\\n' : this.opened ? 'Current branch in an opened worktree\\\n' : '' + }\`${this.branch.getNameWithoutRemote()}\`${this.branch.rebasing ? ' (Rebasing)' : ''}`; let contextValue: string = ContextValues.Branch; if (this.current) { @@ -360,7 +413,7 @@ export class BranchNode extends ViewRefNode this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; @@ -580,7 +653,7 @@ export class BranchNode extends ViewRefNode { constructor( view: View, protected override readonly parent: ViewNode, - public readonly type: 'branch' | 'remote-branch' | 'tag', + public readonly folderType: 'branch' | 'remote-branch' | 'tag', public readonly root: HierarchicalItem, public readonly repoPath: string, public readonly folderName: string, public readonly relativePath: string | undefined, - private readonly _expanded: boolean = false, + private readonly _expand: boolean = false, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('branch-tag-folder', GitUri.fromRepoPath(repoPath), view, parent); - this._uniqueId = getViewNodeId(`${type}-folder+${relativePath ?? folderName}`, this.context); + this._uniqueId = getViewNodeId(`${this.type}+${folderType}+${relativePath ?? folderName}`, this.context); } override get id(): string { @@ -38,17 +38,17 @@ export class BranchOrTagFolderNode extends ViewNode { for (const folder of this.root.children.values()) { if (folder.value === undefined) { // If the folder contains the current branch, expand it by default - const expanded = folder.descendants?.some(n => n instanceof BranchNode && n.current); + const expand = folder.descendants?.some(n => n.is('branch') && (n.current || n.opened)); children.push( new BranchOrTagFolderNode( this.view, this.folderName ? this : this.parent, - this.type, + this.folderType, folder, this.repoPath, folder.name, folder.relativePath, - expanded, + expand, ), ); continue; @@ -65,7 +65,7 @@ export class BranchOrTagFolderNode extends ViewNode { getTreeItem(): TreeItem { const item = new TreeItem( this.label, - this._expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, + this._expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, ); item.id = this.id; item.contextValue = ContextValues.Folder; diff --git a/src/views/nodes/branchTrackingStatusFilesNode.ts b/src/views/nodes/branchTrackingStatusFilesNode.ts index ac315c60061f0..dcb963876a3ed 100644 --- a/src/views/nodes/branchTrackingStatusFilesNode.ts +++ b/src/views/nodes/branchTrackingStatusFilesNode.ts @@ -1,20 +1,21 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import type { FilesComparison } from '../../git/actions/commit'; import { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import type { GitFileWithCommit } from '../../git/models/file'; import { createRevisionRange } from '../../git/models/reference'; -import { groupBy, makeHierarchical } from '../../system/array'; -import { filter, flatMap, map } from '../../system/iterable'; +import { makeHierarchical } from '../../system/array'; +import { filter, flatMap, groupByMap, map } from '../../system/iterable'; import { joinPaths, normalizePath } from '../../system/path'; import { pluralize, sortCompare } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import type { BranchTrackingStatus } from './branchTrackingStatusNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { StatusFileNode } from './statusFileNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class BranchTrackingStatusFilesNode extends ViewNode { +export class BranchTrackingStatusFilesNode extends ViewNode<'tracking-status-files', ViewsWithCommits> { constructor( view: ViewsWithCommits, protected override readonly parent: ViewNode, @@ -22,57 +23,76 @@ export class BranchTrackingStatusFilesNode extends ViewNode { public readonly status: Required, public readonly direction: 'ahead' | 'behind', ) { - super(GitUri.fromRepoPath(status.repoPath), view, parent); + super('tracking-status-files', GitUri.fromRepoPath(status.repoPath), view, parent); this.updateContext({ branch: branch, branchStatus: status, branchStatusUpstreamType: direction }); - this._uniqueId = getViewNodeId('tracking-status-files', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); + } + + get ref1(): string { + return this.branch.ref; + } + + get ref2(): string { + return this.status.upstream?.name; } get repoPath(): string { return this.status.repoPath; } - async getChildren(): Promise { + async getFilesComparison(): Promise { + const grouped = await this.getGroupedFiles(); + return { + files: [...map(grouped, ([, files]) => files[files.length - 1])], + repoPath: this.repoPath, + ref1: this.ref1, + ref2: this.ref2, + title: this.direction === 'ahead' ? `Changes to push to ${this.ref2}` : `Changes to pull from ${this.ref2}`, + }; + } + + private async getGroupedFiles(): Promise> { const log = await this.view.container.git.getLog(this.repoPath, { limit: 0, - ref: createRevisionRange(this.status.upstream, this.branch.ref, this.direction === 'behind' ? '...' : '..'), + ref: createRevisionRange(this.ref2, this.ref1, this.direction === 'behind' ? '...' : '..'), }); + if (log == null) return new Map(); - let files: GitFileWithCommit[]; - - if (log != null) { - await Promise.allSettled( - map( - filter(log.commits.values(), c => c.files == null), - c => c.ensureFullDetails(), - ), - ); + await Promise.allSettled( + map( + filter(log.commits.values(), c => c.files == null), + c => c.ensureFullDetails(), + ), + ); - files = [ - ...flatMap( - log.commits.values(), - c => c.files?.map(f => ({ ...f, commit: c })) ?? [], - ), - ]; - } else { - files = []; - } + const files = [ + ...flatMap(log.commits.values(), c => c.files?.map(f => ({ ...f, commit: c })) ?? []), + ]; files.sort((a, b) => b.commit.date.getTime() - a.commit.date.getTime()); - const groups = groupBy(files, s => s.path); - - let children: FileNode[] = Object.values(groups).map( - files => - new StatusFileNode( - this.view, - this, - files[files.length - 1], - this.repoPath, - files.map(s => s.commit), - this.direction, - ), - ); + const groups = groupByMap(files, s => s.path); + return groups; + } + + async getChildren(): Promise { + const files = await this.getGroupedFiles(); + + let children: FileNode[] = [ + ...map( + files.values(), + files => + new StatusFileNode( + this.view, + this, + files[files.length - 1], + this.repoPath, + files.map(s => s.commit), + this.direction, + ), + ), + ]; if (this.view.config.files.layout !== 'list') { const hierarchy = makeHierarchical( @@ -94,7 +114,7 @@ export class BranchTrackingStatusFilesNode extends ViewNode { async getTreeItem(): Promise { const stats = await this.view.container.git.getChangedFilesCount( this.repoPath, - `${this.status.upstream}${this.direction === 'behind' ? '..' : '...'}`, + this.direction === 'behind' ? `${this.ref1}...${this.ref2}` : `${this.ref2}...`, ); const files = stats?.changedFiles ?? 0; diff --git a/src/views/nodes/branchTrackingStatusNode.ts b/src/views/nodes/branchTrackingStatusNode.ts index d0e72f567dbc7..d603d46ec4040 100644 --- a/src/views/nodes/branchTrackingStatusNode.ts +++ b/src/views/nodes/branchTrackingStatusNode.ts @@ -1,32 +1,38 @@ import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; import type { Colors } from '../../constants'; +import type { FilesComparison } from '../../git/actions/commit'; import { GitUri } from '../../git/gitUri'; import type { GitBranch, GitTrackingState } from '../../git/models/branch'; import { getRemoteNameFromBranchName } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; import { createRevisionRange } from '../../git/models/reference'; -import { GitRemote } from '../../git/models/remote'; +import type { GitRemote } from '../../git/models/remote'; +import { getHighlanderProviders } from '../../git/models/remote'; +import { getUpstreamStatus } from '../../git/models/status'; import { fromNow } from '../../system/date'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import { first, map } from '../../system/iterable'; +import { first, last, map } from '../../system/iterable'; import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; +import type { PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { BranchTrackingStatusFilesNode } from './branchTrackingStatusFilesNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode } from './common'; import { insertDateMarkers } from './helpers'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; export interface BranchTrackingStatus { ref: string; repoPath: string; state: GitTrackingState; - upstream?: string; + upstream?: { name: string; missing: boolean }; } -export class BranchTrackingStatusNode extends ViewNode implements PageableViewNode { +export class BranchTrackingStatusNode + extends ViewNode<'tracking-status', ViewsWithCommits> + implements PageableViewNode +{ limit: number | undefined; constructor( @@ -34,14 +40,15 @@ export class BranchTrackingStatusNode extends ViewNode impleme protected override readonly parent: ViewNode, public readonly branch: GitBranch, public readonly status: BranchTrackingStatus, - public readonly upstreamType: 'ahead' | 'behind' | 'same' | 'none', + public readonly upstreamType: 'ahead' | 'behind' | 'same' | 'missing' | 'none', // Specifies that the node is shown as a root public readonly root: boolean = false, private readonly options?: { showAheadCommits?: boolean; + unpublishedCommits?: Set; }, ) { - super(GitUri.fromRepoPath(status.repoPath), view, parent); + super('tracking-status', GitUri.fromRepoPath(status.repoPath), view, parent); this.updateContext({ branch: branch, @@ -49,7 +56,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme branchStatusUpstreamType: upstreamType, root: root, }); - this._uniqueId = getViewNodeId('tracking-status', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this.limit = this.view.getNodeLastKnownLimit(this); } @@ -61,8 +68,40 @@ export class BranchTrackingStatusNode extends ViewNode impleme return this.uri.repoPath!; } + async getFilesComparison(): Promise { + // if we are ahead we don't actually add the files node, just each of its children individually + if (this.upstreamType === 'ahead') { + const node = new BranchTrackingStatusFilesNode( + this.view, + this, + this.branch, + this.status as Required, + this.upstreamType, + ); + + const comparison = await node?.getFilesComparison(); + if (comparison == null) return undefined; + + // Get the oldest unpublished (unpushed) commit + const ref = this.options?.unpublishedCommits != null ? last(this.options.unpublishedCommits) : undefined; + if (ref == null) return undefined; + + const resolved = await this.view.container.git.resolveReference(this.repoPath, `${ref}^`); + return { + ...comparison, + ref1: resolved, + ref2: comparison.ref1, + title: `Changes to push to ${comparison.ref2}`, + }; + } + + const children = await this.getChildren(); + const node = children.find(c => c.is('tracking-status-files')); + return node?.getFilesComparison(); + } + async getChildren(): Promise { - if (this.upstreamType === 'same' || this.upstreamType === 'none') return []; + if (this.upstreamType === 'same' || this.upstreamType === 'missing' || this.upstreamType === 'none') return []; const log = await this.getLog(); if (log == null) return []; @@ -138,11 +177,27 @@ export class BranchTrackingStatusNode extends ViewNode impleme async getTreeItem(): Promise { let lastFetched = 0; - if (this.upstreamType !== 'none') { + if (this.upstreamType !== 'missing' && this.upstreamType !== 'none') { const repo = this.view.container.git.getRepository(this.repoPath); lastFetched = (await repo?.getLastFetched()) ?? 0; } + function getBranchStatus(this: BranchTrackingStatusNode, remote: GitRemote | undefined) { + return `\`${this.branch.name}\` is ${getUpstreamStatus(this.status.upstream, this.status.state, { + empty: this.status.upstream!.missing + ? `missing upstream \`${this.status.upstream!.name}\`` + : `up to date with \`${this.status.upstream!.name}\`${ + remote?.provider?.name ? ` on ${remote.provider.name}` : '' + }`, + expand: true, + icons: true, + separator: ' and ', + suffix: ` \`${this.status.upstream!.name}\`${ + remote?.provider?.name ? ` on ${remote.provider.name}` : '' + }`, + })}`; + } + let label; let description; let collapsibleState; @@ -153,15 +208,16 @@ export class BranchTrackingStatusNode extends ViewNode impleme case 'ahead': { const remote = await this.branch.getRemote(); - label = `Changes to push to ${remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!)}${ - remote?.provider?.name ? ` on ${remote?.provider.name}` : '' + label = `Outgoing changes to ${ + remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!.name) }`; description = pluralize('commit', this.status.state.ahead); - tooltip = `Branch $(git-branch) ${this.branch.name} is ${pluralize('commit', this.status.state.ahead, { - infix: '$(arrow-up) ', - })} ahead of $(git-branch) ${this.status.upstream}${ - remote?.provider?.name ? ` on ${remote.provider.name}` : '' - }`; + tooltip = `${pluralize('commit', this.status.state.ahead)} to push to \`${ + this.status.upstream!.name + }\`${remote?.provider?.name ? ` on ${remote?.provider.name}` : ''}\\\n${getBranchStatus.call( + this, + remote, + )}`; collapsibleState = TreeItemCollapsibleState.Collapsed; contextValue = this.root @@ -177,15 +233,16 @@ export class BranchTrackingStatusNode extends ViewNode impleme case 'behind': { const remote = await this.branch.getRemote(); - label = `Changes to pull from ${remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!)}${ - remote?.provider?.name ? ` on ${remote.provider.name}` : '' + label = `Incoming changes from ${ + remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!.name) }`; description = pluralize('commit', this.status.state.behind); - tooltip = `Branch $(git-branch) ${this.branch.name} is ${pluralize('commit', this.status.state.behind, { - infix: '$(arrow-down) ', - })} behind $(git-branch) ${this.status.upstream}${ - remote?.provider?.name ? ` on ${remote.provider.name}` : '' - }`; + tooltip = `${pluralize('commit', this.status.state.behind)} to pull from \`${ + this.status.upstream!.name + }\`${remote?.provider?.name ? ` on ${remote.provider.name}` : ''}\\\n${getBranchStatus.call( + this, + remote, + )}`; collapsibleState = TreeItemCollapsibleState.Collapsed; contextValue = this.root @@ -201,13 +258,11 @@ export class BranchTrackingStatusNode extends ViewNode impleme case 'same': { const remote = await this.branch.getRemote(); - label = `Up to date with ${remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!)}${ + label = `Up to date with ${remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!.name)}${ remote?.provider?.name ? ` on ${remote.provider.name}` : '' }`; description = lastFetched ? `Last fetched ${fromNow(new Date(lastFetched))}` : ''; - tooltip = `Branch $(git-branch) ${this.branch.name} is up to date with $(git-branch) ${ - this.status.upstream - }${remote?.provider?.name ? ` on ${remote.provider.name}` : ''}`; + tooltip = getBranchStatus.call(this, remote); collapsibleState = TreeItemCollapsibleState.None; contextValue = this.root @@ -217,15 +272,31 @@ export class BranchTrackingStatusNode extends ViewNode impleme break; } + case 'missing': { + const remote = await this.branch.getRemote(); + + label = `Missing upstream branch${remote?.provider?.name ? ` on ${remote.provider.name}` : ''}`; + description = this.status.upstream!.name; + tooltip = getBranchStatus.call(this, remote); + + collapsibleState = TreeItemCollapsibleState.None; + contextValue = this.root + ? ContextValues.StatusMissingUpstream + : ContextValues.BranchStatusSameAsUpstream; + icon = new ThemeIcon( + 'warning', + new ThemeColor('gitlens.decorations.branchMissingUpstreamForegroundColor' satisfies Colors), + ); + + break; + } case 'none': { const remotes = await this.view.container.git.getRemotesWithProviders(this.branch.repoPath); - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); const providerName = providers?.length ? providers[0].name : undefined; label = `Publish ${this.branch.name} to ${providerName ?? 'a remote'}`; - tooltip = `Branch $(git-branch) ${this.branch.name} hasn't been published to ${ - providerName ?? 'a remote' - }`; + tooltip = `\`${this.branch.name}\` hasn't been published to ${providerName ?? 'a remote'}`; collapsibleState = TreeItemCollapsibleState.None; contextValue = this.root ? ContextValues.StatusNoUpstream : ContextValues.BranchStatusNoUpstream; @@ -271,8 +342,8 @@ export class BranchTrackingStatusNode extends ViewNode impleme if (this._log == null) { const range = this.upstreamType === 'ahead' - ? createRevisionRange(this.status.upstream, this.status.ref) - : createRevisionRange(this.status.ref, this.status.upstream); + ? createRevisionRange(this.status.upstream?.name, this.status.ref) + : createRevisionRange(this.status.ref, this.status.upstream?.name); this._log = await this.view.container.git.getLog(this.uri.repoPath!, { limit: this.limit ?? this.view.config.defaultItemLimit, @@ -295,7 +366,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/branchesNode.ts b/src/views/nodes/branchesNode.ts index d2bfc9550560b..901c6840199f0 100644 --- a/src/views/nodes/branchesNode.ts +++ b/src/views/nodes/branchesNode.ts @@ -2,26 +2,26 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; import { makeHierarchical } from '../../system/array'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import { RepositoriesView } from '../repositoriesView'; import type { ViewsWithBranchesNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { BranchNode } from './branchNode'; import { BranchOrTagFolderNode } from './branchOrTagFolderNode'; import { MessageNode } from './common'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class BranchesNode extends ViewNode { +export class BranchesNode extends CacheableChildrenViewNode<'branches', ViewsWithBranchesNode> { constructor( uri: GitUri, view: ViewsWithBranchesNode, protected override readonly parent: ViewNode, public readonly repo: Repository, ) { - super(uri, view, parent); + super('branches', uri, view, parent); this.updateContext({ repository: repo }); - this._uniqueId = getViewNodeId('branches', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -32,10 +32,8 @@ export class BranchesNode extends ViewNode { return this.repo.path; } - private _children: ViewNode[] | undefined; - async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const branches = await this.repo.getBranches({ // only show local branches filter: b => !b.remote, @@ -43,6 +41,13 @@ export class BranchesNode extends ViewNode { }); if (branches.values.length === 0) return [new MessageNode(this.view, this, 'No branches could be found.')]; + // if (configuration.get('views.collapseWorktreesWhenPossible')) { + // sortBranches(branches.values, { + // current: true, + // openWorktreeBranches: this.context.openWorktreeBranches, + // }); + // } + // TODO@eamodio handle paging const branchNodes = branches.values.map( b => @@ -55,7 +60,7 @@ export class BranchesNode extends ViewNode { false, { showComparison: - this.view instanceof RepositoriesView + this.view.type === 'repositories' ? this.view.config.branches.showBranchComparison : this.view.config.showBranchComparison, }, @@ -75,10 +80,10 @@ export class BranchesNode extends ViewNode { ); const root = new BranchOrTagFolderNode(this.view, this, 'branch', hierarchy, this.repo.path, '', undefined); - this._children = root.getChildren(); + this.children = root.getChildren(); } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -97,9 +102,8 @@ export class BranchesNode extends ViewNode { return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/nodes/codeSuggestionsNode.ts b/src/views/nodes/codeSuggestionsNode.ts new file mode 100644 index 0000000000000..860dd3b232fba --- /dev/null +++ b/src/views/nodes/codeSuggestionsNode.ts @@ -0,0 +1,60 @@ +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GitUri } from '../../git/gitUri'; +import type { PullRequest } from '../../git/models/pullRequest'; +import type { ViewsWithCommits } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { MessageNode } from './common'; +import { DraftNode } from './draftNode'; + +export class CodeSuggestionsNode extends CacheableChildrenViewNode<'drafts-code-suggestions', ViewsWithCommits> { + constructor( + view: ViewsWithCommits, + protected override parent: ViewNode, + public readonly repoPath: string, + private readonly pullRequest: PullRequest, + ) { + super('drafts-code-suggestions', GitUri.fromRepoPath(repoPath), view, parent); + + this._uniqueId = getViewNodeId(this.type, this.context); + } + + override get id(): string { + return this._uniqueId; + } + + async getChildren(): Promise { + if (this.children == null) { + const drafts = await this.getSuggestedChanges(); + + let children: ViewNode[] | undefined; + if (drafts?.length) { + drafts.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + children = drafts.map(d => new DraftNode(this.uri, this.view, this, d)); + } + + if (!children?.length) { + children = [new MessageNode(this.view, this, 'No code suggestions')]; + } + + this.children = children; + } + + return this.children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Code Suggestions', TreeItemCollapsibleState.Collapsed); + item.contextValue = ContextValues.CodeSuggestions; + return item; + } + + private async getSuggestedChanges() { + const repo = this.view.container.git.getRepository(this.repoPath); + if (repo == null) return []; + + const drafts = await this.view.container.drafts.getCodeSuggestions(this.pullRequest, repo); + return drafts; + } +} diff --git a/src/views/nodes/commitFileNode.ts b/src/views/nodes/commitFileNode.ts index 84860c19462c9..0d63ef0434bdb 100644 --- a/src/views/nodes/commitFileNode.ts +++ b/src/views/nodes/commitFileNode.ts @@ -1,6 +1,7 @@ import type { Command, Selection } from 'vscode'; import { MarkdownString, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; -import type { DiffWithPreviousCommandArgs } from '../../commands'; +import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; +import type { TreeViewRefFileNodeTypes } from '../../constants'; import { Commands, Schemes } from '../../constants'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; import { GitUri } from '../../git/gitUri'; @@ -11,27 +12,30 @@ import { getGitFileStatusIcon } from '../../git/models/file'; import type { GitRevisionReference } from '../../git/models/reference'; import { joinPaths, relativeDir } from '../../system/path'; import type { ViewsWithCommits, ViewsWithStashes } from '../viewBase'; -import type { ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewRefFileNode } from './viewNode'; - -export class CommitFileNode< - TView extends ViewsWithCommits | ViewsWithStashes = ViewsWithCommits, -> extends ViewRefFileNode { +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefFileNode } from './abstract/viewRefNode'; + +export abstract class CommitFileNodeBase< + Type extends TreeViewRefFileNodeTypes, + TView extends ViewsWithCommits | ViewsWithStashes, +> extends ViewRefFileNode { constructor( + type: Type, view: TView, parent: ViewNode, file: GitFile, public commit: GitCommit, - private readonly _options: { + private readonly options?: { branch?: GitBranch; selection?: Selection; unpublished?: boolean; - } = {}, + }, ) { - super(GitUri.fromFile(file, commit.repoPath, commit.sha), view, parent, file); + super(type, GitUri.fromFile(file, commit.repoPath, commit.sha), view, parent, file); this.updateContext({ commit: commit, file: file }); - this._uniqueId = getViewNodeId('commit-file', this.context); + this._uniqueId = getViewNodeId(type, this.context); } override get id(): string { @@ -106,9 +110,9 @@ export class CommitFileNode< protected get contextValue(): string { if (!this.commit.isUncommitted) { - return `${ContextValues.File}+committed${this._options.branch?.current ? '+current' : ''}${ - this._options.branch?.current && this._options.branch.sha === this.commit.ref ? '+HEAD' : '' - }${this._options.unpublished ? '+unpublished' : ''}`; + return `${ContextValues.File}+committed${this.options?.branch?.current ? '+current' : ''}${ + this.options?.branch?.current && this.options.branch.sha === this.commit.ref ? '+HEAD' : '' + }${this.options?.unpublished ? '+unpublished' : ''}`; } return this.commit.isUncommittedStaged ? `${ContextValues.File}+staged` : `${ContextValues.File}+unstaged`; @@ -165,7 +169,7 @@ export class CommitFileNode< if (this.commit.lines.length) { line = this.commit.lines[0].line - 1; } else { - line = this._options.selection?.active.line ?? 0; + line = this.options?.selection?.active.line ?? 0; } const commandArgs: DiffWithPreviousCommandArgs = { @@ -184,3 +188,19 @@ export class CommitFileNode< }; } } + +export class CommitFileNode extends CommitFileNodeBase<'commit-file', ViewsWithCommits> { + constructor( + view: ViewsWithCommits, + parent: ViewNode, + file: GitFile, + commit: GitCommit, + options?: { + branch?: GitBranch; + selection?: Selection; + unpublished?: boolean; + }, + ) { + super('commit-file', view, parent, file, commit, options); + } +} diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts index 30dc2f0af3af1..e5b3d99f8c984 100644 --- a/src/views/nodes/commitNode.ts +++ b/src/views/nodes/commitNode.ts @@ -1,6 +1,6 @@ -import type { Command } from 'vscode'; +import type { CancellationToken, Command } from 'vscode'; import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithPreviousCommandArgs } from '../../commands'; +import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; import type { Colors } from '../../constants'; import { Commands } from '../../constants'; import { CommitFormatter } from '../../git/formatters/commitFormatter'; @@ -9,45 +9,49 @@ import type { GitCommit } from '../../git/models/commit'; import type { PullRequest } from '../../git/models/pullRequest'; import type { GitRevisionReference } from '../../git/models/reference'; import type { GitRemote } from '../../git/models/remote'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +import type { RemoteProvider } from '../../git/remotes/remoteProvider'; import { makeHierarchical } from '../../system/array'; -import { pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/cancellation'; import { configuration } from '../../system/configuration'; import { getContext } from '../../system/context'; -import { gate } from '../../system/decorators/gate'; import { joinPaths, normalizePath } from '../../system/path'; import type { Deferred } from '../../system/promise'; -import { defer, getSettledValue } from '../../system/promise'; +import { defer, getSettledValue, pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/promise'; import { sortCompare } from '../../system/string'; import type { FileHistoryView } from '../fileHistoryView'; -import { TagsView } from '../tagsView'; import type { ViewsWithCommits } from '../viewBase'; +import { disposeChildren } from '../viewBase'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefNode } from './abstract/viewRefNode'; import { CommitFileNode } from './commitFileNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { PullRequestNode } from './pullRequestNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewRefNode } from './viewNode'; type State = { pullRequest: PullRequest | null | undefined; pendingPullRequest: Promise | undefined; }; -export class CommitNode extends ViewRefNode { +export class CommitNode extends ViewRefNode<'commit', ViewsWithCommits | FileHistoryView, GitRevisionReference, State> { constructor( view: ViewsWithCommits | FileHistoryView, parent: ViewNode, public readonly commit: GitCommit, - private readonly unpublished?: boolean, + protected readonly unpublished?: boolean, public readonly branch?: GitBranch, - private readonly getBranchAndTagTips?: (sha: string, options?: { compact?: boolean }) => string | undefined, - private readonly _options: { expand?: boolean } = {}, + protected readonly getBranchAndTagTips?: (sha: string, options?: { compact?: boolean }) => string | undefined, + protected readonly _options: { expand?: boolean } = {}, ) { - super(commit.getGitUri(), view, parent); + super('commit', commit.getGitUri(), view, parent); this.updateContext({ commit: commit }); - this._uniqueId = getViewNodeId('commit', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); + } + + override dispose() { + super.dispose(); + this.children = undefined; } override get id(): string { @@ -66,22 +70,31 @@ export class CommitNode extends ViewRefNode { - if (this._children == null) { + if (this.children == null) { const commit = this.commit; - let children: (PullRequestNode | FileNode)[] = []; + let children: ViewNode[] = []; let onCompleted: Deferred | undefined; let pullRequest; if ( - !(this.view instanceof TagsView) && + this.view.type !== 'tags' && !this.unpublished && - getContext('gitlens:hasConnectedRemotes') && - this.view.config.pullRequests.enabled && - this.view.config.pullRequests.showForCommits + this.view.config.pullRequests?.enabled && + this.view.config.pullRequests?.showForCommits && + getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(commit.repoPath) ) { pullRequest = this.getState('pullRequest'); if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { @@ -102,8 +115,8 @@ export class CommitNode extends ViewRefNode onCompleted?.fulfill(), 1); } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -172,10 +185,10 @@ export class CommitNode extends ViewRefNode { + override async resolveTreeItem(item: TreeItem, token: CancellationToken): Promise { if (item.tooltip == null) { - item.tooltip = await this.getTooltip(); + item.tooltip = await this.getTooltip(token); } return item; } private async getAssociatedPullRequest( commit: GitCommit, - remote?: GitRemote, + remote?: GitRemote, ): Promise { let pullRequest = this.getState('pullRequest'); if (pullRequest !== undefined) return Promise.resolve(pullRequest ?? undefined); @@ -235,29 +249,36 @@ export class CommitNode extends ViewRefNode { constructor( view: View, protected override readonly parent: ViewNode, private readonly _message: string, private readonly _description?: string, private readonly _tooltip?: string, - private readonly _iconPath?: - | string - | Uri - | { - light: string | Uri; - dark: string | Uri; - } - | ThemeIcon, + private readonly _iconPath?: TreeItem['iconPath'], private readonly _contextValue?: string, ) { - super(unknownGitUri, view, parent); + super('message', unknownGitUri, view, parent); } getChildren(): ViewNode[] | Promise { @@ -49,14 +42,7 @@ export class CommandMessageNode extends MessageNode { message: string, description?: string, tooltip?: string, - iconPath?: - | string - | Uri - | { - light: string | Uri; - dark: string | Uri; - } - | ThemeIcon, + iconPath?: TreeItem['iconPath'], ) { super(view, parent, message, description, tooltip, iconPath); } @@ -75,75 +61,7 @@ export class CommandMessageNode extends MessageNode { } } -export class UpdateableMessageNode extends ViewNode { - constructor( - view: View, - protected override readonly parent: ViewNode, - private _id: string, - private _message: string, - private _tooltip?: string, - private _iconPath?: - | string - | Uri - | { - light: string | Uri; - dark: string | Uri; - } - | ThemeIcon, - ) { - super(unknownGitUri, view, parent); - } - - override get id(): string { - return this._id; - } - - getChildren(): ViewNode[] | Promise { - return []; - } - - getTreeItem(): TreeItem | Promise { - const item = new TreeItem(this._message, TreeItemCollapsibleState.None); - item.id = this.id; - item.contextValue = ContextValues.Message; - item.tooltip = this._tooltip; - item.iconPath = this._iconPath; - return item; - } - - update( - changes: { - message?: string; - tooltip?: string | null; - iconPath?: - | string - | null - | Uri - | { - light: string | Uri; - dark: string | Uri; - } - | ThemeIcon; - }, - view: View, - ) { - if (changes.message !== undefined) { - this._message = changes.message; - } - - if (changes.tooltip !== undefined) { - this._tooltip = changes.tooltip === null ? undefined : changes.tooltip; - } - - if (changes.iconPath !== undefined) { - this._iconPath = changes.iconPath === null ? undefined : changes.iconPath; - } - - view.triggerNodeChange(this); - } -} - -export abstract class PagerNode extends ViewNode { +export abstract class PagerNode extends ViewNode<'pager'> { constructor( view: View, parent: ViewNode & PageableViewNode, @@ -155,7 +73,7 @@ export abstract class PagerNode extends ViewNode { getCount?: () => Promise; }, // protected readonly pageSize: number = configuration.get('views.pageItemLimit'), // protected readonly countFn?: () => Promise, // protected readonly context?: Record, // protected readonly beforeLoadCallback?: (mode: 'all' | 'more') => void, ) { - super(unknownGitUri, view, parent); + super('pager', unknownGitUri, view, parent); } async loadAll() { diff --git a/src/views/nodes/compareBranchNode.ts b/src/views/nodes/compareBranchNode.ts index 189d73238f3b7..7170c6484e009 100644 --- a/src/views/nodes/compareBranchNode.ts +++ b/src/views/nodes/compareBranchNode.ts @@ -6,29 +6,39 @@ import { GlyphChars } from '../../constants'; import type { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import { createRevisionRange, shortenRevision } from '../../git/models/reference'; +import type { GitUser } from '../../git/models/user'; +import type { CommitsQueryResults, FilesQueryResults } from '../../git/queryResults'; +import { getCommitsQuery, getFilesQuery } from '../../git/queryResults'; import { CommandQuickPickItem } from '../../quickpicks/items/common'; import { showReferencePicker } from '../../quickpicks/referencePicker'; -import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import { getSettledValue } from '../../system/promise'; import { pluralize } from '../../system/string'; import type { ViewsWithBranches } from '../viewBase'; import type { WorktreesView } from '../worktreesView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { getComparisonCheckedFiles, getComparisonStoragePrefix, resetComparisonCheckedFiles, restoreComparisonCheckedFiles, } from './compareResultsNode'; -import type { CommitsQueryResults } from './resultsCommitsNode'; import { ResultsCommitsNode } from './resultsCommitsNode'; -import type { FilesQueryResults } from './resultsFilesNode'; import { ResultsFilesNode } from './resultsFilesNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, SubscribeableViewNode } from './viewNode'; -export class CompareBranchNode extends SubscribeableViewNode { - private _children: ViewNode[] | undefined; +type State = { + filterCommits: GitUser[] | undefined; +}; + +export class CompareBranchNode extends SubscribeableViewNode< + 'compare-branch', + ViewsWithBranches | WorktreesView, + ViewNode, + State +> { private _compareWith: StoredBranchComparison | undefined; constructor( @@ -39,12 +49,16 @@ export class CompareBranchNode extends SubscribeableViewNode | undefined { - return this.view.onDidChangeNodesCheckedState(this.onNodesCheckedStateChanged, this); + return weakEvent(this.view.onDidChangeNodesCheckedState, this.onNodesCheckedStateChanged, this); } private onNodesCheckedStateChanged(e: TreeCheckboxChangeEvent) { @@ -83,19 +110,21 @@ export class CompareBranchNode extends SubscribeableViewNode { if (this._compareWith == null) return []; - if (this._children == null) { + if (this.children == null) { const ahead = this.ahead; const behind = this.behind; - const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount(this.branch.repoPath, [ - createRevisionRange(behind.ref1, behind.ref2, '...'), - ]); + const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount( + this.branch.repoPath, + [createRevisionRange(behind.ref1, behind.ref2, '...')], + { authors: this.filterByAuthors }, + ); const mergeBase = (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2, { forkPoint: true, })) ?? (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2)); - this._children = [ + const children: ViewNode[] = [ new ResultsCommitsNode( this.view, this, @@ -138,19 +167,27 @@ export class CompareBranchNode extends SubscribeableViewNode Promise { - const repoPath = this.repoPath; - return async (limit: number | undefined) => { - const log = await this.view.container.git.getLog(repoPath, { - limit: limit, - ref: range, - }); - - const results: Mutable> = { - log: log, - hasMore: log?.hasMore ?? true, - }; - if (results.hasMore) { - results.more = async (limit: number | undefined) => { - results.log = (await results.log?.more?.(limit)) ?? results.log; - results.hasMore = results.log?.hasMore ?? true; - }; - } - - return results as CommitsQueryResults; - }; + return getCommitsQuery(this.view.container, this.repoPath, range, this.filterByAuthors); } - private async getFilesQuery(): Promise { - let comparison; - if (!this._compareWith?.ref) { - comparison = this.branch.ref; + private getFilesQuery(): Promise { + let ref1 = this.branch.ref; + let ref2 = this._compareWith?.ref; + + if (!ref2) { + ref2 = ref1; + ref1 = ''; } else if (this.compareWithWorkingTree) { - comparison = this._compareWith.ref; - } else { - comparison = `${this._compareWith.ref}..${this.branch.ref}`; + ref1 = ''; } - const [filesResult, statsResult] = await Promise.allSettled([ - this.view.container.git.getDiffStatus(this.repoPath, comparison), - this.view.container.git.getChangedFilesCount(this.repoPath, comparison), - ]); - - const files = getSettledValue(filesResult) ?? []; - return { - label: `${pluralize('file', files.length, { zero: 'No' })} changed`, - files: files, - stats: getSettledValue(statsResult), - }; + return getFilesQuery(this.view.container, this.repoPath, ref1, ref2); } private getStorageId() { diff --git a/src/views/nodes/comparePickerNode.ts b/src/views/nodes/comparePickerNode.ts index 74214394e7b3d..2fe0f52aff7ff 100644 --- a/src/views/nodes/comparePickerNode.ts +++ b/src/views/nodes/comparePickerNode.ts @@ -3,7 +3,7 @@ import type { StoredNamedRef } from '../../constants'; import { GlyphChars } from '../../constants'; import { unknownGitUri } from '../../git/gitUri'; import type { SearchAndCompareView, SearchAndCompareViewNode } from '../searchAndCompareView'; -import { ContextValues, ViewNode } from './viewNode'; +import { ContextValues, ViewNode } from './abstract/viewNode'; interface RepoRef { label: string; @@ -11,7 +11,7 @@ interface RepoRef { ref: string | StoredNamedRef; } -export class ComparePickerNode extends ViewNode { +export class ComparePickerNode extends ViewNode<'compare-picker', SearchAndCompareView> { readonly order: number = Date.now(); constructor( @@ -19,7 +19,7 @@ export class ComparePickerNode extends ViewNode { parent: SearchAndCompareViewNode, public readonly selectedRef: RepoRef, ) { - super(unknownGitUri, view, parent); + super('compare-picker', unknownGitUri, view, parent); } getChildren(): ViewNode[] { diff --git a/src/views/nodes/compareResultsNode.ts b/src/views/nodes/compareResultsNode.ts index 77c2759ae88e6..1fd337ddaefb0 100644 --- a/src/views/nodes/compareResultsNode.ts +++ b/src/views/nodes/compareResultsNode.ts @@ -1,25 +1,37 @@ -import type { Disposable, TreeCheckboxChangeEvent } from 'vscode'; -import { ThemeIcon, TreeItem, TreeItemCheckboxState, TreeItemCollapsibleState, window } from 'vscode'; +import type { TreeCheckboxChangeEvent } from 'vscode'; +import { Disposable, ThemeIcon, TreeItem, TreeItemCheckboxState, TreeItemCollapsibleState, window } from 'vscode'; import { md5 } from '@env/crypto'; import type { StoredNamedRef } from '../../constants'; +import type { FilesComparison } from '../../git/actions/commit'; import { GitUri } from '../../git/gitUri'; import { createRevisionRange, shortenRevision } from '../../git/models/reference'; +import type { GitUser } from '../../git/models/user'; +import type { CommitsQueryResults, FilesQueryResults } from '../../git/queryResults'; +import { getAheadBehindFilesQuery, getCommitsQuery, getFilesQuery } from '../../git/queryResults'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; -import { getSettledValue } from '../../system/promise'; +import { weakEvent } from '../../system/event'; import { pluralize } from '../../system/string'; import type { SearchAndCompareView } from '../searchAndCompareView'; import type { View } from '../viewBase'; -import type { CommitsQueryResults } from './resultsCommitsNode'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { ResultsCommitsNode } from './resultsCommitsNode'; -import type { FilesQueryResults } from './resultsFilesNode'; import { ResultsFilesNode } from './resultsFilesNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, SubscribeableViewNode } from './viewNode'; let instanceId = 0; -export class CompareResultsNode extends SubscribeableViewNode { +type State = { + filterCommits: GitUser[] | undefined; +}; + +export class CompareResultsNode extends SubscribeableViewNode< + 'compare-results', + SearchAndCompareView, + ViewNode, + State +> { private _instanceId: number; constructor( @@ -30,14 +42,14 @@ export class CompareResultsNode extends SubscribeableViewNode | undefined { - return this.view.onDidChangeNodesCheckedState(this.onNodesCheckedStateChanged, this); + return Disposable.from( + weakEvent(this.view.onDidChangeNodesCheckedState, this.onNodesCheckedStateChanged, this), + weakEvent( + this.view.container.integrations.onDidChangeConnectionState, + this.onIntegrationConnectionStateChanged, + this, + ), + ); + } + + private onIntegrationConnectionStateChanged() { + this.view.triggerNodeChange(this.parent); } private onNodesCheckedStateChanged(e: TreeCheckboxChangeEvent) { @@ -95,22 +131,23 @@ export class CompareResultsNode extends SubscribeableViewNode { - if (this._children == null) { + if (this.children == null) { const ahead = this.ahead; const behind = this.behind; - const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount(this.repoPath, [ - createRevisionRange(behind.ref1 || 'HEAD', behind.ref2, '...'), - ]); + const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount( + this.repoPath, + [createRevisionRange(behind.ref1 || 'HEAD', behind.ref2, '...')], + { authors: this.filterByAuthors }, + ); + const mergeBase = (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2, { forkPoint: true, })) ?? (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2)); - this._children = [ + const children: ViewNode[] = [ new ResultsCommitsNode( this.view, this, @@ -151,19 +188,27 @@ export class CompareResultsNode extends SubscribeableViewNode([this._compareWith.ref, this._ref.ref]); } - @gate() - @debug() - override refresh(reset: boolean = false) { - if (!reset) return; + async getFilesComparison(): Promise { + const children = await this.getChildren(); + const node = children.find(c => c.is('results-files')); + return node?.getFilesComparison(); + } - this._children = undefined; + @log() + clearReviewed() { + resetComparisonCheckedFiles(this.view, this.getStorageId()); + void this.store(); } @log() @@ -221,123 +272,35 @@ export class CompareResultsNode extends SubscribeableViewNode this.view.reveal(this, { expand: true, focus: true, select: true })); } private async getAheadFilesQuery(): Promise { - return this.getAheadBehindFilesQuery( + return getAheadBehindFilesQuery( + this.view.container, + this.repoPath, createRevisionRange(this._compareWith?.ref || 'HEAD', this._ref.ref || 'HEAD', '...'), this._ref.ref === '', ); } private async getBehindFilesQuery(): Promise { - return this.getAheadBehindFilesQuery( + return getAheadBehindFilesQuery( + this.view.container, + this.repoPath, createRevisionRange(this._ref.ref || 'HEAD', this._compareWith.ref || 'HEAD', '...'), false, ); } - private async getAheadBehindFilesQuery( - comparison: string, - compareWithWorkingTree: boolean, - ): Promise { - const [filesResult, workingFilesResult, statsResult, workingStatsResult] = await Promise.allSettled([ - this.view.container.git.getDiffStatus(this.repoPath, comparison), - compareWithWorkingTree ? this.view.container.git.getDiffStatus(this.repoPath, 'HEAD') : undefined, - this.view.container.git.getChangedFilesCount(this.repoPath, comparison), - compareWithWorkingTree ? this.view.container.git.getChangedFilesCount(this.repoPath, 'HEAD') : undefined, - ]); - - let files = getSettledValue(filesResult) ?? []; - let stats: FilesQueryResults['stats'] = getSettledValue(statsResult); - - if (compareWithWorkingTree) { - const workingFiles = getSettledValue(workingFilesResult); - if (workingFiles != null) { - if (files.length === 0) { - files = workingFiles ?? []; - } else { - for (const wf of workingFiles) { - const index = files.findIndex(f => f.path === wf.path); - if (index !== -1) { - files.splice(index, 1, wf); - } else { - files.push(wf); - } - } - } - } - - const workingStats = getSettledValue(workingStatsResult); - if (workingStats != null) { - if (stats == null) { - stats = workingStats; - } else { - stats = { - additions: stats.additions + workingStats.additions, - deletions: stats.deletions + workingStats.deletions, - changedFiles: files.length, - approximated: true, - }; - } - } - } - - return { - label: `${pluralize('file', files.length, { zero: 'No' })} changed`, - files: files, - stats: stats, - }; - } - private getCommitsQuery(range: string): (limit: number | undefined) => Promise { - const repoPath = this.repoPath; - return async (limit: number | undefined) => { - const log = await this.view.container.git.getLog(repoPath, { - limit: limit, - ref: range, - }); - - const results: Mutable> = { - log: log, - hasMore: log?.hasMore ?? true, - }; - if (results.hasMore) { - results.more = async (limit: number | undefined) => { - results.log = (await results.log?.more?.(limit)) ?? results.log; - results.hasMore = results.log?.hasMore ?? true; - }; - } - - return results as CommitsQueryResults; - }; + return getCommitsQuery(this.view.container, this.repoPath, range, this.filterByAuthors); } - private async getFilesQuery(): Promise { - let comparison; - if (this._compareWith.ref === '') { - debugger; - throw new Error('Cannot get files for comparisons of a ref with working tree'); - } else if (this._ref.ref === '') { - comparison = this._compareWith.ref; - } else { - comparison = `${this._compareWith.ref}..${this._ref.ref}`; - } - - const [filesResult, statsResult] = await Promise.allSettled([ - this.view.container.git.getDiffStatus(this.repoPath, comparison), - this.view.container.git.getChangedFilesCount(this.repoPath, comparison), - ]); - - const files = getSettledValue(filesResult) ?? []; - return { - label: `${pluralize('file', files.length, { zero: 'No' })} changed`, - files: files, - stats: getSettledValue(statsResult), - }; + private getFilesQuery(): Promise { + return getFilesQuery(this.view.container, this.repoPath, this._ref.ref, this._compareWith.ref); } private getStorageId() { @@ -345,10 +308,12 @@ export class CompareResultsNode extends SubscribeableViewNode implements PageableViewNode { +export class ContributorNode extends ViewNode<'contributor', ViewsWithContributors> implements PageableViewNode { limit: number | undefined; constructor( @@ -25,16 +25,17 @@ export class ContributorNode extends ViewNode implements view: ViewsWithContributors, protected override readonly parent: ViewNode, public readonly contributor: GitContributor, - private readonly _options?: { + private readonly options?: { all?: boolean; ref?: string; presence: Map | undefined; + showMergeCommits?: boolean; }, ) { - super(uri, view, parent); + super('contributor', uri, view, parent); this.updateContext({ contributor: contributor }); - this._uniqueId = getViewNodeId('contributor', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this.limit = this.view.getNodeLastKnownLimit(this); } @@ -42,8 +43,18 @@ export class ContributorNode extends ViewNode implements return this._uniqueId; } - override toClipboard(): string { - return `${this.contributor.name}${this.contributor.email ? ` <${this.contributor.email}>` : ''}`; + override toClipboard(type?: ClipboardType): string { + const text = `${this.contributor.name}${this.contributor.email ? ` <${this.contributor.email}>` : ''}`; + switch (type) { + case 'markdown': + return this.contributor.email ? `[${text}](mailto:${this.contributor.email})` : text; + default: + return text; + } + } + + override getUrl(): string { + return this.contributor.email ? `mailto:${this.contributor.email}` : ''; } get repoPath(): string { @@ -72,7 +83,7 @@ export class ContributorNode extends ViewNode implements } async getTreeItem(): Promise { - const presence = this._options?.presence?.get(this.contributor.email!); + const presence = this.options?.presence?.get(this.contributor.email!); const item = new TreeItem( this.contributor.current ? `${this.contributor.label} (you)` : this.contributor.label, @@ -167,9 +178,10 @@ export class ContributorNode extends ViewNode implements private async getLog() { if (this._log == null) { this._log = await this.view.container.git.getLog(this.uri.repoPath!, { - all: this._options?.all, - ref: this._options?.ref, + all: this.options?.all, + ref: this.options?.ref, limit: this.limit ?? this.view.config.defaultItemLimit, + merges: this.options?.showMergeCommits, authors: [ { name: this.contributor.name, @@ -196,7 +208,7 @@ export class ContributorNode extends ViewNode implements }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/contributorsNode.ts b/src/views/nodes/contributorsNode.ts index 9ed2cdc5ff74a..5ab09793a1305 100644 --- a/src/views/nodes/contributorsNode.ts +++ b/src/views/nodes/contributorsNode.ts @@ -1,17 +1,22 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { GitUri } from '../../git/gitUri'; -import { GitContributor } from '../../git/models/contributor'; +import type { GitContributor } from '../../git/models/contributor'; +import { sortContributors } from '../../git/models/contributor'; import type { Repository } from '../../git/models/repository'; import { configuration } from '../../system/configuration'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import { timeout } from '../../system/decorators/timeout'; import type { ViewsWithContributorsNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { MessageNode } from './common'; import { ContributorNode } from './contributorNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class ContributorsNode extends ViewNode { +export class ContributorsNode extends CacheableChildrenViewNode< + 'contributors', + ViewsWithContributorsNode, + ContributorNode +> { protected override splatted = true; constructor( @@ -19,11 +24,12 @@ export class ContributorsNode extends ViewNode { view: ViewsWithContributorsNode, protected override readonly parent: ViewNode, public readonly repo: Repository, + private readonly options?: { all?: boolean; showMergeCommits?: boolean; stats?: boolean }, ) { - super(uri, view, parent); + super('contributors', uri, view, parent); this.updateContext({ repository: repo }); - this._uniqueId = getViewNodeId('contributors', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -34,11 +40,9 @@ export class ContributorsNode extends ViewNode { return this.repo.path; } - private _children: ContributorNode[] | undefined; - async getChildren(): Promise { - if (this._children == null) { - const all = configuration.get('views.contributors.showAllBranches'); + if (this.children == null) { + const all = this.options?.all ?? configuration.get('views.contributors.showAllBranches'); let ref: string | undefined; // If we aren't getting all branches, get the upstream of the current branch if there is one @@ -51,25 +55,31 @@ export class ContributorsNode extends ViewNode { } catch {} } - const stats = configuration.get('views.contributors.showStatistics'); + const stats = this.options?.stats ?? configuration.get('views.contributors.showStatistics'); - const contributors = await this.repo.getContributors({ all: all, ref: ref, stats: stats }); + const contributors = await this.repo.getContributors({ + all: all, + merges: this.options?.showMergeCommits, + ref: ref, + stats: stats, + }); if (contributors.length === 0) return [new MessageNode(this.view, this, 'No contributors could be found.')]; - GitContributor.sort(contributors); - const presenceMap = await this.maybeGetPresenceMap(contributors); + sortContributors(contributors); + const presenceMap = this.view.container.vsls.enabled ? await this.getPresenceMap(contributors) : undefined; - this._children = contributors.map( + this.children = contributors.map( c => new ContributorNode(this.uri, this.view, this, c, { all: all, ref: ref, presence: presenceMap, + showMergeCommits: this.options?.showMergeCommits, }), ); } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -83,24 +93,22 @@ export class ContributorsNode extends ViewNode { } updateAvatar(email: string) { - if (this._children == null) return; + if (this.children == null) return; - for (const child of this._children) { + for (const child of this.children) { if (child.contributor.email === email) { void child.triggerChange(); } } } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } @debug({ args: false }) - @timeout(250) - private async maybeGetPresenceMap(contributors: GitContributor[]) { + private async getPresenceMap(contributors: GitContributor[]) { // Only get presence for the current user, because it is far too slow otherwise const email = contributors.find(c => c.current)?.email; if (email == null) return undefined; diff --git a/src/views/nodes/draftNode.ts b/src/views/nodes/draftNode.ts new file mode 100644 index 0000000000000..5a79299b2d071 --- /dev/null +++ b/src/views/nodes/draftNode.ts @@ -0,0 +1,86 @@ +import type { Uri } from 'vscode'; +import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { getAvatarUri } from '../../avatars'; +import type { GitUri } from '../../git/gitUri'; +import type { Draft } from '../../gk/models/drafts'; +import { configuration } from '../../system/configuration'; +import { formatDate, fromNow } from '../../system/date'; +import type { DraftsView } from '../draftsView'; +import type { ViewsWithCommits } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; + +export class DraftNode extends ViewNode<'draft', ViewsWithCommits | DraftsView> { + constructor( + uri: GitUri, + view: ViewsWithCommits | DraftsView, + protected override parent: ViewNode, + public readonly draft: Draft, + ) { + super('draft', uri, view, parent); + + this.updateContext({ draft: draft }); + this._uniqueId = getViewNodeId(this.type, this.context); + } + + override get id(): string { + return this._uniqueId; + } + + override toClipboard(): string { + return this.getUrl(); + } + + override getUrl(): string { + return this.view.container.drafts.generateWebUrl(this.draft.id); + } + + getChildren(): ViewNode[] { + return []; + } + + getTreeItem(): TreeItem { + const label = this.draft.title ?? `Draft (${this.draft.id})`; + const item = new TreeItem(label, TreeItemCollapsibleState.None); + + const dateFormat = configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma'; + + // Only show updated time if it is more than a 30s after the created time + const showUpdated = this.draft.updatedAt.getTime() - this.draft.createdAt.getTime() >= 30000; + + item.id = this.id; + let contextValue: string = ContextValues.Draft; + if (this.draft.isMine) { + contextValue += '+mine'; + } + item.contextValue = contextValue; + item.description = fromNow(this.draft.updatedAt); + item.command = { + title: 'Open', + command: 'gitlens.views.draft.open', + arguments: [this], + }; + + let avatarUri: Uri | undefined; + if (this.view.config.avatars && this.draft.author != null) { + avatarUri = this.draft.author.avatarUri ?? getAvatarUri(this.draft.author.email); + } + + item.iconPath = + avatarUri ?? new ThemeIcon(this.draft.type == 'suggested_pr_change' ? 'gitlens-code-suggestion' : 'cloud'); + + item.tooltip = new MarkdownString( + `${label}${this.draft.description ? `\\\n${this.draft.description}` : ''}\n\nCreated ${ + this.draft.author?.name ? ` by ${this.draft.author.name}` : '' + } ${fromNow(this.draft.createdAt)}   _(${formatDate(this.draft.createdAt, dateFormat)})_${ + showUpdated + ? ` \\\nLast updated ${fromNow(this.draft.updatedAt)}   _(${formatDate( + this.draft.updatedAt, + dateFormat, + )})_` + : '' + }`, + ); + + return item; + } +} diff --git a/src/views/nodes/fileHistoryNode.ts b/src/views/nodes/fileHistoryNode.ts index e004d28b432a3..e6ac98716f0ce 100644 --- a/src/views/nodes/fileHistoryNode.ts +++ b/src/views/nodes/fileHistoryNode.ts @@ -9,19 +9,24 @@ import { configuration } from '../../system/configuration'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { memoize } from '../../system/decorators/memoize'; +import { weakEvent } from '../../system/event'; import { filterMap, flatMap, map, uniqueBy } from '../../system/iterable'; import { Logger } from '../../system/logger'; import { basename } from '../../system/path'; import type { FileHistoryView } from '../fileHistoryView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; import { FileHistoryTrackerNode } from './fileHistoryTrackerNode'; import { FileRevisionAsCommitNode } from './fileRevisionAsCommitNode'; import { insertDateMarkers } from './helpers'; -import type { PageableViewNode, ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, SubscribeableViewNode } from './viewNode'; -export class FileHistoryNode extends SubscribeableViewNode implements PageableViewNode { +export class FileHistoryNode + extends SubscribeableViewNode<'file-history', FileHistoryView> + implements PageableViewNode +{ limit: number | undefined; protected override splatted = true; @@ -33,12 +38,12 @@ export class FileHistoryNode extends SubscribeableViewNode impl private readonly folder: boolean, private readonly branch: GitBranch | undefined, ) { - super(uri, view, parent); + super('file-history', uri, view, parent); if (branch != null) { this.updateContext({ branch: branch }); } - this._uniqueId = getViewNodeId(`file-history+${uri.toString()}`, this.context); + this._uniqueId = getViewNodeId(`${this.type}+${uri.toString()}`, this.context); this.limit = this.view.getNodeLastKnownLimit(this); } @@ -65,9 +70,7 @@ export class FileHistoryNode extends SubscribeableViewNode impl ? this.view.container.git.getStatusForFiles(this.uri.repoPath, this.getPathOrGlob()) : undefined, this.uri.sha == null ? this.view.container.git.getCurrentUser(this.uri.repoPath) : undefined, - this.branch != null - ? this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch.name) - : undefined, + this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch?.name), range ? this.view.container.git.getLogRefsOnly(this.uri.repoPath, { limit: 0, @@ -83,7 +86,15 @@ export class FileHistoryNode extends SubscribeableViewNode impl uniqueBy( flatMap(fileStatuses, f => f.getPseudoCommits(this.view.container, currentUser)), c => c.sha, - (original, c) => original.with({ files: { files: [...original.files!, ...c.files!] } }), + (original, c) => + original.with({ + files: { + files: [ + ...(original.files ?? (original.file != null ? [original.file] : [])), + ...(c.files ?? (c.file != null ? [c.file] : [])), + ], + }, + }), ), commit => new CommitNode(this.view, this, commit), ); @@ -110,18 +121,18 @@ export class FileHistoryNode extends SubscribeableViewNode impl c, unpublishedCommits?.has(c.ref), this.branch, - undefined, + getBranchAndTagTips, { expand: false, }, ) : c.file != null - ? new FileRevisionAsCommitNode(this.view, this, c.file, c, { - branch: this.branch, - getBranchAndTagTips: getBranchAndTagTips, - unpublished: unpublishedCommits?.has(c.ref), - }) - : undefined, + ? new FileRevisionAsCommitNode(this.view, this, c.file, c, { + branch: this.branch, + getBranchAndTagTips: getBranchAndTagTips, + unpublished: unpublishedCommits?.has(c.ref), + }) + : undefined, ), this, ), @@ -175,14 +186,17 @@ export class FileHistoryNode extends SubscribeableViewNode impl if (repo == null) return undefined; const subscription = Disposable.from( - repo.onDidChange(this.onRepositoryChanged, this), - repo.onDidChangeFileSystem(this.onFileSystemChanged, this), - repo.startWatchingFileSystem(), - configuration.onDidChange(e => { - if (configuration.changed(e, 'advanced.fileHistoryFollowsRenames')) { - this.view.resetNodeLastKnownLimit(this); - } - }), + weakEvent(repo.onDidChange, this.onRepositoryChanged, this), + weakEvent(repo.onDidChangeFileSystem, this.onFileSystemChanged, this, [repo.watchFileSystem()]), + weakEvent( + configuration.onDidChange, + e => { + if (configuration.changed(e, 'advanced.fileHistoryFollowsRenames')) { + this.view.resetNodeLastKnownLimit(this); + } + }, + this, + ), ); return subscription; @@ -261,7 +275,7 @@ export class FileHistoryNode extends SubscribeableViewNode impl }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/fileHistoryTrackerNode.ts b/src/views/nodes/fileHistoryTrackerNode.ts index 100e65a8fe9e6..d2ec6e5dae326 100644 --- a/src/views/nodes/fileHistoryTrackerNode.ts +++ b/src/views/nodes/fileHistoryTrackerNode.ts @@ -8,41 +8,44 @@ import { UriComparer } from '../../system/comparers'; import { setContext } from '../../system/context'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import type { Deferrable } from '../../system/function'; import { debounce } from '../../system/function'; import { Logger } from '../../system/logger'; import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; import { isVirtualUri } from '../../system/utils'; import type { FileHistoryView } from '../fileHistoryView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { FileHistoryNode } from './fileHistoryNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; -export class FileHistoryTrackerNode extends SubscribeableViewNode { +export class FileHistoryTrackerNode extends SubscribeableViewNode<'file-history-tracker', FileHistoryView> { private _base: string | undefined; - private _child: FileHistoryNode | undefined; protected override splatted = true; constructor(view: FileHistoryView) { - super(unknownGitUri, view); + super('file-history-tracker', unknownGitUri, view); } override dispose() { super.dispose(); - - this.resetChild(); + this.child = undefined; } - @debug() - private resetChild() { - if (this._child == null) return; + private _child: FileHistoryNode | undefined; + protected get child(): FileHistoryNode | undefined { + return this._child; + } + protected set child(value: FileHistoryNode | undefined) { + if (this._child === value) return; - this._child.dispose(); - this._child = undefined; + this._child?.dispose(); + this._child = value; } async getChildren(): Promise { - if (this._child == null) { + if (this.child == null) { if (!this.hasUri) { this.view.description = undefined; @@ -79,10 +82,10 @@ export class FileHistoryTrackerNode extends SubscribeableViewNode b.name === commitish.sha, })); } - this._child = new FileHistoryNode(fileUri, this.view, this, folder, branch); + this.child = new FileHistoryNode(fileUri, this.view, this, folder, branch); } - return this._child.getChildren(); + return this.child.getChildren(); } getTreeItem(): TreeItem { @@ -110,9 +113,8 @@ export class FileHistoryTrackerNode extends SubscribeableViewNode { +export class FileRevisionAsCommitNode extends ViewRefFileNode< + 'file-commit', + ViewsWithCommits | FileHistoryView | LineHistoryView +> { constructor( view: ViewsWithCommits | FileHistoryView | LineHistoryView, parent: ViewNode, @@ -36,7 +40,7 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode { + override async resolveTreeItem(item: TreeItem, token: CancellationToken): Promise { if (item.tooltip == null) { - item.tooltip = await this.getTooltip(); + item.tooltip = await this.getTooltip(token); } return item; } @@ -203,52 +205,76 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode string | undefined; + unpublished?: boolean; + }, +) { + const [remotesResult, _] = await Promise.allSettled([ + container.git.getBestRemotesWithProviders(commit.repoPath, options?.cancellation), + commit.message == null ? commit.ensureFullDetails() : undefined, + ]); + + if (options?.cancellation?.isCancellationRequested) return undefined; + + const remotes = getSettledValue(remotesResult, []); + const [remote] = remotes; + + let enrichedAutolinks; + let pr; + + if (remote?.hasIntegration()) { + const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ + pauseOnCancelOrTimeoutMapTuplePromise(commit.getEnrichedAutolinks(remote), options?.cancellation), + commit.getAssociatedPullRequest(remote), + ]); + + const enrichedAutolinksMaybeResult = getSettledValue(enrichedAutolinksResult); + if (!enrichedAutolinksMaybeResult?.paused) { + enrichedAutolinks = enrichedAutolinksMaybeResult?.value; + } + pr = getSettledValue(prResult); + } + + const status = StatusFileFormatter.fromTemplate( + `\${status}\${ (originalPath)}\${'  •  'changesDetail}`, + file, + ); + return CommitFormatter.fromTemplateAsync(tooltipWithStatusFormat.replace('{{slot-status}}', status), commit, { + enrichedAutolinks: enrichedAutolinks, + dateFormat: configuration.get('defaultDateFormat'), + getBranchAndTagTips: options?.getBranchAndTagTips, + messageAutolinks: true, + messageIndent: 4, + pullRequest: pr, + outputFormat: 'markdown', + remotes: remotes, + unpublished: options?.unpublished, + }); +} diff --git a/src/views/nodes/folderNode.ts b/src/views/nodes/folderNode.ts index aad5758eaf2b6..cb03e6c452a42 100644 --- a/src/views/nodes/folderNode.ts +++ b/src/views/nodes/folderNode.ts @@ -5,8 +5,8 @@ import type { HierarchicalItem } from '../../system/array'; import { sortCompare } from '../../system/string'; import type { StashesView } from '../stashesView'; import type { ViewsWithCommits } from '../viewBase'; -import type { ViewFileNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; +import type { ViewFileNode } from './abstract/viewFileNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; export interface FileNode extends ViewFileNode { folderName: string; @@ -18,7 +18,7 @@ export interface FileNode extends ViewFileNode { // root?: HierarchicalItem; } -export class FolderNode extends ViewNode { +export class FolderNode extends ViewNode<'folder', ViewsWithCommits | StashesView> { readonly priority: number = 1; constructor( @@ -30,9 +30,9 @@ export class FolderNode extends ViewNode { public readonly relativePath: string | undefined, private readonly containsWorkingFiles?: boolean, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('folder', GitUri.fromRepoPath(repoPath), view, parent); - this._uniqueId = getViewNodeId(`folder+${relativePath ?? folderName}`, this.context); + this._uniqueId = getViewNodeId(`${this.type}+${relativePath ?? folderName}`, this.context); } override get id(): string { diff --git a/src/views/nodes/groupingNode.ts b/src/views/nodes/groupingNode.ts new file mode 100644 index 0000000000000..424f4ae919eca --- /dev/null +++ b/src/views/nodes/groupingNode.ts @@ -0,0 +1,33 @@ +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { unknownGitUri } from '../../git/gitUri'; +import type { View } from '../viewBase'; +import { ContextValues, ViewNode } from './abstract/viewNode'; + +export class GroupingNode extends ViewNode<'grouping'> { + constructor( + view: View, + private readonly label: string, + private readonly childrenOrFn: TChild[] | Promise | (() => TChild[] | Promise), + private readonly collapsibleState: TreeItemCollapsibleState = TreeItemCollapsibleState.Expanded, + private readonly description?: string, + private readonly tooltip?: string, + private readonly iconPath?: TreeItem['iconPath'], + private readonly contextValue?: string, + ) { + super('grouping', unknownGitUri, view); + } + + getChildren(): ViewNode[] | Promise { + return typeof this.childrenOrFn === 'function' ? this.childrenOrFn() : this.childrenOrFn; + } + + getTreeItem(): TreeItem { + const item = new TreeItem(this.label, this.collapsibleState); + item.id = this.id; + item.contextValue = this.contextValue ?? ContextValues.Grouping; + item.description = this.description; + item.tooltip = this.tooltip; + item.iconPath = this.iconPath; + return item; + } +} diff --git a/src/views/nodes/helpers.ts b/src/views/nodes/helpers.ts index 47d9c4b236a89..01619e1fea458 100644 --- a/src/views/nodes/helpers.ts +++ b/src/views/nodes/helpers.ts @@ -1,7 +1,7 @@ import type { GitCommit } from '../../git/models/commit'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { MessageNode } from './common'; -import type { ViewNode } from './viewNode'; -import { ContextValues } from './viewNode'; const markers: [number, string][] = [ [0, 'Less than a week ago'], diff --git a/src/views/nodes/lineHistoryNode.ts b/src/views/nodes/lineHistoryNode.ts index adc56a20df9d9..b4ab4806ee652 100644 --- a/src/views/nodes/lineHistoryNode.ts +++ b/src/views/nodes/lineHistoryNode.ts @@ -11,19 +11,21 @@ import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/mode import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { memoize } from '../../system/decorators/memoize'; +import { weakEvent } from '../../system/event'; import { filterMap } from '../../system/iterable'; import { Logger } from '../../system/logger'; import type { FileHistoryView } from '../fileHistoryView'; import type { LineHistoryView } from '../lineHistoryView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { LoadMoreNode, MessageNode } from './common'; import { FileRevisionAsCommitNode } from './fileRevisionAsCommitNode'; import { insertDateMarkers } from './helpers'; import { LineHistoryTrackerNode } from './lineHistoryTrackerNode'; -import type { PageableViewNode, ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, SubscribeableViewNode } from './viewNode'; export class LineHistoryNode - extends SubscribeableViewNode + extends SubscribeableViewNode<'line-history', FileHistoryView | LineHistoryView> implements PageableViewNode { limit: number | undefined; @@ -38,13 +40,13 @@ export class LineHistoryNode public readonly selection: Selection, private readonly editorContents: string | undefined, ) { - super(uri, view, parent); + super('line-history', uri, view, parent); if (branch != null) { this.updateContext({ branch: branch }); } this._uniqueId = getViewNodeId( - `file-history+${uri.toString()}+[${selection.start.line},${selection.start.character}-${ + `${this.type}+${uri.toString()}+[${selection.start.line},${selection.start.character}-${ selection.end.line },${selection.end.character}]`, this.context, @@ -78,9 +80,7 @@ export class LineHistoryNode ? await this.view.container.git.getBlameForRangeContents(this.uri, selection, this.editorContents) : await this.view.container.git.getBlameForRange(this.uri, selection) : undefined, - this.branch != null - ? this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch.name) - : undefined, + this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch?.name), range ? this.view.container.git.getLogRefsOnly(this.uri.repoPath, { limit: 0, @@ -199,9 +199,8 @@ export class LineHistoryNode if (repo == null) return undefined; const subscription = Disposable.from( - repo.onDidChange(this.onRepositoryChanged, this), - repo.onDidChangeFileSystem(this.onFileSystemChanged, this), - repo.startWatchingFileSystem(), + weakEvent(repo.onDidChange, this.onRepositoryChanged, this), + weakEvent(repo.onDidChangeFileSystem, this.onFileSystemChanged, this, [repo.watchFileSystem()]), ); return subscription; @@ -274,7 +273,7 @@ export class LineHistoryNode }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/lineHistoryTrackerNode.ts b/src/views/nodes/lineHistoryTrackerNode.ts index d49bc62582ab2..62b5fcfc2c80e 100644 --- a/src/views/nodes/lineHistoryTrackerNode.ts +++ b/src/views/nodes/lineHistoryTrackerNode.ts @@ -9,43 +9,49 @@ import { UriComparer } from '../../system/comparers'; import { setContext } from '../../system/context'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import { debounce } from '../../system/function'; import { Logger } from '../../system/logger'; import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; -import type { LinesChangeEvent } from '../../trackers/gitLineTracker'; +import type { LinesChangeEvent } from '../../trackers/lineTracker'; import type { FileHistoryView } from '../fileHistoryView'; import type { LineHistoryView } from '../lineHistoryView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { LineHistoryNode } from './lineHistoryNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; -export class LineHistoryTrackerNode extends SubscribeableViewNode { +export class LineHistoryTrackerNode extends SubscribeableViewNode< + 'line-history-tracker', + FileHistoryView | LineHistoryView +> { private _base: string | undefined; - private _child: LineHistoryNode | undefined; private _editorContents: string | undefined; private _selection: Selection | undefined; protected override splatted = true; constructor(view: FileHistoryView | LineHistoryView) { - super(unknownGitUri, view); + super('line-history-tracker', unknownGitUri, view); } override dispose() { super.dispose(); - - this.resetChild(); + this.child = undefined; } - @debug() - private resetChild() { - if (this._child == null) return; + private _child: LineHistoryNode | undefined; + protected get child(): LineHistoryNode | undefined { + return this._child; + } + protected set child(value: LineHistoryNode | undefined) { + if (this._child === value) return; - this._child.dispose(); - this._child = undefined; + this._child?.dispose(); + this._child = value; } async getChildren(): Promise { - if (this._child == null) { + if (this.child == null) { if (!this.hasUri) { this.view.description = undefined; @@ -53,7 +59,10 @@ export class LineHistoryTrackerNode extends SubscribeableViewNode b.name === commitish.sha, })); } - this._child = new LineHistoryNode(fileUri, this.view, this, branch, this._selection, this._editorContents); + this.child = new LineHistoryNode(fileUri, this.view, this, branch, selection, editorContents); } - return this._child.getChildren(); + return this.child.getChildren(); } getTreeItem(): TreeItem { @@ -117,9 +126,8 @@ export class LineHistoryTrackerNode extends SubscribeableViewNode { - if (e.pending) return; - - onActiveLinesChanged(e); - }), + weakEvent( + this.view.container.lineTracker.onDidChangeActiveLines, + (e: LinesChangeEvent) => { + if (e.pending) return; + + onActiveLinesChanged(e); + }, + this, + ), ); } diff --git a/src/views/nodes/mergeConflictCurrentChangesNode.ts b/src/views/nodes/mergeConflictCurrentChangesNode.ts index 1fa5db2bb3dc7..e0a2d3f403198 100644 --- a/src/views/nodes/mergeConflictCurrentChangesNode.ts +++ b/src/views/nodes/mergeConflictCurrentChangesNode.ts @@ -1,9 +1,9 @@ -import type { Command } from 'vscode'; +import type { CancellationToken, Command } from 'vscode'; import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithCommandArgs } from '../../commands'; +import type { DiffWithCommandArgs } from '../../commands/diffWith'; import { Commands, GlyphChars } from '../../constants'; -import { CommitFormatter } from '../../git/formatters/commitFormatter'; import { GitUri } from '../../git/gitUri'; +import type { GitCommit } from '../../git/models/commit'; import type { GitFile } from '../../git/models/file'; import type { GitMergeStatus } from '../../git/models/merge'; import type { GitRebaseStatus } from '../../git/models/rebase'; @@ -13,16 +13,28 @@ import { configuration } from '../../system/configuration'; import type { FileHistoryView } from '../fileHistoryView'; import type { LineHistoryView } from '../lineHistoryView'; import type { ViewsWithCommits } from '../viewBase'; -import { ContextValues, ViewNode } from './viewNode'; +import { ContextValues, ViewNode } from './abstract/viewNode'; +import { getFileRevisionAsCommitTooltip } from './fileRevisionAsCommitNode'; -export class MergeConflictCurrentChangesNode extends ViewNode { +export class MergeConflictCurrentChangesNode extends ViewNode< + 'conflict-current-changes', + ViewsWithCommits | FileHistoryView | LineHistoryView +> { constructor( view: ViewsWithCommits | FileHistoryView | LineHistoryView, protected override readonly parent: ViewNode, private readonly status: GitMergeStatus | GitRebaseStatus, private readonly file: GitFile, ) { - super(GitUri.fromFile(file, status.repoPath, 'HEAD'), view, parent); + super('conflict-current-changes', GitUri.fromFile(file, status.repoPath, 'HEAD'), view, parent); + } + + private _commit: Promise | undefined; + private async getCommit(): Promise { + if (this._commit == null) { + this._commit = this.view.container.git.getCommit(this.status.repoPath, 'HEAD'); + } + return this._commit; } getChildren(): ViewNode[] { @@ -30,7 +42,7 @@ export class MergeConflictCurrentChangesNode extends ViewNode { - const commit = await this.view.container.git.getCommit(this.status.repoPath, 'HEAD'); + const commit = await this.getCommit(); const item = new TreeItem('Current changes', TreeItemCollapsibleState.None); item.contextValue = ContextValues.MergeConflictCurrentChanges; @@ -41,31 +53,6 @@ export class MergeConflictCurrentChangesNode extends ViewNode { + if (item.tooltip == null) { + item.tooltip = await this.getTooltip(token); + } + return item; + } + + private async getTooltip(cancellation: CancellationToken) { + const commit = await this.getCommit(); + if (cancellation.isCancellationRequested) return undefined; + + const markdown = new MarkdownString( + `Current changes on ${getReferenceLabel(this.status.current, { label: false })}\\\n$(file)${ + GlyphChars.Space + }${this.file.path}`, + true, + ); + + if (commit == null) return markdown; + + const tooltip = await getFileRevisionAsCommitTooltip( + this.view.container, + commit, + this.file, + this.view.config.formats.commits.tooltipWithStatus, + { cancellation: cancellation }, + ); + + markdown.appendMarkdown(`\n\n${tooltip}`); + markdown.isTrusted = true; + + return markdown; + } } diff --git a/src/views/nodes/mergeConflictFileNode.ts b/src/views/nodes/mergeConflictFileNode.ts index 1894edd4f3f2b..dcfcb66a7cab4 100644 --- a/src/views/nodes/mergeConflictFileNode.ts +++ b/src/views/nodes/mergeConflictFileNode.ts @@ -8,20 +8,21 @@ import type { GitRebaseStatus } from '../../git/models/rebase'; import { createCoreCommand } from '../../system/command'; import { relativeDir } from '../../system/path'; import type { ViewsWithCommits } from '../viewBase'; +import { ViewFileNode } from './abstract/viewFileNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import type { FileNode } from './folderNode'; import { MergeConflictCurrentChangesNode } from './mergeConflictCurrentChangesNode'; import { MergeConflictIncomingChangesNode } from './mergeConflictIncomingChangesNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewFileNode } from './viewNode'; -export class MergeConflictFileNode extends ViewFileNode implements FileNode { +export class MergeConflictFileNode extends ViewFileNode<'conflict-file', ViewsWithCommits> implements FileNode { constructor( view: ViewsWithCommits, parent: ViewNode, file: GitFile, public readonly status: GitMergeStatus | GitRebaseStatus, ) { - super(GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent, file); + super('conflict-file', GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent, file); } override toClipboard(): string { diff --git a/src/views/nodes/mergeConflictFilesNode.ts b/src/views/nodes/mergeConflictFilesNode.ts new file mode 100644 index 0000000000000..f603bbc9a642f --- /dev/null +++ b/src/views/nodes/mergeConflictFilesNode.ts @@ -0,0 +1,55 @@ +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GitUri } from '../../git/gitUri'; +import type { GitMergeStatus } from '../../git/models/merge'; +import type { GitRebaseStatus } from '../../git/models/rebase'; +import type { GitStatusFile } from '../../git/models/status'; +import { makeHierarchical } from '../../system/array'; +import { joinPaths, normalizePath } from '../../system/path'; +import { pluralize, sortCompare } from '../../system/string'; +import type { ViewsWithCommits } from '../viewBase'; +import { ViewNode } from './abstract/viewNode'; +import type { FileNode } from './folderNode'; +import { FolderNode } from './folderNode'; +import { MergeConflictFileNode } from './mergeConflictFileNode'; + +export class MergeConflictFilesNode extends ViewNode<'conflict-files', ViewsWithCommits> { + constructor( + view: ViewsWithCommits, + protected override readonly parent: ViewNode, + private readonly status: GitMergeStatus | GitRebaseStatus, + private readonly conflicts: GitStatusFile[], + ) { + super('conflict-files', GitUri.fromRepoPath(status.repoPath), view, parent); + } + + get repoPath(): string { + return this.uri.repoPath!; + } + + getChildren(): ViewNode[] { + let children: (FileNode | FolderNode)[] = this.conflicts.map( + f => new MergeConflictFileNode(this.view, this, f, this.status), + ); + + if (this.view.config.files.layout !== 'list') { + const hierarchy = makeHierarchical( + children as FileNode[], + n => n.uri.relativePath.split('/'), + (...parts: string[]) => normalizePath(joinPaths(...parts)), + this.view.config.files.compact, + ); + + const root = new FolderNode(this.view, this, hierarchy, this.repoPath, '', undefined); + children = root.getChildren(); + } else { + children.sort((a, b) => sortCompare(a.label!, b.label!)); + } + + return children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem(pluralize('conflict', this.conflicts.length), TreeItemCollapsibleState.Expanded); + return item; + } +} diff --git a/src/views/nodes/mergeConflictIncomingChangesNode.ts b/src/views/nodes/mergeConflictIncomingChangesNode.ts index 78aebb0444041..3682c86b46bfc 100644 --- a/src/views/nodes/mergeConflictIncomingChangesNode.ts +++ b/src/views/nodes/mergeConflictIncomingChangesNode.ts @@ -1,9 +1,9 @@ -import type { Command } from 'vscode'; +import type { CancellationToken, Command } from 'vscode'; import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithCommandArgs } from '../../commands'; +import type { DiffWithCommandArgs } from '../../commands/diffWith'; import { Commands, GlyphChars } from '../../constants'; -import { CommitFormatter } from '../../git/formatters/commitFormatter'; import { GitUri } from '../../git/gitUri'; +import type { GitCommit } from '../../git/models/commit'; import type { GitFile } from '../../git/models/file'; import type { GitMergeStatus } from '../../git/models/merge'; import type { GitRebaseStatus } from '../../git/models/rebase'; @@ -13,16 +13,31 @@ import { configuration } from '../../system/configuration'; import type { FileHistoryView } from '../fileHistoryView'; import type { LineHistoryView } from '../lineHistoryView'; import type { ViewsWithCommits } from '../viewBase'; -import { ContextValues, ViewNode } from './viewNode'; +import { ContextValues, ViewNode } from './abstract/viewNode'; +import { getFileRevisionAsCommitTooltip } from './fileRevisionAsCommitNode'; -export class MergeConflictIncomingChangesNode extends ViewNode { +export class MergeConflictIncomingChangesNode extends ViewNode< + 'conflict-incoming-changes', + ViewsWithCommits | FileHistoryView | LineHistoryView +> { constructor( view: ViewsWithCommits | FileHistoryView | LineHistoryView, protected override readonly parent: ViewNode, private readonly status: GitMergeStatus | GitRebaseStatus, private readonly file: GitFile, ) { - super(GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent); + super('conflict-incoming-changes', GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent); + } + + private _commit: Promise | undefined; + private async getCommit(): Promise { + if (this._commit == null) { + const ref = this.status.type === 'rebase' ? this.status.steps.current.commit?.ref : this.status.HEAD.ref; + if (ref == null) return undefined; + + this._commit = this.view.container.git.getCommit(this.status.repoPath, ref); + } + return this._commit; } getChildren(): ViewNode[] { @@ -30,10 +45,7 @@ export class MergeConflictIncomingChangesNode extends ViewNode { - const commit = await this.view.container.git.getCommit( - this.status.repoPath, - this.status.type === 'rebase' ? this.status.steps.current.commit.ref : this.status.HEAD.ref, - ); + const commit = await this.getCommit(); const item = new TreeItem('Incoming changes', TreeItemCollapsibleState.None); item.contextValue = ContextValues.MergeConflictIncomingChanges; @@ -49,41 +61,6 @@ export class MergeConflictIncomingChangesNode extends ViewNode { + if (item.tooltip == null) { + item.tooltip = await this.getTooltip(token); + } + return item; + } + + private async getTooltip(cancellation: CancellationToken) { + const commit = await this.getCommit(); + if (cancellation.isCancellationRequested) return undefined; + + const markdown = new MarkdownString( + `Incoming changes from ${getReferenceLabel(this.status.incoming, { label: false })}\\\n$(file)${ + GlyphChars.Space + }${this.file.path}`, + true, + ); + + if (commit == null) { + markdown.appendMarkdown( + this.status.type === 'rebase' + ? `\n\n${getReferenceLabel(this.status.steps.current.commit, { + capitalize: true, + label: false, + })}` + : `\n\n${getReferenceLabel(this.status.HEAD, { + capitalize: true, + label: false, + })}`, + ); + return markdown; + } + + const tooltip = await getFileRevisionAsCommitTooltip( + this.view.container, + commit, + this.file, + this.view.config.formats.commits.tooltipWithStatus, + { cancellation: cancellation }, + ); + + markdown.appendMarkdown(`\n\n${tooltip}`); + markdown.isTrusted = true; + + return markdown; + } } diff --git a/src/views/nodes/mergeStatusNode.ts b/src/views/nodes/mergeStatusNode.ts index fb3404452dff1..f67a3b861ecbf 100644 --- a/src/views/nodes/mergeStatusNode.ts +++ b/src/views/nodes/mergeStatusNode.ts @@ -1,20 +1,16 @@ -import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { CoreColors } from '../../constants'; +import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; +import type { Colors } from '../../constants'; import { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import type { GitMergeStatus } from '../../git/models/merge'; import { getReferenceLabel } from '../../git/models/reference'; import type { GitStatus } from '../../git/models/status'; -import { makeHierarchical } from '../../system/array'; -import { joinPaths, normalizePath } from '../../system/path'; -import { pluralize, sortCompare } from '../../system/string'; +import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; -import type { FileNode } from './folderNode'; -import { FolderNode } from './folderNode'; -import { MergeConflictFileNode } from './mergeConflictFileNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; +import { MergeConflictFilesNode } from './mergeConflictFilesNode'; -export class MergeStatusNode extends ViewNode { +export class MergeStatusNode extends ViewNode<'merge-status', ViewsWithCommits> { constructor( view: ViewsWithCommits, protected override readonly parent: ViewNode, @@ -24,10 +20,10 @@ export class MergeStatusNode extends ViewNode { // Specifies that the node is shown as a root public readonly root: boolean, ) { - super(GitUri.fromRepoPath(mergeStatus.repoPath), view, parent); + super('merge-status', GitUri.fromRepoPath(mergeStatus.repoPath), view, parent); - this.updateContext({ branch: branch, root: root }); - this._uniqueId = getViewNodeId('merge-status', this.context); + this.updateContext({ branch: branch, root: root, status: 'merging' }); + this._uniqueId = getViewNodeId(this.type, this.context); } get repoPath(): string { @@ -35,50 +31,43 @@ export class MergeStatusNode extends ViewNode { } getChildren(): ViewNode[] { - if (this.status?.hasConflicts !== true) return []; - - let children: FileNode[] = this.status.conflicts.map( - f => new MergeConflictFileNode(this.view, this, f, this.mergeStatus), - ); - - if (this.view.config.files.layout !== 'list') { - const hierarchy = makeHierarchical( - children, - n => n.uri.relativePath.split('/'), - (...parts: string[]) => normalizePath(joinPaths(...parts)), - this.view.config.files.compact, - ); - - const root = new FolderNode(this.view, this, hierarchy, this.repoPath, '', undefined); - children = root.getChildren() as FileNode[]; - } else { - children.sort((a, b) => sortCompare(a.label!, b.label!)); - } - - return children; + return this.status?.hasConflicts + ? [new MergeConflictFilesNode(this.view, this, this.mergeStatus, this.status.conflicts)] + : []; } getTreeItem(): TreeItem { + const hasConflicts = this.status?.hasConflicts === true; const item = new TreeItem( - `${this.status?.hasConflicts ? 'Resolve conflicts before merging' : 'Merging'} ${ + `${hasConflicts ? 'Resolve conflicts before merging' : 'Merging'} ${ this.mergeStatus.incoming != null ? `${getReferenceLabel(this.mergeStatus.incoming, { expand: false, icon: false })} ` : '' }into ${getReferenceLabel(this.mergeStatus.current, { expand: false, icon: false })}`, - TreeItemCollapsibleState.Expanded, + hasConflicts ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None, ); item.id = this.id; item.contextValue = ContextValues.Merge; - item.description = this.status?.hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; - item.iconPath = this.status?.hasConflicts - ? new ThemeIcon('warning', new ThemeColor('list.warningForeground' satisfies CoreColors)) - : new ThemeIcon('debug-pause', new ThemeColor('list.foreground' satisfies CoreColors)); + item.description = hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; + item.iconPath = hasConflicts + ? new ThemeIcon( + 'warning', + new ThemeColor( + 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' satisfies Colors, + ), + ) + : new ThemeIcon( + 'warning', + new ThemeColor('gitlens.decorations.statusMergingOrRebasingForegroundColor' satisfies Colors), + ); const markdown = new MarkdownString( - `${`Merging ${ - this.mergeStatus.incoming != null ? getReferenceLabel(this.mergeStatus.incoming) : '' - }into ${getReferenceLabel(this.mergeStatus.current)}`}${ - this.status?.hasConflicts ? `\n\n${pluralize('conflicted file', this.status.conflicts.length)}` : '' + `Merging ${ + this.mergeStatus.incoming != null ? getReferenceLabel(this.mergeStatus.incoming, { label: false }) : '' + }into ${getReferenceLabel(this.mergeStatus.current, { label: false })}${ + hasConflicts + ? `\n\nResolve ${pluralize('conflict', this.status.conflicts.length)} before continuing` + : '' }`, true, ); @@ -86,6 +75,7 @@ export class MergeStatusNode extends ViewNode { markdown.isTrusted = true; item.tooltip = markdown; + item.resourceUri = Uri.parse(`gitlens-view://status/merging${hasConflicts ? '/conflicts' : ''}`); return item; } diff --git a/src/views/nodes/pullRequestNode.ts b/src/views/nodes/pullRequestNode.ts index d8ec21fcadb65..f518d8232a244 100644 --- a/src/views/nodes/pullRequestNode.ts +++ b/src/views/nodes/pullRequestNode.ts @@ -4,10 +4,20 @@ import { GitBranch } from '../../git/models/branch'; import type { GitCommit } from '../../git/models/commit'; import { getIssueOrPullRequestMarkdownIcon, getIssueOrPullRequestThemeIcon } from '../../git/models/issue'; import type { PullRequest } from '../../git/models/pullRequest'; +import { getComparisonRefsForPullRequest } from '../../git/models/pullRequest'; +import type { GitBranchReference } from '../../git/models/reference'; +import { createRevisionRange } from '../../git/models/reference'; +import { getAheadBehindFilesQuery, getCommitsQuery } from '../../git/queryResults'; +import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ClipboardType, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { CodeSuggestionsNode } from './codeSuggestionsNode'; +import { ResultsCommitsNode } from './resultsCommitsNode'; +import { ResultsFilesNode } from './resultsFilesNode'; -export class PullRequestNode extends ViewNode { +export class PullRequestNode extends CacheableChildrenViewNode<'pullrequest', ViewsWithCommits> { readonly repoPath: string; constructor( @@ -15,6 +25,7 @@ export class PullRequestNode extends ViewNode { protected override readonly parent: ViewNode, public readonly pullRequest: PullRequest, branchOrCommitOrRepoPath: GitBranch | GitCommit | string, + private readonly options?: { expand?: boolean }, ) { let branchOrCommit; let repoPath; @@ -25,7 +36,7 @@ export class PullRequestNode extends ViewNode { branchOrCommit = branchOrCommitOrRepoPath; } - super(GitUri.fromRepoPath(repoPath), view, parent); + super('pullrequest', GitUri.fromRepoPath(repoPath), view, parent); if (branchOrCommit != null) { if (branchOrCommit instanceof GitBranch) { @@ -35,7 +46,8 @@ export class PullRequestNode extends ViewNode { } } - this._uniqueId = getViewNodeId('pullrequest', this.context); + this.updateContext({ pullRequest: pullRequest }); + this._uniqueId = getViewNodeId(this.type, this.context); this.repoPath = repoPath; } @@ -43,18 +55,122 @@ export class PullRequestNode extends ViewNode { return this._uniqueId; } - override toClipboard(): string { + override toClipboard(type?: ClipboardType): string { + switch (type) { + case 'markdown': + return `[${this.pullRequest.id}](${this.pullRequest.url}) ${this.pullRequest.title}`; + default: + return this.pullRequest.url; + } + } + + override getUrl(): string { return this.pullRequest.url; } - getChildren(): ViewNode[] { - return []; + get baseRef(): GitBranchReference | undefined { + if (this.pullRequest.refs?.base != null) { + return { + refType: 'branch', + repoPath: this.repoPath, + ref: this.pullRequest.refs.base.sha, + name: this.pullRequest.refs.base.branch, + remote: true, + }; + } + return undefined; + } + + get ref(): GitBranchReference | undefined { + if (this.pullRequest.refs?.head != null) { + return { + refType: 'branch', + repoPath: this.repoPath, + ref: this.pullRequest.refs.head.sha, + name: this.pullRequest.refs.head.branch, + remote: true, + }; + } + return undefined; + } + + async getChildren(): Promise { + if (this.children == null) { + const refs = await getComparisonRefsForPullRequest( + this.view.container, + this.repoPath, + this.pullRequest.refs!, + ); + + const comparison = { + ref1: refs.base.ref, + ref2: refs.head.ref, + }; + + const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount(this.repoPath, [ + createRevisionRange(comparison.ref2, comparison.ref1, '...'), + ]); + + const children = [ + new ResultsCommitsNode( + this.view, + this, + this.repoPath, + 'Commits', + { + query: getCommitsQuery( + this.view.container, + this.repoPath, + createRevisionRange(comparison.ref1, comparison.ref2, '..'), + ), + comparison: comparison, + }, + { + autolinks: false, + expand: false, + description: pluralize('commit', aheadBehindCounts?.ahead ?? 0), + }, + ), + new CodeSuggestionsNode(this.view, this, this.repoPath, this.pullRequest), + new ResultsFilesNode( + this.view, + this, + this.repoPath, + comparison.ref1, + comparison.ref2, + () => + getAheadBehindFilesQuery( + this.view.container, + this.repoPath, + createRevisionRange(comparison.ref1, comparison.ref2, '...'), + false, + ), + undefined, + { expand: true, timeout: false }, + ), + ]; + + this.children = children; + } + return this.children; } getTreeItem(): TreeItem { - const item = new TreeItem(`#${this.pullRequest.id}: ${this.pullRequest.title}`, TreeItemCollapsibleState.None); + const hasRefs = this.pullRequest.refs?.base != null && this.pullRequest.refs.head != null; + + const item = new TreeItem( + `#${this.pullRequest.id}: ${this.pullRequest.title}`, + hasRefs + ? this.options?.expand + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.None, + ); item.id = this.id; item.contextValue = ContextValues.PullRequest; + if (this.pullRequest.refs?.base != null && this.pullRequest.refs.head != null) { + item.contextValue += `+refs`; + } item.description = `${this.pullRequest.state}, ${this.pullRequest.formatDateFromNow()}`; item.iconPath = getIssueOrPullRequestThemeIcon(this.pullRequest); diff --git a/src/views/nodes/rebaseCommitNode.ts b/src/views/nodes/rebaseCommitNode.ts new file mode 100644 index 0000000000000..58df769d00b3b --- /dev/null +++ b/src/views/nodes/rebaseCommitNode.ts @@ -0,0 +1,26 @@ +import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { CommitFormatter } from '../../git/formatters/commitFormatter'; +import { ContextValues } from './abstract/viewNode'; +import { CommitNode } from './commitNode'; + +export class RebaseCommitNode extends CommitNode { + // eslint-disable-next-line @typescript-eslint/require-await + override async getTreeItem(): Promise { + const item = new TreeItem( + `Paused at commit ${this.commit.shortSha}`, + this._options.expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, + ); + item.id = this.id; + item.contextValue = `${ContextValues.Commit}+rebase`; + item.description = CommitFormatter.fromTemplate(`\${message}`, this.commit, { + messageTruncateAtNewLine: true, + }); + item.iconPath = new ThemeIcon('debug-pause'); + + return item; + } + + protected override getTooltipTemplate(): string { + return `Rebase paused at ${super.getTooltipTemplate()}`; + } +} diff --git a/src/views/nodes/rebaseStatusNode.ts b/src/views/nodes/rebaseStatusNode.ts index dae9dfaad1241..771d9eb5f9d35 100644 --- a/src/views/nodes/rebaseStatusNode.ts +++ b/src/views/nodes/rebaseStatusNode.ts @@ -1,31 +1,18 @@ -import type { Command } from 'vscode'; import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; -import type { DiffWithPreviousCommandArgs } from '../../commands'; -import type { CoreColors } from '../../constants'; -import { Commands } from '../../constants'; -import { CommitFormatter } from '../../git/formatters/commitFormatter'; +import type { Colors } from '../../constants'; import { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; -import type { GitCommit } from '../../git/models/commit'; import type { GitRebaseStatus } from '../../git/models/rebase'; -import type { GitRevisionReference } from '../../git/models/reference'; import { getReferenceLabel } from '../../git/models/reference'; import type { GitStatus } from '../../git/models/status'; -import { makeHierarchical } from '../../system/array'; -import { pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/cancellation'; import { executeCoreCommand } from '../../system/command'; -import { configuration } from '../../system/configuration'; -import { joinPaths, normalizePath } from '../../system/path'; -import { getSettledValue } from '../../system/promise'; -import { pluralize, sortCompare } from '../../system/string'; +import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; -import { CommitFileNode } from './commitFileNode'; -import type { FileNode } from './folderNode'; -import { FolderNode } from './folderNode'; -import { MergeConflictFileNode } from './mergeConflictFileNode'; -import { ContextValues, getViewNodeId, ViewNode, ViewRefNode } from './viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; +import { MergeConflictFilesNode } from './mergeConflictFilesNode'; +import { RebaseCommitNode } from './rebaseCommitNode'; -export class RebaseStatusNode extends ViewNode { +export class RebaseStatusNode extends ViewNode<'rebase-status', ViewsWithCommits> { constructor( view: ViewsWithCommits, protected override readonly parent: ViewNode, @@ -35,10 +22,10 @@ export class RebaseStatusNode extends ViewNode { // Specifies that the node is shown as a root public readonly root: boolean, ) { - super(GitUri.fromRepoPath(rebaseStatus.repoPath), view, parent); + super('rebase-status', GitUri.fromRepoPath(rebaseStatus.repoPath), view, parent); - this.updateContext({ branch: branch, root: root }); - this._uniqueId = getViewNodeId('merge-status', this.context); + this.updateContext({ branch: branch, root: root, status: 'rebasing' }); + this._uniqueId = getViewNodeId(this.type, this.context); } get repoPath(): string { @@ -46,65 +33,80 @@ export class RebaseStatusNode extends ViewNode { } async getChildren(): Promise { - let children: FileNode[] = - this.status?.conflicts.map(f => new MergeConflictFileNode(this.view, this, f, this.rebaseStatus)) ?? []; - - if (this.view.config.files.layout !== 'list') { - const hierarchy = makeHierarchical( - children, - n => n.uri.relativePath.split('/'), - (...parts: string[]) => normalizePath(joinPaths(...parts)), - this.view.config.files.compact, - ); - - const root = new FolderNode(this.view, this, hierarchy, this.repoPath, '', undefined); - children = root.getChildren() as FileNode[]; - } else { - children.sort((a, b) => sortCompare(a.label!, b.label!)); + const children: (MergeConflictFilesNode | RebaseCommitNode)[] = []; + + const revision = this.rebaseStatus.steps.current.commit; + if (revision != null) { + const commit = + revision != null + ? await this.view.container.git.getCommit(this.rebaseStatus.repoPath, revision.ref) + : undefined; + if (commit != null) { + children.push(new RebaseCommitNode(this.view, this, commit)); + } } - const commit = await this.view.container.git.getCommit( - this.rebaseStatus.repoPath, - this.rebaseStatus.steps.current.commit.ref, - ); - if (commit != null) { - children.unshift(new RebaseCommitNode(this.view, this, commit) as any); + if (this.status?.hasConflicts) { + children.push(new MergeConflictFilesNode(this.view, this, this.rebaseStatus, this.status.conflicts)); } return children; } getTreeItem(): TreeItem { + const started = this.rebaseStatus.steps.total > 0; + const pausedAtCommit = started && this.rebaseStatus.steps.current.commit != null; + const hasConflicts = this.status?.hasConflicts === true; + const item = new TreeItem( - `${this.status?.hasConflicts ? 'Resolve conflicts to continue rebasing' : 'Rebasing'} ${ + `${hasConflicts ? 'Resolve conflicts to continue rebasing' : started ? 'Rebasing' : 'Pending rebase of'} ${ this.rebaseStatus.incoming != null - ? `${getReferenceLabel(this.rebaseStatus.incoming, { expand: false, icon: false })}` + ? getReferenceLabel(this.rebaseStatus.incoming, { expand: false, icon: false }) : '' - } (${this.rebaseStatus.steps.current.number}/${this.rebaseStatus.steps.total})`, - TreeItemCollapsibleState.Expanded, + } onto ${getReferenceLabel(this.rebaseStatus.current ?? this.rebaseStatus.onto, { + expand: false, + icon: false, + })}${started ? ` (${this.rebaseStatus.steps.current.number}/${this.rebaseStatus.steps.total})` : ''}`, + pausedAtCommit ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None, ); item.id = this.id; item.contextValue = ContextValues.Rebase; - item.description = this.status?.hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; - item.iconPath = this.status?.hasConflicts - ? new ThemeIcon('warning', new ThemeColor('list.warningForeground' satisfies CoreColors)) - : new ThemeIcon('debug-pause', new ThemeColor('list.foreground' satisfies CoreColors)); + item.description = hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; + item.iconPath = hasConflicts + ? new ThemeIcon( + 'warning', + new ThemeColor( + 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' satisfies Colors, + ), + ) + : new ThemeIcon( + 'warning', + new ThemeColor('gitlens.decorations.statusMergingOrRebasingForegroundColor' satisfies Colors), + ); const markdown = new MarkdownString( - `${`Rebasing ${ - this.rebaseStatus.incoming != null ? getReferenceLabel(this.rebaseStatus.incoming) : '' - }onto ${getReferenceLabel(this.rebaseStatus.current)}`}\n\nStep ${ - this.rebaseStatus.steps.current.number - } of ${this.rebaseStatus.steps.total}\\\nPaused at ${getReferenceLabel( - this.rebaseStatus.steps.current.commit, - { icon: true }, - )}${this.status?.hasConflicts ? `\n\n${pluralize('conflicted file', this.status.conflicts.length)}` : ''}`, + `${started ? 'Rebasing' : 'Pending rebase of'} ${ + this.rebaseStatus.incoming != null + ? getReferenceLabel(this.rebaseStatus.incoming, { label: false }) + : '' + } onto ${getReferenceLabel(this.rebaseStatus.current ?? this.rebaseStatus.onto, { label: false })}${ + started + ? `\n\nPaused at step ${this.rebaseStatus.steps.current.number} of ${ + this.rebaseStatus.steps.total + }${ + hasConflicts + ? `\\\nResolve ${pluralize('conflict', this.status.conflicts.length)} before continuing` + : '' + }` + : '' + }`, true, ); markdown.supportHtml = true; markdown.isTrusted = true; item.tooltip = markdown; + item.resourceUri = Uri.parse(`gitlens-view://status/rebasing${hasConflicts ? '/conflicts' : ''}`); return item; } @@ -116,124 +118,3 @@ export class RebaseStatusNode extends ViewNode { }); } } - -export class RebaseCommitNode extends ViewRefNode { - constructor( - view: ViewsWithCommits, - parent: ViewNode, - public readonly commit: GitCommit, - ) { - super(commit.getGitUri(), view, parent); - } - - override toClipboard(): string { - return `${this.commit.shortSha}: ${this.commit.summary}`; - } - - get ref(): GitRevisionReference { - return this.commit; - } - - async getChildren(): Promise { - const commit = this.commit; - - const commits = await commit.getCommitsForFiles(); - let children: FileNode[] = commits.map(c => new CommitFileNode(this.view, this, c.file!, c)); - - if (this.view.config.files.layout !== 'list') { - const hierarchy = makeHierarchical( - children, - n => n.uri.relativePath.split('/'), - (...parts: string[]) => normalizePath(joinPaths(...parts)), - this.view.config.files.compact, - ); - - const root = new FolderNode(this.view, this, hierarchy, this.repoPath, '', undefined); - children = root.getChildren() as FileNode[]; - } else { - children.sort((a, b) => sortCompare(a.label!, b.label!)); - } - - return children; - } - - getTreeItem(): TreeItem { - const item = new TreeItem(`Paused at commit ${this.commit.shortSha}`, TreeItemCollapsibleState.Collapsed); - - // item.contextValue = ContextValues.RebaseCommit; - - item.description = CommitFormatter.fromTemplate(`\${message}`, this.commit, { - messageTruncateAtNewLine: true, - }); - item.iconPath = new ThemeIcon('git-commit'); - - return item; - } - - override getCommand(): Command | undefined { - const commandArgs: DiffWithPreviousCommandArgs = { - commit: this.commit, - uri: this.uri, - line: 0, - showOptions: { - preserveFocus: true, - preview: true, - }, - }; - return { - title: 'Open Changes with Previous Revision', - command: Commands.DiffWithPrevious, - arguments: [undefined, commandArgs], - }; - } - - override async resolveTreeItem(item: TreeItem): Promise { - if (item.tooltip == null) { - item.tooltip = await this.getTooltip(); - } - return item; - } - - private async getTooltip() { - const [remotesResult, _] = await Promise.allSettled([ - this.view.container.git.getBestRemotesWithProviders(this.commit.repoPath), - this.commit.message == null ? this.commit.ensureFullDetails() : undefined, - ]); - - const remotes = getSettledValue(remotesResult, []); - const [remote] = remotes; - - let enrichedAutolinks; - let pr; - - if (remote?.hasRichIntegration()) { - const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ - pauseOnCancelOrTimeoutMapTuplePromise(this.commit.getEnrichedAutolinks(remote)), - this.commit.getAssociatedPullRequest(remote), - ]); - - enrichedAutolinks = getSettledValue(enrichedAutolinksResult)?.value; - pr = getSettledValue(prResult); - } - - const tooltip = await CommitFormatter.fromTemplateAsync( - `Rebase paused at ${this.view.config.formats.commits.tooltip}`, - this.commit, - { - enrichedAutolinks: enrichedAutolinks, - dateFormat: configuration.get('defaultDateFormat'), - messageAutolinks: true, - messageIndent: 4, - pullRequest: pr, - outputFormat: 'markdown', - remotes: remotes, - }, - ); - - const markdown = new MarkdownString(tooltip, true); - markdown.supportHtml = true; - markdown.isTrusted = true; - - return markdown; - } -} diff --git a/src/views/nodes/reflogNode.ts b/src/views/nodes/reflogNode.ts index ec55b9fc5408d..2672ac7166996 100644 --- a/src/views/nodes/reflogNode.ts +++ b/src/views/nodes/reflogNode.ts @@ -2,16 +2,19 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { GitUri } from '../../git/gitUri'; import type { GitReflog } from '../../git/models/reflog'; import type { Repository } from '../../git/models/repository'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import type { RepositoriesView } from '../repositoriesView'; import type { WorkspacesView } from '../workspacesView'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { LoadMoreNode, MessageNode } from './common'; import { ReflogRecordNode } from './reflogRecordNode'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class ReflogNode extends ViewNode implements PageableViewNode { +export class ReflogNode + extends CacheableChildrenViewNode<'reflog', RepositoriesView | WorkspacesView> + implements PageableViewNode +{ limit: number | undefined; constructor( @@ -20,10 +23,10 @@ export class ReflogNode extends ViewNode impl parent: ViewNode, public readonly repo: Repository, ) { - super(uri, view, parent); + super('reflog', uri, view, parent); this.updateContext({ repository: repo }); - this._uniqueId = getViewNodeId('reflog', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this.limit = this.view.getNodeLastKnownLimit(this); } @@ -31,10 +34,8 @@ export class ReflogNode extends ViewNode impl return this._uniqueId; } - private _children: ViewNode[] | undefined; - async getChildren(): Promise { - if (this._children === undefined) { + if (this.children === undefined) { const children = []; const reflog = await this.getReflog(); @@ -48,9 +49,9 @@ export class ReflogNode extends ViewNode impl children.push(new LoadMoreNode(this.view, this, children[children.length - 1])); } - this._children = children; + this.children = children; } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -66,10 +67,10 @@ export class ReflogNode extends ViewNode impl return item; } - @gate() @debug() override refresh(reset?: boolean) { - this._children = undefined; + super.refresh(true); + if (reset) { this._reflog = undefined; } @@ -93,7 +94,7 @@ export class ReflogNode extends ViewNode impl async loadMore(limit?: number) { let reflog = await this.getReflog(); - if (reflog === undefined || !reflog.hasMore) return; + if (!reflog?.hasMore) return; reflog = await reflog.more?.(limit ?? this.view.config.pageItemLimit); if (this._reflog === reflog) return; diff --git a/src/views/nodes/reflogRecordNode.ts b/src/views/nodes/reflogRecordNode.ts index f46ebf7f78c2c..7774d9878db08 100644 --- a/src/views/nodes/reflogRecordNode.ts +++ b/src/views/nodes/reflogRecordNode.ts @@ -7,12 +7,12 @@ import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import type { ViewsWithCommits } from '../viewBase'; +import type { PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class ReflogRecordNode extends ViewNode implements PageableViewNode { +export class ReflogRecordNode extends ViewNode<'reflog-record', ViewsWithCommits> implements PageableViewNode { limit: number | undefined; constructor( @@ -20,10 +20,10 @@ export class ReflogRecordNode extends ViewNode implements Page parent: ViewNode, public readonly record: GitReflogRecord, ) { - super(GitUri.fromRepoPath(record.repoPath), view, parent); + super('reflog-record', GitUri.fromRepoPath(record.repoPath), view, parent); this.updateContext({ reflog: record }); - this._uniqueId = getViewNodeId('reflog-record', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this.limit = this.view.getNodeLastKnownLimit(this); } @@ -101,7 +101,7 @@ export class ReflogRecordNode extends ViewNode implements Page }, () => this.getLog(), ); - if (log === undefined || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/remoteNode.ts b/src/views/nodes/remoteNode.ts index e4bb095b85693..02cc0b7198889 100644 --- a/src/views/nodes/remoteNode.ts +++ b/src/views/nodes/remoteNode.ts @@ -7,12 +7,12 @@ import type { Repository } from '../../git/models/repository'; import { makeHierarchical } from '../../system/array'; import { log } from '../../system/decorators/log'; import type { ViewsWithRemotes } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { BranchNode } from './branchNode'; import { BranchOrTagFolderNode } from './branchOrTagFolderNode'; import { MessageNode } from './common'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class RemoteNode extends ViewNode { +export class RemoteNode extends ViewNode<'remote', ViewsWithRemotes> { constructor( uri: GitUri, view: ViewsWithRemotes, @@ -20,10 +20,10 @@ export class RemoteNode extends ViewNode { public readonly repo: Repository, public readonly remote: GitRemote, ) { - super(uri, view, parent); + super('remote', uri, view, parent); this.updateContext({ repository: repo, remote: remote }); - this._uniqueId = getViewNodeId('remote', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -92,14 +92,19 @@ export class RemoteNode extends ViewNode { provider.avatarUri != null && this.view.config.avatars ? provider.avatarUri : provider.icon === 'remote' - ? new ThemeIcon('cloud') - : { - dark: this.view.container.context.asAbsolutePath(`images/dark/icon-${provider.icon}.svg`), - light: this.view.container.context.asAbsolutePath(`images/light/icon-${provider.icon}.svg`), - }; - - if (provider.hasRichIntegration()) { - const connected = provider.maybeConnected ?? (await provider.isConnected()); + ? new ThemeIcon('cloud') + : { + dark: this.view.container.context.asAbsolutePath( + `images/dark/icon-${provider.icon}.svg`, + ), + light: this.view.container.context.asAbsolutePath( + `images/light/icon-${provider.icon}.svg`, + ), + }; + + if (this.remote.hasIntegration()) { + const integration = await this.view.container.integrations.getByRemote(this.remote); + const connected = integration?.maybeConnected ?? (await integration?.isConnected()); item.contextValue = `${ContextValues.Remote}${connected ? '+connected' : '+disconnected'}`; item.tooltip = `${this.remote.name} (${provider.name} ${GlyphChars.Dash} ${ diff --git a/src/views/nodes/remotesNode.ts b/src/views/nodes/remotesNode.ts index ca4617c456717..f534e8ec0d103 100644 --- a/src/views/nodes/remotesNode.ts +++ b/src/views/nodes/remotesNode.ts @@ -1,24 +1,25 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import type { ViewsWithRemotesNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { MessageNode } from './common'; import { RemoteNode } from './remoteNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class RemotesNode extends ViewNode { +export class RemotesNode extends CacheableChildrenViewNode<'remotes', ViewsWithRemotesNode> { constructor( uri: GitUri, view: ViewsWithRemotesNode, protected override readonly parent: ViewNode, public readonly repo: Repository, ) { - super(uri, view, parent); + super('remotes', uri, view, parent); this.updateContext({ repository: repo }); - this._uniqueId = getViewNodeId('remotes', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -29,19 +30,17 @@ export class RemotesNode extends ViewNode { return this.repo.path; } - private _children: ViewNode[] | undefined; - async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const remotes = await this.repo.getRemotes({ sort: true }); if (remotes.length === 0) { return [new MessageNode(this.view, this, 'No remotes could be found')]; } - this._children = remotes.map(r => new RemoteNode(this.uri, this.view, this, this.repo, r)); + this.children = remotes.map(r => new RemoteNode(this.uri, this.view, this, this.repo, r)); } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -53,9 +52,8 @@ export class RemotesNode extends ViewNode { return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/nodes/repositoriesNode.ts b/src/views/nodes/repositoriesNode.ts index 58f6d5fbfaec4..32ddb9eae0103 100644 --- a/src/views/nodes/repositoriesNode.ts +++ b/src/views/nodes/repositoriesNode.ts @@ -4,48 +4,34 @@ import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; import { GitUri, unknownGitUri } from '../../git/gitUri'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import { debounce, szudzikPairing } from '../../system/function'; import { Logger } from '../../system/logger'; import type { ViewsWithRepositoriesNode } from '../viewBase'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { MessageNode } from './common'; import { RepositoryNode } from './repositoryNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; - -export class RepositoriesNode extends SubscribeableViewNode { - private _children: (RepositoryNode | MessageNode)[] | undefined; +export class RepositoriesNode extends SubscribeableViewNode< + 'repositories', + ViewsWithRepositoriesNode, + RepositoryNode | MessageNode +> { constructor(view: ViewsWithRepositoriesNode) { - super(unknownGitUri, view); - } - - override dispose() { - super.dispose(); - - this.resetChildren(); - } - - @debug() - private resetChildren() { - if (this._children == null) return; - - for (const child of this._children) { - if ('dispose' in child) { - child.dispose(); - } - } - this._children = undefined; + super('repositories', unknownGitUri, view); } getChildren(): ViewNode[] { - if (this._children == null) { + if (this.children == null) { const repositories = this.view.container.git.openRepositories; if (repositories.length === 0) return [new MessageNode(this.view, this, 'No repositories could be found.')]; - this._children = repositories.map(r => new RepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r)); + this.children = repositories.map(r => new RepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r)); } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -82,10 +68,11 @@ export class RepositoriesNode extends SubscribeableViewNode c.repo.id === id); + const child = (this.children as RepositoryNode[]).find(c => c.repo.id === id); if (child != null) { children.push(child); void child.refresh(); @@ -112,23 +99,21 @@ export class RepositoriesNode extends SubscribeableViewNode n instanceof RepositoryNode && n.repo.containsUri(uri)) as + const node = this.children.find(n => n instanceof RepositoryNode && n.repo.containsUri(uri)) as | RepositoryNode | undefined; if (node == null) return; - // Check to see if this repo has a descendent that is already selected + // Check to see if this repo has a descendant that is already selected let parent = this.view.selection.length === 0 ? undefined : this.view.selection[0]; while (parent != null) { if (parent === node) return; diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index 88deb7fce4ad1..eede962b92db6 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -3,7 +3,7 @@ import { GlyphChars } from '../../constants'; import { Features } from '../../features'; import type { GitUri } from '../../git/gitUri'; import { GitBranch } from '../../git/models/branch'; -import { GitRemote } from '../../git/models/remote'; +import { getHighlanderProviders } from '../../git/models/remote'; import type { RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../git/models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; import type { GitStatus } from '../../git/models/status'; @@ -16,9 +16,13 @@ import type { import { findLastIndex } from '../../system/array'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import { disposableInterval } from '../../system/function'; import { pad } from '../../system/string'; import type { ViewsWithRepositories } from '../viewBase'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { AmbientContext, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { BranchesNode } from './branchesNode'; import { BranchNode } from './branchNode'; import { BranchTrackingStatusNode } from './branchTrackingStatusNode'; @@ -32,12 +36,9 @@ import { RemotesNode } from './remotesNode'; import { StashesNode } from './stashesNode'; import { StatusFilesNode } from './statusFilesNode'; import { TagsNode } from './tagsNode'; -import type { AmbientContext, ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, SubscribeableViewNode } from './viewNode'; import { WorktreesNode } from './worktreesNode'; -export class RepositoryNode extends SubscribeableViewNode { - private _children: ViewNode[] | undefined; +export class RepositoryNode extends SubscribeableViewNode<'repository', ViewsWithRepositories> { private _status: Promise; constructor( @@ -47,10 +48,10 @@ export class RepositoryNode extends SubscribeableViewNode public readonly repo: Repository, context?: AmbientContext, ) { - super(uri, view, parent); + super('repository', uri, view, parent); this.updateContext({ ...context, repository: this.repo }); - this._uniqueId = getViewNodeId('repository', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this._status = this.repo.getStatus(); } @@ -76,7 +77,7 @@ export class RepositoryNode extends SubscribeableViewNode } async getChildren(): Promise { - if (this._children === undefined) { + if (this.children === undefined) { const children = []; const status = await this._status; @@ -89,26 +90,13 @@ export class RepositoryNode extends SubscribeableViewNode true, undefined, status.sha, - status.upstream ? { name: status.upstream, missing: false } : undefined, + status.upstream, status.state.ahead, status.state.behind, status.detached, status.rebasing, ); - if (this.view.config.showBranchComparison !== false) { - children.push( - new CompareBranchNode( - this.uri, - this.view, - this, - branch, - this.view.config.showBranchComparison, - true, - ), - ); - } - const [mergeStatus, rebaseStatus] = await Promise.all([ this.view.container.git.getMergeStatus(status.repoPath), this.view.container.git.getRebaseStatus(status.repoPath), @@ -137,7 +125,7 @@ export class RepositoryNode extends SubscribeableViewNode ); } } - } else { + } else if (!status.detached) { children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'none', true)); } } @@ -147,6 +135,19 @@ export class RepositoryNode extends SubscribeableViewNode children.push(new StatusFilesNode(this.view, this, status, range)); } + if (this.view.config.showBranchComparison !== false) { + children.push( + new CompareBranchNode( + this.uri, + this.view, + this, + branch, + this.view.config.showBranchComparison, + true, + ), + ); + } + if (children.length !== 0 && !this.view.config.compact) { children.push(new MessageNode(this.view, this, '', GlyphChars.Dash.repeat(2), '')); } @@ -156,7 +157,7 @@ export class RepositoryNode extends SubscribeableViewNode new BranchNode(this.uri, this.view, this, this.repo, branch, true, { showAsCommits: true, showComparison: false, - showCurrent: false, + showCurrentOrOpened: false, showStatus: false, showTracking: false, }), @@ -192,9 +193,9 @@ export class RepositoryNode extends SubscribeableViewNode children.push(new ReflogNode(this.uri, this.view, this, this.repo)); } - this._children = children; + this.children = children; } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -225,18 +226,20 @@ export class RepositoryNode extends SubscribeableViewNode } } - let iconSuffix; + let iconType: '' | '-solid' | '-cloud' = ''; + let iconColor: '' | '-blue' | '-green' | '-yellow' | '-red' = ''; + // TODO@axosoft-ramint Temporary workaround, remove when our git commands work on closed repos. if (this.repo.closed) { contextValue += '+closed'; - iconSuffix = ''; + iconType = ''; } else { - iconSuffix = '-solid'; + iconType = '-solid'; } if (this.repo.virtual) { contextValue += '+virtual'; - iconSuffix = '-cloud'; + iconType = '-cloud'; } const status = await this._status; @@ -258,7 +261,7 @@ export class RepositoryNode extends SubscribeableViewNode let providerName; if (status.upstream != null) { - const providers = GitRemote.getHighlanderProviders( + const providers = getHighlanderProviders( await this.view.container.git.getRemotesWithProviders(status.repoPath), ); providerName = providers?.length ? providers[0].name : undefined; @@ -267,25 +270,24 @@ export class RepositoryNode extends SubscribeableViewNode providerName = remote?.provider?.name; } - iconSuffix += workingStatus ? '-blue' : ''; if (status.upstream != null) { tooltip += ` is ${status.getUpstreamStatus({ - empty: `up to date with $(git-branch) ${status.upstream}${ + empty: `up to date with $(git-branch) ${status.upstream.name}${ providerName ? ` on ${providerName}` : '' }`, expand: true, icons: true, separator: ', ', - suffix: ` $(git-branch) ${status.upstream}${providerName ? ` on ${providerName}` : ''}`, + suffix: ` $(git-branch) ${status.upstream.name}${providerName ? ` on ${providerName}` : ''}`, })}`; if (status.state.behind) { contextValue += '+behind'; - iconSuffix += '-red'; + iconColor = '-red'; } if (status.state.ahead) { - iconSuffix += status.state.behind ? '-yellow' : '-green'; contextValue += '+ahead'; + iconColor = status.state.behind ? '-yellow' : '-green'; } } @@ -295,6 +297,7 @@ export class RepositoryNode extends SubscribeableViewNode prefix: '\n', separator: '\n', })}`; + iconColor = '-blue'; } } @@ -314,8 +317,8 @@ export class RepositoryNode extends SubscribeableViewNode lastFetched ? `${pad(GlyphChars.Dot, 1, 1)}Last fetched ${Repository.formatLastFetched(lastFetched)}` : '' }`; item.iconPath = { - dark: this.view.container.context.asAbsolutePath(`images/dark/icon-repo${iconSuffix}.svg`), - light: this.view.container.context.asAbsolutePath(`images/light/icon-repo${iconSuffix}.svg`), + dark: this.view.container.context.asAbsolutePath(`images/dark/icon-repo${iconType}${iconColor}.svg`), + light: this.view.container.context.asAbsolutePath(`images/light/icon-repo${iconType}${iconColor}.svg`), }; if (workspace != null && !this.repo.closed) { @@ -349,10 +352,10 @@ export class RepositoryNode extends SubscribeableViewNode @gate() @debug() override async refresh(reset: boolean = false) { + super.refresh(reset); + if (reset) { this._status = this.repo.getStatus(); - - this._children = undefined; } await this.ensureSubscription(); @@ -374,7 +377,7 @@ export class RepositoryNode extends SubscribeableViewNode protected async subscribe() { const lastFetched = (await this.repo?.getLastFetched()) ?? 0; - const disposables = [this.repo.onDidChange(this.onRepositoryChanged, this)]; + const disposables = [weakEvent(this.repo.onDidChange, this.onRepositoryChanged, this)]; const interval = Repository.getLastFetchedUpdateInterval(lastFetched); if (lastFetched !== 0 && interval > 0) { @@ -396,8 +399,9 @@ export class RepositoryNode extends SubscribeableViewNode if (this.view.config.includeWorkingTree) { disposables.push( - this.repo.onDidChangeFileSystem(this.onFileSystemChanged, this), - this.repo.startWatchingFileSystem(), + weakEvent(this.repo.onDidChangeFileSystem, this.onFileSystemChanged, this, [ + this.repo.watchFileSystem(), + ]), ); } @@ -420,25 +424,22 @@ export class RepositoryNode extends SubscribeableViewNode private async onFileSystemChanged(_e: RepositoryFileSystemChangeEvent) { this._status = this.repo.getStatus(); - if (this._children !== undefined) { + if (this.children !== undefined) { const status = await this._status; - let index = this._children.findIndex(c => c instanceof StatusFilesNode); + let index = this.children.findIndex(c => c.type === 'status-files'); if (status !== undefined && (status.state.ahead || status.files.length !== 0)) { let deleteCount = 1; if (index === -1) { - index = findLastIndex( - this._children, - c => c instanceof BranchTrackingStatusNode || c instanceof BranchNode, - ); + index = findLastIndex(this.children, c => c.type === 'tracking-status' || c.type === 'branch'); deleteCount = 0; index++; } const range = undefined; //status.upstream ? createRange(status.upstream, status.sha) : undefined; - this._children.splice(index, deleteCount, new StatusFilesNode(this.view, this, status, range)); + this.children.splice(index, deleteCount, new StatusFilesNode(this.view, this, status, range)); } else if (index !== -1) { - this._children.splice(index, 1); + this.children.splice(index, 1); } } @@ -454,7 +455,7 @@ export class RepositoryNode extends SubscribeableViewNode } if ( - this._children == null || + this.children == null || e.changed( RepositoryChange.Config, RepositoryChange.Index, @@ -471,21 +472,21 @@ export class RepositoryNode extends SubscribeableViewNode } if (e.changed(RepositoryChange.Remotes, RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { - const node = this._children.find(c => c instanceof RemotesNode); + const node = this.children.find(c => c.type === 'remotes'); if (node != null) { this.view.triggerNodeChange(node); } } if (e.changed(RepositoryChange.Stash, RepositoryChangeComparisonMode.Any)) { - const node = this._children.find(c => c instanceof StashesNode); + const node = this.children.find(c => c.type === 'stashes'); if (node != null) { this.view.triggerNodeChange(node); } } if (e.changed(RepositoryChange.Tags, RepositoryChangeComparisonMode.Any)) { - const node = this._children.find(c => c instanceof TagsNode); + const node = this.children.find(c => c.type === 'tags'); if (node != null) { this.view.triggerNodeChange(node); } diff --git a/src/views/nodes/resultsCommitsNode.ts b/src/views/nodes/resultsCommitsNode.ts index 2a44aaec5f5e1..4707bf357b3d5 100644 --- a/src/views/nodes/resultsCommitsNode.ts +++ b/src/views/nodes/resultsCommitsNode.ts @@ -1,37 +1,37 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; import { isStash } from '../../git/models/commit'; -import type { GitLog } from '../../git/models/log'; +import type { CommitsQueryResults, FilesQueryResults } from '../../git/queryResults'; import { configuration } from '../../system/configuration'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import type { Deferred } from '../../system/promise'; -import { cancellable, defer, PromiseCancelledError } from '../../system/promise'; +import { defer, pauseOnCancelOrTimeout } from '../../system/promise'; import type { ViewsWithCommits } from '../viewBase'; +import type { PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { AutolinkedItemsNode } from './autolinkedItemsNode'; import { CommitNode } from './commitNode'; -import { LoadMoreNode } from './common'; +import { LoadMoreNode, MessageNode } from './common'; import { insertDateMarkers } from './helpers'; -import type { FilesQueryResults } from './resultsFilesNode'; import { ResultsFilesNode } from './resultsFilesNode'; import { StashNode } from './stashNode'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; - -export interface CommitsQueryResults { - readonly label: string; - readonly log: GitLog | undefined; - readonly hasMore: boolean; - more?(limit: number | undefined): Promise; + +interface Options { + autolinks: boolean; + expand: boolean; + description?: string; } export class ResultsCommitsNode - extends ViewNode + extends ViewNode<'results-commits', View> implements PageableViewNode { limit: number | undefined; + private readonly _options: Options; + constructor( view: View, protected override readonly parent: ViewNode, @@ -48,18 +48,18 @@ export class ResultsCommitsNode Promise; }; }, - private readonly _options: { description?: string; expand?: boolean } = undefined!, + options?: Partial, splatted?: boolean, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('results-commits', GitUri.fromRepoPath(repoPath), view, parent); if (_results.direction != null) { this.updateContext({ branchStatusUpstreamType: _results.direction }); } - this._uniqueId = getViewNodeId('results-commits', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this.limit = this.view.getNodeLastKnownLimit(this); - this._options = { expand: true, ..._options }; + this._options = { autolinks: true, expand: true, ...options }; if (splatted != null) { this.splatted = splatted; } @@ -77,6 +77,10 @@ export class ResultsCommitsNode | undefined; async getChildren(): Promise { @@ -84,19 +88,24 @@ export class ResultsCommitsNode(); const { log } = await this.getCommitsQueryResults(); - if (log == null) return []; + if (log == null) { + this._onChildrenCompleted?.fulfill(); + return [new MessageNode(this.view, this, 'No results found')]; + } const [getBranchAndTagTips] = await Promise.all([ this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath), ]); - const children: ViewNode[] = [ - new AutolinkedItemsNode(this.view, this, this.uri.repoPath!, log, this._expandAutolinks), - ]; + const children: ViewNode[] = []; + if (this._options.autolinks) { + children.push(new AutolinkedItemsNode(this.view, this, this.uri.repoPath!, log, this._expandAutolinks)); + } this._expandAutolinks = false; const { files } = this._results; - if (files != null) { + // Can't support showing files when commits are filtered + if (files != null && !this.isComparisonFiltered) { children.push( new ResultsFilesNode( this.view, @@ -106,9 +115,7 @@ export class ResultsCommitsNode { - void (await ex.promise); - try { - await this._onChildrenCompleted?.promise; - } catch {} - - setTimeout(() => void this.triggerChange(false), 1); - }, 1); - } + : this._options.expand //|| log.count === 1 + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.Collapsed; + } else { + queueMicrotask(async () => { + try { + await this._onChildrenCompleted?.promise; + } catch { + return; + } + + void (await result.value); + this.view.triggerNodeChange(this.parent); + }); // Need to use Collapsed before we have results or the item won't show up in the view until the children are awaited // https://github.com/microsoft/vscode/issues/54806 & https://github.com/microsoft/vscode/issues/62214 @@ -224,7 +233,7 @@ export class ResultsCommitsNode): Promise { const results = await this.getCommitsQueryResults(); - if (results == null || !results.hasMore) return; + if (!results?.hasMore) return; if (context != null && 'expandAutolinks' in context) { this._expandAutolinks = Boolean(context.expandAutolinks); diff --git a/src/views/nodes/resultsFileNode.ts b/src/views/nodes/resultsFileNode.ts index 74d57c84b61ef..8d957a46ac3e4 100644 --- a/src/views/nodes/resultsFileNode.ts +++ b/src/views/nodes/resultsFileNode.ts @@ -1,6 +1,6 @@ import type { Command } from 'vscode'; import { TreeItem, TreeItemCheckboxState, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithCommandArgs } from '../../commands'; +import type { DiffWithCommandArgs } from '../../commands/diffWith'; import { Commands } from '../../constants'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; import { GitUri } from '../../git/gitUri'; @@ -10,16 +10,17 @@ import type { GitRevisionReference } from '../../git/models/reference'; import { createReference } from '../../git/models/reference'; import { joinPaths, relativeDir } from '../../system/path'; import type { View } from '../viewBase'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefFileNode } from './abstract/viewRefNode'; import { getComparisonStoragePrefix } from './compareResultsNode'; import type { FileNode } from './folderNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewRefFileNode } from './viewNode'; type State = { checked: TreeItemCheckboxState; }; -export class ResultsFileNode extends ViewRefFileNode implements FileNode { +export class ResultsFileNode extends ViewRefFileNode<'results-file', View, State> implements FileNode { constructor( view: View, parent: ViewNode, @@ -29,7 +30,7 @@ export class ResultsFileNode extends ViewRefFileNode implements Fil public readonly ref2: string, private readonly direction: 'ahead' | 'behind' | undefined, ) { - super(GitUri.fromFile(file, repoPath, ref1 || ref2), view, parent, file); + super('results-file', GitUri.fromFile(file, repoPath, ref1 || ref2), view, parent, file); this.updateContext({ file: file }); if (this.context.storedComparisonId != null) { @@ -37,7 +38,7 @@ export class ResultsFileNode extends ViewRefFileNode implements Fil file.path }`; } else { - this._uniqueId = getViewNodeId('results-file', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } } diff --git a/src/views/nodes/resultsFilesNode.ts b/src/views/nodes/resultsFilesNode.ts index 75479ebfdf5b4..9d63624e0b829 100644 --- a/src/views/nodes/resultsFilesNode.ts +++ b/src/views/nodes/resultsFilesNode.ts @@ -1,7 +1,8 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import type { FilesComparison } from '../../git/actions/commit'; import { GitUri } from '../../git/gitUri'; -import type { GitDiffShortStat } from '../../git/models/diff'; import type { GitFile } from '../../git/models/file'; +import type { FilesQueryResults } from '../../git/queryResults'; import { makeHierarchical } from '../../system/array'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; @@ -10,10 +11,10 @@ import { joinPaths, normalizePath } from '../../system/path'; import { cancellable, PromiseCancelledError } from '../../system/promise'; import { pluralize, sortCompare } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { ResultsFileNode } from './resultsFileNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; type State = { filter: FilesQueryFilter | undefined; @@ -24,15 +25,14 @@ export enum FilesQueryFilter { Right = 1, } -export interface FilesQueryResults { - label: string; - files: GitFile[] | undefined; - stats?: (GitDiffShortStat & { approximated?: boolean }) | undefined; - - filtered?: Map; +interface Options { + expand: boolean; + timeout: false | number; } -export class ResultsFilesNode extends ViewNode { +export class ResultsFilesNode extends ViewNode<'results-files', ViewsWithCommits, State> { + private readonly _options: Options; + constructor( view: ViewsWithCommits, protected override parent: ViewNode, @@ -41,17 +41,15 @@ export class ResultsFilesNode extends ViewNode { public readonly ref2: string, private readonly _filesQuery: () => Promise, private readonly direction: 'ahead' | 'behind' | undefined, - private readonly _options: { - expand?: boolean; - } = undefined!, + options?: Partial, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('results-files', GitUri.fromRepoPath(repoPath), view, parent); if (this.direction != null) { this.updateContext({ branchStatusUpstreamType: this.direction }); } - this._uniqueId = getViewNodeId('results-files', this.context); - this._options = { expand: true, ..._options }; + this._uniqueId = getViewNodeId(this.type, this.context); + this._options = { expand: true, timeout: 100, ...options }; } override get id(): string { @@ -74,6 +72,16 @@ export class ResultsFilesNode extends ViewNode { return this.filter != null || (this.ref1 !== this.ref2 && this.direction === undefined); } + async getFilesComparison(): Promise { + const { files } = await this.getFilesQueryResults(); + return { + files: files ?? [], + repoPath: this.repoPath, + ref1: this.ref1, + ref2: this.ref2, + }; + } + private getFilterContextValue(): string { switch (this.filter) { case FilesQueryFilter.Left: @@ -124,7 +132,10 @@ export class ResultsFilesNode extends ViewNode { const filter = this.filter; try { - const results = await cancellable(this.getFilesQueryResults(), 100); + const results = await cancellable( + this.getFilesQueryResults(), + this._options.timeout === false ? undefined : this._options.timeout, + ); label = results.label; if (filter == null && results.stats != null) { description = `${pluralize('addition', results.stats.additions)} (+), ${pluralize( @@ -156,8 +167,8 @@ export class ResultsFilesNode extends ViewNode { files == null || files.length === 0 ? TreeItemCollapsibleState.None : this._options.expand - ? TreeItemCollapsibleState.Expanded - : TreeItemCollapsibleState.Collapsed; + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.Collapsed; } } catch (ex) { if (ex instanceof PromiseCancelledError) { @@ -200,7 +211,7 @@ export class ResultsFilesNode extends ViewNode { private _filesQueryResults: Promise | undefined; private _filterResults: Promise | undefined; - async getFilesQueryResults() { + private async getFilesQueryResults() { if (this._filesQueryResults === undefined) { this._filesQueryResults = this._filesQuery(); } @@ -249,6 +260,6 @@ export class ResultsFilesNode extends ViewNode { if (results.filtered == null) { results.filtered = new Map(); } - results.filtered.set(filter, filterTo == null ? [] : results.files!.filter(f => filterTo!.has(f.path))); + results.filtered.set(filter, filterTo == null ? [] : results.files!.filter(f => filterTo.has(f.path))); } } diff --git a/src/views/nodes/searchResultsNode.ts b/src/views/nodes/searchResultsNode.ts index 1a7c26f7cf0c2..f373315413661 100644 --- a/src/views/nodes/searchResultsNode.ts +++ b/src/views/nodes/searchResultsNode.ts @@ -4,16 +4,16 @@ import { md5 } from '@env/crypto'; import { executeGitCommand } from '../../git/actions'; import { GitUri } from '../../git/gitUri'; import type { GitLog } from '../../git/models/log'; +import type { CommitsQueryResults } from '../../git/queryResults'; import type { SearchQuery } from '../../git/search'; import { getSearchQueryComparisonKey, getStoredSearchQuery } from '../../git/search'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { pluralize } from '../../system/string'; import type { SearchAndCompareView } from '../searchAndCompareView'; -import type { CommitsQueryResults } from './resultsCommitsNode'; +import type { PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { ResultsCommitsNode } from './resultsCommitsNode'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; let instanceId = 0; @@ -24,7 +24,7 @@ interface SearchQueryResults { more?(limit: number | undefined): Promise; } -export class SearchResultsNode extends ViewNode implements PageableViewNode { +export class SearchResultsNode extends ViewNode<'search-results', SearchAndCompareView> implements PageableViewNode { private _instanceId: number; constructor( @@ -49,11 +49,11 @@ export class SearchResultsNode extends ViewNode implements | undefined, private _storedAt: number = 0, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('search-results', GitUri.fromRepoPath(repoPath), view, parent); this._instanceId = instanceId++; this.updateContext({ searchId: `${getSearchQueryComparisonKey(this._search)}+${this._instanceId}` }); - this._uniqueId = getViewNodeId('search-results', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); // If this is a new search, save it if (this._storedAt === 0) { @@ -66,6 +66,10 @@ export class SearchResultsNode extends ViewNode implements return this._uniqueId; } + override toClipboard(): string { + return this.search.query; + } + get order(): number { return this._storedAt; } diff --git a/src/views/nodes/stashFileNode.ts b/src/views/nodes/stashFileNode.ts index dd2a9c2e1f8fa..69142d9da90a1 100644 --- a/src/views/nodes/stashFileNode.ts +++ b/src/views/nodes/stashFileNode.ts @@ -1,14 +1,13 @@ import type { GitStashCommit } from '../../git/models/commit'; import type { GitFile } from '../../git/models/file'; import type { ViewsWithStashes } from '../viewBase'; -import { CommitFileNode } from './commitFileNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues } from './viewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; +import { CommitFileNodeBase } from './commitFileNode'; -export class StashFileNode extends CommitFileNode { - // eslint-disable-next-line @typescript-eslint/no-useless-constructor +export class StashFileNode extends CommitFileNodeBase<'stash-file', ViewsWithStashes> { constructor(view: ViewsWithStashes, parent: ViewNode, file: GitFile, commit: GitStashCommit) { - super(view, parent, file, commit); + super('stash-file', view, parent, file, commit); } protected override get contextValue(): string { diff --git a/src/views/nodes/stashNode.ts b/src/views/nodes/stashNode.ts index 7060c3c22115c..640e02db57d62 100644 --- a/src/views/nodes/stashNode.ts +++ b/src/views/nodes/stashNode.ts @@ -1,29 +1,32 @@ -import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import type { CancellationToken } from 'vscode'; +import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { CommitFormatter } from '../../git/formatters/commitFormatter'; import type { GitStashCommit } from '../../git/models/commit'; import type { GitStashReference } from '../../git/models/reference'; import { makeHierarchical } from '../../system/array'; import { configuration } from '../../system/configuration'; import { joinPaths, normalizePath } from '../../system/path'; +import { getSettledValue, pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/promise'; import { sortCompare } from '../../system/string'; import type { ViewsWithStashes } from '../viewBase'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefNode } from './abstract/viewRefNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { StashFileNode } from './stashFileNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewRefNode } from './viewNode'; -export class StashNode extends ViewRefNode { +export class StashNode extends ViewRefNode<'stash', ViewsWithStashes, GitStashReference> { constructor( view: ViewsWithStashes, protected override parent: ViewNode, public readonly commit: GitStashCommit, private readonly options?: { icon?: boolean }, ) { - super(commit.getGitUri(), view, parent); + super('stash', commit.getGitUri(), view, parent); this.updateContext({ commit: commit }); - this._uniqueId = getViewNodeId('stash', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -76,15 +79,60 @@ export class StashNode extends ViewRefNode if (this.options?.icon) { item.iconPath = new ThemeIcon('archive'); } - item.tooltip = CommitFormatter.fromTemplate( - `\${'On 'stashOnRef\n}\${ago} (\${date})\n\n\${message}`, + + return item; + } + + override async resolveTreeItem(item: TreeItem, token: CancellationToken): Promise { + if (item.tooltip == null) { + item.tooltip = await this.getTooltip(token); + } + return item; + } + + private async getTooltip(cancellation: CancellationToken) { + const [remotesResult, _] = await Promise.allSettled([ + this.view.container.git.getBestRemotesWithProviders(this.commit.repoPath, cancellation), + this.commit.message == null ? this.commit.ensureFullDetails() : undefined, + ]); + + if (cancellation.isCancellationRequested) return undefined; + + const remotes = getSettledValue(remotesResult, []); + const [remote] = remotes; + + let enrichedAutolinks; + + if (remote?.hasIntegration()) { + const [enrichedAutolinksResult] = await Promise.allSettled([ + pauseOnCancelOrTimeoutMapTuplePromise(this.commit.getEnrichedAutolinks(remote), cancellation), + ]); + + if (cancellation.isCancellationRequested) return undefined; + + const enrichedAutolinksMaybeResult = getSettledValue(enrichedAutolinksResult); + if (!enrichedAutolinksMaybeResult?.paused) { + enrichedAutolinks = enrichedAutolinksMaybeResult?.value; + } + } + + const tooltip = await CommitFormatter.fromTemplateAsync( + configuration.get('views.formats.stashes.tooltip'), this.commit, { + enrichedAutolinks: enrichedAutolinks, dateFormat: configuration.get('defaultDateFormat'), - // messageAutolinks: true, + messageAutolinks: true, + messageIndent: 4, + outputFormat: 'markdown', + remotes: remotes, }, ); - return item; + const markdown = new MarkdownString(tooltip, true); + markdown.supportHtml = true; + markdown.isTrusted = true; + + return markdown; } } diff --git a/src/views/nodes/stashesNode.ts b/src/views/nodes/stashesNode.ts index bfcf4993dd682..10b7ba0fd4bbf 100644 --- a/src/views/nodes/stashesNode.ts +++ b/src/views/nodes/stashesNode.ts @@ -1,25 +1,26 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import type { ViewsWithStashesNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { MessageNode } from './common'; import { StashNode } from './stashNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class StashesNode extends ViewNode { +export class StashesNode extends CacheableChildrenViewNode<'stashes', ViewsWithStashesNode> { constructor( uri: GitUri, view: ViewsWithStashesNode, protected override parent: ViewNode, public readonly repo: Repository, ) { - super(uri, view, parent); + super('stashes', uri, view, parent); this.updateContext({ repository: repo }); - this._uniqueId = getViewNodeId('stashes', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -30,17 +31,15 @@ export class StashesNode extends ViewNode { return this.repo.path; } - private _children: ViewNode[] | undefined; - async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const stash = await this.repo.getStash(); if (stash == null) return [new MessageNode(this.view, this, 'No stashes could be found.')]; - this._children = [...map(stash.commits.values(), c => new StashNode(this.view, this, c))]; + this.children = [...map(stash.commits.values(), c => new StashNode(this.view, this, c))]; } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -51,9 +50,8 @@ export class StashesNode extends ViewNode { return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/nodes/statusFileNode.ts b/src/views/nodes/statusFileNode.ts index eec927a202665..38864458f8ec7 100644 --- a/src/views/nodes/statusFileNode.ts +++ b/src/views/nodes/statusFileNode.ts @@ -1,6 +1,6 @@ import type { Command } from 'vscode'; import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithCommandArgs } from '../../commands'; +import type { DiffWithCommandArgs } from '../../commands/diffWith'; import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; import { Commands } from '../../constants'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; @@ -11,17 +11,18 @@ import { getGitFileStatusIcon } from '../../git/models/file'; import { joinPaths, relativeDir } from '../../system/path'; import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; +import { ViewFileNode } from './abstract/viewFileNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { FileRevisionAsCommitNode } from './fileRevisionAsCommitNode'; import type { FileNode } from './folderNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewFileNode } from './viewNode'; -export class StatusFileNode extends ViewFileNode implements FileNode { +export class StatusFileNode extends ViewFileNode<'status-file', ViewsWithCommits> implements FileNode { public readonly commits: GitCommit[]; - private readonly _direction: 'ahead' | 'behind'; private readonly _hasStagedChanges: boolean; private readonly _hasUnstagedChanges: boolean; + private readonly _type: 'ahead' | 'behind' | 'working'; constructor( view: ViewsWithCommits, @@ -29,7 +30,7 @@ export class StatusFileNode extends ViewFileNode implements Fi file: GitFile, repoPath: string, commits: GitCommit[], - direction: 'ahead' | 'behind' = 'ahead', + type: 'ahead' | 'behind' | 'working', ) { let hasStagedChanges = false; let hasUnstagedChanges = false; @@ -55,11 +56,11 @@ export class StatusFileNode extends ViewFileNode implements Fi } } - super(GitUri.fromFile(file, repoPath, ref), view, parent, file); + super('status-file', GitUri.fromFile(file, repoPath, ref), view, parent, file); this.commits = commits; - this._direction = direction; + this._type = type; this._hasStagedChanges = hasStagedChanges; this._hasUnstagedChanges = hasUnstagedChanges; } @@ -251,24 +252,63 @@ export class StatusFileNode extends ViewFileNode implements Fi }; } - const commit = this._direction === 'behind' ? this.commits[0] : this.commits[this.commits.length - 1]; - const file = commit.files?.find(f => f.path === this.file.path) ?? this.file; - const commandArgs: DiffWithCommandArgs = { - lhs: { - sha: this._direction === 'behind' ? commit.sha : `${commit.sha}^`, - uri: GitUri.fromFile(file, this.repoPath, undefined, true), - }, - rhs: { - sha: '', - uri: GitUri.fromFile(this.file, this.repoPath), - }, - repoPath: this.repoPath, - line: 0, - showOptions: { - preserveFocus: true, - preview: true, - }, - }; + let commandArgs: DiffWithCommandArgs; + switch (this._type) { + case 'ahead': + case 'behind': { + const lhs = this.commits[this.commits.length - 1]; + const rhs = this.commits[0]; + + commandArgs = { + lhs: { + sha: `${lhs.sha}^`, + uri: GitUri.fromFile( + lhs.files?.find(f => f.path === this.file.path) ?? this.file.path, + this.repoPath, + `${lhs.sha}^`, + true, + ), + }, + rhs: { + sha: rhs.sha, + uri: GitUri.fromFile( + rhs.files?.find(f => f.path === this.file.path) ?? this.file.path, + this.repoPath, + rhs.sha, + ), + }, + repoPath: this.repoPath, + line: 0, + showOptions: { + preserveFocus: true, + preview: true, + }, + }; + break; + } + default: { + const commit = this.commits[this.commits.length - 1]; + const file = commit.files?.find(f => f.path === this.file.path) ?? this.file; + commandArgs = { + lhs: { + sha: `${commit.sha}^`, + uri: GitUri.fromFile(file, this.repoPath, undefined, true), + }, + rhs: { + sha: '', + uri: GitUri.fromFile(this.file, this.repoPath), + }, + repoPath: this.repoPath, + line: 0, + showOptions: { + preserveFocus: true, + preview: true, + }, + }; + break; + } + } + return { title: 'Open Changes', command: Commands.DiffWith, diff --git a/src/views/nodes/statusFilesNode.ts b/src/views/nodes/statusFilesNode.ts index 5fd5a165f4ed2..fb9e587a31dc2 100644 --- a/src/views/nodes/statusFilesNode.ts +++ b/src/views/nodes/statusFilesNode.ts @@ -1,38 +1,29 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; -import type { GitTrackingState } from '../../git/models/branch'; import type { GitCommit } from '../../git/models/commit'; import type { GitFileWithCommit } from '../../git/models/file'; import type { GitLog } from '../../git/models/log'; import type { GitStatus, GitStatusFile } from '../../git/models/status'; -import { groupBy, makeHierarchical } from '../../system/array'; -import { filter, flatMap, map } from '../../system/iterable'; +import { makeHierarchical } from '../../system/array'; +import { filter, flatMap, groupBy, map } from '../../system/iterable'; import { joinPaths, normalizePath } from '../../system/path'; import { pluralize, sortCompare } from '../../system/string'; import type { ViewsWithWorkingTree } from '../viewBase'; -import { WorktreesView } from '../worktreesView'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { StatusFileNode } from './statusFileNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class StatusFilesNode extends ViewNode { +export class StatusFilesNode extends ViewNode<'status-files', ViewsWithWorkingTree> { constructor( view: ViewsWithWorkingTree, protected override readonly parent: ViewNode, - public readonly status: - | GitStatus - | { - readonly repoPath: string; - readonly files: GitStatusFile[]; - readonly state: GitTrackingState; - readonly upstream?: string; - }, + public readonly status: GitStatus, public readonly range: string | undefined, ) { - super(GitUri.fromRepoPath(status.repoPath), view, parent); + super('status-files', GitUri.fromRepoPath(status.repoPath), view, parent); - this._uniqueId = getViewNodeId('status-files', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -68,10 +59,7 @@ export class StatusFilesNode extends ViewNode { } } - if ( - (this.view instanceof WorktreesView || this.view.config.includeWorkingTree) && - this.status.files.length !== 0 - ) { + if ((this.view.type === 'worktrees' || this.view.config.includeWorkingTree) && this.status.files.length !== 0) { files.unshift( ...flatMap(this.status.files, f => map(f.getPseudoCommits(this.view.container, undefined), c => this.getFileWithPseudoCommit(f, c)), @@ -91,6 +79,7 @@ export class StatusFilesNode extends ViewNode { files[files.length - 1], repoPath, files.map(s => s.commit), + 'working', ), ); @@ -113,14 +102,14 @@ export class StatusFilesNode extends ViewNode { async getTreeItem(): Promise { let files = - this.view instanceof WorktreesView || this.view.config.includeWorkingTree ? this.status.files.length : 0; + this.view.type === 'worktrees' || this.view.config.includeWorkingTree ? this.status.files.length : 0; if (this.range != null) { if (this.status.upstream != null && this.status.state.ahead > 0) { if (files > 0) { const aheadFiles = await this.view.container.git.getDiffStatus( this.repoPath, - `${this.status.upstream}...`, + `${this.status.upstream?.name}...`, ); if (aheadFiles != null) { @@ -137,7 +126,7 @@ export class StatusFilesNode extends ViewNode { } else { const stats = await this.view.container.git.getChangedFilesCount( this.repoPath, - `${this.status.upstream}...`, + `${this.status.upstream?.name}...`, ); if (stats != null) { files += stats.changedFiles; @@ -150,6 +139,7 @@ export class StatusFilesNode extends ViewNode { const label = files === -1 ? '?? files changed' : `${pluralize('file', files)} changed`; const item = new TreeItem(label, TreeItemCollapsibleState.Collapsed); + item.description = 'working tree'; item.id = this.id; item.contextValue = ContextValues.StatusFiles; item.iconPath = { diff --git a/src/views/nodes/tagNode.ts b/src/views/nodes/tagNode.ts index cee554a139e49..77cb73bd574c9 100644 --- a/src/views/nodes/tagNode.ts +++ b/src/views/nodes/tagNode.ts @@ -11,13 +11,14 @@ import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import { pad } from '../../system/string'; import type { ViewsWithTags } from '../viewBase'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefNode } from './abstract/viewRefNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; import { insertDateMarkers } from './helpers'; -import type { PageableViewNode, ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, ViewRefNode } from './viewNode'; -export class TagNode extends ViewRefNode implements PageableViewNode { +export class TagNode extends ViewRefNode<'tag', ViewsWithTags, GitTagReference> implements PageableViewNode { limit: number | undefined; constructor( @@ -26,10 +27,10 @@ export class TagNode extends ViewRefNode impleme public override parent: ViewNode, public readonly tag: GitTag, ) { - super(uri, view, parent); + super('tag', uri, view, parent); this.updateContext({ tag: tag }); - this._uniqueId = getViewNodeId('tag', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this.limit = this.view.getNodeLastKnownLimit(this); } @@ -133,7 +134,7 @@ export class TagNode extends ViewRefNode impleme }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/tagsNode.ts b/src/views/nodes/tagsNode.ts index 97404cdb42864..1ed3a3875b2af 100644 --- a/src/views/nodes/tagsNode.ts +++ b/src/views/nodes/tagsNode.ts @@ -2,25 +2,26 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; import { makeHierarchical } from '../../system/array'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import type { ViewsWithTagsNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { BranchOrTagFolderNode } from './branchOrTagFolderNode'; import { MessageNode } from './common'; import { TagNode } from './tagNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; -export class TagsNode extends ViewNode { +export class TagsNode extends CacheableChildrenViewNode<'tags', ViewsWithTagsNode> { constructor( uri: GitUri, view: ViewsWithTagsNode, protected override readonly parent: ViewNode, public readonly repo: Repository, ) { - super(uri, view, parent); + super('tags', uri, view, parent); this.updateContext({ repository: repo }); - this._uniqueId = getViewNodeId('tags', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -31,10 +32,8 @@ export class TagsNode extends ViewNode { return this.repo.path; } - private _children: ViewNode[] | undefined; - async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const tags = await this.repo.getTags({ sort: true }); if (tags.values.length === 0) return [new MessageNode(this.view, this, 'No tags could be found.')]; @@ -52,10 +51,10 @@ export class TagsNode extends ViewNode { ); const root = new BranchOrTagFolderNode(this.view, this, 'tag', hierarchy, this.repo.path, '', undefined); - this._children = root.getChildren(); + this.children = root.getChildren(); } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -66,9 +65,8 @@ export class TagsNode extends ViewNode { return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts deleted file mode 100644 index 834c322a63837..0000000000000 --- a/src/views/nodes/viewNode.ts +++ /dev/null @@ -1,795 +0,0 @@ -import type { Command, Event, TreeViewVisibilityChangeEvent } from 'vscode'; -import { Disposable, MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { GlyphChars } from '../../constants'; -import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; -import type { GitUri } from '../../git/gitUri'; -import { unknownGitUri } from '../../git/gitUri'; -import type { GitBranch } from '../../git/models/branch'; -import type { GitCommit } from '../../git/models/commit'; -import type { GitContributor } from '../../git/models/contributor'; -import type { GitFile } from '../../git/models/file'; -import type { GitReference, GitRevisionReference } from '../../git/models/reference'; -import { getReferenceLabel } from '../../git/models/reference'; -import type { GitReflogRecord } from '../../git/models/reflog'; -import { GitRemote } from '../../git/models/remote'; -import type { RepositoryChangeEvent } from '../../git/models/repository'; -import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; -import type { GitTag } from '../../git/models/tag'; -import type { GitWorktree } from '../../git/models/worktree'; -import type { SubscriptionChangeEvent } from '../../plus/subscription/subscriptionService'; -import type { - CloudWorkspace, - CloudWorkspaceRepositoryDescriptor, - LocalWorkspace, - LocalWorkspaceRepositoryDescriptor, -} from '../../plus/workspaces/models'; -import { gate } from '../../system/decorators/gate'; -import { debug, log, logName } from '../../system/decorators/log'; -import { is as isA, szudzikPairing } from '../../system/function'; -import { getLoggableName } from '../../system/logger'; -import { pad } from '../../system/string'; -import type { View } from '../viewBase'; -import type { BranchTrackingStatus } from './branchTrackingStatusNode'; - -export const enum ContextValues { - ActiveFileHistory = 'gitlens:history:active:file', - ActiveLineHistory = 'gitlens:history:active:line', - AutolinkedItems = 'gitlens:autolinked:items', - AutolinkedIssue = 'gitlens:autolinked:issue', - AutolinkedItem = 'gitlens:autolinked:item', - Branch = 'gitlens:branch', - Branches = 'gitlens:branches', - BranchStatusAheadOfUpstream = 'gitlens:status-branch:upstream:ahead', - BranchStatusBehindUpstream = 'gitlens:status-branch:upstream:behind', - BranchStatusNoUpstream = 'gitlens:status-branch:upstream:none', - BranchStatusSameAsUpstream = 'gitlens:status-branch:upstream:same', - BranchStatusFiles = 'gitlens:status-branch:files', - Commit = 'gitlens:commit', - Commits = 'gitlens:commits', - Compare = 'gitlens:compare', - CompareBranch = 'gitlens:compare:branch', - ComparePicker = 'gitlens:compare:picker', - ComparePickerWithRef = 'gitlens:compare:picker:ref', - CompareResults = 'gitlens:compare:results', - CompareResultsCommits = 'gitlens:compare:results:commits', - Contributor = 'gitlens:contributor', - Contributors = 'gitlens:contributors', - DateMarker = 'gitlens:date-marker', - File = 'gitlens:file', - FileHistory = 'gitlens:history:file', - Folder = 'gitlens:folder', - LineHistory = 'gitlens:history:line', - Merge = 'gitlens:merge', - MergeConflictCurrentChanges = 'gitlens:merge-conflict:current', - MergeConflictIncomingChanges = 'gitlens:merge-conflict:incoming', - Message = 'gitlens:message', - MessageSignIn = 'gitlens:message:signin', - Pager = 'gitlens:pager', - PullRequest = 'gitlens:pullrequest', - Rebase = 'gitlens:rebase', - Reflog = 'gitlens:reflog', - ReflogRecord = 'gitlens:reflog-record', - Remote = 'gitlens:remote', - Remotes = 'gitlens:remotes', - Repositories = 'gitlens:repositories', - Repository = 'gitlens:repository', - RepositoryFolder = 'gitlens:repo-folder', - ResultsFile = 'gitlens:file:results', - ResultsFiles = 'gitlens:results:files', - SearchAndCompare = 'gitlens:searchAndCompare', - SearchResults = 'gitlens:search:results', - SearchResultsCommits = 'gitlens:search:results:commits', - Stash = 'gitlens:stash', - Stashes = 'gitlens:stashes', - StatusFileCommits = 'gitlens:status:file:commits', - StatusFiles = 'gitlens:status:files', - StatusAheadOfUpstream = 'gitlens:status:upstream:ahead', - StatusBehindUpstream = 'gitlens:status:upstream:behind', - StatusNoUpstream = 'gitlens:status:upstream:none', - StatusSameAsUpstream = 'gitlens:status:upstream:same', - Tag = 'gitlens:tag', - Tags = 'gitlens:tags', - UncommittedFiles = 'gitlens:uncommitted:files', - Workspace = 'gitlens:workspace', - WorkspaceMissingRepository = 'gitlens:workspaceMissingRepository', - Workspaces = 'gitlens:workspaces', - Worktree = 'gitlens:worktree', - Worktrees = 'gitlens:worktrees', -} - -export interface AmbientContext { - readonly autolinksId?: string; - readonly branch?: GitBranch; - readonly branchStatus?: BranchTrackingStatus; - readonly branchStatusUpstreamType?: 'ahead' | 'behind' | 'same' | 'none'; - readonly commit?: GitCommit; - readonly comparisonId?: string; - readonly contributor?: GitContributor; - readonly file?: GitFile; - readonly reflog?: GitReflogRecord; - readonly remote?: GitRemote; - readonly repository?: Repository; - readonly root?: boolean; - readonly searchId?: string; - readonly storedComparisonId?: string; - readonly tag?: GitTag; - readonly workspace?: CloudWorkspace | LocalWorkspace; - readonly wsRepositoryDescriptor?: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor; - readonly worktree?: GitWorktree; -} - -export function getViewNodeId(type: string, context: AmbientContext): string { - let uniqueness = ''; - if (context.root) { - uniqueness += '/root'; - } - if (context.workspace != null) { - uniqueness += `/ws/${context.workspace.id}`; - } - if (context.wsRepositoryDescriptor != null) { - uniqueness += `/wsrepo/${context.wsRepositoryDescriptor.id}`; - } - if (context.repository != null) { - uniqueness += `/repo/${context.repository.id}`; - } - if (context.worktree != null) { - uniqueness += `/worktree/${context.worktree.uri.path}`; - } - if (context.remote != null) { - uniqueness += `/remote/${context.remote.name}`; - } - if (context.tag != null) { - uniqueness += `/tag/${context.tag.id}`; - } - if (context.branch != null) { - uniqueness += `/branch/${context.branch.id}`; - } - if (context.branchStatus != null) { - uniqueness += `/status/${context.branchStatus.upstream ?? '-'}`; - } - if (context.branchStatusUpstreamType != null) { - uniqueness += `/status-direction/${context.branchStatusUpstreamType}`; - } - if (context.reflog != null) { - uniqueness += `/reflog/${context.reflog.sha}+${context.reflog.selector}+${context.reflog.command}+${ - context.reflog.commandArgs ?? '' - }+${context.reflog.date.getTime()}`; - } - if (context.contributor != null) { - uniqueness += `/contributor/${ - context.contributor.id ?? - `${context.contributor.username}+${context.contributor.email}+${context.contributor.name}` - }`; - } - if (context.autolinksId != null) { - uniqueness += `/autolinks/${context.autolinksId}`; - } - if (context.comparisonId != null) { - uniqueness += `/comparison/${context.comparisonId}`; - } - if (context.searchId != null) { - uniqueness += `/search/${context.searchId}`; - } - if (context.commit != null) { - uniqueness += `/commit/${context.commit.sha}`; - } - if (context.file != null) { - uniqueness += `/file/${context.file.path}+${context.file.status}`; - } - - return `gitlens://viewnode/${type}${uniqueness}`; -} - -@logName((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`) -export abstract class ViewNode { - protected _uniqueId!: string; - - protected splatted = false; - - constructor( - // public readonly id: string | undefined, - uri: GitUri, - public readonly view: TView, - protected parent?: ViewNode, - ) { - this._uri = uri; - } - - get id(): string | undefined { - return this._uniqueId; - } - - private _context: AmbientContext | undefined; - protected get context(): AmbientContext { - return this._context ?? this.parent?.context ?? {}; - } - - protected updateContext(context: AmbientContext, reset: boolean = false) { - this._context = this.getNewContext(context, reset); - } - - protected getNewContext(context: AmbientContext, reset: boolean = false) { - return { ...(reset ? this.parent?.context : this.context), ...context }; - } - - toClipboard?(): string; - - toString(): string { - const id = this.id; - return `${getLoggableName(this)}${id != null ? `(${id})` : ''}`; - } - - protected _uri: GitUri; - get uri(): GitUri { - return this._uri; - } - - abstract getChildren(): ViewNode[] | Promise; - - getParent(): ViewNode | undefined { - // If this node's parent has been splatted (e.g. not shown itself, but its children are), then return its grandparent - return this.parent?.splatted ? this.parent?.getParent() : this.parent; - } - - abstract getTreeItem(): TreeItem | Promise; - - resolveTreeItem?(item: TreeItem): TreeItem | Promise; - - getCommand(): Command | undefined { - return undefined; - } - - refresh?(reset?: boolean): boolean | void | Promise | Promise; - - @gate((reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode) => - JSON.stringify([reset, force, avoidSelf?.toString()]), - ) - @debug() - triggerChange(reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode): Promise { - // If this node has been splatted (e.g. not shown itself, but its children are), then delegate the change to its parent - if (this.splatted && this.parent != null && this.parent !== avoidSelf) { - return this.parent.triggerChange(reset, force); - } - - return this.view.refreshNode(this, reset, force); - } - - getSplattedChild?(): Promise; - - deleteState = StateKey>(key?: T): void { - if (this.id == null) { - debugger; - throw new Error('Id is required to delete state'); - } - this.view.nodeState.deleteState(this.id, key as string); - } - - getState = StateKey>(key: T): StateValue | undefined { - if (this.id == null) { - debugger; - throw new Error('Id is required to get state'); - } - return this.view.nodeState.getState(this.id, key as string); - } - - storeState = StateKey>( - key: T, - value: StateValue, - sticky?: boolean, - ): void { - if (this.id == null) { - debugger; - throw new Error('Id is required to store state'); - } - this.view.nodeState.storeState(this.id, key as string, value, sticky); - } -} - -export function isViewNode(node: any): node is ViewNode { - return node instanceof ViewNode; -} - -export function isViewFileNode(node: any): node is ViewFileNode { - return node instanceof ViewFileNode; -} - -type StateKey = keyof T; -type StateValue> = P extends keyof T ? T[P] : never; - -export abstract class ViewFileNode extends ViewNode< - TView, - State -> { - constructor( - uri: GitUri, - view: TView, - public override parent: ViewNode, - public readonly file: GitFile, - ) { - super(uri, view, parent); - } - - get repoPath(): string { - return this.uri.repoPath!; - } - - override toString(): string { - return `${super.toString()}:${this.file.path}`; - } -} - -export abstract class ViewRefNode< - TView extends View = View, - TReference extends GitReference = GitReference, - State extends object = any, -> extends ViewNode { - constructor( - uri: GitUri, - view: TView, - protected override readonly parent: ViewNode, - ) { - super(uri, view, parent); - } - - abstract get ref(): TReference; - - get repoPath(): string { - return this.uri.repoPath!; - } - - override toString(): string { - return `${super.toString()}:${getReferenceLabel(this.ref, false)}`; - } -} - -export abstract class ViewRefFileNode extends ViewFileNode< - TView, - State -> { - abstract get ref(): GitRevisionReference; - - override toString(): string { - return `${super.toString()}:${this.file.path}`; - } -} - -export interface PageableViewNode extends ViewNode { - readonly id: string; - limit?: number; - readonly hasMore: boolean; - loadMore(limit?: number | { until?: string | undefined }, context?: Record): Promise; -} - -export function isPageableViewNode(node: ViewNode): node is ViewNode & PageableViewNode { - return isA(node, 'loadMore'); -} - -export abstract class SubscribeableViewNode extends ViewNode { - protected disposable: Disposable; - protected subscription: Promise | undefined; - - protected loaded: boolean = false; - - constructor(uri: GitUri, view: TView, parent?: ViewNode) { - super(uri, view, parent); - - const disposables = [ - this.view.onDidChangeVisibility(this.onVisibilityChanged, this), - // this.view.onDidChangeNodeCollapsibleState(this.onNodeCollapsibleStateChanged, this), - ]; - - if (canAutoRefreshView(this.view)) { - disposables.push(this.view.onDidChangeAutoRefresh(this.onAutoRefreshChanged, this)); - } - - const getTreeItem = this.getTreeItem; - this.getTreeItem = function (this: SubscribeableViewNode) { - this.loaded = true; - void this.ensureSubscription(); - return getTreeItem.apply(this); - }; - - const getChildren = this.getChildren; - this.getChildren = function (this: SubscribeableViewNode) { - this.loaded = true; - void this.ensureSubscription(); - return getChildren.apply(this); - }; - - this.disposable = Disposable.from(...disposables); - } - - @debug() - dispose() { - void this.unsubscribe(); - - this.disposable?.dispose(); - } - - @gate() - @debug() - override async triggerChange(reset: boolean = false, force: boolean = false): Promise { - if (!this.loaded) return; - - if (reset && !this.view.visible) { - this._pendingReset = reset; - } - await super.triggerChange(reset, force); - } - - private _canSubscribe: boolean = true; - protected get canSubscribe(): boolean { - return this._canSubscribe; - } - protected set canSubscribe(value: boolean) { - if (this._canSubscribe === value) return; - - this._canSubscribe = value; - - void this.ensureSubscription(); - if (value) { - void this.triggerChange(); - } - } - - private _etag: number | undefined; - protected abstract etag(): number; - - private _pendingReset: boolean = false; - private get requiresResetOnVisible(): boolean { - let reset = this._pendingReset; - this._pendingReset = false; - - const etag = this.etag(); - if (etag !== this._etag) { - this._etag = etag; - reset = true; - } - - return reset; - } - - protected abstract subscribe(): Disposable | undefined | Promise; - - @debug() - protected async unsubscribe(): Promise { - this._etag = this.etag(); - - if (this.subscription != null) { - const subscriptionPromise = this.subscription; - this.subscription = undefined; - - (await subscriptionPromise)?.dispose(); - } - } - - @debug() - protected onAutoRefreshChanged() { - this.onVisibilityChanged({ visible: this.view.visible }); - } - - // protected onParentCollapsibleStateChanged?(state: TreeItemCollapsibleState): void; - // protected onCollapsibleStateChanged?(state: TreeItemCollapsibleState): void; - - // protected collapsibleState: TreeItemCollapsibleState | undefined; - // protected onNodeCollapsibleStateChanged(e: TreeViewNodeCollapsibleStateChangeEvent) { - // if (e.element === this) { - // this.collapsibleState = e.state; - // if (this.onCollapsibleStateChanged !== undefined) { - // this.onCollapsibleStateChanged(e.state); - // } - // } else if (e.element === this.parent) { - // if (this.onParentCollapsibleStateChanged !== undefined) { - // this.onParentCollapsibleStateChanged(e.state); - // } - // } - // } - - @debug() - protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { - void this.ensureSubscription(); - - if (e.visible) { - void this.triggerChange(this.requiresResetOnVisible); - } - } - - @gate() - @debug() - async ensureSubscription() { - // We only need to subscribe if we are visible and if auto-refresh enabled (when supported) - if (!this.canSubscribe || !this.view.visible || (canAutoRefreshView(this.view) && !this.view.autoRefresh)) { - await this.unsubscribe(); - - return; - } - - // If we already have a subscription, just kick out - if (this.subscription != null) return; - - this.subscription = Promise.resolve(this.subscribe()); - void (await this.subscription); - } - - @gate() - @debug() - async resetSubscription() { - await this.unsubscribe(); - await this.ensureSubscription(); - } -} - -export abstract class RepositoryFolderNode< - TView extends View = View, - TChild extends ViewNode = ViewNode, -> extends SubscribeableViewNode { - protected override splatted = true; - protected child: TChild | undefined; - - constructor( - uri: GitUri, - view: TView, - protected override readonly parent: ViewNode, - public readonly repo: Repository, - splatted: boolean, - private readonly options?: { showBranchAndLastFetched?: boolean }, - ) { - super(uri, view, parent); - - this.updateContext({ repository: this.repo }); - this._uniqueId = getViewNodeId('repository-folder', this.context); - - this.splatted = splatted; - } - - override get id(): string { - return this._uniqueId; - } - - override toClipboard(): string { - return this.repo.path; - } - - get repoPath(): string { - return this.repo.path; - } - - async getTreeItem(): Promise { - this.splatted = false; - - const branch = await this.repo.getBranch(); - const ahead = (branch?.state.ahead ?? 0) > 0; - const behind = (branch?.state.behind ?? 0) > 0; - - const expand = ahead || behind || this.repo.starred || this.view.container.git.isRepositoryForEditor(this.repo); - - const item = new TreeItem( - this.repo.formattedName ?? this.uri.repoPath ?? '', - expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, - ); - item.contextValue = `${ContextValues.RepositoryFolder}${this.repo.starred ? '+starred' : ''}`; - if (ahead) { - item.contextValue += '+ahead'; - } - if (behind) { - item.contextValue += '+behind'; - } - - if (branch != null && this.options?.showBranchAndLastFetched) { - const lastFetched = (await this.repo.getLastFetched()) ?? 0; - - const status = branch.getTrackingStatus(); - item.description = `${status ? `${status}${pad(GlyphChars.Dot, 1, 1)}` : ''}${branch.name}${ - lastFetched - ? `${pad(GlyphChars.Dot, 1, 1)}Last fetched ${Repository.formatLastFetched(lastFetched)}` - : '' - }`; - - let providerName; - if (branch.upstream != null) { - const providers = GitRemote.getHighlanderProviders( - await this.view.container.git.getRemotesWithProviders(branch.repoPath), - ); - providerName = providers?.length ? providers[0].name : undefined; - } else { - const remote = await branch.getRemote(); - providerName = remote?.provider?.name; - } - - item.tooltip = new MarkdownString( - `${this.repo.formattedName ?? this.uri.repoPath ?? ''}${ - lastFetched - ? `${pad(GlyphChars.Dash, 2, 2)}Last fetched ${Repository.formatLastFetched( - lastFetched, - false, - )}` - : '' - }${this.repo.formattedName ? `\n${this.uri.repoPath}` : ''}\n\nCurrent branch $(git-branch) ${ - branch.name - }${ - branch.upstream != null - ? ` is ${branch.getTrackingStatus({ - empty: branch.upstream.missing - ? `missing upstream $(git-branch) ${branch.upstream.name}` - : `up to date with $(git-branch) ${branch.upstream.name}${ - providerName ? ` on ${providerName}` : '' - }`, - expand: true, - icons: true, - separator: ', ', - suffix: ` $(git-branch) ${branch.upstream.name}${ - providerName ? ` on ${providerName}` : '' - }`, - })}` - : `hasn't been published to ${providerName ?? 'a remote'}` - }`, - true, - ); - } else { - item.tooltip = `${ - this.repo.formattedName ? `${this.repo.formattedName}\n${this.uri.repoPath}` : this.uri.repoPath ?? '' - }`; - } - - return item; - } - - override async getSplattedChild() { - if (this.child == null) { - await this.getChildren(); - } - - return this.child; - } - - @gate() - @debug() - override async refresh(reset: boolean = false) { - await this.child?.triggerChange(reset, false, this); - - await this.ensureSubscription(); - } - - @log() - async star() { - await this.repo.star(); - // void this.parent!.triggerChange(); - } - - @log() - async unstar() { - await this.repo.unstar(); - // void this.parent!.triggerChange(); - } - - @debug() - protected subscribe(): Disposable | Promise { - return this.repo.onDidChange(this.onRepositoryChanged, this); - } - - protected override etag(): number { - return this.repo.etag; - } - - protected abstract changed(e: RepositoryChangeEvent): boolean; - - @debug({ args: { 0: e => e.toString() } }) - private onRepositoryChanged(e: RepositoryChangeEvent) { - if (e.changed(RepositoryChange.Closed, RepositoryChangeComparisonMode.Any)) { - this.dispose(); - void this.parent?.triggerChange(true); - - return; - } - - if ( - e.changed(RepositoryChange.Opened, RepositoryChangeComparisonMode.Any) || - e.changed(RepositoryChange.Starred, RepositoryChangeComparisonMode.Any) - ) { - void this.parent?.triggerChange(true); - - return; - } - - if (this.changed(e)) { - void (this.loaded ? this : this.parent ?? this).triggerChange(true); - } - } -} - -export abstract class RepositoriesSubscribeableNode< - TView extends View = View, - TChild extends ViewNode & Disposable = ViewNode & Disposable, -> extends SubscribeableViewNode { - protected override splatted = true; - protected children: TChild[] | undefined; - - constructor(view: TView) { - super(unknownGitUri, view); - } - - override dispose() { - super.dispose(); - this.resetChildren(); - } - - private resetChildren() { - if (this.children == null) return; - - for (const child of this.children) { - if ('dispose' in child) { - child.dispose(); - } - } - this.children = undefined; - } - - override async getSplattedChild() { - if (this.children == null) { - await this.getChildren(); - } - - return this.children?.length === 1 ? this.children[0] : undefined; - } - - @gate() - @debug() - override refresh(reset: boolean = false) { - if (this.children == null) return; - - if (reset) { - this.resetChildren(); - } - } - - protected override etag(): number { - return szudzikPairing(this.view.container.git.etag, this.view.container.subscription.etag); - } - - @debug() - protected subscribe(): Disposable | Promise { - return Disposable.from( - this.view.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), - this.view.container.subscription.onDidChange(this.onSubscriptionChanged, this), - ); - } - - private onRepositoriesChanged(_e: RepositoriesChangeEvent) { - void this.triggerChange(true); - } - - private onSubscriptionChanged(e: SubscriptionChangeEvent) { - if (e.current.plan !== e.previous.plan) { - void this.triggerChange(true); - } - } -} - -interface AutoRefreshableView { - autoRefresh: boolean; - onDidChangeAutoRefresh: Event; -} - -export function canAutoRefreshView(view: View): view is View & AutoRefreshableView { - return isA(view, 'onDidChangeAutoRefresh'); -} - -export function canClearNode(node: ViewNode): node is ViewNode & { clear(): void | Promise } { - return typeof (node as ViewNode & { clear(): void | Promise }).clear === 'function'; -} - -export function canEditNode(node: ViewNode): node is ViewNode & { edit(): void | Promise } { - return typeof (node as ViewNode & { edit(): void | Promise }).edit === 'function'; -} - -export function canGetNodeRepoPath(node?: ViewNode): node is ViewNode & { repoPath: string | undefined } { - return node != null && 'repoPath' in node && typeof node.repoPath === 'string'; -} - -export function canViewDismissNode(view: View): view is View & { dismissNode(node: ViewNode): void } { - return typeof (view as View & { dismissNode(node: ViewNode): void }).dismissNode === 'function'; -} - -export function getNodeRepoPath(node?: ViewNode): string | undefined { - return canGetNodeRepoPath(node) ? node.repoPath : undefined; -} diff --git a/src/views/nodes/workspaceMissingRepositoryNode.ts b/src/views/nodes/workspaceMissingRepositoryNode.ts index 263939df1c5c7..16a8dba4292e0 100644 --- a/src/views/nodes/workspaceMissingRepositoryNode.ts +++ b/src/views/nodes/workspaceMissingRepositoryNode.ts @@ -8,19 +8,19 @@ import type { LocalWorkspaceRepositoryDescriptor, } from '../../plus/workspaces/models'; import type { WorkspacesView } from '../workspacesView'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; -export class WorkspaceMissingRepositoryNode extends ViewNode { +export class WorkspaceMissingRepositoryNode extends ViewNode<'workspace-missing-repository', WorkspacesView> { constructor( view: WorkspacesView, parent: ViewNode, public readonly workspace: CloudWorkspace | LocalWorkspace, public readonly wsRepositoryDescriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, ) { - super(unknownGitUri, view, parent); + super('workspace-missing-repository', unknownGitUri, view, parent); this.updateContext({ wsRepositoryDescriptor: wsRepositoryDescriptor }); - this._uniqueId = getViewNodeId('missing-workspace-repository', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { diff --git a/src/views/nodes/workspaceNode.ts b/src/views/nodes/workspaceNode.ts index c679d37c77f48..1faac30c4bfee 100644 --- a/src/views/nodes/workspaceNode.ts +++ b/src/views/nodes/workspaceNode.ts @@ -3,42 +3,31 @@ import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; import { GitUri } from '../../git/gitUri'; import type { CloudWorkspace, LocalWorkspace } from '../../plus/workspaces/models'; import { createCommand } from '../../system/command'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import type { WorkspacesView } from '../workspacesView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { CommandMessageNode, MessageNode } from './common'; import { RepositoryNode } from './repositoryNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, getViewNodeId, SubscribeableViewNode } from './viewNode'; import { WorkspaceMissingRepositoryNode } from './workspaceMissingRepositoryNode'; -export class WorkspaceNode extends SubscribeableViewNode { +export class WorkspaceNode extends SubscribeableViewNode< + 'workspace', + WorkspacesView, + CommandMessageNode | MessageNode | RepositoryNode | WorkspaceMissingRepositoryNode +> { constructor( uri: GitUri, view: WorkspacesView, protected override parent: ViewNode, public readonly workspace: CloudWorkspace | LocalWorkspace, ) { - super(uri, view, parent); + super('workspace', uri, view, parent); this.updateContext({ workspace: workspace }); - this._uniqueId = getViewNodeId('workspace', this.context); - } - - override dispose() { - super.dispose(); - this.resetChildren(); - } - - private resetChildren() { - if (this._children == null) return; - - for (const child of this._children) { - if ('dispose' in child) { - child.dispose(); - } - } - this._children = undefined; + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -49,19 +38,15 @@ export class WorkspaceNode extends SubscribeableViewNode { return this.workspace.name; } - private _children: - | (CommandMessageNode | MessageNode | RepositoryNode | WorkspaceMissingRepositoryNode)[] - | undefined; - async getChildren(): Promise { - if (this._children == null) { - this._children = []; + if (this.children == null) { + const children = []; try { const descriptors = await this.workspace.getRepositoryDescriptors(); - if (descriptors == null || descriptors.length === 0) { - this._children.push( + if (!descriptors?.length) { + children.push( new CommandMessageNode( this.view, this, @@ -73,7 +58,9 @@ export class WorkspaceNode extends SubscribeableViewNode { 'No repositories', ), ); - return this._children; + + this.children = children; + return this.children; } const reposByName = await this.workspace.getRepositoriesByName({ force: true }); @@ -81,13 +68,11 @@ export class WorkspaceNode extends SubscribeableViewNode { for (const descriptor of descriptors) { const repo = reposByName.get(descriptor.name)?.repository; if (!repo) { - this._children.push( - new WorkspaceMissingRepositoryNode(this.view, this, this.workspace, descriptor), - ); + children.push(new WorkspaceMissingRepositoryNode(this.view, this, this.workspace, descriptor)); continue; } - this._children.push( + children.push( new RepositoryNode( GitUri.fromRepoPath(repo.path), this.view, @@ -98,11 +83,14 @@ export class WorkspaceNode extends SubscribeableViewNode { ); } } catch (ex) { + this.children = undefined; return [new MessageNode(this.view, this, 'Failed to load repositories')]; } + + this.children = children; } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -110,7 +98,7 @@ export class WorkspaceNode extends SubscribeableViewNode { const cloud = this.workspace.type === 'cloud'; - let contextValue = `${ContextValues.Workspace}`; + let contextValue: string = ContextValues.Workspace; item.resourceUri = undefined; const descriptionItems = []; if (cloud) { @@ -146,23 +134,15 @@ export class WorkspaceNode extends SubscribeableViewNode { return item; } - @gate() - @debug() - override refresh(reset: boolean = false) { - if (this._children == null) return; - - if (reset) { - this.resetChildren(); - } - } - protected override etag(): number { return this.view.container.git.etag; } @debug() protected subscribe(): Disposable | Promise { - return Disposable.from(this.view.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this)); + return Disposable.from( + weakEvent(this.view.container.git.onDidChangeRepositories, this.onRepositoriesChanged, this), + ); } private onRepositoriesChanged(_e: RepositoriesChangeEvent) { diff --git a/src/views/nodes/workspacesViewNode.ts b/src/views/nodes/workspacesViewNode.ts deleted file mode 100644 index 3bb021418639f..0000000000000 --- a/src/views/nodes/workspacesViewNode.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { gate } from '../../system/decorators/gate'; -import { debug } from '../../system/decorators/log'; -import type { WorkspacesView } from '../workspacesView'; -import { MessageNode } from './common'; -import { RepositoriesNode } from './repositoriesNode'; -import { ViewNode } from './viewNode'; -import { WorkspaceNode } from './workspaceNode'; - -export class WorkspacesViewNode extends ViewNode { - private _children: (WorkspaceNode | MessageNode | RepositoriesNode)[] | undefined; - - async getChildren(): Promise { - if (this._children == null) { - const children: (WorkspaceNode | MessageNode | RepositoriesNode)[] = []; - - const { cloudWorkspaces, cloudWorkspaceInfo, localWorkspaces, localWorkspaceInfo } = - await this.view.container.workspaces.getWorkspaces(); - - if (cloudWorkspaces.length || localWorkspaces.length) { - children.push(new RepositoriesNode(this.view)); - - for (const workspace of cloudWorkspaces) { - children.push(new WorkspaceNode(this.uri, this.view, this, workspace)); - } - - if (cloudWorkspaceInfo != null) { - children.push(new MessageNode(this.view, this, cloudWorkspaceInfo)); - } - - for (const workspace of localWorkspaces) { - children.push(new WorkspaceNode(this.uri, this.view, this, workspace)); - } - - if (cloudWorkspaces.length === 0 && cloudWorkspaceInfo == null) { - children.push(new MessageNode(this.view, this, 'No cloud workspaces found.')); - } - - if (localWorkspaceInfo != null) { - children.push(new MessageNode(this.view, this, localWorkspaceInfo)); - } - } - - this._children = children; - } - - return this._children; - } - - getTreeItem(): TreeItem { - const item = new TreeItem('Workspaces', TreeItemCollapsibleState.Expanded); - return item; - } - - @gate() - @debug() - override refresh() { - if (this._children == null) return; - - if (this._children.length) { - for (const child of this._children) { - if ('dispose' in child) { - child.dispose(); - } - } - } - - this._children = undefined; - } -} diff --git a/src/views/nodes/worktreeNode.ts b/src/views/nodes/worktreeNode.ts index ea87653576ff6..0dd86012f9c70 100644 --- a/src/views/nodes/worktreeNode.ts +++ b/src/views/nodes/worktreeNode.ts @@ -5,31 +5,33 @@ import type { GitBranch } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; import type { PullRequest, PullRequestState } from '../../git/models/pullRequest'; import { shortenRevision } from '../../git/models/reference'; -import { GitRemote } from '../../git/models/remote'; +import { getHighlanderProviderName } from '../../git/models/remote'; +import type { GitStatus } from '../../git/models/status'; import type { GitWorktree } from '../../git/models/worktree'; import { getContext } from '../../system/context'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; -import { Logger } from '../../system/logger'; import type { Deferred } from '../../system/promise'; import { defer, getSettledValue } from '../../system/promise'; import { pad } from '../../system/string'; import type { ViewsWithWorktrees } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; import { CompareBranchNode } from './compareBranchNode'; import { insertDateMarkers } from './helpers'; import { PullRequestNode } from './pullRequestNode'; import { UncommittedFilesNode } from './UncommittedFilesNode'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; type State = { pullRequest: PullRequest | null | undefined; pendingPullRequest: Promise | undefined; }; -export class WorktreeNode extends ViewNode { +export class WorktreeNode extends CacheableChildrenViewNode<'worktree', ViewsWithWorktrees, ViewNode, State> { limit: number | undefined; private _branch: GitBranch | undefined; @@ -39,11 +41,12 @@ export class WorktreeNode extends ViewNode { view: ViewsWithWorktrees, protected override readonly parent: ViewNode, public readonly worktree: GitWorktree, + private readonly worktreeStatus: { status: GitStatus | undefined; missing: boolean } | undefined, ) { - super(uri, view, parent); + super('worktree', uri, view, parent); this.updateContext({ worktree: worktree }); - this._uniqueId = getViewNodeId('worktree', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); this.limit = this.view.getNodeLastKnownLimit(this); } @@ -59,21 +62,20 @@ export class WorktreeNode extends ViewNode { return this.uri.repoPath!; } - private _children: ViewNode[] | undefined; - async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const branch = this._branch; let onCompleted: Deferred | undefined; let pullRequest; + const pullRequestInsertIndex = 0; if ( branch != null && this.view.config.pullRequests.enabled && this.view.config.pullRequests.showForBranches && (branch.upstream != null || branch.remote) && - getContext('gitlens:hasConnectedRemotes') + getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(branch.repoPath) ) { pullRequest = this.getState('pullRequest'); if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { @@ -96,9 +98,9 @@ export class WorktreeNode extends ViewNode { clearTimeout(timeout); // If we found a pull request, insert it into the children cache (if loaded) and refresh the node - if (pr != null && this._children != null) { - this._children.splice( - this._children[0] instanceof CompareBranchNode ? 1 : 0, + if (pr != null && this.children != null) { + this.children.splice( + pullRequestInsertIndex, 0, new PullRequestNode(this.view, this, pr, branch), ); @@ -112,27 +114,29 @@ export class WorktreeNode extends ViewNode { } } - const [logResult, getBranchAndTagTipsResult, statusResult, unpublishedCommitsResult] = - await Promise.allSettled([ - this.getLog(), - this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath), - this.worktree.getStatus(), - branch != null && !branch.remote - ? this.view.container.git.getBranchAheadRange(branch).then(range => - range - ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { - limit: 0, - ref: range, - }) - : undefined, - ) - : undefined, - ]); + const [logResult, getBranchAndTagTipsResult, unpublishedCommitsResult] = await Promise.allSettled([ + this.getLog(), + this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath), + branch != null && !branch.remote + ? this.view.container.git.getBranchAheadRange(branch).then(range => + range + ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { + limit: 0, + ref: range, + }) + : undefined, + ) + : undefined, + ]); const log = getSettledValue(logResult); if (log == null) return [new MessageNode(this.view, this, 'No commits could be found.')]; const children = []; + if (branch != null && pullRequest != null) { + children.push(new PullRequestNode(this.view, this, pullRequest, branch)); + } + if (branch != null && this.view.config.showBranchComparison !== false) { children.push( new CompareBranchNode( @@ -146,10 +150,6 @@ export class WorktreeNode extends ViewNode { ); } - if (branch != null && pullRequest != null) { - children.push(new PullRequestNode(this.view, this, pullRequest, branch)); - } - const unpublishedCommits = getSettledValue(unpublishedCommitsResult); const getBranchAndTagTips = getSettledValue(getBranchAndTagTipsResult); @@ -175,17 +175,15 @@ export class WorktreeNode extends ViewNode { children.push(new LoadMoreNode(this.view, this, children[children.length - 1])); } - const status = getSettledValue(statusResult); - - if (status?.hasChanges) { - children.unshift(new UncommittedFilesNode(this.view, this, status, undefined)); + if (this.worktreeStatus?.status?.hasChanges) { + children.unshift(new UncommittedFilesNode(this.view, this, this.worktreeStatus.status, undefined)); } - this._children = children; + this.children = children; onCompleted?.fulfill(); } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -202,12 +200,12 @@ export class WorktreeNode extends ViewNode { this.worktree.main ? `_Main${this.worktree.opened ? ', Active_' : '_'}` : this.worktree.opened - ? '_Active_' - : '' + ? '_Active_' + : '' } ` : ''; - let missing = false; + const status = this.worktreeStatus?.status; switch (this.worktree.type) { case 'bare': @@ -219,18 +217,12 @@ export class WorktreeNode extends ViewNode { ); break; case 'branch': { - const [branchResult, statusResult] = await Promise.allSettled([ - this.worktree.getBranch(), - this.worktree.getStatus(), - ]); - const branch = getSettledValue(branchResult); - const status = getSettledValue(statusResult); - + const { branch } = this.worktree; this._branch = branch; tooltip.appendMarkdown( `${this.worktree.main ? '$(pass) ' : ''}Worktree for Branch $(git-branch) ${ - branch?.getNameWithoutRemote() ?? this.worktree.branch + branch?.getNameWithoutRemote() ?? branch?.name }${indicators}\\\n\`${this.worktree.friendlyPath}\``, ); icon = new ThemeIcon('git-branch'); @@ -244,9 +236,6 @@ export class WorktreeNode extends ViewNode { expand: true, })}`, ); - } else if (statusResult.status === 'rejected') { - Logger.error(statusResult.reason, 'Worktree status failed'); - missing = true; } if (branch != null) { @@ -306,7 +295,7 @@ export class WorktreeNode extends ViewNode { })}`, ); } else { - const providerName = GitRemote.getHighlanderProviderName( + const providerName = getHighlanderProviderName( await this.view.container.git.getRemotesWithProviders(branch.repoPath), ); @@ -325,14 +314,6 @@ export class WorktreeNode extends ViewNode { )}${indicators}\\\n\`${this.worktree.friendlyPath}\``, ); - let status; - try { - status = await this.worktree.getStatus(); - } catch (ex) { - Logger.error(ex, 'Worktree status failed'); - missing = true; - } - if (status != null) { hasChanges = status.hasChanges; tooltip.appendMarkdown( @@ -353,6 +334,7 @@ export class WorktreeNode extends ViewNode { tooltip.appendMarkdown(`\n\n$(loading~spin) Loading associated pull request${GlyphChars.Ellipsis}`); } + const missing = this.worktreeStatus?.missing ?? false; if (missing) { tooltip.appendMarkdown(`\n\n${GlyphChars.Warning} Unable to locate worktree path`); } @@ -367,21 +349,21 @@ export class WorktreeNode extends ViewNode { pendingPullRequest != null ? new ThemeIcon('loading~spin') : this.worktree.opened - ? new ThemeIcon('check') - : icon; + ? new ThemeIcon('check') + : icon; item.tooltip = tooltip; item.resourceUri = missing ? Uri.parse('gitlens-view://worktree/missing') : hasChanges - ? Uri.parse('gitlens-view://worktree/changes') - : undefined; + ? Uri.parse('gitlens-view://worktree/changes') + : undefined; return item; } - @gate() @debug() override refresh(reset?: boolean) { - this._children = undefined; + super.refresh(true); + if (reset) { this._log = undefined; this.deleteState(); @@ -434,7 +416,7 @@ export class WorktreeNode extends ViewNode { }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; @@ -442,7 +424,7 @@ export class WorktreeNode extends ViewNode { this._log = log; this.limit = log?.count; - this._children = undefined; + this.children = undefined; void this.triggerChange(false); } } diff --git a/src/views/nodes/worktreesNode.ts b/src/views/nodes/worktreesNode.ts index 8351d98ae98e8..c2f3d0d1a007c 100644 --- a/src/views/nodes/worktreesNode.ts +++ b/src/views/nodes/worktreesNode.ts @@ -3,26 +3,28 @@ import { GlyphChars } from '../../constants'; import { PlusFeatures } from '../../features'; import type { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; -import { gate } from '../../system/decorators/gate'; +import { sortWorktrees } from '../../git/models/worktree'; +import { mapAsync } from '../../system/array'; import { debug } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; import type { ViewsWithWorktreesNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { MessageNode } from './common'; -import { ContextValues, getViewNodeId, ViewNode } from './viewNode'; import { WorktreeNode } from './worktreeNode'; -export class WorktreesNode extends ViewNode { - private _children: WorktreeNode[] | undefined; - +export class WorktreesNode extends CacheableChildrenViewNode<'worktrees', ViewsWithWorktreesNode, WorktreeNode> { constructor( uri: GitUri, view: ViewsWithWorktreesNode, protected override readonly parent: ViewNode, public readonly repo: Repository, ) { - super(uri, view, parent); + super('worktrees', uri, view, parent); this.updateContext({ repository: repo }); - this._uniqueId = getViewNodeId('worktrees', this.context); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { @@ -34,17 +36,27 @@ export class WorktreesNode extends ViewNode { } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const access = await this.repo.access(PlusFeatures.Worktrees); if (!access.allowed) return []; const worktrees = await this.repo.getWorktrees(); if (worktrees.length === 0) return [new MessageNode(this.view, this, 'No worktrees could be found.')]; - this._children = worktrees.map(wt => new WorktreeNode(this.uri, this.view, this, wt)); + this.children = await mapAsync(sortWorktrees(worktrees), async w => { + let status; + let missing = false; + try { + status = await w.getStatus(); + } catch (ex) { + Logger.error(ex, `Worktree status failed: ${w.uri.toString(true)}`); + missing = true; + } + return new WorktreeNode(this.uri, this.view, this, w, { status: status, missing: missing }); + }); } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -58,15 +70,14 @@ export class WorktreesNode extends ViewNode { item.contextValue = ContextValues.Worktrees; item.description = access.allowed ? undefined - : ` ${GlyphChars.Warning} Requires a trial or paid plan for use on privately hosted repos`; + : ` ${GlyphChars.Warning} Requires a trial or paid plan for use on privately-hosted repos`; // TODO@eamodio `folder` icon won't work here for some reason item.iconPath = new ThemeIcon('folder-opened'); return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/pullRequestView.ts b/src/views/pullRequestView.ts new file mode 100644 index 0000000000000..f3d9aa8bdf3c3 --- /dev/null +++ b/src/views/pullRequestView.ts @@ -0,0 +1,150 @@ +import type { ConfigurationChangeEvent, Disposable } from 'vscode'; +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import type { PullRequestViewConfig, ViewFilesLayout } from '../config'; +import { Commands } from '../constants'; +import type { Container } from '../container'; +import { unknownGitUri } from '../git/gitUri'; +import type { GitBranch } from '../git/models/branch'; +import type { GitCommit } from '../git/models/commit'; +import type { PullRequest } from '../git/models/pullRequest'; +import { executeCommand } from '../system/command'; +import { configuration } from '../system/configuration'; +import { setContext } from '../system/context'; +import { ViewNode } from './nodes/abstract/viewNode'; +import { PullRequestNode } from './nodes/pullRequestNode'; +import { ViewBase } from './viewBase'; +import { registerViewCommand } from './viewCommands'; + +export class PullRequestViewNode extends ViewNode<'pullrequest', PullRequestView> { + private child: PullRequestNode | undefined; + + constructor(view: PullRequestView) { + super('pullrequest', unknownGitUri, view); + } + + getChildren(): PullRequestNode[] { + return this.child != null ? [this.child] : []; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Pull Request', TreeItemCollapsibleState.Expanded); + return item; + } + + async setPullRequest(pr: PullRequest | undefined, branchOrCommitOrRepoPath: GitBranch | GitCommit | string) { + if (pr != null) { + this.child = new PullRequestNode(this.view, this, pr, branchOrCommitOrRepoPath, { expand: true }); + this.view.description = `${pr.repository.owner}/${pr.repository.repo}#${pr.id}`; + void setContext('gitlens:views:pullRequest:visible', true); + } else { + this.child = undefined; + this.view.description = undefined; + void setContext('gitlens:views:pullRequest:visible', false); + } + await this.triggerChange(); + } +} + +export class PullRequestView extends ViewBase<'pullRequest', PullRequestViewNode, PullRequestViewConfig> { + protected readonly configKey = 'pullRequest'; + + constructor(container: Container) { + super(container, 'pullRequest', 'Pull Request', 'commitsView'); + } + + override get canReveal(): boolean { + return false; + } + + override get canSelectMany(): boolean { + return false; + } + + protected override get showCollapseAll(): boolean { + return false; + } + + close() { + this.setVisible(false); + } + + async showPullRequest(pr: PullRequest | undefined, branchOrCommitOrRepoPath: GitBranch | GitCommit | string) { + if (pr != null) { + this.description = `${pr.repository.owner}/${pr.repository.repo}#${pr.id}`; + this.setVisible(true); + } else { + this.description = undefined; + this.setVisible(false); + } + + await this.ensureRoot().setPullRequest(pr, branchOrCommitOrRepoPath); + if (pr != null) { + await this.show(); + } + } + + private setVisible(visible: boolean) { + void setContext('gitlens:views:pullRequest:visible', visible); + } + + protected getRoot() { + return new PullRequestViewNode(this); + } + + protected registerCommands(): Disposable[] { + void this.container.viewCommands; + return [ + registerViewCommand( + this.getQualifiedCommand('copy'), + () => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), + this, + ), + registerViewCommand(this.getQualifiedCommand('refresh'), () => this.refresh(true), this), + registerViewCommand(this.getQualifiedCommand('close'), () => this.close(), this), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToAuto'), + () => this.setFilesLayout('auto'), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToList'), + () => this.setFilesLayout('list'), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToTree'), + () => this.setFilesLayout('tree'), + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), + ]; + } + + protected override filterConfigurationChanged(e: ConfigurationChangeEvent) { + const changed = super.filterConfigurationChanged(e); + if ( + !changed && + !configuration.changed(e, 'defaultDateFormat') && + !configuration.changed(e, 'defaultDateLocale') && + !configuration.changed(e, 'defaultDateShortFormat') && + !configuration.changed(e, 'defaultDateSource') && + !configuration.changed(e, 'defaultDateStyle') && + !configuration.changed(e, 'defaultGravatarsStyle') && + !configuration.changed(e, 'defaultTimeFormat') && + !configuration.changed(e, 'plusFeatures.enabled') + ) { + return false; + } + + return true; + } + + private setFilesLayout(layout: ViewFilesLayout) { + return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); + } + + private setShowAvatars(enabled: boolean) { + return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); + } +} diff --git a/src/views/remotesView.ts b/src/views/remotesView.ts index 0ef31cf9a5bd4..6b6db6edb350f 100644 --- a/src/views/remotesView.ts +++ b/src/views/remotesView.ts @@ -11,17 +11,18 @@ import type { GitBranchReference, GitRevisionReference } from '../git/models/ref import { getReferenceLabel } from '../git/models/reference'; import type { GitRemote } from '../git/models/remote'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { gate } from '../system/decorators/gate'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { BranchNode } from './nodes/branchNode'; import { BranchOrTagFolderNode } from './nodes/branchOrTagFolderNode'; import { RemoteNode } from './nodes/remoteNode'; import { RemotesNode } from './nodes/remotesNode'; import { RepositoryNode } from './nodes/repositoryNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -48,9 +49,16 @@ export class RemotesRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let repositories = this.view.container.git.openRepositories; + if (configuration.get('views.collapseWorktreesWhenPossible')) { + const grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No remotes could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading remotes...' + : 'No remotes could be found.'; return []; } @@ -104,6 +112,10 @@ export class RemotesView extends ViewBase<'remotes', RemotesViewNode, RemotesVie return this.config.reveal || !configuration.get('views.repositories.showRemotes'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new RemotesViewNode(this); } @@ -169,7 +181,9 @@ export class RemotesView extends ViewBase<'remotes', RemotesViewNode, RemotesVie !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && !configuration.changed(e, 'integrations.enabled') && - !configuration.changed(e, 'sortBranchesBy') + !configuration.changed(e, 'sortBranchesBy') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') ) { return false; } @@ -209,6 +223,7 @@ export class RemotesView extends ViewBase<'remotes', RemotesViewNode, RemotesVie const branches = await this.container.git.getCommitBranches( commit.repoPath, commit.ref, + undefined, isCommit(commit) ? { commitDate: commit.committer.date, remotes: true } : { remotes: true }, ); if (branches.length === 0) return undefined; diff --git a/src/views/repositoriesView.ts b/src/views/repositoriesView.ts index 9ede04c21ba09..d2f88a383aa76 100644 --- a/src/views/repositoriesView.ts +++ b/src/views/repositoriesView.ts @@ -51,10 +51,6 @@ export class RepositoriesView extends ViewBase<'repositories', RepositoriesNode, return this._onDidChangeAutoRefresh.event; } - override get canSelectMany(): boolean { - return false; - } - protected getRoot() { return new RepositoriesNode(this); } @@ -254,7 +250,8 @@ export class RepositoriesView extends ViewBase<'repositories', RepositoriesNode, !configuration.changed(e, 'defaultTimeFormat') && !configuration.changed(e, 'sortBranchesBy') && !configuration.changed(e, 'sortContributorsBy') && - !configuration.changed(e, 'sortTagsBy') + !configuration.changed(e, 'sortTagsBy') && + !configuration.changed(e, 'sortRepositoriesBy') ) { return false; } @@ -329,6 +326,7 @@ export class RepositoriesView extends ViewBase<'repositories', RepositoriesNode, let branches = await this.container.git.getCommitBranches( commit.repoPath, commit.ref, + undefined, isCommit(commit) ? { commitDate: commit.committer.date } : undefined, ); if (branches.length !== 0) { @@ -362,6 +360,7 @@ export class RepositoriesView extends ViewBase<'repositories', RepositoriesNode, branches = await this.container.git.getCommitBranches( commit.repoPath, commit.ref, + undefined, isCommit(commit) ? { commitDate: commit.committer.date, remotes: true } : { remotes: true }, ); if (branches.length === 0) return undefined; diff --git a/src/views/searchAndCompareView.ts b/src/views/searchAndCompareView.ts index 70b278e5d63b7..75d0e6c8c3a25 100644 --- a/src/views/searchAndCompareView.ts +++ b/src/views/searchAndCompareView.ts @@ -19,36 +19,51 @@ import { gate } from '../system/decorators/gate'; import { debug, log } from '../system/decorators/log'; import { updateRecordValue } from '../system/object'; import { isPromise } from '../system/promise'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import { ContextValues, ViewNode } from './nodes/abstract/viewNode'; import { ComparePickerNode } from './nodes/comparePickerNode'; import { CompareResultsNode, restoreComparisonCheckedFiles } from './nodes/compareResultsNode'; import { FilesQueryFilter, ResultsFilesNode } from './nodes/resultsFilesNode'; import { SearchResultsNode } from './nodes/searchResultsNode'; -import { ContextValues, RepositoryFolderNode, ViewNode } from './nodes/viewNode'; -import { ViewBase } from './viewBase'; +import { disposeChildren, ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; -export class SearchAndCompareViewNode extends ViewNode { +export class SearchAndCompareViewNode extends ViewNode<'search-compare', SearchAndCompareView> { protected override splatted = true; private comparePicker: ComparePickerNode | undefined; constructor(view: SearchAndCompareView) { - super(unknownGitUri, view); + super('search-compare', unknownGitUri, view); + } + + override dispose() { + super.dispose(); + disposeChildren(this._children); } private _children: (ComparePickerNode | CompareResultsNode | SearchResultsNode)[] | undefined; private get children(): (ComparePickerNode | CompareResultsNode | SearchResultsNode)[] { if (this._children == null) { - this._children = []; + const children = []; // Get stored searches & comparisons const stored = this.view.getStoredNodes(); if (stored.length !== 0) { - this._children.push(...stored); + children.push(...stored); } + + disposeChildren(this._children, children); + this._children = children; } return this._children; } + private set children(value: (ComparePickerNode | CompareResultsNode | SearchResultsNode)[] | undefined) { + if (this.children === value) return; + + disposeChildren(this.children, value); + this._children = value; + } getChildren(): ViewNode[] { const children = this.children; @@ -66,10 +81,11 @@ export class SearchAndCompareViewNode extends ViewNode { } addOrReplace(results: CompareResultsNode | SearchResultsNode) { - const children = this.children; + const children = [...this.children]; if (children.includes(results)) return; children.push(results); + this.children = children; this.view.triggerNodeChange(); } @@ -79,7 +95,7 @@ export class SearchAndCompareViewNode extends ViewNode { if (this.children.length === 0) return; this.removeComparePicker(true); - this._children!.length = 0; + this.children = []; await this.view.clearStorage(); @@ -98,13 +114,14 @@ export class SearchAndCompareViewNode extends ViewNode { node.dismiss(); } - const children = this.children; + const children = [...this.children]; if (children.length === 0) return; const index = children.indexOf(node); if (index === -1) return; children.splice(index, 1); + this.children = children; this.view.triggerNodeChange(); } @@ -141,9 +158,9 @@ export class SearchAndCompareViewNode extends ViewNode { const pick = await showReferencePicker( repoPath, `Compare ${this.getRefName(selectedRef.ref)} with`, - 'Choose a reference to compare with', + 'Choose a reference (branch, tag, etc) to compare with', { - allowEnteringRefs: true, + allowRevisions: true, picked: typeof selectedRef.ref === 'string' ? selectedRef.ref : selectedRef.ref.ref, // checkmarks: true, include: ReferencesQuickPickIncludes.BranchesAndTags | ReferencesQuickPickIncludes.HEAD, @@ -177,15 +194,16 @@ export class SearchAndCompareViewNode extends ViewNode { let prompt = options?.prompt ?? false; let ref2; if (ref == null) { - const pick = await showReferencePicker(repoPath, 'Compare', 'Choose a reference to compare', { - allowEnteringRefs: { ranges: true }, - // checkmarks: false, - include: - ReferencesQuickPickIncludes.BranchesAndTags | - ReferencesQuickPickIncludes.HEAD | - ReferencesQuickPickIncludes.WorkingTree, - sort: { branches: { current: true }, tags: {} }, - }); + const pick = await showReferencePicker( + repoPath, + 'Compare', + 'Choose a reference (branch, tag, etc) to compare', + { + allowRevisions: { ranges: true }, + include: ReferencesQuickPickIncludes.All, + sort: { branches: { current: true }, tags: {} }, + }, + ); if (pick == null) { await this.triggerChange(); @@ -210,7 +228,11 @@ export class SearchAndCompareViewNode extends ViewNode { repoPath: repoPath, ref: ref, }); - this.children.unshift(this.comparePicker); + + const children = [...this.children]; + children.unshift(this.comparePicker); + this.children = children; + void setContext('gitlens:views:canCompare', true); await this.triggerChange(); @@ -224,17 +246,19 @@ export class SearchAndCompareViewNode extends ViewNode { private getRefName(ref: string | StoredNamedRef): string { return typeof ref === 'string' - ? shortenRevision(ref, { strings: { working: 'Working Tree' } })! - : ref.label ?? shortenRevision(ref.ref)!; + ? shortenRevision(ref, { strings: { working: 'Working Tree' } }) + : ref.label ?? shortenRevision(ref.ref); } private removeComparePicker(silent: boolean = false) { void setContext('gitlens:views:canCompare', false); if (this.comparePicker != null) { - const children = this.children; + const children = [...this.children]; const index = children.indexOf(this.comparePicker); if (index !== -1) { children.splice(index, 1); + this.children = children; + if (!silent) { void this.triggerChange(); } @@ -255,6 +279,10 @@ export class SearchAndCompareView extends ViewBase< super(container, 'searchAndCompare', 'Search & Compare', 'searchAndCompareView'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new SearchAndCompareViewNode(this); } @@ -345,15 +373,22 @@ export class SearchAndCompareView extends ViewBase< this.root.dismiss(node); } - compare(repoPath: string, ref1: string | StoredNamedRef, ref2: string | StoredNamedRef) { - return this.addResults( - new CompareResultsNode( - this, - this.ensureRoot(), - repoPath, - typeof ref1 === 'string' ? { ref: ref1 } : ref1, - typeof ref2 === 'string' ? { ref: ref2 } : ref2, - ), + compare( + repoPath: string, + ref1: string | StoredNamedRef, + ref2: string | StoredNamedRef, + options?: { reveal?: boolean }, + ): Promise { + return this.addResultsNode( + () => + new CompareResultsNode( + this, + this.ensureRoot(), + repoPath, + typeof ref1 === 'string' ? { ref: ref1 } : ref1, + typeof ref2 === 'string' ? { ref: ref2 } : ref2, + ), + options?.reveal === false ? false : undefined, ); } @@ -401,7 +436,10 @@ export class SearchAndCompareView extends ViewBase< return; } - await this.addResults(new SearchResultsNode(this, this.root!, repoPath, search, labels, results), reveal); + await this.addResultsNode( + () => new SearchResultsNode(this, this.root!, repoPath, search, labels, results), + reveal, + ); } getStoredNodes() { @@ -470,22 +508,31 @@ export class SearchAndCompareView extends ViewBase< return node; } - private async addResults( - results: CompareResultsNode | SearchResultsNode, - options: { - expand?: boolean | number; - focus?: boolean; - select?: boolean; - } = { expand: true, focus: true, select: true }, - ) { - if (!this.visible) { + private async addResultsNode( + resultsNodeFn: () => T, + reveal: + | { + expand?: boolean | number; + focus?: boolean; + select?: boolean; + } + | false = { expand: true, focus: true, select: true }, + ): Promise { + if (!this.visible && reveal !== false) { await this.show(); } const root = this.ensureRoot(); - root.addOrReplace(results); - queueMicrotask(() => this.reveal(results, options)); + // Deferred creating the results node until the view is visible (otherwise we will hit a duplicate timing issue when storing the new node, but then loading it from storage during the view's initialization) + const resultsNode = resultsNodeFn(); + root.addOrReplace(resultsNode); + + if (reveal !== false) { + queueMicrotask(() => this.reveal(resultsNode, reveal)); + } + + return resultsNode; } private setFilesLayout(layout: ViewFilesLayout) { diff --git a/src/views/stashesView.ts b/src/views/stashesView.ts index 956cf26279492..efa60bdf83cbd 100644 --- a/src/views/stashesView.ts +++ b/src/views/stashesView.ts @@ -1,10 +1,4 @@ -import type { - CancellationToken, - ConfigurationChangeEvent, - Disposable, - TreeViewSelectionChangeEvent, - TreeViewVisibilityChangeEvent, -} from 'vscode'; +import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode'; import { ProgressLocation, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; import type { StashesViewConfig, ViewFilesLayout } from '../config'; import { Commands } from '../constants'; @@ -13,15 +7,14 @@ import { GitUri } from '../git/gitUri'; import type { GitStashReference } from '../git/models/reference'; import { getReferenceLabel } from '../git/models/reference'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { gate } from '../system/decorators/gate'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { StashesNode } from './nodes/stashesNode'; -import { StashFileNode } from './nodes/stashFileNode'; -import { StashNode } from './nodes/stashNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -42,9 +35,16 @@ export class StashesRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let repositories = this.view.container.git.openRepositories; + if (configuration.get('views.collapseWorktreesWhenPossible')) { + const grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No stashes could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading stashes...' + : 'No stashes could be found.'; return []; } @@ -98,6 +98,10 @@ export class StashesView extends ViewBase<'stashes', StashesViewNode, StashesVie return this.config.reveal || !configuration.get('views.repositories.showStashes'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new StashesViewNode(this); } @@ -147,7 +151,9 @@ export class StashesView extends ViewBase<'stashes', StashesViewNode, StashesVie !configuration.changed(e, 'defaultDateSource') && !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && - !configuration.changed(e, 'defaultTimeFormat') + !configuration.changed(e, 'defaultTimeFormat') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') ) { return false; } @@ -155,48 +161,6 @@ export class StashesView extends ViewBase<'stashes', StashesViewNode, StashesVie return true; } - protected override onSelectionChanged(e: TreeViewSelectionChangeEvent) { - super.onSelectionChanged(e); - this.notifySelections(); - } - - protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { - super.onVisibilityChanged(e); - if (e.visible) { - this.notifySelections(); - } - } - - private notifySelections() { - const node = this.selection?.[0]; - if (node == null) return; - - if (node instanceof StashNode || node instanceof StashFileNode) { - this.container.events.fire( - 'commit:selected', - { - commit: node.commit, - interaction: 'passive', - preserveFocus: true, - preserveVisibility: true, - }, - { source: this.id }, - ); - } - - if (node instanceof StashFileNode) { - this.container.events.fire( - 'file:selected', - { - uri: node.uri, - preserveFocus: true, - preserveVisibility: true, - }, - { source: this.id }, - ); - } - } - findStash(stash: GitStashReference, token?: CancellationToken) { const { repoPath } = stash; diff --git a/src/views/tagsView.ts b/src/views/tagsView.ts index 47b953cf141c1..66bab7ff26d34 100644 --- a/src/views/tagsView.ts +++ b/src/views/tagsView.ts @@ -7,14 +7,15 @@ import { GitUri } from '../git/gitUri'; import type { GitTagReference } from '../git/models/reference'; import { getReferenceLabel } from '../git/models/reference'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { gate } from '../system/decorators/gate'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { BranchOrTagFolderNode } from './nodes/branchOrTagFolderNode'; import { TagsNode } from './nodes/tagsNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -35,9 +36,16 @@ export class TagsRepositoryNode extends RepositoryFolderNode export class TagsViewNode extends RepositoriesSubscribeableNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let repositories = this.view.container.git.openRepositories; + if (configuration.get('views.collapseWorktreesWhenPossible')) { + const grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No tags could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading tags...' + : 'No tags could be found.'; return []; } @@ -91,6 +99,10 @@ export class TagsView extends ViewBase<'tags', TagsViewNode, TagsViewConfig> { return this.config.reveal || !configuration.get('views.repositories.showTags'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new TagsViewNode(this); } @@ -145,7 +157,9 @@ export class TagsView extends ViewBase<'tags', TagsViewNode, TagsViewConfig> { !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && - !configuration.changed(e, 'sortTagsBy') + !configuration.changed(e, 'sortTagsBy') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') ) { return false; } diff --git a/src/views/viewBase.ts b/src/views/viewBase.ts index 696c94d1f24ce..c25508381b4e7 100644 --- a/src/views/viewBase.ts +++ b/src/views/viewBase.ts @@ -18,6 +18,7 @@ import type { ContributorsViewConfig, FileHistoryViewConfig, LineHistoryViewConfig, + PullRequestViewConfig, RemotesViewConfig, RepositoriesViewConfig, SearchAndCompareViewConfig, @@ -28,7 +29,7 @@ import type { WorktreesViewConfig, } from '../config'; import { viewsCommonConfigKeys, viewsConfigKeys } from '../config'; -import type { TreeViewCommandSuffixesByViewType, TreeViewTypes } from '../constants'; +import type { TreeViewCommandSuffixesByViewType, TreeViewIds, TreeViewTypes } from '../constants'; import type { Container } from '../container'; import { executeCoreCommand } from '../system/command'; import { configuration } from '../system/configuration'; @@ -42,10 +43,12 @@ import type { TrackedUsageFeatures } from '../telemetry/usageTracker'; import type { BranchesView } from './branchesView'; import type { CommitsView } from './commitsView'; import type { ContributorsView } from './contributorsView'; +import type { DraftsView } from './draftsView'; import type { FileHistoryView } from './fileHistoryView'; import type { LineHistoryView } from './lineHistoryView'; -import type { PageableViewNode, ViewNode } from './nodes/viewNode'; -import { isPageableViewNode } from './nodes/viewNode'; +import type { PageableViewNode, ViewNode } from './nodes/abstract/viewNode'; +import { isPageableViewNode } from './nodes/abstract/viewNode'; +import type { PullRequestView } from './pullRequestView'; import type { RemotesView } from './remotesView'; import type { RepositoriesView } from './repositoriesView'; import type { SearchAndCompareView } from './searchAndCompareView'; @@ -58,8 +61,10 @@ export type View = | BranchesView | CommitsView | ContributorsView + | DraftsView | FileHistoryView | LineHistoryView + | PullRequestView | RemotesView | RepositoriesView | SearchAndCompareView @@ -79,7 +84,7 @@ export type ViewsWithRepositories = RepositoriesView | WorkspacesView; export type ViewsWithRepositoriesNode = RepositoriesView | WorkspacesView; export type ViewsWithRepositoryFolders = Exclude< View, - FileHistoryView | LineHistoryView | RepositoriesView | WorkspacesView + DraftsView | FileHistoryView | LineHistoryView | PullRequestView | RepositoriesView | WorkspacesView >; export type ViewsWithStashes = StashesView | ViewsWithCommits; export type ViewsWithStashesNode = RepositoriesView | StashesView | WorkspacesView; @@ -102,6 +107,7 @@ export abstract class ViewBase< | FileHistoryViewConfig | CommitsViewConfig | LineHistoryViewConfig + | PullRequestViewConfig | RemotesViewConfig | RepositoriesViewConfig | SearchAndCompareViewConfig @@ -111,10 +117,13 @@ export abstract class ViewBase< > implements TreeDataProvider, Disposable { - get id(): `gitlens.views.${Type}` { + get id(): TreeViewIds { return `gitlens.views.${this.type}`; } + protected _onDidInitialize = new EventEmitter(); + private initialized = false; + protected _onDidChangeTreeData = new EventEmitter(); get onDidChangeTreeData(): Event { return this._onDidChangeTreeData.event; @@ -191,8 +200,9 @@ export abstract class ViewBase< this: ViewBase, item: TreeItem, node: ViewNode, + token: CancellationToken, ) { - item = await resolveTreeItemFn.apply(this, [item, node]); + item = await resolveTreeItemFn.apply(this, [item, node, token]); addDebuggingInfo(item, node, node.getParent()); @@ -219,10 +229,7 @@ export abstract class ViewBase< } get canSelectMany(): boolean { - return ( - this.container.prereleaseOrDebugging && - configuration.get('views.experimental.multiSelect.enabled', undefined, false) - ); + return false; } private _nodeState: ViewNodeState | undefined; @@ -348,7 +355,22 @@ export abstract class ViewBase< if (node != null) return node.getChildren(); const root = this.ensureRoot(); - return root.getChildren(); + const children = root.getChildren(); + if (!this.initialized) { + if (isPromise(children)) { + void children.then(() => { + if (!this.initialized) { + this.initialized = true; + setTimeout(() => this._onDidInitialize.fire(), 1); + } + }); + } else { + this.initialized = true; + setTimeout(() => this._onDidInitialize.fire(), 1); + } + } + + return children; } getParent(node: ViewNode): ViewNode | undefined { @@ -359,8 +381,8 @@ export abstract class ViewBase< return node.getTreeItem(); } - resolveTreeItem(item: TreeItem, node: ViewNode): TreeItem | Promise { - return node.resolveTreeItem?.(item) ?? item; + resolveTreeItem(item: TreeItem, node: ViewNode, token: CancellationToken): TreeItem | Promise { + return node.resolveTreeItem?.(item, token) ?? item; } protected onElementCollapsed(e: TreeViewExpansionEvent) { @@ -388,6 +410,7 @@ export abstract class ViewBase< protected onSelectionChanged(e: TreeViewSelectionChangeEvent) { this._onDidChangeSelection.fire(e); + this.notifySelections(); } protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { @@ -396,6 +419,45 @@ export abstract class ViewBase< } this._onDidChangeVisibility.fire(e); + if (e.visible) { + this.notifySelections(); + } + } + + private notifySelections() { + const node = this.selection?.[0]; + if (node == null) return; + + if ( + node.is('commit') || + node.is('stash') || + node.is('file-commit') || + node.is('commit-file') || + node.is('stash-file') + ) { + this.container.events.fire( + 'commit:selected', + { + commit: node.commit, + interaction: 'passive', + preserveFocus: true, + preserveVisibility: true, + }, + { source: this.id }, + ); + } + + if (node.is('file-commit') || node.is('commit-file') || node.is('stash-file')) { + this.container.events.fire( + 'file:selected', + { + uri: node.uri, + preserveFocus: true, + preserveVisibility: true, + }, + { source: this.id }, + ); + } } get activeSelection(): ViewNode | undefined { @@ -423,17 +485,12 @@ export abstract class ViewBase< }) async findNode( predicate: (node: ViewNode) => boolean, - { - allowPaging = false, - canTraverse, - maxDepth = 2, - token, - }: { + options?: { allowPaging?: boolean; canTraverse?: (node: ViewNode) => boolean | Promise; maxDepth?: number; token?: CancellationToken; - } = {}, + }, ): Promise { const scope = getLogScope(); @@ -442,10 +499,10 @@ export abstract class ViewBase< const node = await this.findNodeCoreBFS( predicate, this.ensureRoot(), - allowPaging, - canTraverse, - maxDepth, - token, + options?.allowPaging ?? false, + options?.canTraverse, + options?.maxDepth ?? 2, + options?.token, ); return node; @@ -455,12 +512,14 @@ export abstract class ViewBase< } } - if (this.root != null) return find.call(this); + if (this.initialized) return find.call(this); // If we have no root (e.g. never been initialized) force it so the tree will load properly - await this.show({ preserveFocus: true }); + void this.show({ preserveFocus: true }); // Since we have to show the view, give the view time to load and let the callstack unwind before we try to find the node - return new Promise(resolve => setTimeout(() => resolve(find.call(this)), 100)); + return new Promise(resolve => + once(this._onDidInitialize.event)(() => resolve(find.call(this)), this), + ); } private async findNodeCoreBFS( @@ -519,7 +578,7 @@ export abstract class ViewBase< await this.loadMoreNodeChildren(node, defaultPageSize); - pagedChildren = await cancellable(Promise.resolve(node.getChildren()), token ?? 60000, { + pagedChildren = await cancellable(Promise.resolve(node.getChildren()), 60000, token, { onDidCancel: resolve => resolve([]), }); @@ -672,6 +731,57 @@ export abstract class ViewBase< return this._config; } + + // NOTE: @eamodio uncomment to track node leaks + // private _nodeTracking = new Map(); + // private registry = new FinalizationRegistry(uuid => { + // const id = this._nodeTracking.get(uuid); + + // Logger.log(`@@@ ${this.type} Finalizing [${uuid}]:${id}`); + + // this._nodeTracking.delete(uuid); + + // if (id != null) { + // const c = count(this._nodeTracking.values(), v => v === id); + // Logger.log(`@@@ ${this.type} [${padLeft(String(c), 3)}] ${id}`); + // } + // }); + + // registerNode(node: ViewNode) { + // const uuid = node.uuid; + + // Logger.log(`@@@ ${this.type}.registerNode [${uuid}]:${node.id}`); + + // this._nodeTracking.set(uuid, node.id); + // this.registry.register(node, uuid); + // } + + // unregisterNode(node: ViewNode) { + // const uuid = node.uuid; + + // Logger.log(`@@@ ${this.type}.unregisterNode [${uuid}]:${node.id}`); + + // this._nodeTracking.delete(uuid); + // this.registry.unregister(node); + // } + + // private _timer = setInterval(() => { + // const counts = new Map(); + // for (const value of this._nodeTracking.values()) { + // const count = counts.get(value) ?? 0; + // counts.set(value, count + 1); + // } + + // let total = 0; + // for (const [id, count] of counts) { + // if (count > 1) { + // Logger.log(`@@@ ${this.type} [${padLeft(String(count), 3)}] ${id}`); + // } + // total += count; + // } + + // Logger.log(`@@@ ${this.type} total=${total}`); + // }, 10000); } export class ViewNodeState implements Disposable { @@ -697,6 +807,9 @@ export class ViewNodeState implements Disposable { for (const [id, map] of store) { if (id.startsWith(prefix)) { map.delete(key); + if (map.size === 0) { + store.delete(id); + } } } } @@ -707,8 +820,17 @@ export class ViewNodeState implements Disposable { this._store?.delete(id); this._stickyStore?.delete(id); } else { - this._store?.get(id)?.delete(key); - this._stickyStore?.get(id)?.delete(key); + for (const store of [this._store, this._stickyStore]) { + if (store == null) continue; + + const map = store.get(id); + if (map == null) continue; + + map.delete(key); + if (map.size === 0) { + store.delete(id); + } + } } } @@ -754,3 +876,23 @@ export class ViewNodeState implements Disposable { } } } + +export function disposeChildren(oldChildren: ViewNode[] | undefined, newChildren?: ViewNode[]) { + if (!oldChildren?.length) return; + + const children = newChildren?.length ? oldChildren.filter(c => !newChildren.includes(c)) : [...oldChildren]; + if (!children.length) return; + + if (children.length > 1000) { + // Defer the disposals to avoid impacting the treeview's rendering + setTimeout(() => { + for (const child of children) { + child.dispose(); + } + }, 500); + } else { + for (const child of children) { + child.dispose(); + } + } +} diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 66d9e727d822e..1ef32a2d403b7 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -1,14 +1,14 @@ import type { Disposable, TextDocumentShowOptions } from 'vscode'; -import { env, Uri, window } from 'vscode'; +import { env, Uri, window, workspace } from 'vscode'; +import { getTempFile } from '@env/platform'; import type { CreatePullRequestActionContext, OpenPullRequestActionContext } from '../api/gitlens'; -import type { - DiffWithCommandArgs, - DiffWithPreviousCommandArgs, - DiffWithWorkingCommandArgs, - OpenFileAtRevisionCommandArgs, -} from '../commands'; +import type { DiffWithCommandArgs } from '../commands/diffWith'; +import type { DiffWithPreviousCommandArgs } from '../commands/diffWithPrevious'; +import type { DiffWithWorkingCommandArgs } from '../commands/diffWithWorking'; +import type { OpenFileAtRevisionCommandArgs } from '../commands/openFileAtRevision'; +import type { OpenOnRemoteCommandArgs } from '../commands/openOnRemote'; import type { ViewShowBranchComparison } from '../config'; -import { Commands } from '../constants'; +import { Commands, GlyphChars } from '../constants'; import type { Container } from '../container'; import { browseAtRevision } from '../git/actions'; import * as BranchActions from '../git/actions/branch'; @@ -21,8 +21,13 @@ import * as TagActions from '../git/actions/tag'; import * as WorktreeActions from '../git/actions/worktree'; import { GitUri } from '../git/gitUri'; import { deletedOrMissing } from '../git/models/constants'; -import type { GitStashReference } from '../git/models/reference'; -import { createReference, getReferenceLabel, shortenRevision } from '../git/models/reference'; +import { matchContributor } from '../git/models/contributor'; +import { getComparisonRefsForPullRequest } from '../git/models/pullRequest'; +import { createReference, shortenRevision } from '../git/models/reference'; +import { RemoteResourceType } from '../git/models/remoteResource'; +import { showPatchesView } from '../plus/drafts/actions'; +import { showContributorsPicker } from '../quickpicks/contributorsPicker'; +import { mapAsync } from '../system/array'; import { executeActionCommand, executeCommand, @@ -33,46 +38,47 @@ import { } from '../system/command'; import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; -import { debug } from '../system/decorators/log'; -import { sequentialize } from '../system/function'; +import { log } from '../system/decorators/log'; +import { partial, sequentialize } from '../system/function'; import type { OpenWorkspaceLocation } from '../system/utils'; -import { openWorkspace } from '../system/utils'; -import type { BranchesNode } from './nodes/branchesNode'; -import { BranchNode } from './nodes/branchNode'; -import { BranchTrackingStatusNode } from './nodes/branchTrackingStatusNode'; -import { CommitFileNode } from './nodes/commitFileNode'; -import { CommitNode } from './nodes/commitNode'; -import type { PagerNode } from './nodes/common'; -import { CompareBranchNode } from './nodes/compareBranchNode'; -import { ContributorNode } from './nodes/contributorNode'; -import { FileHistoryNode } from './nodes/fileHistoryNode'; -import { FileRevisionAsCommitNode } from './nodes/fileRevisionAsCommitNode'; -import { FolderNode } from './nodes/folderNode'; -import { LineHistoryNode } from './nodes/lineHistoryNode'; -import { MergeConflictFileNode } from './nodes/mergeConflictFileNode'; -import { PullRequestNode } from './nodes/pullRequestNode'; -import { RemoteNode } from './nodes/remoteNode'; -import { RepositoryNode } from './nodes/repositoryNode'; -import { ResultsFileNode } from './nodes/resultsFileNode'; -import { ResultsFilesNode } from './nodes/resultsFilesNode'; -import { StashFileNode } from './nodes/stashFileNode'; -import { StashNode } from './nodes/stashNode'; -import { StatusFileNode } from './nodes/statusFileNode'; -import { TagNode } from './nodes/tagNode'; -import type { TagsNode } from './nodes/tagsNode'; +import { openUrl, openWorkspace, revealInFileExplorer } from '../system/utils'; +import type { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ClipboardType } from './nodes/abstract/viewNode'; import { - canClearNode, canEditNode, canViewDismissNode, getNodeRepoPath, isPageableViewNode, - RepositoryFolderNode, ViewNode, - ViewRefFileNode, - ViewRefNode, -} from './nodes/viewNode'; -import { WorktreeNode } from './nodes/worktreeNode'; -import { WorktreesNode } from './nodes/worktreesNode'; +} from './nodes/abstract/viewNode'; +import { ViewRefFileNode, ViewRefNode } from './nodes/abstract/viewRefNode'; +import type { BranchesNode } from './nodes/branchesNode'; +import type { BranchNode } from './nodes/branchNode'; +import type { BranchTrackingStatusFilesNode } from './nodes/branchTrackingStatusFilesNode'; +import type { BranchTrackingStatusNode } from './nodes/branchTrackingStatusNode'; +import type { CommitFileNode } from './nodes/commitFileNode'; +import type { CommitNode } from './nodes/commitNode'; +import type { PagerNode } from './nodes/common'; +import type { CompareResultsNode } from './nodes/compareResultsNode'; +import type { ContributorNode } from './nodes/contributorNode'; +import type { DraftNode } from './nodes/draftNode'; +import type { FileHistoryNode } from './nodes/fileHistoryNode'; +import type { FileRevisionAsCommitNode } from './nodes/fileRevisionAsCommitNode'; +import type { FolderNode } from './nodes/folderNode'; +import type { LineHistoryNode } from './nodes/lineHistoryNode'; +import type { MergeConflictFileNode } from './nodes/mergeConflictFileNode'; +import type { PullRequestNode } from './nodes/pullRequestNode'; +import type { RemoteNode } from './nodes/remoteNode'; +import type { RepositoryNode } from './nodes/repositoryNode'; +import type { ResultsFileNode } from './nodes/resultsFileNode'; +import type { ResultsFilesNode } from './nodes/resultsFilesNode'; +import type { StashFileNode } from './nodes/stashFileNode'; +import type { StashNode } from './nodes/stashNode'; +import type { StatusFileNode } from './nodes/statusFileNode'; +import type { TagNode } from './nodes/tagNode'; +import type { TagsNode } from './nodes/tagsNode'; +import type { WorktreeNode } from './nodes/worktreeNode'; +import type { WorktreesNode } from './nodes/worktreesNode'; interface CompareSelectedInfo { ref: string; @@ -80,37 +86,37 @@ interface CompareSelectedInfo { uri?: Uri; } -const enum ViewCommandMultiSelectMode { - Disallowed, - Allowed, - Custom, -} - export function registerViewCommand( command: string, callback: (...args: any[]) => unknown, thisArg?: any, - multiSelect: ViewCommandMultiSelectMode = ViewCommandMultiSelectMode.Allowed, + multiselect: boolean | 'sequential' = false, ): Disposable { return registerCommand( command, (...args: any[]) => { - if (multiSelect !== ViewCommandMultiSelectMode.Disallowed) { - let [node, nodes, ...rest] = args; - // If there is a node followed by an array of nodes, then we want to execute the command for each - if (node instanceof ViewNode && Array.isArray(nodes) && nodes[0] instanceof ViewNode) { - nodes = nodes.filter(n => n?.constructor === node.constructor); - - if (multiSelect === ViewCommandMultiSelectMode.Custom) { - return callback.apply(thisArg, [node, nodes, ...rest]); + if (multiselect) { + const [active, selection, ...rest] = args; + + // If there is a node followed by an array of nodes, then check how we want to execute the command + if (active instanceof ViewNode && Array.isArray(selection) && selection[0] instanceof ViewNode) { + const nodes = selection.filter((n): n is ViewNode => n?.constructor === active.constructor); + + if (multiselect === 'sequential') { + if (!nodes.includes(active)) { + nodes.splice(0, 0, active); + } + + // Execute the command for each node sequentially + return sequentialize( + callback, + nodes.map<[ViewNode, ...any[]]>(n => [n, ...rest]), + thisArg, + ); } - return sequentialize( - callback, - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (nodes as ViewNode[]).map(n => [n, ...rest]), - thisArg, - ); + // Delegate to the callback to handle the multi-select + return callback.apply(thisArg, [active, nodes, ...rest]); } } @@ -122,22 +128,15 @@ export function registerViewCommand( export class ViewCommands { constructor(private readonly container: Container) { - registerViewCommand('gitlens.views.clearNode', (n: ViewNode) => canClearNode(n) && n.clear(), this); - // Register independently as it already handles copying multiple nodes - registerCommand( - Commands.ViewsCopy, - async (active: ViewNode | undefined, selection: ViewNode[]) => { - selection = Array.isArray(selection) ? selection : active != null ? [active] : []; - if (selection.length === 0) return; - - const data = selection - .map(n => n.toClipboard?.()) - .filter(s => Boolean(s)) - .join('\n'); - await env.clipboard.writeText(data); - }, - this, - ); + registerViewCommand('gitlens.views.clearComparison', n => this.clearComparison(n), this); + registerViewCommand('gitlens.views.clearReviewed', n => this.clearReviewed(n), this); + registerViewCommand(Commands.ViewsCopy, partial(copyNode, 'text'), this, true); + registerViewCommand(Commands.ViewsCopyAsMarkdown, partial(copyNode, 'markdown'), this, true); + registerViewCommand(Commands.ViewsCopyUrl, copyNodeUrl, this); + registerViewCommand(`${Commands.ViewsCopyUrl}.multi`, copyNodeUrl, this, true); + registerViewCommand(Commands.ViewsOpenUrl, openNodeUrl, this); + registerViewCommand(`${Commands.ViewsOpenUrl}.multi`, openNodeUrl, this, true); + registerViewCommand('gitlens.views.collapseNode', () => executeCoreCommand('list.collapseAllToFocus'), this); registerViewCommand( 'gitlens.views.dismissNode', (n: ViewNode) => canViewDismissNode(n.view) && n.view.dismissNode(n), @@ -162,6 +161,7 @@ export class ViewCommands { return n.view.refreshNode(n, reset == null ? true : reset); }, this, + 'sequential', ); registerViewCommand( @@ -187,8 +187,11 @@ export class ViewCommands { registerViewCommand('gitlens.views.unsetAsDefault', this.unsetAsDefault, this); registerViewCommand('gitlens.views.openInTerminal', this.openInTerminal, this); + registerViewCommand('gitlens.views.openInIntegratedTerminal', this.openInIntegratedTerminal, this); registerViewCommand('gitlens.views.star', this.star, this); + registerViewCommand('gitlens.views.star.multi', this.star, this, 'sequential'); registerViewCommand('gitlens.views.unstar', this.unstar, this); + registerViewCommand('gitlens.views.unstar.multi', this.unstar, this, 'sequential'); registerViewCommand('gitlens.views.browseRepoAtRevision', this.browseRepoAtRevision, this); registerViewCommand( @@ -209,6 +212,36 @@ export class ViewCommands { registerViewCommand('gitlens.views.addAuthors', this.addAuthors, this); registerViewCommand('gitlens.views.addAuthor', this.addAuthor, this); + registerViewCommand('gitlens.views.addAuthor.multi', this.addAuthor, this, true); + + registerViewCommand( + 'gitlens.views.openBranchOnRemote', + n => executeCommand(Commands.OpenBranchOnRemote, n), + this, + ); + registerViewCommand( + 'gitlens.views.openBranchOnRemote.multi', + n => executeCommand(Commands.OpenBranchOnRemote, n), + this, + 'sequential', + ); + + registerViewCommand( + 'gitlens.views.copyRemoteCommitUrl', + (n, nodes) => this.openCommitOnRemote(n, nodes, true), + this, + ); + registerViewCommand( + 'gitlens.views.copyRemoteCommitUrl.multi', + (n, nodes) => this.openCommitOnRemote(n, nodes, true), + this, + ); + registerViewCommand('gitlens.views.openCommitOnRemote', (n, nodes) => this.openCommitOnRemote(n, nodes), this); + registerViewCommand( + 'gitlens.views.openCommitOnRemote.multi', + (n, nodes) => this.openCommitOnRemote(n, nodes), + this, + ); registerViewCommand('gitlens.views.openChanges', this.openChanges, this); registerViewCommand('gitlens.views.openChangesWithWorking', this.openChangesWithWorking, this); @@ -216,8 +249,23 @@ export class ViewCommands { registerViewCommand('gitlens.views.openFile', this.openFile, this); registerViewCommand('gitlens.views.openFileRevision', this.openRevision, this); registerViewCommand('gitlens.views.openChangedFiles', this.openFiles, this); - registerViewCommand('gitlens.views.openChangedFileDiffs', this.openAllChanges, this); - registerViewCommand('gitlens.views.openChangedFileDiffsWithWorking', this.openAllChangesWithWorking, this); + registerViewCommand('gitlens.views.openOnlyChangedFiles', this.openOnlyChangedFiles); + registerViewCommand('gitlens.views.openChangedFileDiffs', (n, o) => this.openAllChanges(n, o), this); + registerViewCommand( + 'gitlens.views.openChangedFileDiffsWithWorking', + (n, o) => this.openAllChangesWithWorking(n, o), + this, + ); + registerViewCommand( + 'gitlens.views.openChangedFileDiffsIndividually', + (n, o) => this.openAllChanges(n, o, true), + this, + ); + registerViewCommand( + 'gitlens.views.openChangedFileDiffsWithWorkingIndividually', + (n, o) => this.openAllChangesWithWorking(n, o, true), + this, + ); registerViewCommand('gitlens.views.openChangedFileRevisions', this.openRevisions, this); registerViewCommand('gitlens.views.applyChanges', this.applyChanges, this); registerViewCommand('gitlens.views.highlightChanges', this.highlightChanges, this); @@ -236,8 +284,15 @@ export class ViewCommands { registerViewCommand('gitlens.views.unstageDirectory', this.unstageDirectory, this); registerViewCommand('gitlens.views.unstageFile', this.unstageFile, this); + registerViewCommand( + 'gitlens.views.openChangedFileDiffsWithMergeBase', + this.openChangedFileDiffsWithMergeBase, + this, + ); + registerViewCommand('gitlens.views.compareAncestryWithWorking', this.compareAncestryWithWorking, this); registerViewCommand('gitlens.views.compareWithHead', this.compareHeadWith, this); + registerViewCommand('gitlens.views.compareWithMergeBase', this.compareWithMergeBase, this); registerViewCommand('gitlens.views.compareWithUpstream', this.compareWithUpstream, this); registerViewCommand('gitlens.views.compareWithSelected', this.compareWithSelected, this); registerViewCommand('gitlens.views.selectForCompare', this.selectForCompare, this); @@ -256,20 +311,24 @@ export class ViewCommands { this, ); - registerViewCommand('gitlens.views.cherryPick', this.cherryPick, this, ViewCommandMultiSelectMode.Custom); + registerViewCommand('gitlens.views.cherryPick', this.cherryPick, this); + registerViewCommand('gitlens.views.cherryPick.multi', this.cherryPick, this, true); registerViewCommand('gitlens.views.title.createBranch', () => this.createBranch()); registerViewCommand('gitlens.views.createBranch', this.createBranch, this); registerViewCommand('gitlens.views.deleteBranch', this.deleteBranch, this); + registerViewCommand('gitlens.views.deleteBranch.multi', this.deleteBranch, this, true); registerViewCommand('gitlens.views.renameBranch', this.renameBranch, this); - registerViewCommand('gitlens.views.title.applyStash', () => this.applyStash()); - registerViewCommand('gitlens.views.stash.delete', this.deleteStash, this, ViewCommandMultiSelectMode.Custom); + registerViewCommand('gitlens.views.stash.apply', this.applyStash, this); + registerViewCommand('gitlens.views.stash.delete', this.deleteStash, this); + registerViewCommand('gitlens.views.stash.delete.multi', this.deleteStash, this, true); registerViewCommand('gitlens.views.stash.rename', this.renameStash, this); registerViewCommand('gitlens.views.title.createTag', () => this.createTag()); registerViewCommand('gitlens.views.createTag', this.createTag, this); registerViewCommand('gitlens.views.deleteTag', this.deleteTag, this); + registerViewCommand('gitlens.views.deleteTag.multi', this.deleteTag, this, true); registerViewCommand('gitlens.views.mergeBranchInto', this.merge, this); registerViewCommand('gitlens.views.pushToCommit', this.pushToCommit, this); @@ -286,46 +345,67 @@ export class ViewCommands { registerViewCommand('gitlens.views.createPullRequest', this.createPullRequest, this); registerViewCommand('gitlens.views.openPullRequest', this.openPullRequest, this); + registerViewCommand('gitlens.views.openPullRequestChanges', this.openPullRequestChanges, this); + registerViewCommand('gitlens.views.openPullRequestComparison', this.openPullRequestComparison, this); + + registerViewCommand('gitlens.views.draft.open', this.openDraft, this); + registerViewCommand('gitlens.views.draft.openOnWeb', this.openDraftOnWeb, this); registerViewCommand('gitlens.views.title.createWorktree', () => this.createWorktree()); registerViewCommand('gitlens.views.createWorktree', this.createWorktree, this); registerViewCommand('gitlens.views.deleteWorktree', this.deleteWorktree, this); + registerViewCommand('gitlens.views.deleteWorktree.multi', this.deleteWorktree, this, true); registerViewCommand('gitlens.views.openWorktree', this.openWorktree, this); registerViewCommand('gitlens.views.revealRepositoryInExplorer', this.revealRepositoryInExplorer, this); registerViewCommand('gitlens.views.revealWorktreeInExplorer', this.revealWorktreeInExplorer, this); registerViewCommand( 'gitlens.views.openWorktreeInNewWindow', - n => this.openWorktree(n, { location: 'newWindow' }), + n => this.openWorktree(n, undefined, { location: 'newWindow' }), + this, + ); + registerViewCommand( + 'gitlens.views.openWorktreeInNewWindow.multi', + (n, nodes) => this.openWorktree(n, nodes, { location: 'newWindow' }), + this, + true, + ); + + registerViewCommand( + 'gitlens.views.setResultsCommitsFilterAuthors', + n => this.setResultsCommitsFilter(n, true), + this, + ); + registerViewCommand( + 'gitlens.views.setResultsCommitsFilterOff', + n => this.setResultsCommitsFilter(n, false), this, ); } - @debug() + + @log() private addAuthors(node?: ViewNode) { return ContributorActions.addAuthors(getNodeRepoPath(node)); } - @debug() - private addAuthor(node?: ContributorNode) { - if (node instanceof ContributorNode) { - return ContributorActions.addAuthors( - node.repoPath, - node.contributor.current ? undefined : node.contributor, - ); - } + @log() + private addAuthor(node?: ContributorNode, nodes?: ContributorNode[]) { + if (!node?.is('contributor')) return Promise.resolve(); - return Promise.resolve(); + const contributors = nodes?.length ? nodes.map(n => n.contributor) : [node.contributor]; + return ContributorActions.addAuthors( + node.repoPath, + contributors.filter(c => !c.current), + ); } - @debug() + @log() private addRemote(node?: ViewNode) { return RemoteActions.add(getNodeRepoPath(node)); } - @debug() + @log() private applyChanges(node: ViewRefFileNode) { - if (!(node instanceof ViewRefFileNode)) return Promise.resolve(); - - if (node instanceof ResultsFileNode) { + if (node.is('results-file')) { return CommitActions.applyChanges( node.file, createReference(node.ref1, node.repoPath), @@ -333,17 +413,19 @@ export class ViewCommands { ); } - if (node.ref == null || node.ref.ref === 'HEAD') return Promise.resolve(); + if (!(node instanceof ViewRefFileNode) || node.ref == null || node.ref.ref === 'HEAD') return Promise.resolve(); return CommitActions.applyChanges(node.file, node.ref); } - @debug() - private applyStash() { - return StashActions.apply(); + @log() + private applyStash(node: StashNode) { + if (!node.is('stash')) return Promise.resolve(); + + return StashActions.apply(node.repoPath, node.commit); } - @debug() + @log() private browseRepoAtRevision( node: ViewRefNode | ViewRefFileNode, options?: { before?: boolean; openInNewWindow?: boolean }, @@ -356,35 +438,51 @@ export class ViewCommands { }); } - @debug() + @log() private cherryPick(node: CommitNode, nodes?: CommitNode[]) { - if (!(node instanceof CommitNode)) return Promise.resolve(); + if (!node.is('commit')) return Promise.resolve(); - if (nodes != null && nodes.length !== 0) { - return RepoActions.cherryPick( - node.repoPath, - nodes.map(n => n.ref), - ); + const refs = nodes?.length ? nodes.map(n => n.ref) : [node.ref]; + return RepoActions.cherryPick(node.repoPath, refs); + } + + @log() + private clearComparison(node: ViewNode) { + if (node.is('compare-branch')) { + void node.clear(); + } + } + + @log() + private clearReviewed(node: ViewNode) { + let compareNode; + if (node.is('results-files')) { + compareNode = node.getParent(); + if (compareNode == null) return; + } else { + compareNode = node; } - return RepoActions.cherryPick(node.repoPath, node.ref); + if (compareNode.isAny('compare-branch', 'compare-results')) { + compareNode.clearReviewed(); + } } - @debug() + @log() private closeRepository(node: RepositoryNode | RepositoryFolderNode) { - if (!(node instanceof RepositoryNode) && !(node instanceof RepositoryFolderNode)) return; + if (!node.isAny('repository', 'repo-folder')) return; node.repo.closed = true; } - @debug() + @log() private async createBranch(node?: ViewRefNode | ViewRefFileNode | BranchesNode | BranchTrackingStatusNode) { let from = node instanceof ViewRefNode || node instanceof ViewRefFileNode ? node?.ref - : node instanceof BranchTrackingStatusNode - ? node.branch - : undefined; + : node?.is('tracking-status') + ? node.branch + : undefined; if (from == null) { const branch = await this.container.git.getBranch( node?.repoPath ?? this.container.git.getBestRepository()?.uri, @@ -394,11 +492,9 @@ export class ViewCommands { return BranchActions.create(node?.repoPath, from); } - @debug() + @log() private async createPullRequest(node: BranchNode | BranchTrackingStatusNode) { - if (!(node instanceof BranchNode) && !(node instanceof BranchTrackingStatusNode)) { - return Promise.resolve(); - } + if (!node.isAny('branch', 'tracking-status')) return Promise.resolve(); const remote = await node.branch.getRemote(); @@ -427,14 +523,14 @@ export class ViewCommands { }); } - @debug() + @log() private async createTag(node?: ViewRefNode | ViewRefFileNode | TagsNode | BranchTrackingStatusNode) { let from = node instanceof ViewRefNode || node instanceof ViewRefFileNode ? node?.ref - : node instanceof BranchTrackingStatusNode - ? node.branch - : undefined; + : node?.is('tracking-status') + ? node.branch + : undefined; if (from == null) { const branch = await this.container.git.getBranch( node?.repoPath ?? this.container.git.getBestRepository()?.uri, @@ -444,81 +540,70 @@ export class ViewCommands { return TagActions.create(node?.repoPath, from); } - @debug() + @log() private async createWorktree(node?: BranchNode | WorktreesNode) { - if (node instanceof WorktreesNode) { + if (node?.is('worktrees')) { node = undefined; } - if (node != null && !(node instanceof BranchNode)) return undefined; + if (node != null && !node.is('branch')) return undefined; return WorktreeActions.create(node?.repoPath, undefined, node?.ref); } - @debug() - private deleteBranch(node: BranchNode) { - if (!(node instanceof BranchNode)) return Promise.resolve(); + @log() + private deleteBranch(node: BranchNode, nodes?: BranchNode[]) { + if (!node.is('branch')) return Promise.resolve(); - return BranchActions.remove(node.repoPath, node.branch); + const refs = nodes?.length ? nodes.map(n => n.branch) : [node.branch]; + return BranchActions.remove(node.repoPath, refs); } - @debug() + @log() private deleteStash(node: StashNode, nodes?: StashNode[]) { - if (!(node instanceof StashNode)) return Promise.resolve(); + if (!node.is('stash')) return Promise.resolve(); - if (nodes != null && nodes.length !== 0) { - const sorted = nodes.sort((a, b) => parseInt(b.commit.number, 10) - parseInt(a.commit.number, 10)); - - return sequentialize( - StashActions.drop, - sorted.map<[string, GitStashReference]>(n => [n.repoPath, n.commit]), - this, - ); - } - return StashActions.drop(node.repoPath, node.commit); + const refs = nodes?.length ? nodes.map(n => n.commit) : [node.commit]; + return StashActions.drop(node.repoPath, refs); } - @debug() + @log() private renameStash(node: StashNode) { - if (!(node instanceof StashNode)) return Promise.resolve(); + if (!node.is('stash')) return Promise.resolve(); return StashActions.rename(node.repoPath, node.commit); } - @debug() - private deleteTag(node: TagNode) { - if (!(node instanceof TagNode)) return Promise.resolve(); + @log() + private deleteTag(node: TagNode, nodes?: TagNode[]) { + if (!node.is('tag')) return Promise.resolve(); - return TagActions.remove(node.repoPath, node.tag); + const refs = nodes?.length ? nodes.map(n => n.tag) : [node.tag]; + return TagActions.remove(node.repoPath, refs); } - @debug() - private async deleteWorktree(node: WorktreeNode) { - if (!(node instanceof WorktreeNode)) return undefined; + @log() + private async deleteWorktree(node: WorktreeNode, nodes?: WorktreeNode[]) { + if (!node.is('worktree')) return undefined; - return WorktreeActions.remove(node.repoPath, node.worktree.uri); + const worktrees = nodes?.length ? nodes.map(n => n.worktree) : [node.worktree]; + const uris = worktrees.filter(w => !w.main && !w.opened).map(w => w.uri); + return WorktreeActions.remove(node.repoPath, uris); } - @debug() + @log() private fetch(node: RemoteNode | RepositoryNode | RepositoryFolderNode | BranchNode | BranchTrackingStatusNode) { - if (node instanceof RepositoryNode || node instanceof RepositoryFolderNode) return RepoActions.fetch(node.repo); - if (node instanceof RemoteNode) return RemoteActions.fetch(node.remote.repoPath, node.remote.name); - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('repository', 'repo-folder')) return RepoActions.fetch(node.repo); + if (node.is('remote')) return RemoteActions.fetch(node.remote.repoPath, node.remote.name); + if (node.isAny('branch', 'tracking-status')) { return RepoActions.fetch(node.repoPath, node.root ? undefined : node.branch); } return Promise.resolve(); } - @debug() + @log() private async highlightChanges(node: CommitFileNode | StashFileNode | FileRevisionAsCommitNode | ResultsFileNode) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof StashFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof ResultsFileNode) - ) { - return; - } + if (!node.isAny('commit-file', 'stash-file', 'file-commit', 'results-file')) return; await this.openFile(node, { preserveFocus: true, preview: true }); void (await this.container.fileAnnotations.toggle( @@ -529,18 +614,11 @@ export class ViewCommands { )); } - @debug() + @log() private async highlightRevisionChanges( node: CommitFileNode | StashFileNode | FileRevisionAsCommitNode | ResultsFileNode, ) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof StashFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof ResultsFileNode) - ) { - return; - } + if (!node.isAny('commit-file', 'stash-file', 'file-commit', 'results-file')) return; await this.openFile(node, { preserveFocus: true, preview: true }); void (await this.container.fileAnnotations.toggle( @@ -551,23 +629,30 @@ export class ViewCommands { )); } - @debug() + @log() private merge(node: BranchNode | TagNode) { - if (!(node instanceof BranchNode) && !(node instanceof TagNode)) return Promise.resolve(); + if (!node.isAny('branch', 'tag')) return Promise.resolve(); + + return RepoActions.merge(node.repoPath, node.is('branch') ? node.branch : node.tag); + } + + @log() + private openInTerminal(node: BranchTrackingStatusNode | RepositoryNode | RepositoryFolderNode) { + if (!node.isAny('tracking-status', 'repository', 'repo-folder')) return Promise.resolve(); - return RepoActions.merge(node.repoPath, node instanceof BranchNode ? node.branch : node.tag); + return executeCoreCommand('openInTerminal', Uri.file(node.repoPath)); } - @debug() - private openInTerminal(node: RepositoryNode | RepositoryFolderNode) { - if (!(node instanceof RepositoryNode) && !(node instanceof RepositoryFolderNode)) return Promise.resolve(); + @log() + private openInIntegratedTerminal(node: BranchTrackingStatusNode | RepositoryNode | RepositoryFolderNode) { + if (!node.isAny('tracking-status', 'repository', 'repo-folder')) return Promise.resolve(); - return executeCoreCommand('openInTerminal', Uri.file(node.repo.path)); + return executeCoreCommand('openInIntegratedTerminal', Uri.file(node.repoPath)); } - @debug() + @log() private openPullRequest(node: PullRequestNode) { - if (!(node instanceof PullRequestNode)) return Promise.resolve(); + if (!node.is('pullrequest')) return Promise.resolve(); return executeActionCommand('openPullRequest', { repoPath: node.uri.repoPath!, @@ -583,54 +668,116 @@ export class ViewCommands { }); } - @debug() - private openWorktree(node: WorktreeNode, options?: { location?: OpenWorkspaceLocation }) { - if (!(node instanceof WorktreeNode)) return; + @log() + private async openPullRequestChanges(node: PullRequestNode) { + if (!node.is('pullrequest')) return Promise.resolve(); + if (node.pullRequest.refs?.base == null || node.pullRequest.refs.head == null) return Promise.resolve(); + + const refs = await getComparisonRefsForPullRequest(this.container, node.repoPath, node.pullRequest.refs); + return CommitActions.openComparisonChanges( + this.container, + { + repoPath: refs.repoPath, + lhs: refs.base.ref, + rhs: refs.head.ref, + }, + { + title: `Changes in Pull Request #${node.pullRequest.id}`, + }, + ); + } + + @log() + private async openPullRequestComparison(node: PullRequestNode) { + if (!node.is('pullrequest')) return Promise.resolve(); + if (node.pullRequest.refs?.base == null || node.pullRequest.refs.head == null) return Promise.resolve(); + + const refs = await getComparisonRefsForPullRequest(this.container, node.repoPath, node.pullRequest.refs); + return this.container.searchAndCompareView.compare(refs.repoPath, refs.head, refs.base); + } - openWorkspace(node.worktree.uri, options); + @log() + private async openDraft(node: DraftNode) { + await showPatchesView({ mode: 'view', draft: node.draft }); } - @debug() + @log() + private async openDraftOnWeb(node: DraftNode) { + const url = this.container.drafts.generateWebUrl(node.draft); + await openUrl(url); + } + + @log() + private async openWorktree( + node: WorktreeNode, + nodes?: WorktreeNode[], + options?: { location?: OpenWorkspaceLocation }, + ) { + if (!node.is('worktree')) return; + + let uri; + if (nodes?.length && options?.location === 'newWindow') { + type VSCodeWorkspace = { + folders: ({ name: string; path: string } | { name: string; uri: Uri })[]; + settings: { [key: string]: unknown }; + }; + + // TODO@eamodio hash the folder paths to get a unique, but re-usable workspace name? + const codeWorkspace: VSCodeWorkspace = { + folders: nodes.map(n => ({ name: n.worktree.name, path: n.worktree.uri.fsPath })), + settings: {}, + }; + uri = Uri.file(getTempFile(`worktrees-${Date.now()}.code-workspace`)); + + await workspace.fs.writeFile(uri, new TextEncoder().encode(JSON.stringify(codeWorkspace, null, 2))); + } else { + uri = node.worktree.uri; + } + + openWorkspace(uri, options); + } + + @log() private pruneRemote(node: RemoteNode) { - if (!(node instanceof RemoteNode)) return Promise.resolve(); + if (!node.is('remote')) return Promise.resolve(); return RemoteActions.prune(node.remote.repoPath, node.remote.name); } - @debug() + @log() private async removeRemote(node: RemoteNode) { - if (!(node instanceof RemoteNode)) return Promise.resolve(); + if (!node.is('remote')) return Promise.resolve(); return RemoteActions.remove(node.remote.repoPath, node.remote.name); } - @debug() + @log() private publishBranch(node: BranchNode | BranchTrackingStatusNode) { - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('branch', 'tracking-status')) { return RepoActions.push(node.repoPath, undefined, node.branch); } return Promise.resolve(); } - @debug() + @log() private publishRepository(node: BranchNode | BranchTrackingStatusNode) { - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('branch', 'tracking-status')) { return executeCoreGitCommand('git.publish', Uri.file(node.repoPath)); } return Promise.resolve(); } - @debug() + @log() private pull(node: RepositoryNode | RepositoryFolderNode | BranchNode | BranchTrackingStatusNode) { - if (node instanceof RepositoryNode || node instanceof RepositoryFolderNode) return RepoActions.pull(node.repo); - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('repository', 'repo-folder')) return RepoActions.pull(node.repo); + if (node.isAny('branch', 'tracking-status')) { return RepoActions.pull(node.repoPath, node.root ? undefined : node.branch); } return Promise.resolve(); } - @debug() + @log() private push( node: | RepositoryNode @@ -641,15 +788,15 @@ export class ViewCommands { | FileRevisionAsCommitNode, force?: boolean, ) { - if (node instanceof RepositoryNode || node instanceof RepositoryFolderNode) { + if (node.isAny('repository', 'repo-folder')) { return RepoActions.push(node.repo, force); } - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('branch', 'tracking-status')) { return RepoActions.push(node.repoPath, force, node.root ? undefined : node.branch); } - if (node instanceof CommitNode || node instanceof FileRevisionAsCommitNode) { + if (node.isAny('commit', 'file-commit')) { if (node.isTip) { return RepoActions.push(node.repoPath, force); } @@ -660,32 +807,27 @@ export class ViewCommands { return Promise.resolve(); } - @debug() + @log() private pushToCommit(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return Promise.resolve(); + if (!node.isAny('commit', 'file-commit')) return Promise.resolve(); return RepoActions.push(node.repoPath, false, node.commit); } - @debug() + @log() private rebase(node: BranchNode | CommitNode | FileRevisionAsCommitNode | TagNode) { - if ( - !(node instanceof BranchNode) && - !(node instanceof CommitNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof TagNode) - ) { + if (!node.isAny('branch', 'commit', 'file-commit', 'tag')) { return Promise.resolve(); } return RepoActions.rebase(node.repoPath, node.ref); } - @debug() + @log() private rebaseToRemote(node: BranchNode | BranchTrackingStatusNode) { - if (!(node instanceof BranchNode) && !(node instanceof BranchTrackingStatusNode)) return Promise.resolve(); + if (!node.isAny('branch', 'tracking-status')) return Promise.resolve(); - const upstream = node instanceof BranchNode ? node.branch.upstream?.name : node.status.upstream; + const upstream = node.is('branch') ? node.branch.upstream?.name : node.status.upstream?.name; if (upstream == null) return Promise.resolve(); return RepoActions.rebase( @@ -698,16 +840,16 @@ export class ViewCommands { ); } - @debug() + @log() private renameBranch(node: BranchNode) { - if (!(node instanceof BranchNode)) return Promise.resolve(); + if (!node.is('branch')) return Promise.resolve(); return BranchActions.rename(node.repoPath, node.branch); } - @debug() + @log() private resetCommit(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return Promise.resolve(); + if (!node.isAny('commit', 'file-commit')) return Promise.resolve(); return RepoActions.reset( node.repoPath, @@ -719,16 +861,16 @@ export class ViewCommands { ); } - @debug() + @log() private resetToCommit(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return Promise.resolve(); + if (!node.isAny('commit', 'file-commit')) return Promise.resolve(); return RepoActions.reset(node.repoPath, node.ref); } - @debug() + @log() private resetToTip(node: BranchNode) { - if (!(node instanceof BranchNode)) return Promise.resolve(); + if (!node.is('branch')) return Promise.resolve(); return RepoActions.reset( node.repoPath, @@ -736,60 +878,56 @@ export class ViewCommands { ); } - @debug() + @log() private restore(node: ViewRefFileNode) { if (!(node instanceof ViewRefFileNode)) return Promise.resolve(); return CommitActions.restoreFile(node.file, node.ref); } - @debug() + @log() private revealRepositoryInExplorer(node: RepositoryNode) { - if (!(node instanceof RepositoryNode)) return undefined; + if (!node.is('repository')) return undefined; - return RepoActions.revealInFileExplorer(node.repo); + return revealInFileExplorer(node.repo.uri); } - @debug() + @log() private revealWorktreeInExplorer(node: WorktreeNode) { - if (!(node instanceof WorktreeNode)) return undefined; + if (!node.is('worktree')) return undefined; - return WorktreeActions.revealInFileExplorer(node.worktree); + return revealInFileExplorer(node.worktree.uri); } - @debug() + @log() private revert(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return Promise.resolve(); + if (!node.isAny('commit', 'file-commit')) return Promise.resolve(); return RepoActions.revert(node.repoPath, node.ref); } - @debug() + @log() private setAsDefault(node: RemoteNode) { - if (!(node instanceof RemoteNode)) return Promise.resolve(); + if (!node.is('remote')) return Promise.resolve(); return node.setAsDefault(); } - @debug() + @log() private setBranchComparison(node: ViewNode, comparisonType: Exclude) { - if (!(node instanceof CompareBranchNode)) return undefined; + if (!node.is('compare-branch')) return undefined; return node.setComparisonType(comparisonType); } - @debug() + @log() private setShowRelativeDateMarkers(enabled: boolean) { return configuration.updateEffective('views.showRelativeDateMarkers', enabled); } - @debug() + @log() private async stageFile(node: CommitFileNode | FileRevisionAsCommitNode | StatusFileNode) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof StatusFileNode) - ) { + if (!node.isAny('commit-file', 'file-commit') && !node.is('status-file')) { return; } @@ -797,140 +935,142 @@ export class ViewCommands { void node.triggerChange(); } - @debug() + @log() private async stageDirectory(node: FolderNode) { - if (!(node instanceof FolderNode) || !node.relativePath) return; + if (!node.is('folder') || !node.relativePath) return; await this.container.git.stageDirectory(node.repoPath, node.relativePath); void node.triggerChange(); } - @debug() + @log() private star(node: BranchNode | RepositoryNode | RepositoryFolderNode) { - if ( - !(node instanceof BranchNode) && - !(node instanceof RepositoryNode) && - !(node instanceof RepositoryFolderNode) - ) { + if (!node.isAny('branch', 'repository', 'repo-folder')) { return Promise.resolve(); } return node.star(); } - @debug() + @log() private switch(node?: ViewNode) { return RepoActions.switchTo(getNodeRepoPath(node)); } - @debug() + @log() private switchTo(node?: ViewNode) { if (node instanceof ViewRefNode) { - return RepoActions.switchTo( - node.repoPath, - node instanceof BranchNode && node.branch.current ? undefined : node.ref, - ); + return RepoActions.switchTo(node.repoPath, node.is('branch') && node.branch.current ? undefined : node.ref); } return RepoActions.switchTo(getNodeRepoPath(node)); } - @debug() + @log() private async undoCommit(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return; + if (!node.isAny('commit', 'file-commit')) return; - const repo = await this.container.git.getOrOpenScmRepository(node.repoPath); - const commit = await repo?.getCommit('HEAD'); - - if (commit?.hash !== node.ref.ref) { - void window.showWarningMessage( - `Commit ${getReferenceLabel(node.ref, { - capitalize: true, - icon: false, - })} cannot be undone, because it is no longer the most recent commit.`, - ); - - return; - } - - await executeCoreGitCommand('git.undoCommit', node.repoPath); + await CommitActions.undoCommit(this.container, node.ref); } - @debug() + @log() private unsetAsDefault(node: RemoteNode) { - if (!(node instanceof RemoteNode)) return Promise.resolve(); + if (!node.is('remote')) return Promise.resolve(); return node.setAsDefault(false); } - @debug() + @log() private async unstageFile(node: CommitFileNode | FileRevisionAsCommitNode | StatusFileNode) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof StatusFileNode) - ) { - return; - } + if (!node.isAny('commit-file', 'file-commit', 'status-file')) return; await this.container.git.unstageFile(node.repoPath, node.file.path); void node.triggerChange(); } - @debug() + @log() private async unstageDirectory(node: FolderNode) { - if (!(node instanceof FolderNode) || !node.relativePath) return; + if (!node.is('folder') || !node.relativePath) return; await this.container.git.unstageDirectory(node.repoPath, node.relativePath); void node.triggerChange(); } - @debug() + @log() private unstar(node: BranchNode | RepositoryNode | RepositoryFolderNode) { - if ( - !(node instanceof BranchNode) && - !(node instanceof RepositoryNode) && - !(node instanceof RepositoryFolderNode) - ) { - return Promise.resolve(); - } - + if (!node.isAny('branch', 'repository', 'repo-folder')) return Promise.resolve(); return node.unstar(); } - @debug() + @log() private compareHeadWith(node: ViewRefNode | ViewRefFileNode) { - if (!(node instanceof ViewRefNode) && !(node instanceof ViewRefFileNode)) return Promise.resolve(); - if (node instanceof ViewRefFileNode) { return this.compareFileWith(node.repoPath, node.uri, node.ref.ref, undefined, 'HEAD'); } + if (!(node instanceof ViewRefNode)) return Promise.resolve(); + return this.container.searchAndCompareView.compare(node.repoPath, 'HEAD', node.ref); } - @debug() + @log() + private async compareWithMergeBase(node: BranchNode) { + if (!node.is('branch')) return Promise.resolve(); + + const branch = await this.container.git.getBranch(node.repoPath); + if (branch == null) return undefined; + + const commonAncestor = await this.container.git.getMergeBase(node.repoPath, branch.ref, node.ref.ref); + if (commonAncestor == null) return undefined; + + return this.container.searchAndCompareView.compare(node.repoPath, node.ref.ref, { + ref: commonAncestor, + label: `${branch.ref} (${shortenRevision(commonAncestor)})`, + }); + } + + @log() + private async openChangedFileDiffsWithMergeBase(node: BranchNode) { + if (!node.is('branch')) return Promise.resolve(); + + const branch = await this.container.git.getBranch(node.repoPath); + if (branch == null) return undefined; + + const commonAncestor = await this.container.git.getMergeBase(node.repoPath, branch.ref, node.ref.ref); + if (commonAncestor == null) return undefined; + + return CommitActions.openComparisonChanges( + this.container, + { repoPath: node.repoPath, lhs: commonAncestor, rhs: node.ref.ref }, + { + title: `Changes between ${branch.ref} (${shortenRevision(commonAncestor)}) ${ + GlyphChars.ArrowLeftRightLong + } ${shortenRevision(node.ref.ref, { strings: { working: 'Working Tree' } })}`, + }, + ); + } + + @log() private compareWithUpstream(node: BranchNode) { - if (!(node instanceof BranchNode)) return Promise.resolve(); - if (node.branch.upstream == null) return Promise.resolve(); + if (!node.is('branch') || node.branch.upstream == null) return Promise.resolve(); return this.container.searchAndCompareView.compare(node.repoPath, node.ref, node.branch.upstream.name); } - @debug() + @log() private compareWorkingWith(node: ViewRefNode | ViewRefFileNode) { - if (!(node instanceof ViewRefNode) && !(node instanceof ViewRefFileNode)) return Promise.resolve(); - if (node instanceof ViewRefFileNode) { return this.compareFileWith(node.repoPath, node.uri, node.ref.ref, undefined, ''); } + if (!(node instanceof ViewRefNode)) return Promise.resolve(); + return this.container.searchAndCompareView.compare(node.repoPath, '', node.ref); } - @debug() + @log() private async compareAncestryWithWorking(node: BranchNode) { - if (!(node instanceof BranchNode)) return undefined; + if (!node.is('branch')) return undefined; const branch = await this.container.git.getBranch(node.repoPath); if (branch == null) return undefined; @@ -938,24 +1078,20 @@ export class ViewCommands { const commonAncestor = await this.container.git.getMergeBase(node.repoPath, branch.ref, node.ref.ref); if (commonAncestor == null) return undefined; - return this.container.searchAndCompareView.compare( - node.repoPath, - { - ref: commonAncestor, - label: `ancestry with ${node.ref.ref} (${shortenRevision(commonAncestor)})`, - }, - '', - ); + return this.container.searchAndCompareView.compare(node.repoPath, '', { + ref: commonAncestor, + label: `${branch.ref} (${shortenRevision(commonAncestor)})`, + }); } - @debug() + @log() private compareWithSelected(node: ViewRefNode | ViewRefFileNode) { if (!(node instanceof ViewRefNode) && !(node instanceof ViewRefFileNode)) return; this.container.searchAndCompareView.compareWithSelected(node.repoPath, node.ref); } - @debug() + @log() private selectForCompare(node: ViewRefNode | ViewRefFileNode) { if (!(node instanceof ViewRefNode) && !(node instanceof ViewRefFileNode)) return; @@ -986,7 +1122,7 @@ export class ViewCommands { }); } - @debug() + @log() private compareFileWithSelected(node: ViewRefFileNode) { if (this._selectedFile == null || !(node instanceof ViewRefFileNode) || node.ref == null) { return Promise.resolve(); @@ -1007,7 +1143,7 @@ export class ViewCommands { private _selectedFile: CompareSelectedInfo | undefined; - @debug() + @log() private selectFileForCompare(node: ViewRefFileNode) { if (!(node instanceof ViewRefFileNode) || node.ref == null) return; @@ -1019,41 +1155,55 @@ export class ViewCommands { void setContext('gitlens:views:canCompare:file', true); } - @debug() - private async openAllChanges(node: CommitNode | StashNode | ResultsFilesNode, options?: TextDocumentShowOptions) { - if (!(node instanceof CommitNode) && !(node instanceof StashNode) && !(node instanceof ResultsFilesNode)) { - return undefined; - } + @log() + private async openAllChanges( + node: + | BranchTrackingStatusFilesNode + | BranchTrackingStatusNode + | CompareResultsNode + | CommitNode + | ResultsFilesNode + | StashNode, + options?: TextDocumentShowOptions & { title?: string }, + individually?: boolean, + ) { + if (node.isAny('compare-results', 'results-files', 'tracking-status', 'tracking-status-files')) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; - if (node instanceof ResultsFilesNode) { - const { files: diff } = await node.getFilesQueryResults(); - if (diff == null || diff.length === 0) return undefined; + if (comparison.title != null) { + options = { ...options, title: comparison.title }; + } - return CommitActions.openAllChanges( - diff, - { - repoPath: node.repoPath, - ref1: node.ref1, - ref2: node.ref2, - }, + return (individually ? CommitActions.openAllChangesIndividually : CommitActions.openAllChanges)( + comparison.files, + { repoPath: comparison.repoPath, lhs: comparison.ref1, rhs: comparison.ref2 }, options, ); } - return CommitActions.openAllChanges(node.commit, options); + if (!node.isAny('commit', 'stash')) return undefined; + + return (individually ? CommitActions.openAllChangesIndividually : CommitActions.openAllChanges)( + node.commit, + options, + ); } - @debug() - private openChanges(node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode) { - if ( - !(node instanceof ViewRefFileNode) && - !(node instanceof MergeConflictFileNode) && - !(node instanceof StatusFileNode) - ) { - return; - } + @log() + private openCommitOnRemote(node: ViewRefNode, nodes?: ViewRefNode[], clipboard?: boolean) { + const refs = nodes?.length ? nodes.map(n => n.ref) : [node.ref]; - if (node instanceof MergeConflictFileNode) { + return executeCommand(Commands.OpenOnRemote, { + repoPath: refs[0].repoPath, + resource: refs.map(r => ({ type: RemoteResourceType.Commit, sha: r.ref })), + clipboard: clipboard, + }); + } + + @log() + private openChanges(node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode) { + if (node.is('conflict-file')) { void executeCommand(Commands.DiffWith, { lhs: { sha: node.status.HEAD.ref, @@ -1074,6 +1224,8 @@ export class ViewCommands { return; } + if (!(node instanceof ViewRefFileNode) && !node.is('status-file')) return; + const command = node.getCommand(); if (command?.arguments == null) return; @@ -1101,82 +1253,39 @@ export class ViewCommands { // }); } - @debug() + @log() private async openAllChangesWithWorking( - node: CommitNode | StashNode | ResultsFilesNode, - options?: TextDocumentShowOptions, + node: + | BranchTrackingStatusFilesNode + | BranchTrackingStatusNode + | CompareResultsNode + | CommitNode + | ResultsFilesNode + | StashNode, + options?: TextDocumentShowOptions & { title?: string }, + individually?: boolean, ) { - if (!(node instanceof CommitNode) && !(node instanceof StashNode) && !(node instanceof ResultsFilesNode)) { - return undefined; - } - - if (node instanceof ResultsFilesNode) { - const { files: diff } = await node.getFilesQueryResults(); - if (diff == null || diff.length === 0) return undefined; - - return CommitActions.openAllChangesWithWorking( - diff, - { - repoPath: node.repoPath, - ref: node.ref1 || node.ref2, - }, - options, - ); + if (node.isAny('compare-results', 'results-files', 'tracking-status', 'tracking-status-files')) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; + + return ( + individually + ? CommitActions.openAllChangesWithWorkingIndividually + : CommitActions.openAllChangesWithWorking + )(comparison.files, { repoPath: comparison.repoPath, ref: comparison.ref1 || comparison.ref2 }, options); } - return CommitActions.openAllChangesWithWorking(node.commit, options); + if (!node.isAny('commit', 'stash')) return undefined; - // options = { preserveFocus: false, preview: false, ...options }; - - // let repoPath: string; - // let files; - // let ref: string; - - // if (node instanceof ResultsFilesNode) { - // const { diff } = await node.getFilesQueryResults(); - // if (diff == null || diff.length === 0) return; - - // repoPath = node.repoPath; - // files = diff; - // ref = node.ref1 || node.ref2; - // } else { - // repoPath = node.commit.repoPath; - // files = node.commit.files; - // ref = node.commit.sha; - // } - - // if (files.length > 20) { - // const result = await window.showWarningMessage( - // `Are you sure you want to open all ${files.length} files?`, - // { title: 'Yes' }, - // { title: 'No', isCloseAffordance: true }, - // ); - // if (result == null || result.title === 'No') return; - // } - - // for (const file of files) { - // if (file.status === 'A' || file.status === 'D') continue; - - // const args: DiffWithWorkingCommandArgs = { - // showOptions: options, - // }; - - // const uri = GitUri.fromFile(file, repoPath, ref); - // await executeCommand(Commands.DiffWithWorking, uri, args); - // } + return ( + individually ? CommitActions.openAllChangesWithWorkingIndividually : CommitActions.openAllChangesWithWorking + )(node.commit, options); } - @debug() + @log() private async openChangesWithWorking(node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode) { - if ( - !(node instanceof ViewRefFileNode) && - !(node instanceof MergeConflictFileNode) && - !(node instanceof StatusFileNode) - ) { - return Promise.resolve(); - } - - if (node instanceof StatusFileNode) { + if (node.is('status-file')) { return executeEditorCommand(Commands.DiffWithWorking, undefined, { uri: node.uri, showOptions: { @@ -1184,7 +1293,9 @@ export class ViewCommands { preview: true, }, }); - } else if (node instanceof MergeConflictFileNode) { + } + + if (node.is('conflict-file')) { return executeEditorCommand(Commands.DiffWithWorking, undefined, { uri: node.baseUri, showOptions: { @@ -1192,7 +1303,9 @@ export class ViewCommands { preview: true, }, }); - } else if (node instanceof FileRevisionAsCommitNode && node.commit.file?.hasConflicts) { + } + + if (node.is('file-commit') && node.commit.file?.hasConflicts) { const baseUri = await node.getConflictBaseUri(); if (baseUri != null) { return executeEditorCommand(Commands.DiffWithWorking, undefined, { @@ -1205,33 +1318,32 @@ export class ViewCommands { } } + if (!(node instanceof ViewRefFileNode)) return Promise.resolve(); + return CommitActions.openChangesWithWorking(node.file, { repoPath: node.repoPath, - ref: node.ref.ref, + ref: node.is('results-file') ? node.ref2 : node.ref.ref, }); } - @debug() + @log() private async openPreviousChangesWithWorking(node: ViewRefFileNode) { if (!(node instanceof ViewRefFileNode)) return Promise.resolve(); return CommitActions.openChangesWithWorking(node.file, { repoPath: node.repoPath, - ref: `${node.ref.ref}^`, + ref: node.is('results-file') ? node.ref1 : `${node.ref.ref}^`, }); } - @debug() + @log() private openFile( node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode | FileHistoryNode | LineHistoryNode, options?: TextDocumentShowOptions, ) { if ( !(node instanceof ViewRefFileNode) && - !(node instanceof MergeConflictFileNode) && - !(node instanceof StatusFileNode) && - !(node instanceof FileHistoryNode) && - !(node instanceof LineHistoryNode) + !node.isAny('conflict-file', 'status-file', 'file-history', 'line-history') ) { return Promise.resolve(); } @@ -1243,23 +1355,45 @@ export class ViewCommands { }); } - @debug() - private async openFiles(node: CommitNode | StashNode | ResultsFilesNode) { - if (!(node instanceof CommitNode) && !(node instanceof StashNode) && !(node instanceof ResultsFilesNode)) { - return undefined; + @log() + private async openFiles( + node: BranchTrackingStatusFilesNode | CompareResultsNode | CommitNode | StashNode | ResultsFilesNode, + ) { + if (node.isAny('compare-results', 'results-files', 'tracking-status', 'tracking-status-files')) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; + + return CommitActions.openFiles(comparison.files, { + repoPath: comparison.repoPath, + ref: comparison.ref1 || comparison.ref2, + }); } + if (!node.isAny('commit', 'stash')) return undefined; - if (node instanceof ResultsFilesNode) { - const { files: diff } = await node.getFilesQueryResults(); - if (diff == null || diff.length === 0) return undefined; + return CommitActions.openFiles(node.commit); + } - return CommitActions.openFiles(diff, node.repoPath, node.ref1 || node.ref2); + @log() + private async openOnlyChangedFiles( + node: BranchTrackingStatusFilesNode | CompareResultsNode | CommitNode | StashNode | ResultsFilesNode, + ) { + if ( + node.is('compare-results') || + node.is('results-files') || + node.is('tracking-status') || + node.is('tracking-status-files') + ) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; + + return CommitActions.openOnlyChangedFiles(comparison.files); } + if (!node.isAny('commit', 'stash')) return undefined; - return CommitActions.openFiles(node.commit); + return CommitActions.openOnlyChangedFiles(node.commit); } - @debug() + @log() private async openRevision( node: | CommitFileNode @@ -1270,14 +1404,7 @@ export class ViewCommands { | StatusFileNode, options?: OpenFileAtRevisionCommandArgs, ) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof ResultsFileNode) && - !(node instanceof StashFileNode) && - !(node instanceof MergeConflictFileNode) && - !(node instanceof StatusFileNode) - ) { + if (!node.isAny('commit-file', 'file-commit', 'results-file', 'stash-file', 'conflict-file', 'status-file')) { return Promise.resolve(); } @@ -1285,7 +1412,7 @@ export class ViewCommands { let uri = options.revisionUri; if (uri == null) { - if (node instanceof ResultsFileNode || node instanceof MergeConflictFileNode) { + if (node.isAny('results-file', 'conflict-file')) { uri = this.container.git.getRevisionUri(node.uri); } else { uri = @@ -1302,19 +1429,121 @@ export class ViewCommands { return CommitActions.openFileAtRevision(uri, options.showOptions ?? { preserveFocus: true, preview: false }); } - @debug() - private async openRevisions(node: CommitNode | StashNode | ResultsFilesNode, _options?: TextDocumentShowOptions) { - if (!(node instanceof CommitNode) && !(node instanceof StashNode) && !(node instanceof ResultsFilesNode)) { - return undefined; + @log() + private async openRevisions( + node: BranchTrackingStatusFilesNode | CompareResultsNode | CommitNode | StashNode | ResultsFilesNode, + options?: TextDocumentShowOptions, + ) { + if (node.isAny('compare-results', 'results-files', 'tracking-status', 'tracking-status-files')) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; + + return CommitActions.openFilesAtRevision(comparison.files, { + repoPath: comparison.repoPath, + lhs: comparison.ref2, + rhs: comparison.ref1, + }); } + if (!node.isAny('commit', 'stash')) return undefined; + + return CommitActions.openFilesAtRevision(node.commit, options); + } + + @log() + private async setResultsCommitsFilter(node: ViewNode, filter: boolean) { + if (!node?.isAny('compare-results', 'compare-branch')) return; + + const repo = this.container.git.getRepository(node.repoPath); + if (repo == null) return; - if (node instanceof ResultsFilesNode) { - const { files: diff } = await node.getFilesQueryResults(); - if (diff == null || diff.length === 0) return undefined; + if (filter) { + let authors = node.getState('filterCommits'); + if (authors == null) { + const current = await this.container.git.getCurrentUser(repo.uri); + authors = current != null ? [current] : undefined; + } + + const result = await showContributorsPicker( + this.container, + repo, + 'Filter Commits', + repo.virtual ? 'Choose a contributor to show commits from' : 'Choose contributors to show commits from', + { + appendReposToTitle: true, + clearButton: true, + multiselect: !repo.virtual, + picked: c => authors?.some(u => matchContributor(c, u)) ?? false, + }, + ); + if (result == null) return; - return CommitActions.openFilesAtRevision(diff, node.repoPath, node.ref1, node.ref2); + if (result.length === 0) { + filter = false; + node.deleteState('filterCommits'); + } else { + node.storeState('filterCommits', result); + } + } else if (repo != null) { + node.deleteState('filterCommits'); + } else { + node.deleteState('filterCommits'); } - return CommitActions.openFilesAtRevision(node.commit); + void node.triggerChange(true); } } + +async function copyNode(type: ClipboardType, active: ViewNode | undefined, selection: ViewNode[]): Promise { + selection = Array.isArray(selection) ? selection : active != null ? [active] : []; + if (selection.length === 0) return; + + const data = ( + await mapAsync( + selection, + n => n.toClipboard?.(type), + s => Boolean(s?.trim()), + ) + ).join('\n'); + await env.clipboard.writeText(data); +} + +async function copyNodeUrl(active: ViewNode | undefined, selection: ViewNode[]): Promise { + const urls = await getNodeUrls(active, selection); + if (urls.length === 0) return; + + await env.clipboard.writeText(urls.join('\n')); +} + +async function openNodeUrl(active: ViewNode | undefined, selection: ViewNode[]): Promise { + const urls = await getNodeUrls(active, selection); + if (urls.length === 0) return; + + if (urls.length > 10) { + const confirm = { title: 'Open' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `Are you sure you want to open ${urls.length} URLs?`, + { modal: true }, + confirm, + cancel, + ); + if (result !== confirm) return; + } + + for (const url of urls) { + if (url == null) continue; + + void openUrl(url); + } +} + +function getNodeUrls(active: ViewNode | undefined, selection: ViewNode[]): Promise { + selection = Array.isArray(selection) ? selection : active != null ? [active] : []; + if (selection.length === 0) return Promise.resolve([]); + + return mapAsync( + selection, + n => n.getUrl?.(), + s => Boolean(s?.trim()), + ); +} diff --git a/src/views/viewDecorationProvider.ts b/src/views/viewDecorationProvider.ts index cfdd93232770d..86e77b92efb4d 100644 --- a/src/views/viewDecorationProvider.ts +++ b/src/views/viewDecorationProvider.ts @@ -19,19 +19,18 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo provideFileDecoration: (uri, token) => { if (uri.scheme !== 'gitlens-view') return undefined; - if (uri.authority === 'branch') { - return this.provideBranchCurrentDecoration(uri, token); + switch (uri.authority) { + case 'branch': + return this.provideBranchDecoration(uri, token); + case 'remote': + return this.provideRemoteDefaultDecoration(uri, token); + case 'status': + return this.provideStatusDecoration(uri, token); + case 'workspaces': + return this.provideWorkspaceDecoration(uri, token); + default: + return undefined; } - - if (uri.authority === 'remote') { - return this.provideRemoteDefaultDecoration(uri, token); - } - - if (uri.authority === 'workspaces') { - return this.provideWorkspaceDecoration(uri, token); - } - - return undefined; }, }), window.registerFileDecorationProvider(this), @@ -148,9 +147,10 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo } provideBranchStatusDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { - const [, , status] = uri.path.split('/'); + const query = new URLSearchParams(uri.query); + const status = query.get('status')! as GitBranchStatus; - switch (status as GitBranchStatus) { + switch (status) { case 'ahead': return { badge: '▲', @@ -192,13 +192,17 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo } } - provideBranchCurrentDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { - const [, , status, current] = uri.path.split('/'); + provideBranchDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const query = new URLSearchParams(uri.query); - if (!current) return undefined; + const current = Boolean(query.get('current')); + const opened = Boolean(query.get('opened')); + const status = query.get('status')! as GitBranchStatus; + + if (!current && !opened) return undefined; let color; - switch (status as GitBranchStatus) { + switch (status) { case 'ahead': color = new ThemeColor('gitlens.decorations.branchAheadForegroundColor' satisfies Colors); break; @@ -219,7 +223,7 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo return { badge: GlyphChars.Check, color: color, - tooltip: 'Current Branch', + tooltip: current ? 'Current Branch' : 'Opened Worktree Branch', }; } @@ -233,4 +237,28 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo tooltip: 'Default Remote', }; } + + provideStatusDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const [, status, conflicts] = uri.path.split('/'); + + switch (status) { + case 'rebasing': + case 'merging': + if (conflicts) { + return { + badge: '!', + color: new ThemeColor( + 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' satisfies Colors, + ), + }; + } + return { + color: new ThemeColor( + 'gitlens.decorations.statusMergingOrRebasingForegroundColor' satisfies Colors, + ), + }; + default: + return undefined; + } + } } diff --git a/src/views/workspacesView.ts b/src/views/workspacesView.ts index 634736a5ed710..9fa04d6e40612 100644 --- a/src/views/workspacesView.ts +++ b/src/views/workspacesView.ts @@ -1,29 +1,91 @@ -import type { Disposable } from 'vscode'; -import { env, ProgressLocation, Uri, window } from 'vscode'; -import type { RepositoriesViewConfig } from '../config'; -import { Commands } from '../constants'; +import type { CancellationToken, Disposable } from 'vscode'; +import { ProgressLocation, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; +import type { WorkspacesViewConfig } from '../config'; +import { Commands, previewBadge, urls } from '../constants'; import type { Container } from '../container'; import { unknownGitUri } from '../git/gitUri'; import type { Repository } from '../git/models/repository'; -import { ensurePlusFeaturesEnabled } from '../plus/subscription/utils'; +import { ensurePlusFeaturesEnabled } from '../plus/gk/utils'; import { executeCommand } from '../system/command'; -import { openWorkspace } from '../system/utils'; -import type { RepositoriesNode } from './nodes/repositoriesNode'; +import { gate } from '../system/decorators/gate'; +import { debug } from '../system/decorators/log'; +import { openUrl, openWorkspace } from '../system/utils'; +import { ViewNode } from './nodes/abstract/viewNode'; +import { MessageNode } from './nodes/common'; +import { RepositoriesNode } from './nodes/repositoriesNode'; import { RepositoryNode } from './nodes/repositoryNode'; import type { WorkspaceMissingRepositoryNode } from './nodes/workspaceMissingRepositoryNode'; import { WorkspaceNode } from './nodes/workspaceNode'; -import { WorkspacesViewNode } from './nodes/workspacesViewNode'; -import { ViewBase } from './viewBase'; +import { disposeChildren, ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; -export class WorkspacesView extends ViewBase<'workspaces', WorkspacesViewNode, RepositoriesViewConfig> { - protected readonly configKey = 'repositories'; +export class WorkspacesViewNode extends ViewNode<'workspaces-view', WorkspacesView> { + constructor(view: WorkspacesView) { + super('workspaces-view', unknownGitUri, view); + } + + private _children: (WorkspaceNode | MessageNode | RepositoriesNode)[] | undefined; + + async getChildren(): Promise { + if (this._children == null) { + const children: (WorkspaceNode | MessageNode | RepositoriesNode)[] = []; + + const { cloudWorkspaces, cloudWorkspaceInfo, localWorkspaces, localWorkspaceInfo } = + await this.view.container.workspaces.getWorkspaces(); + + if (cloudWorkspaces.length || localWorkspaces.length) { + children.push(new RepositoriesNode(this.view)); + + for (const workspace of cloudWorkspaces) { + children.push(new WorkspaceNode(this.uri, this.view, this, workspace)); + } + + if (cloudWorkspaceInfo != null) { + children.push(new MessageNode(this.view, this, cloudWorkspaceInfo)); + } + + for (const workspace of localWorkspaces) { + children.push(new WorkspaceNode(this.uri, this.view, this, workspace)); + } + + if (cloudWorkspaces.length === 0 && cloudWorkspaceInfo == null) { + children.push(new MessageNode(this.view, this, 'No cloud workspaces found.')); + } + + if (localWorkspaceInfo != null) { + children.push(new MessageNode(this.view, this, localWorkspaceInfo)); + } + } + + this._children = children; + } + + return this._children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Workspaces', TreeItemCollapsibleState.Expanded); + return item; + } + + @gate() + @debug() + override refresh() { + if (this._children == null) return; + + disposeChildren(this._children); + this._children = undefined; + } +} + +export class WorkspacesView extends ViewBase<'workspaces', WorkspacesViewNode, WorkspacesViewConfig> { + protected readonly configKey = 'workspaces'; private _disposable: Disposable | undefined; constructor(container: Container) { - super(container, 'workspaces', 'Workspaces', 'workspaceView'); + super(container, 'workspaces', 'Workspaces', 'workspacesView'); - this.description = `PREVIEW\u00a0\u00a0☁️`; + this.description = previewBadge; this.disposables.push(container.workspaces.onDidResetWorkspaces(() => void this.refresh(true))); } @@ -32,12 +94,8 @@ export class WorkspacesView extends ViewBase<'workspaces', WorkspacesViewNode, R super.dispose(); } - override get canSelectMany(): boolean { - return false; - } - protected getRoot() { - return new WorkspacesViewNode(unknownGitUri, this); + return new WorkspacesViewNode(this); } override async show(options?: { preserveFocus?: boolean | undefined }): Promise { @@ -45,20 +103,49 @@ export class WorkspacesView extends ViewBase<'workspaces', WorkspacesViewNode, R return super.show(options); } - override get canReveal(): boolean { - return false; + async findWorkspaceNode(workspaceId: string, token?: CancellationToken) { + return this.findNode((n: any) => n.workspace?.id === workspaceId, { + allowPaging: false, + maxDepth: 2, + canTraverse: n => { + if (n instanceof WorkspacesViewNode) return true; + + return false; + }, + token: token, + }); + } + + async revealWorkspaceNode( + workspaceId: string, + options?: { + select?: boolean; + focus?: boolean; + expand?: boolean | number; + }, + ) { + return window.withProgress( + { + location: ProgressLocation.Notification, + title: `Revealing workspace ${workspaceId} in the side bar...`, + cancellable: true, + }, + async (progress, token) => { + const node = await this.findWorkspaceNode(workspaceId, token); + if (node == null) return undefined; + + await this.ensureRevealNode(node, options); + + return node; + }, + ); } protected registerCommands(): Disposable[] { void this.container.viewCommands; return [ - registerViewCommand( - this.getQualifiedCommand('info'), - () => - env.openExternal(Uri.parse('https://help.gitkraken.com/gitlens/side-bar/#workspaces-☁%ef%b8%8f')), - this, - ), + registerViewCommand(this.getQualifiedCommand('info'), () => openUrl(urls.workspaces), this), registerViewCommand( this.getQualifiedCommand('copy'), () => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), diff --git a/src/views/worktreesView.ts b/src/views/worktreesView.ts index 91ec8283b4de4..8d2e76c4401b5 100644 --- a/src/views/worktreesView.ts +++ b/src/views/worktreesView.ts @@ -2,19 +2,20 @@ import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vs import { ProgressLocation, ThemeColor, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; import type { ViewFilesLayout, WorktreesViewConfig } from '../config'; import type { Colors } from '../constants'; -import { Commands, GlyphChars } from '../constants'; +import { Commands, GlyphChars, proBadge } from '../constants'; import type { Container } from '../container'; import { PlusFeatures } from '../features'; import { GitUri } from '../git/gitUri'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import type { GitWorktree } from '../git/models/worktree'; -import { ensurePlusFeaturesEnabled } from '../plus/subscription/utils'; +import { ensurePlusFeaturesEnabled } from '../plus/gk/utils'; import { executeCommand } from '../system/command'; import { configuration } from '../system/configuration'; import { gate } from '../system/decorators/gate'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { WorktreeNode } from './nodes/worktreeNode'; import { WorktreesNode } from './nodes/worktreesNode'; import { ViewBase } from './viewBase'; @@ -45,9 +46,16 @@ export class WorktreesViewNode extends RepositoriesSubscribeableNode { if (!(await ensurePlusFeaturesEnabled())) return; return super.show(options); @@ -155,7 +167,7 @@ export class WorktreesView extends ViewBase<'worktrees', WorktreesViewNode, Work registerViewCommand( this.getQualifiedCommand('refresh'), async () => { - // this.container.git.resetCaches('worktrees'); + this.container.git.resetCaches('worktrees'); return this.refresh(true); }, this, @@ -211,7 +223,9 @@ export class WorktreesView extends ViewBase<'worktrees', WorktreesViewNode, Work !configuration.changed(e, 'defaultDateSource') && !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && - !configuration.changed(e, 'defaultTimeFormat') + !configuration.changed(e, 'defaultTimeFormat') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') // !configuration.changed(e, 'sortWorktreesBy') ) { return false; diff --git a/src/vsls/host.ts b/src/vsls/host.ts index a024c41d0433a..e7116a80e5538 100644 --- a/src/vsls/host.ts +++ b/src/vsls/host.ts @@ -147,7 +147,7 @@ export class VslsHostService implements Disposable { _cancellation: CancellationToken, ): Promise { const fn = gitWhitelist.get(request.args[0]); - if (fn == null || !fn(request.args)) throw new Error(`Git ${request.args[0]} command is not allowed`); + if (!fn?.(request.args)) throw new Error(`Git ${request.args[0]} command is not allowed`); const { options, args } = request; const [cwd, isRootWorkspace] = this.convertGitCommandCwd(options.cwd); @@ -196,8 +196,8 @@ export class VslsHostService implements Disposable { return { data: data, count: count }; } - // eslint-disable-next-line @typescript-eslint/require-await @log() + // eslint-disable-next-line @typescript-eslint/require-await private async onGetRepositoriesForUriRequest( request: GetRepositoriesForUriRequest, _cancellation: CancellationToken, diff --git a/src/vsls/vsls.ts b/src/vsls/vsls.ts index c8136457a7ae3..21a25e86fe864 100644 --- a/src/vsls/vsls.ts +++ b/src/vsls/vsls.ts @@ -1,3 +1,4 @@ +import type { ConfigurationChangeEvent } from 'vscode'; import { Disposable, extensions, workspace } from 'vscode'; import type { LiveShare, LiveShareExtension, SessionChangeEvent } from '../@types/vsls'; import { Schemes } from '../constants'; @@ -5,7 +6,6 @@ import type { Container } from '../container'; import { configuration } from '../system/configuration'; import { setContext } from '../system/context'; import { debug } from '../system/decorators/log'; -import { timeout } from '../system/decorators/timeout'; import { once } from '../system/event'; import { Logger } from '../system/logger'; import type { Deferred } from '../system/promise'; @@ -43,7 +43,10 @@ export class VslsController implements Disposable { constructor(private readonly container: Container) { this._ready = defer(); - this._disposable = Disposable.from(once(container.onReady)(this.onReady, this)); + this._disposable = Disposable.from( + once(container.onReady)(this.onReady, this), + configuration.onDidChange(this.onConfigurationChanged, this), + ); } dispose() { @@ -59,6 +62,11 @@ export class VslsController implements Disposable { } private async initialize() { + if (!this.enabled) { + void setContext('gitlens:vsls', false); + return; + } + // If we have a vsls: workspace open, we might be a guest, so wait until live share transitions into a mode if (workspace.workspaceFolders?.some(f => f.uri.scheme === Schemes.Vsls)) { this.setReadonly(true); @@ -88,6 +96,12 @@ export class VslsController implements Disposable { } } + private onConfigurationChanged(e: ConfigurationChangeEvent) { + if (configuration.changed(e, 'liveshare.enabled')) { + void this.initialize(); + } + } + private async onLiveShareSessionChanged(api: LiveShare, e: SessionChangeEvent) { this._host?.dispose(); this._host = undefined; @@ -151,6 +165,10 @@ export class VslsController implements Disposable { void setContext('gitlens:readonly', value ? true : undefined); } + get enabled() { + return configuration.get('liveshare.enabled'); + } + async guest() { if (this._guest != null) return this._guest; @@ -196,12 +214,6 @@ export class VslsController implements Disposable { ); } - @debug() - @timeout(250) - maybeGetPresence(email: string | undefined): Promise { - return this.getContactPresence(email); - } - async invite(email: string | undefined) { if (email == null) return undefined; diff --git a/src/webviews/apps/.eslintrc.json b/src/webviews/apps/.eslintrc.json index b2ea94ebb0f79..db4dddde1ba60 100644 --- a/src/webviews/apps/.eslintrc.json +++ b/src/webviews/apps/.eslintrc.json @@ -3,15 +3,6 @@ "env": { "browser": true }, - "rules": { - "import/extensions": [ - "error", - "never", - { - "js": "always" - } - ] - }, "parserOptions": { "project": "src/webviews/apps/tsconfig.json" }, diff --git a/src/webviews/apps/commitDetails/commitDetails.html b/src/webviews/apps/commitDetails/commitDetails.html index 80e3966233598..1edeae36b64ba 100644 --- a/src/webviews/apps/commitDetails/commitDetails.html +++ b/src/webviews/apps/commitDetails/commitDetails.html @@ -6,12 +6,12 @@ @font-face { font-family: 'codicon'; font-display: block; - src: url('#{webroot}/codicon.ttf?2ab61cbaefbdf4c7c5589068100bee0c') format('truetype'); + src: url('#{webroot}/codicon.ttf?79130123c9d3674a686cf03962523e8a') format('truetype'); } @font-face { font-family: 'glicons'; font-display: block; - src: url('#{root}/dist/glicons.woff2?8e33f5a80a91b05940d687a08305c156') format('woff2'); + src: url('#{root}/dist/glicons.woff2?6ae679c0cde70af8c802b5257e2354a4') format('woff2'); } @@ -19,7 +19,7 @@ #{endOfBody} diff --git a/src/webviews/apps/commitDetails/commitDetails.scss b/src/webviews/apps/commitDetails/commitDetails.scss index 3240b960fc458..41ef21f90213f 100644 --- a/src/webviews/apps/commitDetails/commitDetails.scss +++ b/src/webviews/apps/commitDetails/commitDetails.scss @@ -1,335 +1,136 @@ -@use '../shared/styles/theme'; +@use '../shared/styles/details-base'; -:root { - --gitlens-gutter-width: 20px; - --gitlens-scrollbar-gutter-width: 10px; +.vscode-high-contrast, +.vscode-dark { + --gl-color-background-counter: #fff; } -// generic resets -html { - font-size: 62.5%; - // box-sizing: border-box; - font-family: var(--font-family); +.vscode-high-contrast-light, +.vscode-light { + --gl-color-background-counter: #000; } -*, -*:before, -*:after { - box-sizing: border-box; +.commit-detail-panel { + height: 100vh; + display: flex; + flex-direction: column; + gap: 1rem; + overflow: auto; } -body { - font-family: var(--font-family); - font-size: var(--font-size); - color: var(--color-foreground); - padding: 0; - - &.scrollable, - .scrollable { - border-color: transparent; - transition: border-color 1s linear; - - &:hover, - &:focus-within { - &.scrollable, - .scrollable { - border-color: var(--vscode-scrollbarSlider-background); - transition: none; - } - } - } - - &.preload { - &.scrollable, - .scrollable { - transition: none; - } - } +main { + flex: 1 1 auto; + overflow: hidden; + display: flex; + flex-direction: column; } -::-webkit-scrollbar-corner { - background-color: transparent !important; +[hidden] { + display: none !important; } -::-webkit-scrollbar-thumb { - background-color: transparent; - border-color: inherit; - border-right-style: inset; - border-right-width: calc(100vw + 100vh); - border-radius: unset !important; - - &:hover { - border-color: var(--vscode-scrollbarSlider-hoverBackground); - } - - &:active { - border-color: var(--vscode-scrollbarSlider-activeBackground); - } +gl-commit-details, +gl-wip-details { + display: contents; } -a { - text-decoration: none; - &:hover { - text-decoration: underline; - } +webview-pane-group { + height: 100%; + overflow: hidden; } -ul { - list-style: none; - margin: 0; - padding: 0; +.popover-content { + background-color: var(--color-background--level-15); + padding: 0.8rem 1.2rem; } -.bulleted { - list-style: disc; - padding-left: 1.2em; - > li + li { - margin-top: 0.25em; - } -} - -.button { - --button-foreground: var(--vscode-button-foreground); - --button-background: var(--vscode-button-background); - --button-hover-background: var(--vscode-button-hoverBackground); - - display: inline-block; - border: none; - padding: 0.4rem; - font-family: inherit; - font-size: inherit; - line-height: 1.4; - text-align: center; - text-decoration: none; - user-select: none; - background: var(--button-background); - color: var(--button-foreground); - cursor: pointer; - - &:hover { - background: var(--button-hover-background); - } - - &:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 0.2rem; - } - - &--full { - width: 100%; - } - - code-icon { - pointer-events: none; - } -} +.inspect-header { + display: flex; + flex-direction: row; + align-items: flex-start; // center; + justify-content: space-between; + gap: 0.4rem; + border-top: 2px solid var(--color-background--level-15); -.button--busy { - code-icon { - margin-right: 0.5rem; - } - - &[aria-busy='true'] { - opacity: 0.5; - } - - &:not([aria-busy='true']) { - code-icon { - display: none; + &__tabs { + flex: none; + display: flex; + flex-direction: row; + // gap: -0.8rem; + align-items: flex-start; + } + + &__tab { + position: relative; + appearance: none; + background-color: var(--color-background--level-10); + color: var(--color-foreground--85); + border: none; + border-radius: 0 0 0.5rem 0.5rem; + padding: 0.4rem 1.2rem; + cursor: pointer; + + &:last-child { + margin-inline-start: -0.6rem; } - } -} - -.button-container { - margin: 1rem auto 0; - text-align: left; - max-width: 30rem; - transition: max-width 0.2s ease-out; -} - -@media (min-width: 640px) { - .button-container { - max-width: 100%; - } -} - -.button-group { - display: inline-flex; - gap: 0.1rem; - width: 100%; - max-width: 30rem; -} - -.section { - padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width); - - > :first-child { - margin-top: 0; - } - > :last-child { - margin-bottom: 0; - } -} - -.section--message { - padding: { - top: 1rem; - bottom: 1.75rem; - } -} - -.section--empty { - > :last-child { - margin-top: 0.5rem; - } -} - -.section--skeleton { - padding: { - top: 1px; - bottom: 1px; - } -} -.commit-action { - display: inline-flex; - justify-content: center; - align-items: center; - height: 21px; - border-radius: 0.25em; - color: inherit; - padding: 0.2rem; - vertical-align: text-bottom; - text-decoration: none; - - > * { - pointer-events: none; - } - - &:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - &:hover { - color: var(--vscode-foreground); - text-decoration: none; - - .vscode-dark & { - background-color: var(--color-background--lighten-15); + &.is-active { + z-index: 1; + background-color: color-mix(in srgb, var(--gl-color-background-counter) 25%, var(--color-background)); + padding-block: 0.6rem; + box-shadow: 0 -2px 0 0 var(--vscode-button-hoverBackground); + color: var(--color-foreground); } - .vscode-light & { - background-color: var(--color-background--darken-15); - } - } - &.is-active { - .vscode-dark & { - background-color: var(--color-background--lighten-10); + &-tracking { + --gl-pill-foreground: currentColor; + --gl-pill-border: color-mix(in srgb, transparent 80%, var(--color-foreground)); + margin-inline: 0.2rem -0.4rem; } - .vscode-light & { - background-color: var(--color-background--darken-10); - } - } - - &.is-disabled { - opacity: 0.5; - pointer-events: none; - } - - &.is-hidden { - display: none; - } - - &--emphasis-low:not(:hover, :focus, :active) { - opacity: 0.5; - } -} - -.change-list { - margin-bottom: 1rem; -} - -.gl-actionbar { -} -.gl-actionbar__group { -} -.gl-action { -} -.message-block { - font-size: 1.3rem; - border: 1px solid var(--vscode-input-border); - background: var(--vscode-input-background); - padding: 0.5rem; + &-indicator { + --gl-indicator-size: 0.46rem; + position: absolute; + bottom: 0.825rem; + left: 2.1rem; + z-index: 1; - &__text { - margin: 0; - - overflow-y: auto; - overflow-x: hidden; - max-height: 9rem; - - > * { - white-space: break-spaces; - } - - strong { - font-weight: 600; - font-size: 1.4rem; + &--ahead { + --gl-indicator-color: var(--vscode-gitlens-decorations\.branchAheadForegroundColor); + } + &--behind { + --gl-indicator-color: var(--vscode-gitlens-decorations\.branchBehindForegroundColor); + } + &--both { + --gl-indicator-color: var(--vscode-gitlens-decorations\.branchDivergedForegroundColor); + } } - } -} - -.top-details { - position: sticky; - top: 0; - z-index: 1; - padding: { - top: 0.1rem; - left: var(--gitlens-gutter-width); - right: var(--gitlens-scrollbar-gutter-width); - bottom: 0.5rem; - } - background-color: var(--vscode-sideBar-background); - &__actionbar { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - - &-group { - display: flex; - flex: none; + &.is-active &-indicator { + bottom: 1.025rem; } - &.is-pinned { - background-color: var(--color-alert-warningBackground); - box-shadow: 0 0 0 0.1rem var(--color-alert-warningBorder); - border-radius: 0.3rem; - - .commit-action:hover, - .commit-action.is-active { - background-color: var(--color-alert-warningHoverBackground); - } + &-pulse { + position: absolute; + bottom: 0.2rem; + right: 0.4rem; + z-index: 2; } } - &__sha { - margin: 0 0.5rem 0 0.25rem; - } - - &__authors { - flex-basis: 100%; - padding-top: 0.5rem; + &__content { + flex: 1; + min-width: 0; + margin: { + top: 0.3rem; + right: 0.3rem; + } } +} - &__author { - & + & { - margin-top: 0.5rem; - } +.section--message { + > :first-child:not(:last-child) { + margin-bottom: 0.4rem; } } @@ -337,44 +138,27 @@ ul { margin-top: 0.5rem; } -.commit-detail-panel { - max-height: 100vh; - overflow: auto; - scrollbar-gutter: stable; - color: var(--vscode-sideBar-foreground); - background-color: var(--vscode-sideBar-background); - - [aria-hidden='true'] { - display: none; - } +:root { + --gk-avatar-size: 1.6rem; } -.ai-content { - font-size: 1.3rem; - border: 0.1rem solid var(--vscode-input-border, transparent); - background: var(--vscode-input-background); - margin-top: 1rem; - padding: 0.5rem; - - &.has-error { - border-left-color: var(--color-alert-errorBorder); - border-left-width: 0.3rem; - padding-left: 0.8rem; - } +hr { + border: none; + border-top: 1px solid var(--color-foreground--25); +} - &:empty { - display: none; - } +.md-code { + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + padding: 0px 4px 2px 4px; + font-family: var(--vscode-editor-font-family); +} - &__summary { - margin: 0; - overflow-y: auto; - overflow-x: hidden; - // max-height: 9rem; - white-space: break-spaces; +.inline-popover { + display: inline-block; +} - .has-error & { - white-space: normal; - } - } +.tooltip-hint { + white-space: nowrap; + border-bottom: 1px dashed currentColor; } diff --git a/src/webviews/apps/commitDetails/commitDetails.ts b/src/webviews/apps/commitDetails/commitDetails.ts index a5c5d0922ac79..2a021f7883e40 100644 --- a/src/webviews/apps/commitDetails/commitDetails.ts +++ b/src/webviews/apps/commitDetails/commitDetails.ts @@ -1,267 +1,26 @@ /*global*/ -import type { ViewFilesLayout } from '../../../config'; import type { Serialized } from '../../../system/serialize'; -import type { CommitActionsParams, State } from '../../commitDetails/protocol'; -import { - AutolinkSettingsCommandType, - CommitActionsCommandType, - DidChangeNotificationType, - DidExplainCommitCommandType, - ExplainCommitCommandType, - FileActionsCommandType, - NavigateCommitCommandType, - OpenFileCommandType, - OpenFileComparePreviousCommandType, - OpenFileCompareWorkingCommandType, - OpenFileOnRemoteCommandType, - PickCommitCommandType, - PinCommitCommandType, - PreferencesCommandType, - SearchCommitCommandType, -} from '../../commitDetails/protocol'; -import type { IpcMessage } from '../../protocol'; -import { ExecuteCommandType, onIpc } from '../../protocol'; +import type { State } from '../../commitDetails/protocol'; import { App } from '../shared/appBase'; -import type { FileChangeListItem, FileChangeListItemDetail } from '../shared/components/list/file-change-list-item'; -import type { WebviewPane, WebviewPaneExpandedChangeEventDetail } from '../shared/components/webview-pane'; import { DOM } from '../shared/dom'; import type { GlCommitDetailsApp } from './components/commit-details-app'; import './commitDetails.scss'; -import '../shared/components/actions/action-item'; -import '../shared/components/actions/action-nav'; -import '../shared/components/code-icon'; -import '../shared/components/commit/commit-identity'; -import '../shared/components/formatted-date'; -import '../shared/components/rich/issue-pull-request'; -import '../shared/components/skeleton-loader'; -import '../shared/components/commit/commit-stats'; -import '../shared/components/webview-pane'; -import '../shared/components/progress'; -import '../shared/components/list/list-container'; -import '../shared/components/list/list-item'; -import '../shared/components/list/file-change-list-item'; import './components/commit-details-app'; -export const uncommittedSha = '0000000000000000000000000000000000000000'; - -export type CommitState = SomeNonNullable, 'selected'>; +export type CommitState = SomeNonNullable, 'commit'>; export class CommitDetailsApp extends App> { constructor() { super('CommitDetailsApp'); } override onInitialize() { - this.attachState(); - } - - override onBind() { - const disposables = [ - DOM.on('file-change-list-item', 'file-open-on-remote', e => - this.onOpenFileOnRemote(e.detail), - ), - DOM.on('file-change-list-item', 'file-open', e => - this.onOpenFile(e.detail), - ), - DOM.on('file-change-list-item', 'file-compare-working', e => - this.onCompareFileWithWorking(e.detail), - ), - DOM.on('file-change-list-item', 'file-compare-previous', e => - this.onCompareFileWithPrevious(e.detail), - ), - DOM.on('file-change-list-item', 'file-more-actions', e => - this.onFileMoreActions(e.detail), - ), - DOM.on('[data-action="commit-actions"]', 'click', e => this.onCommitActions(e)), - DOM.on('[data-action="pick-commit"]', 'click', e => this.onPickCommit(e)), - DOM.on('[data-action="search-commit"]', 'click', e => this.onSearchCommit(e)), - DOM.on('[data-action="autolink-settings"]', 'click', e => this.onAutolinkSettings(e)), - DOM.on('[data-action="files-layout"]', 'click', e => this.onToggleFilesLayout(e)), - DOM.on('[data-action="pin"]', 'click', e => this.onTogglePin(e)), - DOM.on('[data-action="back"]', 'click', e => this.onNavigate('back', e)), - DOM.on('[data-action="forward"]', 'click', e => this.onNavigate('forward', e)), - DOM.on( - '[data-region="rich-pane"]', - 'expanded-change', - e => this.onExpandedChange(e.detail), - ), - DOM.on('[data-action="explain-commit"]', 'click', e => this.onExplainCommit(e)), - DOM.on('[data-action="switch-ai"]', 'click', e => this.onSwitchAiModel(e)), - ]; - - return disposables; - } - - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data as IpcMessage; - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - switch (msg.method) { - // case DidChangeRichStateNotificationType.method: - // onIpc(DidChangeRichStateNotificationType, msg, params => { - // if (this.state.selected == null) return; - - // assertsSerialized(params); - - // const newState = { ...this.state }; - // if (params.formattedMessage != null) { - // newState.selected!.message = params.formattedMessage; - // } - // // if (params.pullRequest != null) { - // newState.pullRequest = params.pullRequest; - // // } - // // if (params.formattedMessage != null) { - // newState.autolinkedIssues = params.autolinkedIssues; - // // } - - // this.state = newState; - // this.setState(this.state); - - // this.renderRichContent(); - // }); - // break; - case DidChangeNotificationType.method: - onIpc(DidChangeNotificationType, msg, params => { - assertsSerialized(params.state); - - this.state = params.state; - this.setState(this.state); - this.attachState(); - }); - break; - - default: - super.onMessageReceived?.(e); - } - } - - private onCommandClickedCore(action?: string) { - const command = action?.startsWith('command:') ? action.slice(8) : action; - if (command == null) return; - - this.sendCommand(ExecuteCommandType, { command: command }); - } - - private onSwitchAiModel(_e: MouseEvent) { - this.onCommandClickedCore('gitlens.switchAIModel'); - } - - async onExplainCommit(_e: MouseEvent) { - try { - const result = await this.sendCommandWithCompletion( - ExplainCommitCommandType, - undefined, - DidExplainCommitCommandType, - ); - - if (result.error) { - this.component.explain = { error: { message: result.error.message ?? 'Error retrieving content' } }; - } else if (result.summary) { - this.component.explain = { summary: result.summary }; - } else { - this.component.explain = undefined; - this.component.explainBusy = false; - } - } catch (ex) { - this.component.explain = { error: { message: 'Error retrieving content' } }; - } - } - - private onToggleFilesLayout(e: MouseEvent) { - const layout = ((e.target as HTMLElement)?.dataset.filesLayout as ViewFilesLayout) ?? undefined; - if (layout === this.state.preferences?.files?.layout) return; - - const files = { - ...this.state.preferences?.files, - layout: layout ?? 'auto', - compact: this.state.preferences?.files?.compact ?? true, - threshold: this.state.preferences?.files?.threshold ?? 5, - icon: this.state.preferences?.files?.icon ?? 'type', - }; - - this.state = { ...this.state, preferences: { ...this.state.preferences, files: files } }; - this.attachState(); - - this.sendCommand(PreferencesCommandType, { files: files }); - } - - private onExpandedChange(e: WebviewPaneExpandedChangeEventDetail) { - this.state.preferences = { - ...this.state.preferences, - autolinksExpanded: e.expanded, - }; - - this.sendCommand(PreferencesCommandType, { autolinksExpanded: e.expanded }); - } - - private onNavigate(direction: 'back' | 'forward', e: Event) { - e.preventDefault(); - this.sendCommand(NavigateCommitCommandType, { direction: direction }); - } - - private onTogglePin(e: MouseEvent) { - e.preventDefault(); - this.sendCommand(PinCommitCommandType, { pin: !this.state.pinned }); - } - - private onAutolinkSettings(e: MouseEvent) { - e.preventDefault(); - this.sendCommand(AutolinkSettingsCommandType, undefined); - } - - private onSearchCommit(_e: MouseEvent) { - this.sendCommand(SearchCommitCommandType, undefined); - } - - private onPickCommit(_e: MouseEvent) { - this.sendCommand(PickCommitCommandType, undefined); - } - - private onOpenFileOnRemote(e: FileChangeListItemDetail) { - this.sendCommand(OpenFileOnRemoteCommandType, e); - } - - private onOpenFile(e: FileChangeListItemDetail) { - this.sendCommand(OpenFileCommandType, e); - } - - private onCompareFileWithWorking(e: FileChangeListItemDetail) { - this.sendCommand(OpenFileCompareWorkingCommandType, e); - } - - private onCompareFileWithPrevious(e: FileChangeListItemDetail) { - this.sendCommand(OpenFileComparePreviousCommandType, e); - } - - private onFileMoreActions(e: FileChangeListItemDetail) { - this.sendCommand(FileActionsCommandType, e); - } - - private onCommitActions(e: MouseEvent) { - e.preventDefault(); - if (this.state.selected === undefined) { - e.stopPropagation(); - return; - } - - const action = (e.target as HTMLElement)?.getAttribute('data-action-type'); - if (action == null) return; - - this.sendCommand(CommitActionsCommandType, { action: action as CommitActionsParams['action'], alt: e.altKey }); - } - - private _component?: GlCommitDetailsApp; - private get component() { - if (this._component == null) { - this._component = (document.getElementById('app') as GlCommitDetailsApp)!; - } - return this._component; - } - - attachState() { - this.component.state = this.state; + const component = document.getElementById('app') as GlCommitDetailsApp; + component.state = this.state; + DOM.on>(component, 'state-changed', e => { + this.state = e.detail; + this.setState(this.state); + }); } } -function assertsSerialized(obj: unknown): asserts obj is Serialized {} - new CommitDetailsApp(); diff --git a/src/webviews/apps/commitDetails/components/button.css.ts b/src/webviews/apps/commitDetails/components/button.css.ts new file mode 100644 index 0000000000000..0c53ef57f6905 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/button.css.ts @@ -0,0 +1,37 @@ +import { css } from 'lit'; + +export const buttonStyles = css` + .button-container { + margin: 1rem auto 0; + text-align: left; + max-width: 30rem; + transition: max-width 0.2s ease-out; + } + + @media (min-width: 640px) { + .button-container { + max-width: 100%; + } + } + + .button-group { + display: inline-flex; + gap: 0.1rem; + } + .button-group--single { + width: 100%; + max-width: 30rem; + } + + .button-group > *:not(:first-child), + .button-group > *:not(:first-child) gl-button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .button-group > *:not(:last-child), + .button-group > *:not(:last-child) gl-button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +`; diff --git a/src/webviews/apps/commitDetails/components/commit-action.css.ts b/src/webviews/apps/commitDetails/components/commit-action.css.ts new file mode 100644 index 0000000000000..ec51e7c0c6615 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/commit-action.css.ts @@ -0,0 +1,67 @@ +import { css } from 'lit'; + +export const commitActionStyles = css` + .commit-action { + display: inline-flex; + justify-content: center; + align-items: center; + height: 21px; + border-radius: 0.25em; + color: inherit; + padding: 0.2rem; + vertical-align: text-bottom; + text-decoration: none; + gap: 0.2rem; + } + + .commit-action > * { + pointer-events: none; + } + + .commit-action:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .commit-action:hover { + color: var(--vscode-foreground); + text-decoration: none; + } + + :host-context(.vscode-dark) .commit-action:hover { + background-color: var(--color-background--lighten-15); + } + :host-context(.vscode-light) .commit-action:hover { + background-color: var(--color-background--darken-15); + } + + :host-context(.vscode-dark) .commit-action.is-active { + background-color: var(--color-background--lighten-10); + } + :host-context(.vscode-light) .commit-action.is-active { + background-color: var(--color-background--darken-10); + } + + .commit-action.is-disabled { + opacity: 0.5; + pointer-events: none; + } + + .commit-action.is-hidden { + display: none; + } + + .commit-action--emphasis-low:not(:hover, :focus, :active) { + opacity: 0.5; + } + + .pr--opened { + color: var(--vscode-gitlens-openPullRequestIconColor); + } + .pr--closed { + color: var(--vscode-gitlens-closedPullRequestIconColor); + } + .pr--merged { + color: var(--vscode-gitlens-mergedPullRequestIconColor); + } +`; diff --git a/src/webviews/apps/commitDetails/components/commit-details-app.ts b/src/webviews/apps/commitDetails/components/commit-details-app.ts index c32661b11adbe..f589f03c6110f 100644 --- a/src/webviews/apps/commitDetails/components/commit-details-app.ts +++ b/src/webviews/apps/commitDetails/components/commit-details-app.ts @@ -1,20 +1,70 @@ -import type { TemplateResult } from 'lit'; +import { Badge, defineGkElement } from '@gitkraken/shared-web-components'; import { html, LitElement, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { when } from 'lit/directives/when.js'; -import type { Autolink } from '../../../../annotations/autolinks'; -import type { IssueOrPullRequest } from '../../../../git/models/issue'; -import type { PullRequestShape } from '../../../../git/models/pullRequest'; -import type { HierarchicalItem } from '../../../../system/array'; -import { makeHierarchical } from '../../../../system/array'; +import type { ViewFilesLayout } from '../../../../config'; import type { Serialized } from '../../../../system/serialize'; -import type { State } from '../../../commitDetails/protocol'; -import { messageHeadlineSplitterToken } from '../../../commitDetails/protocol'; -import { uncommittedSha } from '../commitDetails'; - -type Files = NonNullable['files']>; -type File = Files[0]; +import { pluralize } from '../../../../system/string'; +import type { DraftState, ExecuteCommitActionsParams, Mode, State } from '../../../commitDetails/protocol'; +import { + ChangeReviewModeCommand, + CreatePatchFromWipCommand, + DidChangeConnectedJiraNotification, + DidChangeDraftStateNotification, + DidChangeHasAccountNotification, + DidChangeNotification, + DidChangeWipStateNotification, + ExecuteCommitActionCommand, + ExecuteFileActionCommand, + ExplainRequest, + FetchCommand, + GenerateRequest, + NavigateCommand, + OpenFileCommand, + OpenFileComparePreviousCommand, + OpenFileCompareWorkingCommand, + OpenFileOnRemoteCommand, + OpenPullRequestChangesCommand, + OpenPullRequestComparisonCommand, + OpenPullRequestDetailsCommand, + OpenPullRequestOnRemoteCommand, + PickCommitCommand, + PinCommand, + PublishCommand, + PullCommand, + PushCommand, + SearchCommitCommand, + ShowCodeSuggestionCommand, + StageFileCommand, + SuggestChangesCommand, + SwitchCommand, + SwitchModeCommand, + UnstageFileCommand, + UpdatePreferencesCommand, +} from '../../../commitDetails/protocol'; +import type { IpcMessage } from '../../../protocol'; +import { ExecuteCommand } from '../../../protocol'; +import type { CreatePatchMetadataEventDetail } from '../../plus/patchDetails/components/gl-patch-create'; +import type { IssuePullRequest } from '../../shared/components/rich/issue-pull-request'; +import type { WebviewPane, WebviewPaneExpandedChangeEventDetail } from '../../shared/components/webview-pane'; +import type { Disposable } from '../../shared/dom'; +import { DOM } from '../../shared/dom'; +import { assertsSerialized, HostIpc } from '../../shared/ipc'; +import type { GlCommitDetails } from './gl-commit-details'; +import type { FileChangeListItemDetail } from './gl-details-base'; +import type { GlInspectNav } from './gl-inspect-nav'; +import type { CreatePatchEventDetail, GenerateState } from './gl-inspect-patch'; +import type { GlWipDetails } from './gl-wip-details'; +import '../../shared/components/code-icon'; +import '../../shared/components/indicators/indicator'; +import '../../shared/components/overlays/tooltip'; +import '../../shared/components/pills/tracking'; +import './gl-commit-details'; +import './gl-wip-details'; +import './gl-inspect-nav'; +import './gl-status-nav'; + +export const uncommittedSha = '0000000000000000000000000000000000000000'; interface ExplainState { cancelled?: boolean; @@ -27,22 +77,61 @@ export class GlCommitDetailsApp extends LitElement { @property({ type: Object }) state?: Serialized; - @state() - explainBusy = false; - @property({ type: Object }) explain?: ExplainState; + @property({ type: Object }) + generate?: GenerateState; + + @state() + draftState: DraftState = { inReview: false }; + + @state() get isUncommitted() { - return this.state?.selected?.sha === uncommittedSha; + return this.state?.commit?.sha === uncommittedSha; } + get hasCommit() { + return this.state?.commit != null; + } + + @state() get isStash() { - return this.state?.selected?.stashNumber != null; + return this.state?.commit?.stashNumber != null; } - get shortSha() { - return this.state?.selected?.shortSha ?? ''; + get wipStatus() { + const wip = this.state?.wip; + if (wip == null) return undefined; + + const branch = wip.branch; + if (branch == null) return undefined; + + const changes = wip.changes; + const working = changes?.files.length ?? 0; + const ahead = branch.tracking?.ahead ?? 0; + const behind = branch.tracking?.behind ?? 0; + const status = + behind > 0 && ahead > 0 + ? 'both' + : behind > 0 + ? 'behind' + : ahead > 0 + ? 'ahead' + : working > 0 + ? 'working' + : undefined; + + const branchName = wip.repositoryCount > 1 ? `${wip.repo.name}:${branch.name}` : branch.name; + + return { + branch: branchName, + upstream: branch.upstream?.name, + ahead: ahead, + behind: behind, + working: wip.changes?.files.length ?? 0, + status: status, + }; } get navigation() { @@ -72,676 +161,575 @@ export class GlCommitDetailsApp extends LitElement { return actions; } - override updated(changedProperties: Map) { - if (changedProperties.has('explain')) { - this.explainBusy = false; - this.querySelector('[data-region="commit-explanation"]')?.scrollIntoView(); - } + private _disposables: Disposable[] = []; + private _hostIpc!: HostIpc; + + constructor() { + super(); + + defineGkElement(Badge); } - private renderEmptyContent() { - return html` -

-

Rich details for commits and stashes are shown as you navigate:

- - - -

Alternatively, search for or choose a commit

- -

- - - - -

-
- `; + private indentPreference = 16; + private updateDocumentProperties() { + const preference = this.state?.preferences?.indent; + if (preference === this.indentPreference) return; + this.indentPreference = preference ?? 16; + + const rootStyle = document.documentElement.style; + rootStyle.setProperty('--gitlens-tree-indent', `${this.indentPreference}px`); } - private renderCommitMessage() { - if (this.state?.selected == null) { - return undefined; + override updated(changedProperties: Map) { + if (changedProperties.has('state')) { + this.updateDocumentProperties(); + if (this.state?.inReview != null && this.state.inReview != this.draftState.inReview) { + this.draftState.inReview = this.state.inReview; + } } + } - const message = this.state.selected.message; - const index = message.indexOf(messageHeadlineSplitterToken); - return html` -
-
- ${when( - index === -1, - () => - html`

- ${unsafeHTML(message)} -

`, - () => - html`

- ${unsafeHTML(message.substring(0, index))}
${unsafeHTML(message.substring(index + 3))} -

`, - )} -
-
- `; + override connectedCallback() { + super.connectedCallback(); + + this._hostIpc = new HostIpc('commit-details'); + + this._disposables = [ + this._hostIpc.onReceiveMessage(e => this.onMessageReceived(e)), + this._hostIpc, + + DOM.on('gl-inspect-nav', 'gl-commit-actions', e => + this.onCommitActions(e), + ), + DOM.on('gl-status-nav', 'gl-branch-action', e => + this.onBranchAction(e.detail.action), + ), + DOM.on('[data-action="pick-commit"]', 'click', e => this.onPickCommit(e)), + DOM.on('[data-action="wip"]', 'click', e => this.onSwitchMode(e, 'wip')), + DOM.on('[data-action="details"]', 'click', e => this.onSwitchMode(e, 'commit')), + DOM.on('[data-action="search-commit"]', 'click', e => this.onSearchCommit(e)), + DOM.on('[data-action="files-layout"]', 'click', e => this.onToggleFilesLayout(e)), + DOM.on('gl-inspect-nav', 'gl-pin', () => this.onTogglePin()), + DOM.on('gl-inspect-nav', 'gl-back', () => this.onNavigate('back')), + DOM.on('gl-inspect-nav', 'gl-forward', () => this.onNavigate('forward')), + DOM.on('[data-action="create-patch"]', 'click', _e => this.onCreatePatchFromWip(true)), + DOM.on( + '[data-region="rich-pane"]', + 'expanded-change', + e => this.onExpandedChange(e.detail, 'autolinks'), + ), + DOM.on( + '[data-region="pullrequest-pane"]', + 'expanded-change', + e => this.onExpandedChange(e.detail, 'pullrequest'), + ), + DOM.on('[data-action="explain-commit"]', 'click', e => this.onExplainCommit(e)), + DOM.on('[data-action="switch-ai"]', 'click', e => this.onSwitchAiModel(e)), + DOM.on('gl-wip-details', 'create-patch', e => + this.onCreatePatchFromWip(e.detail.checked), + ), + + DOM.on('gl-commit-details', 'file-open-on-remote', e => + this.onOpenFileOnRemote(e.detail), + ), + DOM.on('gl-commit-details,gl-wip-details', 'file-open', e => + this.onOpenFile(e.detail), + ), + DOM.on('gl-commit-details', 'file-compare-working', e => + this.onCompareFileWithWorking(e.detail), + ), + DOM.on( + 'gl-commit-details,gl-wip-details', + 'file-compare-previous', + e => this.onCompareFileWithPrevious(e.detail), + ), + DOM.on('gl-commit-details', 'file-more-actions', e => + this.onFileMoreActions(e.detail), + ), + DOM.on('gl-wip-details', 'file-stage', e => + this.onStageFile(e.detail), + ), + DOM.on('gl-wip-details', 'file-unstage', e => + this.onUnstageFile(e.detail), + ), + DOM.on('gl-wip-details', 'data-action', e => + this.onBranchAction(e.detail.name), + ), + DOM.on('gl-wip-details', 'gl-inspect-create-suggestions', e => + this.onSuggestChanges(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-generate-title', e => + this.onCreateGenerateTitle(e.detail), + ), + DOM.on('gl-wip-details', 'gl-show-code-suggestion', e => + this.onShowCodeSuggestion(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-file-compare-previous', e => + this.onCompareFileWithPrevious(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-file-open', e => + this.onOpenFile(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-file-stage', e => + this.onStageFile(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-file-unstage', e => + this.onUnstageFile(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-create-cancelled', () => + this.onDraftStateChanged(false), + ), + DOM.on( + 'gl-status-nav,issue-pull-request', + 'gl-issue-pull-request-details', + () => this.onBranchAction('open-pr-details'), + ), + ]; } - private renderAutoLinks() { - if (this.isUncommitted) { - return undefined; - } + private onSuggestChanges(e: CreatePatchEventDetail) { + this._hostIpc.sendCommand(SuggestChangesCommand, e); + } - const deduped = new Map< - string, - | { type: 'autolink'; value: Serialized } - | { type: 'issue'; value: Serialized } - | { type: 'pr'; value: Serialized } - >(); + private onShowCodeSuggestion(e: { id: string }) { + this._hostIpc.sendCommand(ShowCodeSuggestionCommand, e); + } - if (this.state?.selected?.autolinks != null) { - for (const autolink of this.state.selected.autolinks) { - deduped.set(autolink.id, { type: 'autolink', value: autolink }); - } + private onMessageReceived(msg: IpcMessage) { + switch (true) { + // case DidChangeRichStateNotificationType.method: + // onIpc(DidChangeRichStateNotificationType, msg, params => { + // if (this.state.selected == null) return; + + // assertsSerialized(params); + + // const newState = { ...this.state }; + // if (params.formattedMessage != null) { + // newState.selected!.message = params.formattedMessage; + // } + // // if (params.pullRequest != null) { + // newState.pullRequest = params.pullRequest; + // // } + // // if (params.formattedMessage != null) { + // newState.autolinkedIssues = params.autolinkedIssues; + // // } + + // this.state = newState; + // this.setState(this.state); + + // this.renderRichContent(); + // }); + // break; + case DidChangeNotification.is(msg): + assertsSerialized(msg.params.state); + + this.state = msg.params.state; + this.dispatchEvent(new CustomEvent('state-changed', { detail: this.state })); + // this.setState(this.state); + // this.attachState(); + break; + + case DidChangeWipStateNotification.is(msg): + this.state = { ...this.state!, wip: msg.params.wip, inReview: msg.params.inReview }; + this.dispatchEvent(new CustomEvent('state-changed', { detail: this.state })); + // this.setState(this.state); + // this.attachState(); + break; + case DidChangeDraftStateNotification.is(msg): + this.onDraftStateChanged(msg.params.inReview, true); + break; + case DidChangeConnectedJiraNotification.is(msg): + this.state = { ...this.state!, hasConnectedJira: msg.params.hasConnectedJira }; + this.dispatchEvent(new CustomEvent('state-changed', { detail: this.state })); + break; + case DidChangeHasAccountNotification.is(msg): + this.state = { ...this.state!, hasAccount: msg.params.hasAccount }; + this.dispatchEvent(new CustomEvent('state-changed', { detail: this.state })); + break; } + } - if (this.state?.autolinkedIssues != null) { - for (const issue of this.state.autolinkedIssues) { - deduped.set(issue.id, { type: 'issue', value: issue }); - } - } + override disconnectedCallback() { + this._disposables.forEach(d => d.dispose()); + this._disposables = []; - if (this.state?.pullRequest != null) { - deduped.set(this.state.pullRequest.id, { type: 'pr', value: this.state.pullRequest }); - } + super.disconnectedCallback(); + } - const autolinks: Serialized[] = []; - const issues: Serialized[] = []; - const prs: Serialized[] = []; - - for (const item of deduped.values()) { - switch (item.type) { - case 'autolink': - autolinks.push(item.value); - break; - case 'issue': - issues.push(item.value); - break; - case 'pr': - prs.push(item.value); - break; - } - } + renderTopInspect() { + if (this.state?.commit == null) return nothing; + return html``; + } + + renderTopWip() { + if (this.state?.wip == null) return nothing; + + return html``; + } + + private renderRepoStatusContent(_isWip: boolean) { + const statusIndicator = this.wipStatus?.status; return html` - - Autolinks - ${this.state?.includeRichContent || deduped.size ? `${deduped.size} found ` : ''}${this.state - ?.includeRichContent - ? '' - : '…'} - ${when( - this.state == null, - () => html` -
- -
- -
-
- -
-
- `, - () => { - if (deduped.size === 0) { - return html` -
-

-  Use - autolinks - to linkify external references, like Jira issues or Zendesk tickets, in commit - messages. -

-
- `; - } - return html` -
- ${autolinks.length - ? html` - - ` - : undefined} - ${prs.length - ? html` -
- ${prs.map( - pr => html` - -
- `, - )} - - ` - : undefined} - ${issues.length - ? html` -
- ${issues.map( - issue => html` - - `, - )} -
- ` - : undefined} -
- `; - }, - )} -
+ + ${when( + this.wipStatus?.status != null, + () => + html``, + )} + ${when( + statusIndicator != null, + () => + html``, + )} `; + // ${when( + // isWip !== true && statusIndicator != null, + // () => html``, + // )} } - private renderExplainAi() { - // TODO: add loading and response states + renderWipTooltipContent() { + if (this.wipStatus == null) return 'Overview'; + return html` - - Explain (AI) - - - - - -
-

Let AI assist in understanding the changes made with this commit.

-

- - - -

+ Overview of  ${this.wipStatus.branch} + ${when( + this.wipStatus.status === 'both', + () => + html`
+ ${this.wipStatus!.branch} is + ${pluralize('commit', this.wipStatus!.behind)} behind and + ${pluralize('commit', this.wipStatus!.ahead)} ahead of + ${this.wipStatus!.upstream ?? 'origin'}`, + )} + ${when( + this.wipStatus.status === 'behind', + () => + html`
+ ${this.wipStatus!.branch} is + ${pluralize('commit', this.wipStatus!.behind)} behind + ${this.wipStatus!.upstream ?? 'origin'}`, + )} + ${when( + this.wipStatus.status === 'ahead', + () => + html`
+ ${this.wipStatus!.branch} is + ${pluralize('commit', this.wipStatus!.ahead)} ahead of + ${this.wipStatus!.upstream ?? 'origin'}`, + )} + ${when( + this.wipStatus.working > 0, + () => + html`
+ ${pluralize('working change', this.wipStatus!.working)}`, + )} + `; + } + + renderTopSection() { + const isWip = this.state?.mode === 'wip'; + + return html` +
+ +
${when( - this.explain, - () => html` -
- ${when( - this.explain?.error, - () => - html`

- ${this.explain!.error!.message ?? 'Error retrieving content'} -

`, - )} - ${when( - this.explain?.summary, - () => html`

${this.explain!.summary}

`, - )} -
- `, + this.state?.mode !== 'wip', + () => this.renderTopInspect(), + () => this.renderTopWip(), )}
- +
`; } - private renderCommitStats() { - if (this.state?.selected?.stats?.changedFiles == null) { - return undefined; - } + override render() { + const wip = this.state?.wip; - if (typeof this.state.selected.stats.changedFiles === 'number') { - return html``; - } + return html` +
+ ${this.renderTopSection()} +
+ ${when( + this.state?.mode === 'commit', + () => + html``, + () => + html`) => + this.onDraftStateChanged(e.detail.inReview)} + >`, + )} +
+
+ `; + } - const { added, deleted, changed } = this.state.selected.stats.changedFiles; - return html``; + protected override createRenderRoot() { + return this; } - private renderFileList() { - const files = this.state!.selected!.files!; + private onDraftStateChanged(inReview: boolean, silent = false) { + if (inReview === this.draftState.inReview) return; + this.draftState = { ...this.draftState, inReview: inReview }; + this.requestUpdate('draftState'); - let items; - let classes; + if (!silent) { + this._hostIpc.sendCommand(ChangeReviewModeCommand, { inReview: inReview }); + } + } - if (this.isUncommitted) { - items = []; - classes = `indentGuides-${this.state!.indentGuides}`; + private onBranchAction(name: string) { + switch (name) { + case 'pull': + this._hostIpc.sendCommand(PullCommand, undefined); + break; + case 'push': + this._hostIpc.sendCommand(PushCommand, undefined); + // this.onCommandClickedCore('gitlens.pushRepositories'); + break; + case 'fetch': + this._hostIpc.sendCommand(FetchCommand, undefined); + // this.onCommandClickedCore('gitlens.fetchRepositories'); + break; + case 'publish-branch': + this._hostIpc.sendCommand(PublishCommand, undefined); + // this.onCommandClickedCore('gitlens.publishRepository'); + break; + case 'switch': + this._hostIpc.sendCommand(SwitchCommand, undefined); + // this.onCommandClickedCore('gitlens.views.switchToBranch'); + break; + case 'open-pr-changes': + this._hostIpc.sendCommand(OpenPullRequestChangesCommand, undefined); + break; + case 'open-pr-compare': + this._hostIpc.sendCommand(OpenPullRequestComparisonCommand, undefined); + break; + case 'open-pr-remote': + this._hostIpc.sendCommand(OpenPullRequestOnRemoteCommand, undefined); + break; + case 'open-pr-details': + this._hostIpc.sendCommand(OpenPullRequestDetailsCommand, undefined); + break; + } + } - const staged = files.filter(f => f.staged); - if (staged.length) { - items.push(html`Staged Changes`); + private onCreatePatchFromWip(checked: boolean | 'staged' = true) { + if (this.state?.wip?.changes == null) return; + this._hostIpc.sendCommand(CreatePatchFromWipCommand, { changes: this.state.wip.changes, checked: checked }); + } - for (const f of staged) { - items.push(this.renderFile(f, 2, true)); - } - } + private onCommandClickedCore(action?: string) { + const command = action?.startsWith('command:') ? action.slice(8) : action; + if (command == null) return; - const unstaged = files.filter(f => !f.staged); - if (unstaged.length) { - items.push(html`Unstaged Changes`); + this._hostIpc.sendCommand(ExecuteCommand, { command: command }); + } + + private onSwitchAiModel(_e: MouseEvent) { + this.onCommandClickedCore('gitlens.switchAIModel'); + } - for (const f of unstaged) { - items.push(this.renderFile(f, 2, true)); - } + async onExplainCommit(_e: MouseEvent) { + try { + const result = await this._hostIpc.sendRequest(ExplainRequest, undefined); + if (result.error) { + this.explain = { error: { message: result.error.message ?? 'Error retrieving content' } }; + } else if (result.summary) { + this.explain = { summary: result.summary }; + } else { + this.explain = undefined; } - } else { - items = files.map(f => this.renderFile(f)); + } catch (ex) { + this.explain = { error: { message: 'Error retrieving content' } }; } + } - return html`${items}`; + private async onCreateGenerateTitle(_e: CreatePatchMetadataEventDetail) { + try { + const result = await this._hostIpc.sendRequest(GenerateRequest, undefined); + + if (result.error) { + this.generate = { error: { message: result.error.message ?? 'Error retrieving content' } }; + } else if (result.title || result.description) { + this.generate = { + title: result.title, + description: result.description, + }; + // this.state = { + // ...this.state, + // create: { + // ...this.state.create!, + // title: result.title ?? this.state.create?.title, + // description: result.description ?? this.state.create?.description, + // }, + // }; + // this.setState(this.state); + } else { + this.generate = undefined; + } + } catch (ex) { + this.generate = { error: { message: 'Error retrieving content' } }; + } + this.requestUpdate('generate'); } - private renderFileTree() { - const files = this.state!.selected!.files!; - const compact = this.state!.preferences?.files?.compact ?? true; + private onToggleFilesLayout(e: MouseEvent) { + const layout = ((e.target as HTMLElement)?.dataset.filesLayout as ViewFilesLayout) ?? undefined; + if (layout === this.state?.preferences?.files?.layout) return; - let items; + const files = { + ...this.state!.preferences?.files, + layout: layout ?? 'auto', + }; - if (this.isUncommitted) { - items = []; + this.state = { ...this.state, preferences: { ...this.state!.preferences, files: files } } as any; + // this.attachState(); - const staged = files.filter(f => f.staged); - if (staged.length) { - items.push(html`Staged Changes`); - items.push(...this.renderFileSubtree(staged, 1, compact)); - } + this._hostIpc.sendCommand(UpdatePreferencesCommand, { files: files }); + } - const unstaged = files.filter(f => !f.staged); - if (unstaged.length) { - items.push(html`Unstaged Changes`); - items.push(...this.renderFileSubtree(unstaged, 1, compact)); - } - } else { - items = this.renderFileSubtree(files, 0, compact); + private onExpandedChange(e: WebviewPaneExpandedChangeEventDetail, pane: string) { + let preferenceChange; + if (pane === 'autolinks') { + preferenceChange = { autolinksExpanded: e.expanded }; + } else if (pane === 'pullrequest') { + preferenceChange = { pullRequestExpanded: e.expanded }; } + if (preferenceChange == null) return; - return html`${items}`; - } - - private renderFileSubtree(files: Files, rootLevel: number, compact: boolean) { - const tree = makeHierarchical( - files, - n => n.path.split('/'), - (...parts: string[]) => parts.join('/'), - compact, - ); - const flatTree = flattenHeirarchy(tree); - return flatTree.map(({ level, item }) => { - if (item.name === '') return undefined; - - if (item.value == null) { - return html` - - - ${item.name} - - `; - } + this.state = { + ...this.state, + preferences: { ...this.state!.preferences, ...preferenceChange }, + } as any; + // this.attachState(); - return this.renderFile(item.value, rootLevel + level, true); - }); + this._hostIpc.sendCommand(UpdatePreferencesCommand, preferenceChange); } - private renderFile(file: File, level: number = 1, tree: boolean = false): TemplateResult<1> { - return html` - - `; + private onNavigate(direction: 'back' | 'forward') { + this._hostIpc.sendCommand(NavigateCommand, { direction: direction }); } - private renderChangedFiles() { - const layout = this.state?.preferences?.files?.layout ?? 'auto'; - - let value = 'tree'; - let icon = 'list-tree'; - let label = 'View as Tree'; - let isTree = false; - if (this.state?.selected?.files != null) { - if (layout === 'auto') { - isTree = this.state.selected.files.length > (this.state.preferences?.files?.threshold ?? 5); - } else { - isTree = layout === 'tree'; - } + private onTogglePin() { + this._hostIpc.sendCommand(PinCommand, { pin: !this.state!.pinned }); + } - switch (layout) { - case 'auto': - value = 'list'; - icon = 'gl-list-auto'; - label = 'View as List'; - break; - case 'list': - value = 'tree'; - icon = 'list-flat'; - label = 'View as Tree'; - break; - case 'tree': - value = 'auto'; - icon = 'list-tree'; - label = 'View as Auto'; - break; - } - } + private onPickCommit(_e: MouseEvent) { + this._hostIpc.sendCommand(PickCommitCommand, undefined); + } - return html` - - Files changed - ${this.renderCommitStats()} - - - - -
- ${when( - this.state?.selected?.files == null, - () => html` -
- -
-
- -
-
- -
- `, - () => (isTree ? this.renderFileTree() : this.renderFileList()), - )} -
-
- `; + private onSearchCommit(_e: MouseEvent) { + this._hostIpc.sendCommand(SearchCommitCommand, undefined); } - override render() { - if (this.state?.selected == null) { - return html`
${this.renderEmptyContent()}
`; - } + private onSwitchMode(_e: MouseEvent, mode: Mode) { + this.state = { ...this.state, mode: mode } as any; + // this.attachState(); - const pinLabel = this.state.pinned - ? 'Unpin this Commit\nRestores Automatic Following' - : 'Pin this Commit\nSuspends Automatic Following'; - return html` -
-
-
-
-
-
- - - ${when( - this.navigation.forward, - () => html` - - `, - )} - ${when( - this.state.navigationStack.hint, - () => html` - ${this.state!.navigationStack.hint} - `, - )} -
-
- ${when( - !this.isUncommitted, - () => html` - - - ${this.shortSha} - `, - () => html` - - `, - )} - - ${when( - !this.isUncommitted, - () => html` - - `, - )} -
-
- ${when( - this.state.selected && this.state.selected.stashNumber == null, - () => html` -
    -
  • - -
  • -
- `, - )} -
-
- ${this.renderCommitMessage()} ${this.renderAutoLinks()} ${this.renderChangedFiles()} - ${this.renderExplainAi()} -
-
- `; + this._hostIpc.sendCommand(SwitchModeCommand, { mode: mode, repoPath: this.state!.commit?.repoPath }); } - protected override createRenderRoot() { - return this; + private onOpenFileOnRemote(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(OpenFileOnRemoteCommand, e); } - onExplainChanges(e: MouseEvent | KeyboardEvent) { - if (this.explainBusy === true || (e instanceof KeyboardEvent && e.key !== 'Enter')) { - e.preventDefault(); - e.stopPropagation(); - return; - } - - this.explainBusy = true; + private onOpenFile(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(OpenFileCommand, e); } -} -function flattenHeirarchy(item: HierarchicalItem, level = 0): { level: number; item: HierarchicalItem }[] { - const flattened: { level: number; item: HierarchicalItem }[] = []; - if (item == null) return flattened; + private onCompareFileWithWorking(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(OpenFileCompareWorkingCommand, e); + } - flattened.push({ level: level, item: item }); + private onCompareFileWithPrevious(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(OpenFileComparePreviousCommand, e); + } - if (item.children != null) { - const children = Array.from(item.children.values()); - children.sort((a, b) => { - if (!a.value || !b.value) { - return (a.value ? 1 : -1) - (b.value ? 1 : -1); - } + private onFileMoreActions(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(ExecuteFileActionCommand, e); + } - if (a.relativePath < b.relativePath) { - return -1; - } + private onStageFile(e: FileChangeListItemDetail): void { + this._hostIpc.sendCommand(StageFileCommand, e); + } - if (a.relativePath > b.relativePath) { - return 1; - } + private onUnstageFile(e: FileChangeListItemDetail): void { + this._hostIpc.sendCommand(UnstageFileCommand, e); + } - return 0; - }); + private onCommitActions(e: CustomEvent<{ action: string; alt: boolean }>) { + if (this.state?.commit === undefined) { + return; + } - children.forEach(child => { - flattened.push(...flattenHeirarchy(child, level + 1)); + this._hostIpc.sendCommand(ExecuteCommitActionCommand, { + action: e.detail.action as ExecuteCommitActionsParams['action'], + alt: e.detail.alt, }); } - - return flattened; } diff --git a/src/webviews/apps/commitDetails/components/gl-commit-details.ts b/src/webviews/apps/commitDetails/components/gl-commit-details.ts new file mode 100644 index 0000000000000..a93c57dfde427 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-commit-details.ts @@ -0,0 +1,528 @@ +import { html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { when } from 'lit/directives/when.js'; +import type { Autolink } from '../../../../annotations/autolinks'; +import type { ManageCloudIntegrationsCommandArgs } from '../../../../commands/cloudIntegrations'; +import type { IssueOrPullRequest } from '../../../../git/models/issue'; +import type { PullRequestShape } from '../../../../git/models/pullRequest'; +import type { IssueIntegrationId } from '../../../../plus/integrations/providers/models'; +import type { Serialized } from '../../../../system/serialize'; +import type { State } from '../../../commitDetails/protocol'; +import { messageHeadlineSplitterToken } from '../../../commitDetails/protocol'; +import type { TreeItemAction, TreeItemBase } from '../../shared/components/tree/base'; +import { uncommittedSha } from './commit-details-app'; +import type { File } from './gl-details-base'; +import { GlDetailsBase } from './gl-details-base'; +import '../../shared/components/button'; +import '../../shared/components/code-icon'; +import '../../shared/components/skeleton-loader'; +import '../../shared/components/webview-pane'; +import '../../shared/components/actions/action-item'; +import '../../shared/components/actions/action-nav'; +import '../../shared/components/commit/commit-identity'; +import '../../shared/components/commit/commit-stats'; +import '../../shared/components/overlays/popover'; +import '../../shared/components/overlays/tooltip'; +import '../../shared/components/rich/issue-pull-request'; + +interface ExplainState { + cancelled?: boolean; + error?: { message: string }; + summary?: string; +} + +@customElement('gl-commit-details') +export class GlCommitDetails extends GlDetailsBase { + override readonly tab = 'commit'; + + @property({ type: Object }) + state?: Serialized; + + @state() + get isStash() { + return this.state?.commit?.stashNumber != null; + } + + @state() + get shortSha() { + return this.state?.commit?.shortSha ?? ''; + } + + @state() + explainBusy = false; + + @property({ type: Object }) + explain?: ExplainState; + + get navigation() { + if (this.state?.navigationStack == null) { + return { + back: false, + forward: false, + }; + } + + const actions = { + back: true, + forward: true, + }; + + if (this.state.navigationStack.count <= 1) { + actions.back = false; + actions.forward = false; + } else if (this.state.navigationStack.position === 0) { + actions.back = true; + actions.forward = false; + } else if (this.state.navigationStack.position === this.state.navigationStack.count - 1) { + actions.back = false; + actions.forward = true; + } + + return actions; + } + + override updated(changedProperties: Map) { + if (changedProperties.has('explain')) { + this.explainBusy = false; + this.querySelector('[data-region="commit-explanation"]')?.scrollIntoView(); + } + } + + private renderEmptyContent() { + return html` +
+

Rich details for commits and stashes are shown as you navigate:

+ + + +

Alternatively, show your work-in-progress, or search for or choose a commit

+ +

+ + Overview + +

+

+ + Choose Commit... + + +

+
+ `; + } + + private renderCommitMessage() { + const details = this.state?.commit; + if (details == null) return undefined; + + const message = details.message; + const index = message.indexOf(messageHeadlineSplitterToken); + return html` +
+ ${when( + !this.isStash, + () => html` + + `, + )} +
+ ${when( + index === -1, + () => + html`

+ ${unsafeHTML(message)} +

`, + () => + html`

+ ${unsafeHTML(message.substring(0, index))}
${unsafeHTML(message.substring(index + 3))} +

`, + )} +
+
+ `; + } + + private renderJiraLink() { + if (this.state == null) return 'Jira issues'; + + const { hasAccount, hasConnectedJira } = this.state; + + let message = html`Connect to Jira Cloud + — ${hasAccount ? '' : 'sign up and '}get access to automatic rich Jira autolinks`; + + if (hasAccount && hasConnectedJira) { + message = html` Jira connected + — automatic rich Jira autolinks are enabled`; + } + + return html` + Jira issues + ${message} + `; + } + + private renderAutoLinks() { + if (this.isUncommitted) return undefined; + + const deduped = new Map< + string, + | { type: 'autolink'; value: Serialized } + | { type: 'issue'; value: Serialized } + | { type: 'pr'; value: Serialized } + >(); + + if (this.state?.commit?.autolinks != null) { + for (const autolink of this.state.commit.autolinks) { + deduped.set(autolink.id, { type: 'autolink', value: autolink }); + } + } + + if (this.state?.autolinkedIssues != null) { + for (const issue of this.state.autolinkedIssues) { + deduped.set(issue.id, { type: 'issue', value: issue }); + } + } + + if (this.state?.pullRequest != null) { + deduped.set(this.state.pullRequest.id, { type: 'pr', value: this.state.pullRequest }); + } + + const autolinks: Serialized[] = []; + const issues: Serialized[] = []; + const prs: Serialized[] = []; + + for (const item of deduped.values()) { + switch (item.type) { + case 'autolink': + autolinks.push(item.value); + break; + case 'issue': + issues.push(item.value); + break; + case 'pr': + prs.push(item.value); + break; + } + } + + const { hasAccount, hasConnectedJira } = this.state ?? {}; + const jiraIntegrationLink = `command:gitlens.plus.cloudIntegrations.manage?${encodeURIComponent( + JSON.stringify({ + integrationId: 'jira' as IssueIntegrationId.Jira, + source: 'inspect', + detail: { + action: 'connect', + integration: 'jira', + }, + } satisfies ManageCloudIntegrationsCommandArgs), + )}`; + + return html` + + Autolinks + ${this.state?.includeRichContent || deduped.size ? `${deduped.size} found ` : ''}${this.state + ?.includeRichContent + ? '' + : '…'} + + + + + ${when( + this.state == null, + () => html` +
+ +
+ +
+
+ +
+
+ `, + () => { + if (deduped.size === 0) { + return html` +
+

+  Use + + autolinks + Configure autolinks + + to linkify external references, like ${this.renderJiraLink()} or Zendesk + tickets, in commit messages. +

+
+ `; + } + return html` +
+ ${autolinks.length + ? html` + + ` + : undefined} + ${prs.length + ? html` +
+ ${prs.map( + pr => html` + +
+ `, + )} + + ` + : undefined} + ${issues.length + ? html` +
+ ${issues.map( + issue => html` + + `, + )} +
+ ` + : undefined} +
+ `; + }, + )} +
+ `; + } + + private renderExplainAi() { + if (this.state?.orgSettings.ai === false) return undefined; + + // TODO: add loading and response states + return html` + + Explain (AI) + + + + + +
+

Let AI assist in understanding the changes made with this commit.

+

+ + Explain + Changes + +

+ ${when( + this.explain, + () => html` +
+ ${when( + this.explain?.error, + () => + html`

+ ${this.explain!.error!.message ?? 'Error retrieving content'} +

`, + )} + ${when( + this.explain?.summary, + () => html`

${this.explain!.summary}

`, + )} +
+ `, + )} +
+
+ `; + } + + override render() { + if (this.state?.commit == null) { + return this.renderEmptyContent(); + } + + return html` + ${this.renderCommitMessage()} + + ${this.renderAutoLinks()} + ${this.renderChangedFiles( + this.isStash ? 'stash' : 'commit', + this.renderCommitStats(this.state.commit.stats), + )} + ${this.renderExplainAi()} + + `; + } + + onExplainChanges(e: MouseEvent | KeyboardEvent) { + if (this.explainBusy === true || (e instanceof KeyboardEvent && e.key !== 'Enter')) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + this.explainBusy = true; + } + + private renderCommitStats(stats?: NonNullable['commit']>['stats']) { + if (stats?.changedFiles == null) return undefined; + + if (typeof stats.changedFiles === 'number') { + return html``; + } + + const { added, deleted, changed } = stats.changedFiles; + return html``; + } + + override getFileActions(_file: File, _options?: Partial): TreeItemAction[] { + const actions = [ + { + icon: 'go-to-file', + label: 'Open file', + action: 'file-open', + }, + ]; + + if (this.isUncommitted) { + return actions; + } + + actions.push({ + icon: 'git-compare', + label: 'Open Changes with Working File', + action: 'file-compare-working', + }); + + if (!this.isStash) { + actions.push( + { + icon: 'globe', + label: 'Open on remote', + action: 'file-open-on-remote', + }, + { + icon: 'ellipsis', + label: 'Show more actions', + action: 'file-more-actions', + }, + ); + } + + return actions; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-details-base.ts b/src/webviews/apps/commitDetails/components/gl-details-base.ts new file mode 100644 index 0000000000000..791958be3b316 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-details-base.ts @@ -0,0 +1,524 @@ +import type { TemplateResult } from 'lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { TextDocumentShowOptions } from 'vscode'; +import type { HierarchicalItem } from '../../../../system/array'; +import { makeHierarchical } from '../../../../system/array'; +import { pluralize } from '../../../../system/string'; +import type { Preferences, State } from '../../../commitDetails/protocol'; +import type { + TreeItemAction, + TreeItemActionDetail, + TreeItemBase, + TreeItemCheckedDetail, + TreeItemSelectionDetail, + TreeModel, +} from '../../shared/components/tree/base'; +import '../../shared/components/webview-pane'; +import '../../shared/components/actions/action-item'; +import '../../shared/components/actions/action-nav'; +import '../../shared/components/tree/tree-generator'; + +type Files = Mutable['files']>>; +export type File = Files[0]; +type Mode = 'commit' | 'stash' | 'wip'; + +// Can only import types from 'vscode' +const BesideViewColumn = -2; /*ViewColumn.Beside*/ + +export interface FileChangeListItemDetail extends File { + showOptions?: TextDocumentShowOptions; +} + +export class GlDetailsBase extends LitElement { + readonly tab: 'wip' | 'commit' = 'commit'; + + @property({ type: Array }) + files?: Files; + + @property({ type: Boolean }) + isUncommitted = false; + + @property({ type: Object }) + preferences?: Preferences; + + @property({ attribute: 'empty-text' }) + emptyText? = 'No Files'; + + get fileLayout() { + return this.preferences?.files?.layout ?? 'auto'; + } + + get isCompact() { + return this.preferences?.files?.compact ?? true; + } + + get indentGuides(): 'none' | 'onHover' | 'always' { + return this.preferences?.indentGuides ?? 'none'; + } + + get filesChangedPaneLabel() { + const fileCount = this.files?.length ?? 0; + const filesLabel = fileCount > 0 ? pluralize('file', fileCount) : 'Files'; + return `${filesLabel} changed`; + } + + protected renderChangedFiles(mode: Mode, subtitle?: TemplateResult<1>) { + const fileCount = this.files?.length ?? 0; + const isTree = this.isTree(fileCount); + let value = 'tree'; + let icon = 'list-tree'; + let label = 'View as Tree'; + switch (this.fileLayout) { + case 'auto': + value = 'list'; + icon = 'gl-list-auto'; + label = 'View as List'; + break; + case 'list': + value = 'tree'; + icon = 'list-flat'; + label = 'View as Tree'; + break; + case 'tree': + value = 'auto'; + icon = 'list-tree'; + label = 'View as Auto'; + break; + } + + const treeModel = this.createTreeModel(mode, this.files ?? [], isTree, this.isCompact); + + return html` + + ${this.filesChangedPaneLabel} + ${subtitle} + + + + ${when( + fileCount > 0 && this.tab === 'wip', + () => + html`
+

+ + Commit via SCM + +

+
`, + )} + ${this.renderTreeFileModel(treeModel)} +
+ `; + } + + protected onShareWipChanges(_e: Event, staged: boolean, hasFiles: boolean) { + if (!hasFiles) return; + const event = new CustomEvent('share-wip', { + detail: { + checked: staged, + }, + }); + this.dispatchEvent(event); + } + + protected override createRenderRoot() { + return this; + } + + // Tree Model changes + protected isTree(count: number) { + if (this.fileLayout === 'auto') { + return count > (this.preferences?.files?.threshold ?? 5); + } + return this.fileLayout === 'tree'; + } + + protected createTreeModel(mode: Mode, files: Files, isTree = false, compact = true): TreeModel[] { + if (!this.isUncommitted) { + return this.createFileTreeModel(mode, files, isTree, compact); + } + + const children = []; + const staged: Files = []; + const unstaged: Files = []; + for (const f of files) { + if (f.staged) { + staged.push(f); + } else { + unstaged.push(f); + } + } + + if (staged.length === 0 || unstaged.length === 0) { + children.push(...this.createFileTreeModel(mode, files, isTree, compact)); + } else { + if (staged.length) { + children.push({ + label: 'Staged Changes', + path: '', + level: 1, // isMulti ? 2 : 1, + branch: true, + checkable: false, + expanded: true, + checked: false, // change.checked !== false, + // disableCheck: true, + context: ['staged'], + children: this.createFileTreeModel(mode, staged, isTree, compact, { level: 2 }), + actions: this.getStagedActions(), + }); + } + + if (unstaged.length) { + children.push({ + label: 'Unstaged Changes', + path: '', + level: 1, // isMulti ? 2 : 1, + branch: true, + checkable: false, + expanded: true, + checked: false, // change.checked === true, + context: ['unstaged'], + children: this.createFileTreeModel(mode, unstaged, isTree, compact, { level: 2 }), + actions: this.getUnstagedActions(), + }); + } + } + + return children; + } + + protected sortChildren(children: TreeModel[]): TreeModel[] { + children.sort((a, b) => { + if (a.branch && !b.branch) return -1; + if (!a.branch && b.branch) return 1; + + if (a.label < b.label) return -1; + if (a.label > b.label) return 1; + + return 0; + }); + + return children; + } + + protected createFileTreeModel( + mode: Mode, + files: Files, + isTree = false, + compact = true, + options: Partial = { level: 1 }, + ): TreeModel[] { + if (options.level === undefined) { + options.level = 1; + } + + if (!files.length) { + return [ + { + label: 'No changes', + path: '', + level: options.level, + branch: false, + checkable: false, + expanded: true, + checked: false, + }, + ]; + } + + const children: TreeModel[] = []; + if (isTree) { + const fileTree = makeHierarchical( + files, + n => n.path.split('/'), + (...parts: string[]) => parts.join('/'), + compact, + ); + if (fileTree.children != null) { + for (const child of fileTree.children.values()) { + const childModel = this.walkFileTree(child, { level: options.level }); + children.push(childModel); + } + } + } else { + for (const file of files) { + const child = this.fileToTreeModel(file, { level: options.level, branch: false }, true); + children.push(child); + } + } + + this.sortChildren(children); + + return children; + } + + protected walkFileTree(item: HierarchicalItem, options: Partial = { level: 1 }): TreeModel { + if (options.level === undefined) { + options.level = 1; + } + + let model: TreeModel; + if (item.value == null) { + model = this.folderToTreeModel(item.name, options); + } else { + model = this.fileToTreeModel(item.value, options); + } + + if (item.children != null) { + const children = []; + for (const child of item.children.values()) { + const childModel = this.walkFileTree(child, { ...options, level: options.level + 1 }); + children.push(childModel); + } + + if (children.length > 0) { + this.sortChildren(children); + model.branch = true; + model.children = children; + } + } + + return model; + } + + protected getStagedActions(_options?: Partial): TreeItemAction[] { + if (this.tab === 'wip') { + return [ + { + icon: 'gl-cloud-patch-share', + label: 'Share Staged Changes', + action: 'staged-create-patch', + }, + ]; + } + return []; + } + + protected getUnstagedActions(_options?: Partial): TreeItemAction[] { + if (this.tab === 'wip') { + return [ + { + icon: 'gl-cloud-patch-share', + label: 'Share Unstaged Changes', + action: 'unstaged-create-patch', + }, + ]; + } + return []; + } + + protected getFileActions(_file: File, _options?: Partial): TreeItemAction[] { + return []; + } + + protected fileToTreeModel( + file: File, + options?: Partial, + flat = false, + glue = '/', + ): TreeModel { + const pathIndex = file.path.lastIndexOf(glue); + const fileName = pathIndex !== -1 ? file.path.substring(pathIndex + 1) : file.path; + const filePath = flat && pathIndex !== -1 ? file.path.substring(0, pathIndex) : ''; + + return { + branch: false, + expanded: true, + path: file.path, + level: 1, + checkable: false, + checked: false, + icon: { type: 'status', name: file.status }, // 'file', + label: fileName, + description: flat === true ? filePath : undefined, + context: [file], + actions: this.getFileActions(file, options), + // decorations: [{ type: 'text', label: file.status }], + ...options, + }; + } + + protected folderToTreeModel(name: string, options?: Partial): TreeModel { + return { + branch: false, + expanded: true, + path: name, + level: 1, + checkable: false, + checked: false, + icon: 'folder', + label: name, + ...options, + }; + } + + protected renderTreeFileModel(treeModel: TreeModel[]) { + return html``; + } + + // Tree Model action events + // protected onTreeItemActionClicked?(_e: CustomEvent): void; + protected onTreeItemActionClicked(e: CustomEvent) { + if (!e.detail.context || !e.detail.action) return; + + const action = e.detail.action; + switch (action.action) { + // stage actions + case 'staged-create-patch': + this.onCreatePatch(e); + break; + case 'unstaged-create-patch': + this.onCreatePatch(e, true); + break; + // file actions + case 'file-open': + this.onOpenFile(e); + break; + case 'file-unstage': + this.onUnstageFile(e); + break; + case 'file-stage': + this.onStageFile(e); + break; + case 'file-compare-working': + this.onCompareWorking(e); + break; + case 'file-open-on-remote': + this.onOpenFileOnRemote(e); + break; + case 'file-more-actions': + this.onMoreActions(e); + break; + } + } + + protected onTreeItemChecked?(_e: CustomEvent): void; + + // protected onTreeItemSelected?(_e: CustomEvent): void; + protected onTreeItemSelected(e: CustomEvent) { + if (!e.detail.context) return; + + this.onComparePrevious(e); + } + onCreatePatch(_e: CustomEvent, isAll = false) { + const event = new CustomEvent('create-patch', { + detail: { + checked: isAll ? true : 'staged', + }, + }); + this.dispatchEvent(event); + } + onOpenFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-open', { + detail: this.getEventDetail(file, { + preview: false, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onOpenFileOnRemote(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-open-on-remote', { + detail: this.getEventDetail(file, { + preview: false, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onCompareWorking(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-compare-working', { + detail: this.getEventDetail(file, { + preview: false, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onComparePrevious(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-compare-previous', { + detail: this.getEventDetail(file, { + preview: false, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onMoreActions(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-more-actions', { + detail: this.getEventDetail(file), + }); + this.dispatchEvent(event); + } + + onStageFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-stage', { + detail: this.getEventDetail(file, { + preview: false, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onUnstageFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-unstage', { + detail: this.getEventDetail(file, { + preview: false, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + private getEventDetail(file: File, showOptions?: TextDocumentShowOptions): FileChangeListItemDetail { + return { + path: file.path, + repoPath: file.repoPath, + status: file.status, + // originalPath: this.originalPath, + staged: file.staged, + showOptions: showOptions, + }; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-inspect-nav.ts b/src/webviews/apps/commitDetails/components/gl-inspect-nav.ts new file mode 100644 index 0000000000000..e0023c30bb9c3 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-inspect-nav.ts @@ -0,0 +1,230 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { State } from '../../../commitDetails/protocol'; +import { commitActionStyles } from './commit-action.css'; + +@customElement('gl-inspect-nav') +export class GlInspectNav extends LitElement { + static override styles = [ + commitActionStyles, + css` + *, + *::before, + *::after { + box-sizing: border-box; + } + + :host { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.2rem; + } + + :host([pinned]) { + background-color: var(--color-alert-warningBackground); + box-shadow: 0 0 0 0.1rem var(--color-alert-warningBorder); + border-radius: 0.3rem; + } + + :host([pinned]) .commit-action:hover, + :host([pinned]) .commit-action.is-active { + background-color: var(--color-alert-warningHoverBackground); + } + + .group { + display: flex; + flex: none; + flex-direction: row; + max-width: 100%; + } + + .sha { + margin: 0 0.5rem 0 0.25rem; + } + `, + ]; + + @property({ type: Boolean, reflect: true }) + pinned = false; + + @property({ type: Boolean }) + uncommitted = false; + + @property({ type: Object }) + navigation?: State['navigationStack']; + + @property() + shortSha = ''; + + @property() + stashNumber?: string; + + get navigationState() { + if (this.navigation == null) { + return { + back: false, + forward: false, + }; + } + + const actions = { + back: true, + forward: true, + }; + + if (this.navigation.count <= 1) { + actions.back = false; + actions.forward = false; + } else if (this.navigation.position === 0) { + actions.back = true; + actions.forward = false; + } else if (this.navigation.position === this.navigation.count - 1) { + actions.back = false; + actions.forward = true; + } + + return actions; + } + + handleAction(e: Event) { + const targetEl = e.target as HTMLElement; + const action = targetEl.dataset.action; + if (action == null) return; + + if (action === 'commit-actions') { + const altKey = e instanceof MouseEvent ? e.altKey : false; + this.fireEvent('commit-actions', { action: targetEl.dataset.actionType, alt: altKey }); + } else { + this.fireEvent(action); + } + } + + fireEvent(type: string, detail?: Record) { + this.dispatchEvent(new CustomEvent(`gl-${type}`, { detail: detail })); + } + + override render() { + const pinLabel = this.pinned + ? html`Unpin this Commit
Restores Automatic Following` + : html`Pin this Commit
Suspends Automatic Following`; + + let forwardLabel = 'Forward'; + let backLabel = 'Back'; + if (this.navigation?.hint) { + if (!this.pinned) { + forwardLabel += ` - ${this.navigation.hint}`; + } else { + backLabel += ` - ${this.navigation.hint}`; + } + } + + return html` +
+ ${when( + !this.uncommitted, + () => html` + + + + ${this.stashNumber != null ? `#${this.stashNumber}` : this.shortSha} + + Copy ${this.stashNumber != null ? 'Stash Name' : 'SHA'}
[⌥] Copy Message
+
+ `, + )} +
+
+ ${pinLabel} + + ${when( + this.navigationState.forward, + () => html` + + `, + )} + + ${when( + this.uncommitted, + () => html` + + `, + )} + + ${when( + !this.uncommitted, + () => html` + + `, + )} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-inspect-nav': GlInspectNav; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-inspect-patch.ts b/src/webviews/apps/commitDetails/components/gl-inspect-patch.ts new file mode 100644 index 0000000000000..64b7240588707 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-inspect-patch.ts @@ -0,0 +1,324 @@ +import { css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { DraftVisibility } from '../../../../gk/models/drafts'; +import type { Change, DraftUserSelection } from '../../../../plus/webviews/patchDetails/protocol'; +import type { Preferences, State } from '../../../commitDetails/protocol'; +import { GlElement } from '../../shared/components/element'; +import { buttonStyles } from './button.css'; +import '../../plus/patchDetails/components/gl-patch-create'; + +export interface CreatePatchState { + title?: string; + description?: string; + changes: Record; + creationError?: string; + visibility: DraftVisibility; + userSelections?: DraftUserSelection[]; +} + +export interface CreatePatchEventDetail { + title: string; + description?: string; + visibility: DraftVisibility; + changesets: Record; + userSelections: DraftUserSelection[] | undefined; +} + +export interface GenerateState { + cancelled?: boolean; + error?: { message: string }; + title?: string; + description?: string; +} + +@customElement('gl-inspect-patch') +export class InspectPatch extends GlElement { + static override styles = [ + buttonStyles, + css` + :host { + flex: 1; + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + a { + color: var(--vscode-textLink-foreground); + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + + gl-patch-create { + height: 100%; + display: block; + } + + .pane-groups { + display: flex; + flex-direction: column; + height: 100%; + } + .pane-groups__group { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; + } + .pane-groups__group webview-pane { + flex: none; + } + .pane-groups__group webview-pane[expanded] { + min-height: 0; + flex: 1; + } + + .pane-groups__group-fixed { + flex: none; + } + .pane-groups__group-fixed webview-pane::part(content) { + overflow: visible; + } + + .section { + padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width); + } + .section > :first-child { + margin-top: 0; + } + .section > :last-child { + margin-bottom: 0; + } + + .section--action { + border-top: 1px solid var(--vscode-sideBarSectionHeader-border); + padding-top: 1.5rem; + padding-bottom: 1.5rem; + } + .section--action > :first-child { + padding-top: 0; + } + + /* TODO: these form styles should be moved to a common location */ + .message-input { + padding-top: 0.8rem; + } + + .message-input__control { + flex: 1; + border: 1px solid var(--vscode-input-border); + background: var(--vscode-input-background); + padding: 0.5rem; + font-size: 1.3rem; + line-height: 1.4; + width: 100%; + border-radius: 0.2rem; + color: var(--vscode-input-foreground); + font-family: inherit; + } + + .message-input__control::placeholder { + color: var(--vscode-input-placeholderForeground); + } + + .message-input__control:invalid { + border-color: var(--vscode-inputValidation-errorBorder); + background-color: var(--vscode-inputValidation-errorBackground); + } + + .message-input__control:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .message-input__control:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + } + + .message-input__control--text { + overflow: hidden; + white-space: nowrap; + opacity: 0.7; + } + + .message-input__action { + flex: none; + } + + .message-input__select { + flex: 1; + position: relative; + display: flex; + align-items: stretch; + } + .message-input__select-icon { + position: absolute; + left: 0; + top: 0; + display: flex; + width: 2.4rem; + height: 100%; + align-items: center; + justify-content: center; + pointer-events: none; + color: var(--vscode-foreground); + } + .message-input__select-caret { + position: absolute; + right: 0; + top: 0; + display: flex; + width: 2.4rem; + height: 100%; + align-items: center; + justify-content: center; + pointer-events: none; + color: var(--vscode-foreground); + } + + .message-input__select .message-input__control { + box-sizing: border-box; + appearance: none; + padding-left: 2.4rem; + padding-right: 2.4rem; + } + + .message-input__menu { + position: absolute; + top: 0.8rem; + right: 0; + } + + .section--action > :first-child .message-input__menu { + top: 0; + } + + .message-input--group { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0.6rem; + } + + .message-input--with-menu { + position: relative; + } + + textarea.message-input__control { + resize: vertical; + min-height: 4rem; + max-height: 40rem; + } + + .user-selection-container { + max-height: (2.4rem * 4); + overflow: auto; + } + + .user-selection { + --gk-avatar-size: 2rem; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.4rem; + height: 2.4rem; + } + .user-selection__avatar { + flex: none; + } + + .user-selection__info { + flex: 1; + min-width: 0; + white-space: nowrap; + } + + .user-selection__name { + overflow: hidden; + text-overflow: ellipsis; + } + + .user-selection__actions { + flex: none; + color: var(--gk-button-ghost-color); + } + .user-selection__actions gk-button::part(base) { + padding-right: 0; + padding-block: 0.4rem; + } + + .user-selection__actions gk-button code-icon { + pointer-events: none; + } + + .user-selection__check:not(.is-active) { + opacity: 0; + } + + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.8rem 1.2rem; + line-height: 1.2; + background-color: var(--color-alert-errorBackground); + border-left: 0.3rem solid var(--color-alert-errorBorder); + color: var(--color-alert-foreground); + } + + .alert code-icon { + margin-right: 0.4rem; + vertical-align: baseline; + } + + .alert__content { + font-size: 1.2rem; + line-height: 1.2; + text-align: left; + margin: 0; + } + `, + ]; + + @property({ type: Object }) + orgSettings?: State['orgSettings']; + + @property({ type: Object }) + preferences?: Preferences; + + @property({ type: Object }) + generate?: GenerateState; + + @property({ type: Object }) + createState?: CreatePatchState; + + get patchCreateState() { + return { + preferences: this.preferences, + orgSettings: this.orgSettings, + create: this.createState, + }; + } + + override render() { + return html` { + console.log('gl-patch-file-compare-working', e); + }} + @gl-patch-create-update-metadata=${(e: CustomEvent) => { + console.log('gl-patch-create-update-metadata', e); + }} + >`; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-status-nav.ts b/src/webviews/apps/commitDetails/components/gl-status-nav.ts new file mode 100644 index 0000000000000..1c6d3f65b0bf7 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-status-nav.ts @@ -0,0 +1,166 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { State } from '../../../commitDetails/protocol'; +import { commitActionStyles } from './commit-action.css'; +import '../../shared/components/overlays/popover'; +import '../../shared/components/overlays/tooltip'; + +@customElement('gl-status-nav') +export class GlStatusNav extends LitElement { + static override styles = [ + commitActionStyles, + css` + *, + *::before, + *::after { + box-sizing: border-box; + } + + :host { + display: flex; + flex-direction: row; + /* flex-wrap: wrap; */ + align-items: center; + justify-content: space-between; + gap: 0.2rem; + } + + .tooltip--overflowed { + min-width: 0; + } + + .commit-action--overflowed { + width: 100%; + } + + .branch { + min-width: 0; + max-width: fit-content; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .group { + display: flex; + flex: none; + flex-direction: row; + min-width: 0; + max-width: 100%; + } + + .group:first-child { + min-width: 0; + flex: 0 1 auto; + } + + hr { + border: none; + border-top: 1px solid var(--color-foreground--25); + } + + .md-code { + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + padding: 0px 4px 2px 4px; + font-family: var(--vscode-editor-font-family); + } + `, + ]; + + @property({ type: Object }) + wip?: State['wip']; + + @property({ type: Object }) + preferences?: State['preferences']; + + override render() { + if (this.wip == null) return nothing; + + const changes = this.wip.changes; + const branch = this.wip.branch; + if (changes == null || branch == null) return nothing; + + let prIcon = 'git-pull-request'; + if (this.wip.pullRequest?.state) { + switch (this.wip.pullRequest?.state) { + case 'merged': + prIcon = 'git-merge'; + break; + case 'closed': + prIcon = 'git-pull-request-closed'; + break; + } + } + + return html` +
+ ${when( + this.wip.pullRequest != null, + () => + html` + #${this.wip!.pullRequest!.id} +
+ +
+
`, + )} + + this.handleAction(e, 'switch')} + > + ${when( + this.wip.pullRequest == null, + () => html``, + )}${branch.name} +
+ Switch to Another Branch... +
+ ${this.wip.branch?.name} +
+
+
+ + `; + } + + handleAction(e: MouseEvent, action: string) { + const altKey = e instanceof MouseEvent ? e.altKey : false; + this.dispatchEvent( + new CustomEvent(`gl-branch-action`, { + detail: { + action: action, + alt: altKey, + }, + }), + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-status-nav': GlStatusNav; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-wip-details.ts b/src/webviews/apps/commitDetails/components/gl-wip-details.ts new file mode 100644 index 0000000000000..7fc0d90a15707 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-wip-details.ts @@ -0,0 +1,392 @@ +import { Avatar, defineGkElement } from '@gitkraken/shared-web-components'; +import type { PropertyValueMap } from 'lit'; +import { css, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { when } from 'lit/directives/when.js'; +import type { DraftState, State, Wip } from '../../../commitDetails/protocol'; +import type { TreeItemAction, TreeItemBase } from '../../shared/components/tree/base'; +import type { File } from './gl-details-base'; +import { GlDetailsBase } from './gl-details-base'; +import type { GenerateState } from './gl-inspect-patch'; +import '../../shared/components/button'; +import '../../shared/components/code-icon'; +import '../../shared/components/panes/pane-group'; +import '../../shared/components/pills/tracking'; +import './gl-inspect-patch'; + +@customElement('gl-wip-details') +export class GlWipDetails extends GlDetailsBase { + static override styles = [ + css` + :host { + --gk-avatar-size: 1.6rem; + } + `, + ]; + override readonly tab = 'wip'; + + @property({ type: Object }) + wip?: Wip; + + @property({ type: Object }) + orgSettings?: State['orgSettings']; + + @property({ type: Object }) + draftState?: DraftState; + + @property({ type: Object }) + generate?: GenerateState; + + @state() + get inReview() { + return this.draftState?.inReview ?? false; + } + + get isUnpublished() { + const branch = this.wip?.branch; + return branch?.upstream == null || branch.upstream.missing === true; + } + + get draftsEnabled() { + return this.orgSettings?.drafts === true; + } + + get filesCount() { + return this.files?.length ?? 0; + } + + get branchState() { + const branch = this.wip?.branch; + if (branch == null) return undefined; + + return { + ahead: branch.tracking?.ahead ?? 0, + behind: branch.tracking?.behind ?? 0, + }; + } + + @state() + patchCreateMetadata: { title: string | undefined; description: string | undefined } = { + title: undefined, + description: undefined, + }; + + get patchCreateState() { + const wip = this.wip!; + const key = wip.repo.uri; + const change = { + type: 'wip', + repository: { + name: wip.repo.name, + path: wip.repo.path, + uri: wip.repo.uri, + }, + files: wip.changes?.files ?? [], + checked: true, + }; + + return { + ...this.patchCreateMetadata, + changes: { + [key]: change, + }, + creationError: undefined, + visibility: 'public', + userSelections: undefined, + }; + } + + get codeSuggestions() { + return this.wip?.codeSuggestions ?? []; + } + + constructor() { + super(); + + defineGkElement(Avatar); + } + + protected override updated(changedProperties: PropertyValueMap | Map): void { + super.updated(changedProperties); + + if (changedProperties.has('generate')) { + this.patchCreateMetadata = { + title: this.generate?.title ?? this.patchCreateMetadata.title, + description: this.generate?.description ?? this.patchCreateMetadata.description, + }; + } + } + + override get filesChangedPaneLabel() { + return 'Working Changes'; + } + + renderSecondaryAction() { + if (!this.draftsEnabled || this.inReview) return undefined; + + let label = 'Share as Cloud Patch'; + let action = 'create-patch'; + const pr = this.wip?.pullRequest; + if (pr != null && pr.state === 'opened') { + // const isMe = pr.author.name.endsWith('(you)'); + // if (isMe) { + // label = 'Share with PR Participants'; + // action = 'create-patch'; + // } else { + // label = `Start Review for PR #${pr.id}`; + // action = 'create-patch'; + // } + + if (!this.inReview) { + label = 'Suggest Changes for PR'; + action = 'start-patch-review'; + } else { + label = 'Close Suggestion for PR'; + action = 'end-patch-review'; + } + + return html`

+ + this.onToggleReviewMode(!this.inReview)} + > + ${label} + + this.onDataActionClick('create-patch')} + > + + + +

`; + } + + return html`

+ + this.onDataActionClick(action)} + > + ${label} + + +

`; + } + + renderPrimaryAction() { + const canShare = this.draftsEnabled; + if (this.isUnpublished && canShare) { + return html`

+ + this.onDataActionClick('publish-branch')} + > + Publish Branch + + +

`; + } + + if ((!this.isUnpublished && !canShare) || this.branchState == null) return undefined; + + const { ahead, behind } = this.branchState; + if (ahead === 0 && behind === 0) return undefined; + + const fetchLabel = behind > 0 ? 'Pull' : ahead > 0 ? 'Push' : 'Fetch'; + const fetchIcon = behind > 0 ? 'gl-repo-pull' : ahead > 0 ? 'gl-repo-push' : 'gl-repo-fetch'; + + return html`

+ + this.onDataActionClick(fetchLabel.toLowerCase())} + > + ${fetchLabel} + + + +

`; + } + + renderActions() { + const primaryAction = this.renderPrimaryAction(); + const secondaryAction = this.renderSecondaryAction(); + if (primaryAction == null && secondaryAction == null) return nothing; + + return html`
${primaryAction}${secondaryAction}
`; + } + + renderSuggestedChanges() { + if (this.codeSuggestions.length === 0) return nothing; + // src="${this.issue!.author.avatarUrl}" + // title="${this.issue!.author.name} (author)" + return html` + + + + Code Suggestions + + ${repeat( + this.codeSuggestions, + draft => draft.id, + draft => html` + this.onShowCodeSuggestion(draft.id)} + > + + ${draft.title} + + + `, + )} + + `; + } + + renderPullRequest() { + if (this.wip?.pullRequest == null) return nothing; + + return html` + + Pull Request #${this.wip?.pullRequest?.id} + + this.onDataActionClick('open-pr-changes')} + > + this.onDataActionClick('open-pr-compare')} + > + this.onDataActionClick('open-pr-remote')} + > + +
+ +
+ ${this.renderSuggestedChanges()} +
+ `; + } + + renderIncomingOutgoing() { + if (this.branchState == null || (this.branchState.ahead === 0 && this.branchState.behind === 0)) return nothing; + + return html` + + Incoming / Outgoing + + + + Incoming Changes + ${this.branchState.behind ?? 0} + + + + Outgoing Changes + ${this.branchState.ahead ?? 0} + + + + `; + } + + renderPatchCreation() { + if (!this.inReview) return nothing; + + return html` { + // this.onDataActionClick('create-patch'); + console.log('gl-patch-create-patch', e); + void this.dispatchEvent(new CustomEvent('gl-inspect-create-suggestions', { detail: e.detail })); + }} + >`; + } + + override render() { + if (this.wip == null) return nothing; + + return html` + ${this.renderActions()} + + ${this.renderPullRequest()} + ${when(this.inReview === false, () => this.renderChangedFiles('wip'))}${this.renderPatchCreation()} + + `; + } + + override getFileActions(file: File, _options?: Partial): TreeItemAction[] { + const openFile = { + icon: 'go-to-file', + label: 'Open file', + action: 'file-open', + }; + if (file.staged === true) { + return [openFile, { icon: 'remove', label: 'Unstage changes', action: 'file-unstage' }]; + } + return [openFile, { icon: 'plus', label: 'Stage changes', action: 'file-stage' }]; + } + + onDataActionClick(name: string) { + void this.dispatchEvent(new CustomEvent('data-action', { detail: { name: name } })); + } + + onToggleReviewMode(inReview: boolean) { + this.dispatchEvent(new CustomEvent('draft-state-changed', { detail: { inReview: inReview } })); + } + + onShowCodeSuggestion(id: string) { + this.dispatchEvent(new CustomEvent('gl-show-code-suggestion', { detail: { id: id } })); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-wip-details': GlWipDetails; + } +} diff --git a/src/webviews/apps/home/home.html b/src/webviews/apps/home/home.html index c10bf9b57da84..81c04feb88404 100644 --- a/src/webviews/apps/home/home.html +++ b/src/webviews/apps/home/home.html @@ -6,71 +6,94 @@ @font-face { font-family: 'codicon'; font-display: block; - src: url('#{webroot}/codicon.ttf?2ab61cbaefbdf4c7c5589068100bee0c') format('truetype'); + src: url('#{webroot}/codicon.ttf?79130123c9d3674a686cf03962523e8a') format('truetype'); } @font-face { font-family: 'glicons'; font-display: block; - src: url('#{root}/dist/glicons.woff2?8e33f5a80a91b05940d687a08305c156') format('woff2'); + src: url('#{root}/dist/glicons.woff2?6ae679c0cde70af8c802b5257e2354a4') format('woff2'); } - +
+
@@ -82,7 +105,9 @@

No repository detected

To use GitLens, open a folder containing a git repository or clone from a URL from the Explorer.

- Open a Folder or Repository + Open a Folder or Repository

If you have opened a folder with a repository, please let us know by @@ -100,7 +125,9 @@

Unsafe repository

not being owned by the current user.

- Manage in Source Control + Manage in Source Control

@@ -109,7 +136,9 @@

Untrusted workspace

Unable to open repositories in Restricted Mode.

- Manage Workspace Trust + Manage Workspace Trust

@@ -118,198 +147,358 @@

Untrusted workspace

Features which need a repository are currently unavailable

-