Browse Source

Adds support for Cloud Patches (wip)

Co-authored-by: Keith Daulton <keith.daulton@gitkraken.com>
Co-authored-by: Ramin Tadayon <ramin.tadayon@gitkraken.com>
main
Eric Amodio 1 year ago
parent
commit
ba0a6c87bf
83 changed files with 8529 additions and 785 deletions
  1. +339
    -18
      package.json
  2. +2
    -0
      src/@types/global.d.ts
  3. +1
    -0
      src/commands.ts
  4. +2
    -0
      src/commands/diffWithWorking.ts
  5. +425
    -0
      src/commands/patches.ts
  6. +12
    -0
      src/config.ts
  7. +20
    -2
      src/constants.ts
  8. +33
    -0
      src/container.ts
  9. +64
    -1
      src/env/node/git/git.ts
  10. +219
    -5
      src/env/node/git/localGitProvider.ts
  11. +16
    -1
      src/eventBus.ts
  12. +13
    -7
      src/git/actions/commit.ts
  13. +46
    -0
      src/git/errors.ts
  14. +13
    -1
      src/git/gitProvider.ts
  15. +75
    -1
      src/git/gitProviderService.ts
  16. +6
    -0
      src/git/models/diff.ts
  17. +27
    -0
      src/git/models/patch.ts
  18. +73
    -0
      src/git/parsers/diffParser.ts
  19. +8
    -1
      src/git/remotes/azure-devops.ts
  20. +19
    -4
      src/git/remotes/bitbucket-server.ts
  21. +8
    -1
      src/git/remotes/bitbucket.ts
  22. +7
    -1
      src/git/remotes/custom.ts
  23. +7
    -1
      src/git/remotes/gerrit.ts
  24. +7
    -1
      src/git/remotes/gitea.ts
  25. +10
    -1
      src/git/remotes/github.ts
  26. +14
    -1
      src/git/remotes/gitlab.ts
  27. +7
    -1
      src/git/remotes/google-source.ts
  28. +19
    -2
      src/git/remotes/remoteProvider.ts
  29. +7
    -2
      src/git/remotes/remoteProviders.ts
  30. +218
    -0
      src/gk/models/drafts.ts
  31. +83
    -0
      src/gk/models/repositoryIdentities.ts
  32. +12
    -0
      src/plus/drafts/actions.ts
  33. +520
    -0
      src/plus/drafts/draftsService.ts
  34. +1
    -108
      src/plus/focus/focusService.ts
  35. +19
    -0
      src/plus/gk/serverConnection.ts
  36. +163
    -0
      src/plus/repos/repositoryIdentityService.ts
  37. +109
    -0
      src/plus/utils.ts
  38. +897
    -0
      src/plus/webviews/patchDetails/patchDetailsWebview.ts
  39. +256
    -0
      src/plus/webviews/patchDetails/protocol.ts
  40. +63
    -0
      src/plus/webviews/patchDetails/registration.ts
  41. +233
    -0
      src/plus/webviews/patchDetails/repositoryChangeset.ts
  42. +3
    -2
      src/plus/workspaces/models.ts
  43. +9
    -0
      src/system/brand.ts
  44. +21
    -9
      src/system/iterable.ts
  45. +11
    -1
      src/system/serialize.ts
  46. +2
    -0
      src/telemetry/usageTracker.ts
  47. +54
    -2
      src/uris/deepLinks/deepLink.ts
  48. +155
    -12
      src/uris/deepLinks/deepLinkService.ts
  49. +169
    -0
      src/views/draftsView.ts
  50. +6
    -0
      src/views/nodes/abstract/viewNode.ts
  51. +60
    -0
      src/views/nodes/draftNode.ts
  52. +3
    -1
      src/views/viewBase.ts
  53. +17
    -455
      src/webviews/apps/commitDetails/commitDetails.scss
  54. +19
    -0
      src/webviews/apps/commitDetails/commitDetails.ts
  55. +2
    -2
      src/webviews/apps/commitDetails/components/gl-commit-details.ts
  56. +37
    -4
      src/webviews/apps/commitDetails/components/gl-details-base.ts
  57. +10
    -0
      src/webviews/apps/commitDetails/components/gl-wip-details.ts
  58. +692
    -0
      src/webviews/apps/plus/patchDetails/components/gl-draft-details.ts
  59. +523
    -0
      src/webviews/apps/plus/patchDetails/components/gl-patch-create.ts
  60. +209
    -0
      src/webviews/apps/plus/patchDetails/components/gl-tree-base.ts
  61. +182
    -0
      src/webviews/apps/plus/patchDetails/components/patch-details-app.ts
  62. +27
    -0
      src/webviews/apps/plus/patchDetails/patchDetails.html
  63. +164
    -0
      src/webviews/apps/plus/patchDetails/patchDetails.scss
  64. +337
    -0
      src/webviews/apps/plus/patchDetails/patchDetails.ts
  65. +48
    -46
      src/webviews/apps/shared/components/actions/action-nav.ts
  66. +27
    -0
      src/webviews/apps/shared/components/button.ts
  67. +2
    -1
      src/webviews/apps/shared/components/commit/commit-identity.ts
  68. +15
    -0
      src/webviews/apps/shared/components/element.ts
  69. +4
    -5
      src/webviews/apps/shared/components/list/file-change-list-item.ts
  70. +101
    -1
      src/webviews/apps/shared/components/list/list-item.ts
  71. +35
    -0
      src/webviews/apps/shared/components/styles/lit/base.css.ts
  72. +91
    -0
      src/webviews/apps/shared/components/tree/base.ts
  73. +225
    -0
      src/webviews/apps/shared/components/tree/tree-generator.ts
  74. +271
    -0
      src/webviews/apps/shared/components/tree/tree-item.ts
  75. +217
    -0
      src/webviews/apps/shared/components/tree/tree.css.ts
  76. +120
    -0
      src/webviews/apps/shared/components/tree/tree.ts
  77. +97
    -76
      src/webviews/apps/shared/components/webview-pane.ts
  78. +445
    -0
      src/webviews/apps/shared/styles/details-base.scss
  79. +28
    -0
      src/webviews/commitDetails/commitDetailsWebview.ts
  80. +7
    -5
      src/webviews/commitDetails/protocol.ts
  81. +1
    -1
      src/webviews/webviewController.ts
  82. +8
    -2
      src/webviews/webviewsController.ts
  83. +2
    -0
      webpack.config.js

+ 339
- 18
package.json View File

@ -1912,7 +1912,7 @@
{
"id": "workspaces-view",
"title": "GitKraken Workspaces View",
"order": 23,
"order": 33,
"properties": {
"gitlens.views.workspaces.showBranchComparison": {
"type": [
@ -2121,6 +2121,66 @@
}
},
{
"id": "patch-details-view",
"title": "Patch Details View",
"order": 34,
"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-blame",
"title": "File Blame",
"order": 100,
@ -2364,7 +2424,7 @@
{
"id": "graph",
"title": "Commit Graph",
"order": 105,
"order": 200,
"properties": {
"gitlens.graph.allowMultiple": {
"type": "boolean",
@ -2625,9 +2685,23 @@
}
},
{
"id": "cloud-patches",
"title": "Cloud Patches",
"order": 300,
"properties": {
"gitlens.cloudPatches.enabled": {
"type": "boolean",
"default": false,
"markdownDescription": "Specifies whether to enable the _Cloud Patches_ feature",
"scope": "window",
"order": 10
}
}
},
{
"id": "focus",
"title": "Focus",
"order": 106,
"order": 400,
"properties": {
"gitlens.focus.allowMultiple": {
"type": "boolean",
@ -2641,7 +2715,7 @@
{
"id": "visual-history",
"title": "Visual File History",
"order": 106,
"order": 500,
"properties": {
"gitlens.visualHistory.allowMultiple": {
"type": "boolean",
@ -2662,7 +2736,7 @@
{
"id": "rebase-editor",
"title": "Interactive Rebase Editor",
"order": 107,
"order": 600,
"properties": {
"gitlens.rebaseEditor.ordering": {
"type": "string",
@ -2704,7 +2778,7 @@
{
"id": "git-command-palette",
"title": "Git Command Palette",
"order": 110,
"order": 700,
"properties": {
"gitlens.gitCommands.sortBy": {
"type": "string",
@ -2829,7 +2903,7 @@
{
"id": "integrations",
"title": "Integrations",
"order": 111,
"order": 800,
"properties": {
"gitlens.autolinks": {
"type": [
@ -3047,7 +3121,7 @@
{
"id": "terminal",
"title": "Terminal",
"order": 112,
"order": 900,
"properties": {
"gitlens.terminalLinks.enabled": {
"type": "boolean",
@ -3075,7 +3149,7 @@
{
"id": "ai",
"title": "AI",
"order": 113,
"order": 1000,
"properties": {
"gitlens.ai.experimental.generateCommitMessage.enabled": {
"type": "boolean",
@ -3167,7 +3241,7 @@
{
"id": "date-times",
"title": "Date & Times",
"order": 120,
"order": 1100,
"properties": {
"gitlens.defaultDateStyle": {
"type": "string",
@ -3244,7 +3318,7 @@
{
"id": "sorting",
"title": "Sorting",
"order": 121,
"order": 1200,
"properties": {
"gitlens.sortRepositoriesBy": {
"type": "string",
@ -3333,7 +3407,7 @@
{
"id": "menus-toolbars",
"title": "Menus & Toolbars",
"order": 122,
"order": 1300,
"properties": {
"gitlens.menus": {
"anyOf": [
@ -3708,7 +3782,7 @@
{
"id": "keyboard",
"title": "Keyboard Shortcuts",
"order": 123,
"order": 1400,
"properties": {
"gitlens.keymap": {
"type": "string",
@ -3732,7 +3806,7 @@
{
"id": "modes",
"title": "Modes",
"order": 124,
"order": 1500,
"properties": {
"gitlens.mode.statusBar.enabled": {
"type": "boolean",
@ -3910,7 +3984,7 @@
{
"id": "advanced",
"title": "Advanced",
"order": 1000,
"order": 10000,
"properties": {
"gitlens.detectNestedRepositories": {
"type": "boolean",
@ -4975,6 +5049,31 @@
"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"
},
{
"command": "gitlens.showBranchesView",
"title": "Show Branches View",
"category": "GitLens"
@ -6699,6 +6798,18 @@
"icon": "$(refresh)"
},
{
"command": "gitlens.views.patchDetails.close",
"title": "Close Patch View",
"category": "GitLens",
"icon": "$(close)"
},
{
"command": "gitlens.views.patchDetails.refresh",
"title": "Refresh",
"category": "GitLens",
"icon": "$(refresh)"
},
{
"command": "gitlens.views.commits.copy",
"title": "Copy",
"category": "GitLens"
@ -6839,6 +6950,35 @@
"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.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.drafts.open",
"title": "Open",
"category": "GitLens",
"icon": "$(eye)"
},
{
"command": "gitlens.views.fileHistory.changeBase",
"title": "Change Base...",
"category": "GitLens",
@ -8418,6 +8558,26 @@
"when": "gitlens:enabled"
},
{
"command": "gitlens.createPatch",
"when": "false && gitlens:enabled"
},
{
"command": "gitlens.createCloudPatch",
"when": "gitlens:enabled && config.gitlens.cloudPatches.enabled"
},
{
"command": "gitlens.shareAsCloudPatch",
"when": "gitlens:enabled && config.gitlens.cloudPatches.enabled"
},
{
"command": "gitlens.openCloudPatch",
"when": "false"
},
{
"command": "gitlens.openPatch",
"when": "gitlens:enabled"
},
{
"command": "gitlens.timeline.refresh",
"when": "false"
},
@ -9570,6 +9730,14 @@
"when": "false"
},
{
"command": "gitlens.views.patchDetails.close",
"when": "false"
},
{
"command": "gitlens.views.patchDetails.refresh",
"when": "false"
},
{
"command": "gitlens.views.commits.copy",
"when": "false"
},
@ -9674,6 +9842,26 @@
"when": "false"
},
{
"command": "gitlens.views.drafts.copy",
"when": "false"
},
{
"command": "gitlens.views.drafts.refresh",
"when": "false"
},
{
"command": "gitlens.views.drafts.create",
"when": "false"
},
{
"command": "gitlens.views.drafts.delete",
"when": "false"
},
{
"command": "gitlens.views.drafts.open",
"when": "false"
},
{
"command": "gitlens.views.fileHistory.changeBase",
"when": "false"
},
@ -10636,6 +10824,10 @@
],
"editor/title": [
{
"command": "gitlens.openPatch",
"when": "editorLangId == diff"
},
{
"command": "gitlens.diffWithWorking",
"when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && !isInDiffEditor",
"group": "navigation@-99"
@ -11120,6 +11312,16 @@
"group": "navigation@99"
},
{
"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"
@ -11260,6 +11462,16 @@
"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.fileHistory.setEditorFollowingOn",
"when": "view =~ /^gitlens\\.views\\.fileHistory/ && gitlens:views:fileHistory:canPin && !gitlens:views:fileHistory:editorFollowing",
"group": "navigation@10"
@ -11777,6 +11989,21 @@
"group": "inline@1"
},
{
"command": "gitlens.views.drafts.open",
"when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus",
"group": "inline@1"
},
{
"command": "gitlens.views.drafts.open",
"when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus",
"group": "1_gitlens_actions@1"
},
{
"command": "gitlens.views.drafts.delete",
"when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus",
"group": "6_gitlens_actions@1"
},
{
"command": "gitlens.views.workspaces.convert",
"when": "viewItem =~ /gitlens:repositories\\b(?=.*?\\b\\+workspaces\\b)/ && gitlens:plus",
"group": "inline@1"
@ -12216,9 +12443,19 @@
"group": "1_gitlens_actions_1@1"
},
{
"command": "gitlens.createPatch",
"when": "false && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(commit|stash)\\b/",
"group": "1_gitlens_actions_1@2"
},
{
"command": "gitlens.createCloudPatch",
"when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:(commit|stash)\\b/",
"group": "1_gitlens_actions_1@3"
},
{
"command": "gitlens.views.createTag",
"when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/",
"group": "1_gitlens_actions_1@2"
"group": "1_gitlens_actions_1@4"
},
{
"submenu": "gitlens/commit/changes",
@ -13055,6 +13292,16 @@
"group": "1_gitlens_actions@3"
},
{
"command": "gitlens.createPatch",
"when": "false && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:compare:results(?!:)\\b/",
"group": "1_gitlens_secondary_actions@1"
},
{
"command": "gitlens.createCloudPatch",
"when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:compare:results(?!:)\\b/",
"group": "1_gitlens_secondary_actions@2"
},
{
"command": "gitlens.openComparisonOnRemote",
"when": "viewItem =~ /gitlens:compare:results(?!:)\\b/",
"group": "2_gitlens_quickopen@1 && gitlens:hasRemotes",
@ -13448,9 +13695,19 @@
"group": "1_gitlens_actions_1@1"
},
{
"command": "gitlens.createPatch",
"when": "false && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(commit|stash)\\b/",
"group": "1_gitlens_actions_1@2"
},
{
"command": "gitlens.createCloudPatch",
"when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && 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"
"group": "1_gitlens_actions_1@4"
},
{
"submenu": "gitlens/graph/commit/changes",
@ -13706,6 +13963,16 @@
],
"gitlens/share": [
{
"command": "gitlens.shareAsCloudPatch",
"when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:((commit|stash|compare:results(?!:)|)\\b|file\\b(?=.*?\\b\\+committed\\b))/",
"group": "1_a_gitlens@1"
},
{
"command": "gitlens.shareAsCloudPatch",
"when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.cloudPatches.enabled && webviewItem =~ /gitlens:(commit|stash)\\b/",
"group": "1_a_gitlens@1"
},
{
"command": "gitlens.copyDeepLinkToBranch",
"when": "viewItem =~ /gitlens:(branch\\b(?=.*?\\b\\+(remote|tracking)\\b)|status:upstream(?!:none))\\b/",
"group": "1_gitlens@50"
@ -13909,9 +14176,19 @@
"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 && 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": [
@ -14894,6 +15171,12 @@
"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",
"mac": "cmd+c",
@ -14982,6 +15265,11 @@
"id": "gitlensInspect",
"title": "GitLens Inspect",
"icon": "$(gitlens-gitlens-inspect)"
},
{
"id": "gitlensPatch",
"title": "Patch",
"icon": "$(cloud)"
}
],
"panel": [
@ -15009,6 +15297,20 @@
"when": "!gitlens:hasVirtualFolders"
},
{
"view": "gitlens.views.drafts",
"contents": "Cloud Patches allow you to 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)\n\n☁️ Access is based on your plan, e.g. Free, Pro, etc",
"when": "gitlens:plus"
},
{
"view": "gitlens.views.drafts",
"contents": "[Start Free Pro Trial](command:gitlens.plus.loginOrSignUp)\n\nStart a free 7-day Pro trial to use Cloud Patches, or [sign in](command:gitlens.plus.loginOrSignUp).\n☁️ Requires an account and access is based on your plan, e.g. Free, Pro, etc",
"when": "!gitlens:plus"
},
{
"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."
},
@ -15058,6 +15360,7 @@
{
"type": "webview",
"id": "gitlens.views.home",
"-when": "!gitlens:views:patchDetails:mode || !config.gitlens.cloudPatches.enabled",
"name": "Home",
"contextualTitle": "GL",
"icon": "$(gitlens-gitlens)",
@ -15065,6 +15368,24 @@
"visibility": "visible"
},
{
"type": "webview",
"id": "gitlens.views.patchDetails",
"name": "Patch",
"when": "!gitlens:disabled && config.gitlens.cloudPatches.enabled && gitlens:views:patchDetails:mode",
"contextualTitle": "GL",
"icon": "$(gitlens-commit-view)",
"initialSize": 24
},
{
"id": "gitlens.views.drafts",
"name": "Cloud Patches",
"when": "!gitlens:untrusted && !gitlens:hasVirtualFolders",
"contextualTitle": "GL",
"icon": "$(cloud)",
"initialSize": 2,
"visibility": "visible"
},
{
"id": "gitlens.views.workspaces",
"name": "GitKraken Workspaces",
"when": "!gitlens:untrusted && !gitlens:hasVirtualFolders",

+ 2
- 0
src/@types/global.d.ts View File

@ -28,4 +28,6 @@ export declare global {
export type StartsWith<P extends string, T extends string, S extends string = ''> = T extends `${P}${S}${string}`
? T
: never;
export type UnwrapCustomEvent<T> = T extends CustomEvent<infer U> ? U : never;
}

+ 1
- 0
src/commands.ts View File

@ -40,6 +40,7 @@ import './commands/openPullRequestOnRemote';
import './commands/openRepoOnRemote';
import './commands/openRevisionFile';
import './commands/openWorkingFile';
import './commands/patches';
import './commands/rebaseEditor';
import './commands/refreshHover';
import './commands/remoteProviders';

+ 2
- 0
src/commands/diffWithWorking.ts View File

@ -15,6 +15,7 @@ export interface DiffWithWorkingCommandArgs {
uri?: Uri;
line?: number;
showOptions?: TextDocumentShowOptions;
lhsTitle?: string;
}
@command()
@ -106,6 +107,7 @@ export class DiffWithWorkingCommand extends ActiveEditorCommand {
lhs: {
sha: gitUri.sha,
uri: uri,
title: args?.lhsTitle,
},
rhs: {
sha: '',

+ 425
- 0
src/commands/patches.ts View File

@ -0,0 +1,425 @@
import type { TextEditor } from 'vscode';
import { window, workspace } from 'vscode';
import { Commands } from '../constants';
import type { Container } from '../container';
import type { PatchRevisionRange } from '../git/models/patch';
import { 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 { Change } from '../plus/webviews/patchDetails/protocol';
import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker';
import { command } from '../system/command';
import type { CommandContext } from './base';
import {
ActiveEditorCommand,
Command,
isCommandContextViewNodeHasCommit,
isCommandContextViewNodeHasComparison,
} from './base';
export interface CreatePatchCommandArgs {
ref1?: string;
ref2?: string;
repoPath?: string;
}
@command()
export class CreatePatchCommand extends Command {
constructor(private readonly container: Container) {
super(Commands.CreatePatch);
}
protected override preExecute(context: CommandContext, args?: CreatePatchCommandArgs) {
if (args == null) {
if (context.type === 'viewItem') {
if (isCommandContextViewNodeHasCommit(context)) {
args = {
repoPath: context.node.commit.repoPath,
ref1: context.node.commit.ref,
};
} else if (isCommandContextViewNodeHasComparison(context)) {
args = {
repoPath: context.node.uri.fsPath,
ref1: context.node.compareWithRef.ref,
ref2: context.node.compareRef.ref,
};
}
}
}
return this.execute(args);
}
async execute(args?: CreatePatchCommandArgs) {
let repo;
if (args?.repoPath == null) {
repo = await getRepositoryOrShowPicker('Create Patch');
} else {
repo = this.container.git.getRepository(args.repoPath);
}
if (repo == null) return undefined;
if (args?.ref1 == null) return;
const diff = await getDiffContents(this.container, repo, args);
if (diff == null) return;
// let repo;
// if (args?.repoPath == null) {
// repo = await getRepositoryOrShowPicker('Create Patch');
// } else {
// repo = this.container.git.getRepository(args.repoPath);
// }
// if (repo == null) return;
// const diff = await this.container.git.getDiff(repo.uri, args?.ref1 ?? 'HEAD', args?.ref2);
// 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));
}
}
async function getDiffContents(
container: Container,
repository: Repository,
args: CreatePatchCommandArgs,
): Promise<{ contents: string; revision: PatchRevisionRange } | undefined> {
const sha = args.ref1 ?? 'HEAD';
const diff = await container.git.getDiff(repository.uri, sha, args.ref2);
if (diff == null) return undefined;
return {
contents: diff.contents,
revision: {
baseSha: args.ref2 ?? `${sha}^`,
sha: sha,
},
};
}
interface CreateLocalChange {
title?: string;
description?: string;
changes: Change[];
}
async function createLocalChange(
container: Container,
repository: Repository,
args: CreatePatchCommandArgs,
): Promise<CreateLocalChange | undefined> {
if (args.ref1 == null) return undefined;
const sha = args.ref1 ?? 'HEAD';
// const [branchName] = await container.git.getCommitBranches(repository.uri, sha);
const change: Change = {
type: 'revision',
repository: {
name: repository.name,
path: repository.path,
uri: repository.uri.toString(),
},
files: undefined!,
revision: {
sha: sha,
baseSha: args.ref2 ?? `${sha}^`,
// branchName: branchName ?? 'HEAD',
},
};
const create: CreateLocalChange = { changes: [change] };
const commit = await container.git.getCommit(repository.uri, sha);
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);
}
if (args.ref2 == null) {
change.files = commit.files != null ? [...commit.files] : [];
} else {
const diff = await getDiffContents(container, repository, args);
if (diff == null) return undefined;
const result = await container.git.getDiffFiles(repository.uri, diff.contents);
if (result?.files == null) return;
create.title = `Comparing ${shortenRevision(args.ref2)} with ${shortenRevision(args.ref1)}`;
change.files = result.files;
}
// const change: Change = {
// type: 'commit',
// repository: {
// name: repository.name,
// path: repository.path,
// uri: repository.uri.toString(),
// },
// files: result.files,
// range: {
// ...range,
// branchName: branchName ?? 'HEAD',
// },
// };
return create;
}
@command()
export class CreateCloudPatchCommand extends Command {
constructor(private readonly container: Container) {
super([Commands.CreateCloudPatch, Commands.ShareAsCloudPatch]);
}
protected override preExecute(context: CommandContext, args?: CreatePatchCommandArgs) {
if (args == null) {
if (context.type === 'viewItem') {
if (isCommandContextViewNodeHasCommit(context)) {
args = {
repoPath: context.node.commit.repoPath,
ref1: context.node.commit.ref,
};
} else if (isCommandContextViewNodeHasComparison(context)) {
args = {
repoPath: context.node.uri.fsPath,
ref1: context.node.compareWithRef.ref,
ref2: context.node.compareRef.ref,
};
}
}
}
return this.execute(args);
}
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 createLocalChange(this.container, repo, args);
if (create == null) {
return showPatchesView({ mode: 'create', create: { repositories: [repo] } });
}
return showPatchesView({ mode: 'create', create: create });
// let changes: Change[] | undefined;
// if (args?.repoPath != null) {
// const repo = this.container.git.getRepository(args.repoPath);
// if (repo == null) return;
// const diff = await this.container.git.getDiff(repo.uri, args.ref1 ?? 'HEAD', args.ref2);
// if (diff == null) return;
// const result = await this.container.git.getDiffFiles(args.repoPath, diff.contents);
// if (result?.files == null) return;
// const branch = await repo.getBranch();
// changes = [
// {
// type: 'commit',
// repository: {
// name: repo.name,
// path: repo.path,
// uri: repo.uri.toString(true),
// },
// files: result.files,
// range: {
// baseSha: args.ref2 ?? `${args.ref1 ?? 'HEAD'}^`,
// branchName: branch?.name ?? 'HEAD',
// sha: args.ref1 ?? 'HEAD',
// },
// },
// ];
// }
// let repo;
// if (args?.repoPath == null) {
// repo = await getRepositoryOrShowPicker('Create Cloud Patch');
// } else {
// repo = this.container.git.getRepository(args.repoPath);
// }
// if (repo == null) return;
// const diff = await this.container.git.getDiff(repo.uri, args?.ref1 ?? 'HEAD', args?.ref2);
// if (diff == null) return;
// const d = await workspace.openTextDocument({ content: diff.contents, language: 'diff' });
// await window.showTextDocument(d);
// // ask the user for a title
// const title = await window.showInputBox({
// title: 'Create Cloud Patch',
// prompt: 'Enter a title for the patch',
// validateInput: value => (value == null || value.length === 0 ? 'A title is required' : undefined),
// });
// if (title == null) return;
// // ask the user for an optional description
// const description = await window.showInputBox({
// title: 'Create Cloud Patch',
// prompt: 'Enter an optional description for the patch',
// });
// const patch = await this.container.drafts.createDraft(
// 'patch',
// title,
// {
// contents: diff.contents,
// baseSha: diff.baseSha,
// repository: repo,
// },
// { description: description },
// );
// void this.showPatchNotification(patch);
}
// private async showPatchNotification(patch: Draft | undefined) {
// if (patch == null) return;
// await env.clipboard.writeText(patch.deepLinkUrl);
// const copy = { title: 'Copy Link' };
// const result = await window.showInformationMessage(`Created cloud patch ${patch.id}`, copy);
// if (result === copy) {
// await env.clipboard.writeText(patch.deepLinkUrl);
// }
// }
}
@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 {
id: string;
patchId?: string;
draft?: Draft;
}
@command()
export class OpenCloudPatchCommand extends Command {
constructor(private readonly container: Container) {
super(Commands.OpenCloudPatch);
}
async execute(args?: OpenCloudPatchCommandArgs) {
if (args?.id == null && args?.draft == null) {
void window.showErrorMessage('Cannot open cloud patch: no patch or patch id provided');
return;
}
const draft = args?.draft ?? (await this.container.drafts.getDraft(args?.id));
if (draft == null) {
void window.showErrorMessage(`Cannot open cloud patch: patch ${args.id} not found`);
return;
}
// let patch: DraftPatch | undefined;
// if (args?.patchId) {
// patch = await this.container.drafts.getPatch(args.patchId);
// } else {
// const patches = draft.changesets?.[0]?.patches;
// if (patches == null || patches.length === 0) {
// void window.showErrorMessage(`Cannot open cloud patch: no patch found under id ${args.patchId}`);
// return;
// }
// patch = patches[0];
// if (patch.repo == null && patch.repoData != null) {
// const repo = await this.container.git.findMatchingRepository({
// firstSha: patch.repoData.initialCommitSha,
// remoteUrl: patch.repoData.remote?.url,
// });
// if (repo != null) {
// patch.repo = repo;
// }
// }
// if (patch.repo == null) {
// void window.showErrorMessage(`Cannot open cloud patch: no repository found for patch ${args.patchId}`);
// return;
// }
// // Opens the patch repository if it's not already open
// void this.container.git.getOrOpenRepository(patch.repo.uri);
// const patchContents = await this.container.drafts.getPatchContents(patch.id);
// if (patchContents == null) {
// void window.showErrorMessage(`Cannot open cloud patch: patch not found of contents empty`);
// return;
// }
// patch.contents = patchContents;
// }
// if (patch == null) {
// void window.showErrorMessage(`Cannot open cloud patch: patch not found`);
// return;
// }
void showPatchesView({ mode: 'view', draft: draft });
}
}

+ 12
- 0
src/config.ts View File

@ -41,6 +41,9 @@ export interface Config {
readonly locations: ChangesLocations[];
/*readonly*/ toggleMode: AnnotationsToggleMode;
};
readonly cloudPatches: {
readonly enabled: boolean;
};
readonly codeLens: CodeLensConfig;
readonly currentLine: {
readonly dateFormat: string | null;
@ -573,8 +576,10 @@ interface ViewsConfigs {
readonly commits: CommitsViewConfig;
readonly commitDetails: CommitDetailsViewConfig;
readonly contributors: ContributorsViewConfig;
readonly drafts: object; // TODO@eamodio add real types
readonly fileHistory: FileHistoryViewConfig;
readonly lineHistory: LineHistoryViewConfig;
readonly patchDetails: PatchDetailsViewConfig;
readonly remotes: RemotesViewConfig;
readonly repositories: RepositoriesViewConfig;
readonly searchAndCompare: SearchAndCompareViewConfig;
@ -590,8 +595,10 @@ export const viewsConfigKeys: ViewsConfigKeys[] = [
'commits',
'commitDetails',
'contributors',
'drafts',
'fileHistory',
'lineHistory',
'patchDetails',
'remotes',
'repositories',
'searchAndCompare',
@ -643,6 +650,11 @@ export interface CommitDetailsViewConfig {
};
}
export interface PatchDetailsViewConfig {
readonly avatars: boolean;
readonly files: ViewsFilesConfig;
}
export interface ContributorsViewConfig {
readonly avatars: boolean;
readonly files: ViewsFilesConfig;

+ 20
- 2
src/constants.ts View File

@ -140,6 +140,8 @@ export const enum Commands {
CopyRemoteRepositoryUrl = 'gitlens.copyRemoteRepositoryUrl',
CopyShaToClipboard = 'gitlens.copyShaToClipboard',
CopyRelativePathToClipboard = 'gitlens.copyRelativePathToClipboard',
CreatePatch = 'gitlens.createPatch',
CreateCloudPatch = 'gitlens.createCloudPatch',
CreatePullRequestOnRemote = 'gitlens.createPullRequestOnRemote',
DiffDirectory = 'gitlens.diffDirectory',
DiffDirectoryWithHead = 'gitlens.diffDirectoryWithHead',
@ -185,6 +187,8 @@ export const enum Commands {
OpenFolderHistory = 'gitlens.openFolderHistory',
OpenOnRemote = 'gitlens.openOnRemote',
OpenIssueOnRemote = 'gitlens.openIssueOnRemote',
OpenCloudPatch = 'gitlens.openCloudPatch',
OpenPatch = 'gitlens.openPatch',
OpenPullRequestOnRemote = 'gitlens.openPullRequestOnRemote',
OpenAssociatedPullRequestOnRemote = 'gitlens.openAssociatedPullRequestOnRemote',
OpenRepoOnRemote = 'gitlens.openRepoOnRemote',
@ -229,6 +233,7 @@ export const enum Commands {
ResetTrackedUsage = 'gitlens.resetTrackedUsage',
ResetViewsLayout = 'gitlens.resetViewsLayout',
RevealCommitInView = 'gitlens.revealCommitInView',
ShareAsCloudPatch = 'gitlens.shareAsCloudPatch',
SearchCommits = 'gitlens.showCommitSearch',
SearchCommitsInView = 'gitlens.views.searchAndCompare.searchCommits',
ShowBranchesView = 'gitlens.showBranchesView',
@ -346,6 +351,7 @@ export type TreeViewCommands = `gitlens.views.${
| `setShowAllBranches${'On' | 'Off'}`
| `setShowAvatars${'On' | 'Off'}`
| `setShowStatistics${'On' | 'Off'}`}`
| `drafts.${'copy' | 'refresh' | 'create' | 'delete' | 'open'}`
| `fileHistory.${
| 'copy'
| 'refresh'
@ -451,6 +457,7 @@ export type TreeViewTypes =
| 'branches'
| 'commits'
| 'contributors'
| 'drafts'
| 'fileHistory'
| 'lineHistory'
| 'remotes'
@ -465,7 +472,14 @@ export type TreeViewIds = `gitlens.views.${TreeViewTypes}`;
export type WebviewTypes = 'graph' | 'settings' | 'timeline' | 'welcome' | 'focus';
export type WebviewIds = `gitlens.${WebviewTypes}`;
export type WebviewViewTypes = 'account' | 'commitDetails' | 'graph' | 'graphDetails' | 'home' | 'timeline';
export type WebviewViewTypes =
| 'account'
| 'commitDetails'
| 'graph'
| 'graphDetails'
| 'home'
| 'patchDetails'
| 'timeline';
export type WebviewViewIds = `gitlens.views.${WebviewViewTypes}`;
export type ViewTypes = TreeViewTypes | WebviewViewTypes;
@ -545,6 +559,8 @@ export type TreeViewNodeTypes =
| 'conflict-files'
| 'conflict-current-changes'
| 'conflict-incoming-changes'
| 'draft'
| 'drafts'
| 'merge-status'
| 'message'
| 'pager'
@ -594,6 +610,7 @@ export type ContextKeys =
| `${typeof extensionPrefix}:views:fileHistory:cursorFollowing`
| `${typeof extensionPrefix}:views:fileHistory:editorFollowing`
| `${typeof extensionPrefix}:views:lineHistory:editorFollowing`
| `${typeof extensionPrefix}:views:patchDetails:mode`
| `${typeof extensionPrefix}:views:repositories:autoRefresh`
| `${typeof extensionPrefix}:vsls`
| `${typeof extensionPrefix}:plus`
@ -654,7 +671,8 @@ export type CoreConfiguration =
| 'http.proxyStrictSSL'
| 'search.exclude'
| 'workbench.editorAssociations'
| 'workbench.tree.renderIndentGuides';
| 'workbench.tree.renderIndentGuides'
| 'workbench.tree.indent';
export type CoreGitConfiguration =
| 'git.autoRepositoryDetection'

+ 33
- 0
src/container.ts View File

@ -21,11 +21,13 @@ import { GitLabAuthenticationProvider } from './git/remotes/gitlab';
import { RichRemoteProviderService } from './git/remotes/remoteProviderService';
import { LineHoverController } from './hovers/lineHoverController';
import type { RepositoryPathMappingProvider } from './pathMapping/repositoryPathMappingProvider';
import { DraftService } from './plus/drafts/draftsService';
import { FocusService } from './plus/focus/focusService';
import { AccountAuthenticationProvider } from './plus/gk/account/authenticationProvider';
import { SubscriptionService } from './plus/gk/account/subscriptionService';
import { ServerConnection } from './plus/gk/serverConnection';
import { IntegrationAuthenticationService } from './plus/integrationAuthentication';
import { RepositoryIdentityService } from './plus/repos/repositoryIdentityService';
import { registerAccountWebviewView } from './plus/webviews/account/registration';
import { registerFocusWebviewCommands, registerFocusWebviewPanel } from './plus/webviews/focus/registration';
import type { GraphWebviewShowingArgs } from './plus/webviews/graph/registration';
@ -35,6 +37,8 @@ import {
registerGraphWebviewView,
} from './plus/webviews/graph/registration';
import { GraphStatusBarController } from './plus/webviews/graph/statusbar';
import type { PatchDetailsWebviewShowingArgs } from './plus/webviews/patchDetails/registration';
import { registerPatchDetailsWebviewView } from './plus/webviews/patchDetails/registration';
import type { TimelineWebviewShowingArgs } from './plus/webviews/timeline/registration';
import {
registerTimelineWebviewCommands,
@ -60,6 +64,7 @@ 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 { RemotesView } from './views/remotesView';
@ -255,6 +260,7 @@ export class Container {
this._disposables.push((this._repositoriesView = new RepositoriesView(this)));
this._disposables.push((this._commitDetailsView = registerCommitDetailsWebviewView(this._webviews)));
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._fileHistoryView = new FileHistoryView(this)));
@ -266,6 +272,7 @@ 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)));
@ -387,6 +394,27 @@ export class Container {
return this._cache;
}
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;
@ -569,6 +597,11 @@ export class Container {
return this._mode;
}
private readonly _patchDetailsView: WebviewViewProxy<PatchDetailsWebviewShowingArgs>;
get patchDetailsView() {
return this._patchDetailsView;
}
private readonly _prerelease;
get prerelease() {
return this._prerelease;

+ 64
- 1
src/env/node/git/git.ts View File

@ -11,6 +11,8 @@ import type { GitCommandOptions, GitSpawnOptions } from '../../../git/commandOpt
import { GitErrorHandling } from '../../../git/commandOptions';
import {
BlameIgnoreRevsFileError,
CherryPickError,
CherryPickErrorReason,
FetchError,
FetchErrorReason,
PullError,
@ -142,6 +144,14 @@ 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 };
@ -175,7 +185,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);
@ -343,6 +355,32 @@ export class Git {
return this.git<string>({ cwd: repoPath, stdin: patch }, ...params);
}
async apply2(
repoPath: string,
options?: {
cancellation?: CancellationToken;
configs?: readonly string[];
errors?: GitErrorHandling;
env?: Record<string, unknown>;
stdin?: string;
},
...args: string[]
) {
return this.git<string>(
{
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<string, boolean>();
async blame(
@ -568,6 +606,31 @@ export class Git {
return this.git<string>({ 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<string>({ 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.OverwrittenChanges;
} else if (GitErrors.conflict.test(msg) || GitErrors.conflict.test(ex.stdout ?? '')) {
reason = CherryPickErrorReason.Conflict;
}
throw new CherryPickError(reason, ex, sha);
}
}
// TODO: Expand to include options and other params
async clone(url: string, parentPath: string): Promise<string | undefined> {
let count = 0;

+ 219
- 5
src/env/node/git/localGitProvider.ts View File

@ -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';
@ -18,6 +18,8 @@ import { Features } from '../../../features';
import { GitErrorHandling } from '../../../git/commandOptions';
import {
BlameIgnoreRevsFileError,
CherryPickError,
CherryPickErrorReason,
FetchError,
GitSearchError,
PullError,
@ -25,6 +27,7 @@ import {
PushErrorReason,
StashApplyError,
StashApplyErrorReason,
StashPushError,
WorktreeCreateError,
WorktreeCreateErrorReason,
WorktreeDeleteError,
@ -62,7 +65,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, GitDiffLine, 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 {
@ -108,7 +118,12 @@ import { isUserMatch } from '../../../git/models/user';
import type { GitWorktree } from '../../../git/models/worktree';
import { parseGitBlame } from '../../../git/parsers/blameParser';
import { parseGitBranches } from '../../../git/parsers/branchParser';
import { parseGitDiffNameStatusFiles, parseGitDiffShortStat, parseGitFileDiff } from '../../../git/parsers/diffParser';
import {
parseGitApplyFiles,
parseGitDiffNameStatusFiles,
parseGitDiffShortStat,
parseGitFileDiff,
} from '../../../git/parsers/diffParser';
import {
createLogParserSingle,
createLogParserWithFiles,
@ -1110,6 +1125,194 @@ export class LocalGitProvider implements GitProvider, Disposable {
return undefined;
}
async applyPatchCommit(
repoPath: string,
patchCommitRef: string,
options?: { branchName?: string; createBranchIfNeeded?: boolean; createWorktreePath?: string },
): Promise<void> {
const scope = getLogScope();
// Stash any changes first
const repoStatus = await this.getStatusForRepo(repoPath);
const diffStatus = repoStatus?.getDiffStatus();
if (diffStatus?.added || diffStatus?.deleted || diffStatus?.changed) {
try {
await this.git.stash__push(repoPath, undefined, { includeUntracked: true });
} catch (ex) {
Logger.error(ex, scope);
if (ex instanceof StashPushError) {
void showGenericErrorMessage(`Error applying patch - unable to stash changes: ${ex.message}`);
} else {
void showGenericErrorMessage(`Error applying patch - unable to stash changes`);
}
return;
}
}
let targetPath = repoPath;
const currentBranch = await this.getBranch(repoPath);
const branchExists =
options?.branchName == null ||
(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) {
void showGenericErrorMessage(`Error applying patch - unable to create worktree`);
return;
}
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);
if (ex instanceof WorktreeCreateError) {
void showGenericErrorMessage(`Error applying patch - unable to create worktree: ${ex.message}`);
} else {
void showGenericErrorMessage(`Error applying patch - unable to create worktree`);
}
return;
}
const worktree = await this.container.git.getWorktree(
repoPath,
w => normalizePath(w.uri.fsPath) === normalizePath(options.createWorktreePath!),
);
if (worktree == null) {
void showGenericErrorMessage(`Error applying patch - unable to create worktree`);
return;
}
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, patchCommitRef, { noCommit: true, errors: GitErrorHandling.Throw });
} catch (ex) {
Logger.error(ex, scope);
if (ex instanceof CherryPickError) {
if (ex.reason === CherryPickErrorReason.Conflict) {
void showGenericErrorMessage(
`Error applying patch - conflicts detected. Please resolve conflicts.`,
);
} else {
void showGenericErrorMessage(`Error applying patch - unable to apply patch changes: ${ex.message}`);
}
} else {
void showGenericErrorMessage(`Error applying patch - unable to apply patch changes`);
}
return;
}
void window.showInformationMessage(`Patch applied successfully`);
}
@log({ args: { 1: '<contents>', 3: '<message>' } })
async createUnreachableCommitForPatch(
repoPath: string,
contents: string,
baseRef: string,
message: string,
): Promise<GitCommit | undefined> {
const scope = getLogScope();
// Create a temporary index file
const tempDir = await fs.mkdtemp(path.join(tmpdir(), 'gl-'));
const tempIndex = joinPaths(tempDir, 'index');
try {
// Tell Git to use our soon to be created index file
const env = { GIT_INDEX_FILE: tempIndex };
// Create the temp index file from a base ref/sha
// Get the tree of the base
const newIndex = await this.git.git<string>(
{
cwd: repoPath,
env: env,
},
'ls-tree',
'-z',
'-r',
'--full-name',
baseRef,
);
// Write the tree to our temp index
await this.git.git<string>(
{
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');
// Create a new tree from our patched index
const tree = (
await this.git.git<string>(
{
cwd: repoPath,
env: env,
},
'write-tree',
)
)?.trim();
// Create new commit from the tree
const sha = (
await this.git.git<string>(
{
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.rmdir(tempDir, { recursive: true });
} catch (ex) {
debugger;
}
}
}
@log({ singleLine: true })
private resetCache(
repoPath: string,
@ -2799,6 +3002,17 @@ export class LocalGitProvider implements GitProvider, Disposable {
return diff;
}
@log({ args: { 1: false } })
async getDiffFiles(repoPath: string, contents: string): Promise<GitDiffFiles | undefined> {
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<GitDiffFile | undefined> {
const scope = getLogScope();

+ 16
- 1
src/eventBus.ts View File

@ -5,6 +5,7 @@ import type { CustomEditorIds, WebviewIds, WebviewViewIds } from './constants';
import type { GitCaches } from './git/gitProvider';
import type { GitCommit } from './git/models/commit';
import type { GitRevisionReference } from './git/models/reference';
import type { Draft, LocalDraft } from './gk/models/drafts';
export type CommitSelectedEvent = EventBusEvent<'commit:selected'>;
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<keyof CacheableEventsMapping>(['commit:selected', 'file:selected']);
const _cacheableEventNames = new Set<keyof CacheableEventsMapping>([
'commit:selected',
'draft:selected',
'file:selected',
]);
const _cachedEventArgs = new Map<keyof CacheableEventsMapping, CacheableEventsMapping[keyof CacheableEventsMapping]>();
export class EventBus implements Disposable {

+ 13
- 7
src/git/actions/commit.ts View File

@ -218,12 +218,17 @@ export async function openChanges(
export async function openChanges(
file: GitFile,
refs: { repoPath: string; ref1: string; ref2: string },
options?: TextDocumentShowOptions,
options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string },
): Promise<void>;
export async function openChanges(
file: GitFile,
commitOrRefs: GitCommit | { repoPath: string; ref1: string; ref2: string },
options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string },
): Promise<void>;
export async function openChanges(
file: string | GitFile,
commitOrRefs: GitCommit | { repoPath: string; ref1: string; ref2: string },
options?: TextDocumentShowOptions,
options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string },
) {
const isArgCommit = isCommit(commitOrRefs);
@ -263,8 +268,8 @@ export async function openChanges(
void (await executeCommand<DiffWithCommandArgs>(Commands.DiffWith, {
repoPath: refs.repoPath,
lhs: { uri: lhsUri, sha: refs.ref1 },
rhs: { uri: rhsUri, sha: refs.ref2 },
lhs: { uri: lhsUri, sha: refs.ref1, title: options?.lhsTitle },
rhs: { uri: rhsUri, sha: refs.ref2, title: options?.rhsTitle },
showOptions: options,
}));
}
@ -304,17 +309,17 @@ export async function openChangesWithDiffTool(
export async function openChangesWithWorking(
file: string | GitFile,
commit: GitCommit,
options?: TextDocumentShowOptions,
options?: TextDocumentShowOptions & { lhsTitle?: string },
): Promise<void>;
export async function openChangesWithWorking(
file: GitFile,
ref: { repoPath: string; ref: string },
options?: TextDocumentShowOptions,
options?: TextDocumentShowOptions & { lhsTitle?: string },
): Promise<void>;
export async function openChangesWithWorking(
file: string | GitFile,
commitOrRef: GitCommit | { repoPath: string; ref: string },
options?: TextDocumentShowOptions,
options?: TextDocumentShowOptions & { lhsTitle?: string },
) {
if (typeof file === 'string') {
if (!isCommit(commitOrRef)) throw new Error('Invalid arguments');
@ -342,6 +347,7 @@ export async function openChangesWithWorking(
void (await executeEditorCommand<DiffWithWorkingCommandArgs>(Commands.DiffWithWorking, undefined, {
uri: GitUri.fromFile(file, ref.repoPath, ref.ref),
showOptions: options,
lhsTitle: options?.lhsTitle,
}));
}

+ 46
- 0
src/git/errors.ts View File

@ -314,6 +314,52 @@ export class FetchError extends Error {
}
}
export const enum CherryPickErrorReason {
Conflict,
OverwrittenChanges,
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.OverwrittenChanges:
message = `${baseMessage} because local changes to some files would be overwritten.`;
break;
case CherryPickErrorReason.Conflict:
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');

+ 13
- 1
src/git/gitProvider.ts View File

@ -7,7 +7,7 @@ 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, GitDiffLine, 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';
@ -144,6 +144,17 @@ export interface GitProvider extends Disposable {
options?: { createBranch?: string | undefined } | { path?: string | undefined },
): Promise<void>;
clone?(url: string, parentPath: string): Promise<string | undefined>;
applyPatchCommit?(
repoPath: string,
patchCommitRef: string,
options?: { branchName?: string; createBranchIfNeeded?: boolean; createWorktreePath?: string },
): Promise<void>;
createUnreachableCommitForPatch?(
repoPath: string,
contents: string,
baseRef: string,
message: string,
): Promise<GitCommit | undefined>;
excludeIgnoredUris(repoPath: string, uris: Uri[]): Promise<Uri[]>;
fetch(
repoPath: string,
@ -278,6 +289,7 @@ export interface GitProvider extends Disposable {
ref2?: string,
options?: { context?: number },
): Promise<GitDiff | undefined>;
getDiffFiles?(repoPath: string | Uri, contents: string): Promise<GitDiffFiles | undefined>;
/**
* Returns a file diff between two commits
* @param uri Uri of the file to diff

+ 75
- 1
src/git/gitProviderService.ts View File

@ -66,7 +66,7 @@ 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, GitDiffLine, 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';
@ -86,6 +86,7 @@ import type { GitTag, TagSortOptions } from './models/tag';
import type { GitTreeEntry } from './models/tree';
import type { GitUser } from './models/user';
import type { GitWorktree } from './models/worktree';
import { parseGitRemoteUrl } from './parsers/remoteParser';
import type { RemoteProvider } from './remotes/remoteProvider';
import type { RichRemoteProvider } from './remotes/richRemoteProvider';
import type { GitSearch, SearchQuery } from './search';
@ -706,6 +707,52 @@ export class GitProviderService implements Disposable {
return provider.discoverRepositories(uri, options);
}
@log()
async findMatchingRepository(match: { firstSha?: string; remoteUrl?: string }): Promise<Repository | undefined> {
if (match.firstSha == null && match.remoteUrl == null) return undefined;
let foundRepo;
let remoteDomain = '';
let remotePath = '';
if (match.remoteUrl != null) {
[, remoteDomain, remotePath] = parseGitRemoteUrl(match.remoteUrl);
}
// 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 (match.firstSha != null && match.firstSha !== '-') {
// Repo ID can be any valid SHA in the repo, though standard practice is to use the
// first commit SHA.
if (await this.validateReference(repo.path, match.firstSha)) {
foundRepo = repo;
break;
}
}
}
if (foundRepo == null && match.remoteUrl != null) {
const matchingLocalRepoPaths = await this.container.repositoryPathMapping.getLocalRepoPaths({
remoteUrl: match.remoteUrl,
});
if (matchingLocalRepoPaths.length > 0) {
foundRepo = await this.getOrOpenRepository(Uri.file(matchingLocalRepoPaths[0]));
}
}
return foundRepo;
}
private _subscription: Subscription | undefined;
private async getSubscription(): Promise<Subscription> {
return this._subscription ?? (this._subscription = await this.container.subscription.getSubscription());
@ -1311,6 +1358,27 @@ export class GitProviderService implements Disposable {
return provider.clone?.(url, parentPath);
}
@log()
async applyPatchCommit(
repoPath: string | Uri,
patchCommitRef: string,
options?: { branchName?: string; createBranchIfNeeded?: boolean; createWorktreePath?: string },
): Promise<void> {
const { provider, path } = this.getProvider(repoPath);
return provider.applyPatchCommit?.(path, patchCommitRef, options);
}
@log({ args: { 1: '<contents>', 3: '<message>' } })
async createUnreachableCommitForPatch(
repoPath: string | Uri,
contents: string,
baseRef: string,
message: string,
): Promise<GitCommit | undefined> {
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')) {
@ -1774,6 +1842,12 @@ export class GitProviderService implements Disposable {
return provider.getDiff?.(path, ref1, ref2, options);
}
@log({ args: { 1: false } })
async getDiffFiles(repoPath: string | Uri, contents: string): Promise<GitDiffFiles | undefined> {
const { provider, path } = this.getProvider(repoPath);
return provider.getDiffFiles?.(path, contents);
}
@log()
/**
* Returns a file diff between two commits

+ 6
- 0
src/git/models/diff.ts View File

@ -1,3 +1,5 @@
import type { GitFileChange } from './file';
export interface GitDiff {
readonly baseSha: string;
readonly contents: string;
@ -38,4 +40,8 @@ export interface GitDiffShortStat {
readonly changedFiles: number;
}
export interface GitDiffFiles {
readonly files: GitFileChange[];
}
export type GitDiffFilter = 'A' | 'C' | 'D' | 'M' | 'R' | 'T' | 'U' | 'X' | 'B' | '*';

+ 27
- 0
src/git/models/patch.ts View File

@ -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 `sha` is the commit SHA and `baseSha` is its parent `<sha>^`
* For a commit range `sha` is the tip SHA and `baseSha` is the base SHA
* For a WIP `sha` is the "uncommitted" SHA and `baseSha` is the current HEAD SHA
*/
export interface PatchRevisionRange {
baseSha: string;
sha: 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;
}

+ 73
- 0
src/git/parsers/diffParser.ts View File

@ -1,6 +1,8 @@
import { joinPaths, normalizePath } from '../../system/path';
import { maybeStopWatch } from '../../system/stopwatch';
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?\(-\))?/;
@ -157,6 +159,77 @@ export function parseGitDiffNameStatusFiles(data: string, repoPath: string): Git
return files;
}
export function parseGitApplyFiles(data: string, repoPath: string): GitFileChange[] {
using sw = maybeStopWatch('Git.parseApplyFiles', { log: false, logLevel: 'debug' });
if (!data) return [];
const files = new Map<string, GitFileChange>();
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 [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;

+ 8
- 1
src/git/remotes/azure-devops.ts View File

@ -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() class="o">: RemoteProviderId {
return 'azure-devops';
}
get gkProviderId(): GkProviderId {
return 'azureDevops' satisfies Unbrand<GkProviderId> as Brand<GkProviderId>;
}
get name() {
return 'Azure DevOps';
}

+ 19
- 4
src/git/remotes/bitbucket-server.ts View File

@ -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() class="o">: RemoteProviderId {
return 'bitbucket-server';
}
get gkProviderId(): GkProviderId {
return 'bitbucketServer' satisfies Unbrand<GkProviderId> as Brand<GkProviderId>;
}
get name() {
return this.formatName('Bitbucket Server');
}

+ 8
- 1
src/git/remotes/bitbucket.ts View File

@ -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() class="o">: RemoteProviderId {
return 'bitbucket';
}
get gkProviderId(): GkProviderId {
return 'bitbucket' satisfies Unbrand<GkProviderId> as Brand<GkProviderId>;
}
get name() {
return this.formatName('Bitbucket');
}

+ 7
- 1
src/git/remotes/custom.ts View File

@ -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() class="o">: RemoteProviderId {
return 'custom';
}
get gkProviderId(): GkProviderId | undefined {
return undefined;
}
get name() {
return this.formatName('Custom');
}

+ 7
- 1
src/git/remotes/gerrit.ts View File

@ -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() class="o">: RemoteProviderId {
return 'gerrit';
}
get gkProviderId(): GkProviderId | undefined {
return undefined; // TODO@eamodio DRAFTS add this when supported by backend
}
get name() {
return this.formatName('Gerrit');
}

+ 7
- 1
src/git/remotes/gitea.ts View File

@ -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() class="o">: RemoteProviderId {
return 'gitea';
}
get gkProviderId(): GkProviderId | undefined {
return undefined; // TODO@eamodio DRAFTS add this when supported by backend
}
get name() {
return this.formatName('Gitea');
}

+ 10
- 1
src/git/remotes/github.ts View File

@ -4,10 +4,12 @@ import type { Autolink, DynamicAutolinkReference, MaybeEnrichedAutolink } from '
import type { AutolinkReference } from '../../config';
import { GlyphChars } from '../../constants';
import type { Container } from '../../container';
import type { GkProviderId } from '../../gk/models/repositoryIdentities';
import type {
IntegrationAuthenticationProvider,
IntegrationAuthenticationSessionDescriptor,
} from '../../plus/integrationAuthentication';
import type { Brand, Unbrand } from '../../system/brand';
import { fromNow } from '../../system/date';
import { log } from '../../system/decorators/log';
import { memoize } from '../../system/decorators/memoize';
@ -22,6 +24,7 @@ import type { PullRequest, PullRequestState, SearchedPullRequest } from '../mode
import { isSha } from '../models/reference';
import type { Repository } from '../models/repository';
import type { RepositoryMetadata } from '../models/repositoryMetadata';
import type { RemoteProviderId } from './remoteProvider';
import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider';
const autolinkFullIssuesRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?#([0-9]+)\b(?!]\()/g;
@ -188,10 +191,16 @@ export class GitHubRemote extends RichRemoteProvider
return 'github';
}
get id() {
get id() class="o">: RemoteProviderId {
return 'github';
}
get gkProviderId(): GkProviderId {
return (!isGitHubDotCom(this.domain)
? 'githubEnterprise'
: 'github') satisfies Unbrand<GkProviderId> as Brand<GkProviderId>;
}
get name() {
return this.formatName('GitHub');
}

+ 14
- 1
src/git/remotes/gitlab.ts View File

@ -4,10 +4,12 @@ import type { Autolink, DynamicAutolinkReference, MaybeEnrichedAutolink } from '
import type { AutolinkReference } from '../../config';
import { GlyphChars } from '../../constants';
import type { Container } from '../../container';
import type { GkProviderId } from '../../gk/models/repositoryIdentities';
import type {
IntegrationAuthenticationProvider,
IntegrationAuthenticationSessionDescriptor,
} from '../../plus/integrationAuthentication';
import type { Brand, Unbrand } from '../../system/brand';
import { fromNow } from '../../system/date';
import { log } from '../../system/decorators/log';
import { encodeUrl } from '../../system/encoding';
@ -21,6 +23,7 @@ import type { PullRequest, PullRequestState, SearchedPullRequest } from '../mode
import { isSha } from '../models/reference';
import type { Repository } from '../models/repository';
import type { RepositoryMetadata } from '../models/repositoryMetadata';
import type { RemoteProviderId } from './remoteProvider';
import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider';
const autolinkFullIssuesRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?#([0-9]+)\b(?!]\()/g;
@ -30,6 +33,10 @@ const rangeRegex = /^L(\d+)(?:-(\d+))?$/;
const authProvider = Object.freeze({ id: 'gitlab', scopes: ['read_api', 'read_user', 'read_repository'] });
function isGitLabDotCom(domain: string): boolean {
return equalsIgnoreCase(domain, 'gitlab.com');
}
type GitLabRepositoryDescriptor =
| {
owner: string;
@ -271,10 +278,16 @@ export class GitLabRemote extends RichRemoteProvider
return 'gitlab';
}
get id() {
get id() class="o">: RemoteProviderId {
return 'gitlab';
}
get gkProviderId(): GkProviderId {
return (!isGitLabDotCom(this.domain)
? 'gitlabSelfHosted'
: 'gitlab') satisfies Unbrand<GkProviderId> as Brand<GkProviderId>;
}
get name() {
return this.formatName('GitLab');
}

+ 7
- 1
src/git/remotes/google-source.ts View File

@ -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() class="o">: 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');
}

+ 19
- 2
src/git/remotes/remoteProvider.ts View File

@ -2,6 +2,7 @@ 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 { memoize } from '../../system/decorators/memoize';
import { encodeUrl } from '../../system/encoding';
import type { RemoteProviderReference } from '../models/remoteProvider';
@ -10,6 +11,17 @@ import { RemoteResourceType } from '../models/remoteResource';
import type { Repository } from '../models/repository';
import type { RichRemoteProvider } from './richRemoteProvider';
export type RemoteProviderId =
| 'azure-devops'
| 'bitbucket'
| 'bitbucket-server'
| 'custom'
| 'gerrit'
| 'gitea'
| 'github'
| 'gitlab'
| 'google-source';
export abstract class RemoteProvider implements RemoteProviderReference {
readonly type: 'simple' | 'rich' = 'simple';
protected readonly _name: string | undefined;
@ -46,10 +58,15 @@ export abstract class RemoteProvider implements RemoteProviderReference {
}
get owner(): string | undefined {
return this.path.split('/')[0];
return this.splitPath()[0];
}
get repoName(): string | undefined {
return this.splitPath()[1];
}
abstract get id(): string;
abstract get id(): RemoteProviderId;
abstract get gkProviderId(): GkProviderId | undefined;
abstract get name(): string;
async copy(resource: RemoteResource): Promise<void> {

+ 7
- 2
src/git/remotes/remoteProviders.ts View File

@ -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';
@ -137,10 +138,14 @@ 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);
createBestRemoteProvider(container, providers!, url, domain, path);
}
function createBestRemoteProvider(

+ 218
- 0
src/gk/models/drafts.ts View File

@ -0,0 +1,218 @@
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 interface Draft {
readonly draftType: 'cloud';
readonly type: 'patch' | 'stash';
readonly id: string;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly author: {
id: string;
name: string;
email: string | undefined;
avatar?: string;
};
readonly organizationId?: string;
readonly isPublished: boolean;
readonly title: string;
readonly description?: string;
readonly deepLinkUrl: string;
readonly deepLinkAccess: 'public' | 'private';
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 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;
}
export interface CreateDraftPatchRequestFromChange {
contents: string;
patch: DraftPatchCreateRequest;
repository: Repository;
user: GitUser | undefined;
}
export interface CreateDraftRequest {
type: 'patch' | 'stash';
title: string;
description?: string;
isPublic: boolean;
organizationId?: string;
}
export interface CreateDraftResponse {
id: string;
deepLink: string;
}
export interface DraftResponse {
readonly type: 'patch' | 'stash';
readonly id: string;
readonly createdAt: string;
readonly updatedAt: string;
readonly createdBy: string;
readonly organizationId?: string;
readonly deepLink: string;
readonly isPublic: boolean;
readonly isPublished: boolean;
readonly latestChangesetId: string;
readonly title: string;
readonly description?: string;
}
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;
}
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: {
readonly Host: string[];
};
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: {
readonly Host: string[];
};
readonly method: string;
readonly url: string;
};
}

+ 83
- 0
src/gk/models/repositoryIdentities.ts View File

@ -0,0 +1,83 @@
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<string, 'GkRepositoryId'>;
export interface RepositoryIdentity {
readonly id: GkRepositoryId;
readonly createdAt: Date;
readonly updatedAt: Date;
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;
};
}
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;
};
}

+ 12
- 0
src/plus/drafts/actions.ts View File

@ -0,0 +1,12 @@
import { Container } from '../../container';
import type { WebviewViewShowOptions } from '../../webviews/webviewsController';
import type { ShowCreateDraft, ShowViewDraft } from '../webviews/patchDetails/registration';
type ShowCreateOrOpen = ShowCreateDraft | ShowViewDraft;
export function showPatchesView(createOrOpen: ShowCreateOrOpen, options?: WebviewViewShowOptions): Promise<void> {
if (createOrOpen.mode === 'create') {
options = { ...options, preserveFocus: false, preserveVisibility: false };
}
return Container.instance.patchDetailsView.show(options, createOrOpen);
}

+ 520
- 0
src/plus/drafts/draftsService.ts View File

@ -0,0 +1,520 @@
import type { Disposable } from 'vscode';
import type { Container } from '../../container';
import { isSha, isUncommitted } from '../../git/models/reference';
import { isRepository } from '../../git/models/repository';
import type { GitUser } from '../../git/models/user';
import type {
CreateDraftChange,
CreateDraftPatchRequestFromChange,
CreateDraftRequest,
CreateDraftResponse,
Draft,
DraftChangeset,
DraftChangesetCreateRequest,
DraftChangesetCreateResponse,
DraftChangesetResponse,
DraftPatch,
DraftPatchDetails,
DraftPatchResponse,
DraftResponse,
} from '../../gk/models/drafts';
import type { RepositoryIdentityRequest } from '../../gk/models/repositoryIdentities';
import { log } from '../../system/decorators/log';
import { Logger } from '../../system/logger';
import { getLogScope } from '../../system/logger.scope';
import { getSettledValue } from '../../system/promise';
import type { ServerConnection } from '../gk/serverConnection';
export class DraftService implements Disposable {
constructor(
private readonly container: Container,
private readonly connection: ServerConnection,
) {}
dispose(): void {}
@log({ args: { 2: false } })
async createDraft(
type: 'patch' | 'stash',
title: string,
changes: CreateDraftChange[],
options?: { description?: string; organizationId?: string },
): Promise<Draft> {
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 };
// POST v1/drafts
const createDraftRsp = await this.connection.fetchGkDevApi('v1/drafts', {
method: 'POST',
body: JSON.stringify({
type: type,
title: title,
description: options?.description,
isPublic: true /*organizationId: undefined,*/,
} satisfies CreateDraftRequest),
});
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),
});
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}`);
}
// Upload patch to returned S3 url
await this.connection.fetchRaw(url, {
method: method,
headers: {
'Content-Type': 'plain/text',
Host: headers?.['Host']?.['0'] ?? '',
},
body: contents,
});
patches.push({
type: 'cloud',
id: patch.id,
createdAt: new Date(patch.createdAt),
updatedAt: new Date(patch.updatedAt ?? patch.createdAt),
draftId: patch.draftId,
changesetId: patch.changesetId,
userId: createChangeset.userId,
baseBranchName: patch.baseBranchName,
baseRef: patch.baseCommitSha,
gkRepositoryId: patch.gitRepositoryId,
secureLink: undefined!, // patch.secureDownloadData,
contents: contents,
repository: repository,
});
}
// POST /v1/drafts/:draftId/publish
const publishRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}/publish`, { method: 'POST' });
if (!publishRsp.ok) throw new Error(`Failed to publish draft: ${publishRsp.statusText}`);
type Result = { data: DraftResponse };
const draftRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}`, { method: 'GET' });
const draft = ((await draftRsp.json()) as Result).data;
const author: Draft['author'] = {
id: draft.createdBy,
name: undefined!,
email: undefined,
};
const { account } = await this.container.subscription.getSubscription();
if (draft.createdBy === account?.id) {
author.name = `${account.name} (you)`;
author.email = account.email;
}
return {
draftType: 'cloud',
type: draft.type,
id: draftId,
createdAt: new Date(draft.createdAt),
updatedAt: new Date(draft.updatedAt ?? draft.createdAt),
author: author,
organizationId: draft.organizationId || undefined,
isPublished: draft.isPublished,
title: draft.title,
description: draft.description,
deepLinkUrl: createDraft.deepLink,
deepLinkAccess: draft.isPublic ? 'public' : 'private',
latestChangesetId: draft.latestChangesetId,
changesets: [
{
id: createChangeset.id,
createdAt: new Date(createChangeset.createdAt),
updatedAt: new Date(createChangeset.updatedAt ?? createChangeset.createdAt),
draftId: createChangeset.draftId,
parentChangesetId: createChangeset.parentChangesetId,
userId: createChangeset.userId,
gitUserName: createChangeset.gitUserName,
gitUserEmail: createChangeset.gitUserEmail,
deepLinkUrl: createChangeset.deepLink,
patches: patches,
},
],
} satisfies Draft;
} catch (ex) {
debugger;
Logger.error(ex, scope);
throw ex;
}
}
private async getCreateDraftPatchRequestFromChange(
change: CreateDraftChange,
): Promise<CreateDraftPatchRequestFromChange> {
const [branchNamesResult, diffResult, firstShaResult, remoteResult, userResult] = await Promise.allSettled([
isUncommitted(change.revision.sha)
? this.container.git.getBranch(change.repository.uri).then(b => (b != null ? [b.name] : undefined))
: this.container.git.getCommitBranches(change.repository.uri, change.revision.sha),
change.contents == null
? this.container.git.getDiff(change.repository.path, change.revision.sha, change.revision.baseSha)
: 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
? {
id: remote.provider.gkProviderId,
repoDomain: remote.provider.domain,
repoName: remote.provider.path,
// repoOwnerDomain: ??
}
: undefined,
};
}
const diff = getSettledValue(diffResult);
const contents = change.contents ?? diff?.contents;
if (contents == null) throw new Error(`Unable to diff ${change.revision.baseSha} and ${change.revision.sha}`);
const user = getSettledValue(userResult);
const branchNames = getSettledValue(branchNamesResult);
const branchName = branchNames?.[0] ?? '';
let baseSha = change.revision.baseSha;
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,
},
contents: contents,
repository: change.repository,
user: user,
};
}
@log()
async deleteDraft(id: string): Promise<void> {
await this.connection.fetchGkDevApi(`v1/drafts/${id}`, { method: 'DELETE' });
}
@log()
async getDraft(id: string): Promise<Draft> {
type Result = { data: DraftResponse };
const [rspResult, changesetsResult] = await Promise.allSettled([
this.connection.fetchGkDevApi(`v1/drafts/${id}`, { method: 'GET' }),
this.getChangesets(id),
]);
const rsp = getSettledValue(rspResult);
if (rsp?.ok === false) {
Logger.error(undefined, `Getting draft failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const draft = ((await rsp.json()) as Result).data;
const changesets = getSettledValue(changesetsResult) ?? [];
const author: Draft['author'] = {
id: draft.createdBy,
name: undefined!,
email: undefined,
};
const { account } = await this.container.subscription.getSubscription();
if (draft.createdBy === account?.id) {
author.name = `${account.name} (you)`;
author.email = account.email;
}
return {
draftType: 'cloud',
type: draft.type,
id: draft.id,
createdAt: new Date(draft.createdAt),
updatedAt: new Date(draft.updatedAt ?? draft.createdAt),
author: author,
organizationId: draft.organizationId || undefined,
isPublished: draft.isPublished,
title: draft.title,
description: draft.description,
deepLinkUrl: draft.deepLink,
deepLinkAccess: draft.isPublic ? 'public' : 'private',
latestChangesetId: draft.latestChangesetId,
changesets: changesets,
};
}
@log()
async getDrafts(): Promise<Draft[]> {
type Result = { data: DraftResponse[] };
const rsp = await this.connection.fetchGkDevApi('/v1/drafts', { method: 'GET' });
const draft = ((await rsp.json()) as Result).data;
const { account } = await this.container.subscription.getSubscription();
return draft.map(
(d): Draft => ({
draftType: 'cloud',
type: d.type,
id: d.id,
author:
d.createdBy === account?.id
? { id: d.createdBy, name: `${account.name} (you)`, email: account.email }
: { id: d.createdBy, name: 'Unknown', email: undefined },
organizationId: d.organizationId || undefined,
isPublished: d.isPublished,
title: d.title,
description: d.description,
deepLinkUrl: d.deepLink,
deepLinkAccess: d.isPublic ? 'public' : 'private',
createdAt: new Date(d.createdAt),
updatedAt: new Date(d.updatedAt ?? d.createdAt),
latestChangesetId: d.latestChangesetId,
}),
);
}
@log()
async getChangesets(id: string): Promise<DraftChangeset[]> {
type Result = { data: DraftChangesetResponse[] };
const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/changesets`, { method: 'GET' });
const changeset = ((await rsp.json()) as Result).data;
const changesets: DraftChangeset[] = [];
for (const c of changeset) {
const patches: DraftPatch[] = [];
// const repoPromises = Promise.allSettled(c.patches.map(p => this.getRepositoryForGkId(p.gitRepositoryId)));
for (const p of c.patches) {
// const repoData = await this.getRepositoryData(p.gitRepositoryId);
// const repo = await this.container.git.findMatchingRepository({
// firstSha: repoData.initialCommitSha,
// remoteUrl: repoData.remote?.url,
// });
patches.push({
type: 'cloud',
id: p.id,
createdAt: new Date(p.createdAt),
updatedAt: new Date(p.updatedAt ?? p.createdAt),
draftId: p.draftId,
changesetId: p.changesetId,
userId: c.userId,
baseBranchName: p.baseBranchName,
baseRef: p.baseCommitSha,
gkRepositoryId: p.gitRepositoryId,
secureLink: p.secureDownloadData,
// // TODO@eamodio FIX THIS
// repository: repo,
// repoData: repoData,
});
}
changesets.push({
id: c.id,
createdAt: new Date(c.createdAt),
updatedAt: new Date(c.updatedAt ?? c.createdAt),
draftId: c.draftId,
parentChangesetId: c.parentChangesetId,
userId: c.userId,
gitUserName: c.gitUserName,
gitUserEmail: c.gitUserEmail,
deepLinkUrl: c.deepLink,
patches: patches,
});
}
return changesets;
}
@log()
async getPatch(id: string): Promise<DraftPatch> {
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<DraftPatch> {
type Result = { data: DraftPatchResponse };
// GET /v1/patches/:patchId
const rsp = await this.connection.fetchGkDevApi(`/v1/patches/${id}`, { method: 'GET' });
const data = ((await rsp.json()) as Result).data;
return {
type: 'cloud',
id: data.id,
createdAt: new Date(data.createdAt),
updatedAt: new Date(data.updatedAt ?? data.createdAt),
draftId: data.draftId,
changesetId: data.changesetId,
userId: data.userId,
baseBranchName: data.baseBranchName,
baseRef: data.baseCommitSha,
gkRepositoryId: data.gitRepositoryId,
secureLink: data.secureDownloadData,
};
}
async getPatchDetails(id: string): Promise<DraftPatchDetails>;
async getPatchDetails(patch: DraftPatch): Promise<DraftPatchDetails>;
@log<DraftService['getPatchDetails']>({
args: { 0: idOrPatch => (typeof idOrPatch === 'string' ? idOrPatch : idOrPatch.id) },
})
async getPatchDetails(idOrPatch: string | DraftPatch): Promise<DraftPatchDetails> {
const patch = typeof idOrPatch === 'string' ? await this.getPatchCore(idOrPatch) : idOrPatch;
const [contentsResult, repositoryResult] = await Promise.allSettled([
this.getPatchContentsCore(patch.secureLink),
this.container.repositoryIdentity.getRepositoryOrIdentity(patch.gkRepositoryId),
]);
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<string | undefined> {
const { url, method, headers } = secureLink;
// Download patch from returned S3 url
const contentsRsp = await this.connection.fetchRaw(url, {
method: method,
headers: {
Accept: 'text/plain',
Host: headers?.['Host']?.['0'] ?? '',
},
});
return contentsRsp.text();
}
}

+ 1
- 108
src/plus/focus/focusService.ts View File

@ -1,13 +1,12 @@
import type { Disposable } from 'vscode';
import { window } from 'vscode';
import type { Container } from '../../container';
import type { GitRemote } from '../../git/models/remote';
import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider';
import { log } from '../../system/decorators/log';
import { Logger } from '../../system/logger';
import { getLogScope } from '../../system/logger.scope';
import { isSubscriptionPaidPlan } from '../gk/account/subscription';
import type { ServerConnection } from '../gk/serverConnection';
import { ensureAccount, ensurePaidPlan } from '../utils';
export interface FocusItem {
type: EnrichedItemResponse['entityType'];
@ -189,109 +188,3 @@ export class FocusService implements Disposable {
return this.delete(id, 'unsnooze');
}
}
async function ensurePaidPlan(title: string, container: Container): Promise<boolean> {
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) {
const signIn = { title: 'Start Free GitKraken Trial' };
const cancel = { title: 'Cancel', isCloseAffordance: true };
const result = await window.showWarningMessage(
`${title}\n\nTry our developer productivity and collaboration services free for 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\nContinue to use our developer productivity and collaboration services.`,
{ modal: true },
upgrade,
cancel,
);
if (result === upgrade) {
void container.subscription.purchase();
}
}
return false;
}
return true;
}
async function ensureAccount(title: string, container: Container): Promise<boolean> {
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;
}
if (subscription.account != null) break;
const signIn = { title: 'Sign In / Sign Up' };
const cancel = { title: 'Cancel', isCloseAffordance: true };
const result = await window.showWarningMessage(
`${title}\n\nGain access to our developer productivity and collaboration services.`,
{ modal: true },
signIn,
cancel,
);
if (result === signIn) {
if (await container.subscription.loginOrSignUp()) {
continue;
}
}
return false;
}
return true;
}

+ 19
- 0
src/plus/gk/serverConnection.ts View File

@ -136,6 +136,25 @@ export class ServerConnection implements Disposable {
return this.fetch(this.getGkDevApiUrl(path), init, token);
}
async fetchRaw(url: RequestInfo, init?: RequestInit): Promise<Response> {
const scope = getLogScope();
try {
const options = {
agent: getProxyAgent(),
...init,
headers: {
'User-Agent': this.userAgent,
...init?.headers,
},
};
return await _fetch(url, 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;

+ 163
- 0
src/plus/repos/repositoryIdentityService.ts View File

@ -0,0 +1,163 @@
import type { Disposable } from 'vscode';
import { Uri } from 'vscode';
import type { Container } from '../../container';
import { shortenRevision } from '../../git/models/reference';
import type { Repository } from '../../git/models/repository';
import { parseGitRemoteUrl } from '../../git/parsers/remoteParser';
import { getRemoteProviderMatcher } from '../../git/remotes/remoteProviders';
import type {
GkRepositoryId,
RepositoryIdentity,
RepositoryIdentityResponse,
} 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 {}
getRepository(
id: GkRepositoryId,
options?: { openIfNeeded?: boolean; prompt?: boolean },
): Promise<Repository | undefined>;
getRepository(
identity: RepositoryIdentity,
options?: { openIfNeeded?: boolean; prompt?: boolean },
): Promise<Repository | undefined>;
@log()
getRepository(
idOrIdentity: GkRepositoryId | RepositoryIdentity,
options?: { openIfNeeded?: boolean },
): Promise<Repository | undefined> {
return this.locateRepository(idOrIdentity, options);
}
@log()
async getRepositoryOrIdentity(
id: GkRepositoryId,
options?: { openIfNeeded?: boolean; prompt?: boolean },
): Promise<Repository | RepositoryIdentity> {
const identity = await this.getRepositoryIdentity(id);
return (await this.locateRepository(identity, options)) ?? identity;
}
private async locateRepository(
id: GkRepositoryId,
options?: { openIfNeeded?: boolean; prompt?: boolean },
): Promise<Repository | undefined>;
private async locateRepository(
identity: RepositoryIdentity,
options?: { openIfNeeded?: boolean; prompt?: boolean },
): Promise<Repository | undefined>;
private async locateRepository(
idOrIdentity: GkRepositoryId | RepositoryIdentity,
options?: { openIfNeeded?: boolean; prompt?: boolean },
): Promise<Repository | undefined>;
@log()
private async locateRepository(
idOrIdentity: GkRepositoryId | RepositoryIdentity,
options?: { openIfNeeded?: boolean; prompt?: boolean },
): Promise<Repository | undefined> {
const identity =
typeof idOrIdentity === 'string' ? await this.getRepositoryIdentity(idOrIdentity) : idOrIdentity;
const matches = 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: true });
}
} 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 (identity.initialCommitSha != null && identity.initialCommitSha !== 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.uri, identity.initialCommitSha)) {
foundRepo = repo;
break;
}
}
}
}
if (foundRepo == null && options?.prompt) {
// TODO@eamodio prompt the user here if we pass in
}
return foundRepo;
}
@log()
async getRepositoryIdentity(id: GkRepositoryId): Promise<RepositoryIdentity> {
type Result = { data: RepositoryIdentityResponse };
const rsp = await this.connection.fetchGkDevApi(`/v1/git-repositories/${id}`, { 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,
};
}
}

+ 109
- 0
src/plus/utils.ts View File

@ -0,0 +1,109 @@
import { window } from 'vscode';
import type { Container } from '../container';
import { isSubscriptionPaidPlan } from './gk/account/subscription';
export async function ensurePaidPlan(title: string, container: Container): Promise<boolean> {
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) {
const signIn = { title: 'Start Free GitKraken Trial' };
const cancel = { title: 'Cancel', isCloseAffordance: true };
const result = await window.showWarningMessage(
`${title}\n\nTry our developer productivity and collaboration services free for 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\nContinue to use our developer productivity and collaboration services.`,
{ modal: true },
upgrade,
cancel,
);
if (result === upgrade) {
void container.subscription.purchase();
}
}
return false;
}
return true;
}
export async function ensureAccount(title: string, container: Container): Promise<boolean> {
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;
}
if (subscription.account != null) break;
const signIn = { title: 'Sign In / Sign Up' };
const cancel = { title: 'Cancel', isCloseAffordance: true };
const result = await window.showWarningMessage(
`${title}\n\nGain access to our developer productivity and collaboration services.`,
{ modal: true },
signIn,
cancel,
);
if (result === signIn) {
if (await container.subscription.loginOrSignUp()) {
continue;
}
}
return false;
}
return true;
}

+ 897
- 0
src/plus/webviews/patchDetails/patchDetailsWebview.ts View File

@ -0,0 +1,897 @@
import type { ConfigurationChangeEvent } from 'vscode';
import { Disposable, env, Uri, window } from 'vscode';
import type { CoreConfiguration } from '../../../constants';
import { Commands } from '../../../constants';
import type { Container } from '../../../container';
import { openChanges, openChangesWithWorking, openFile } from '../../../git/actions/commit';
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 } from '../../../git/models/reference';
import { isRepository } from '../../../git/models/repository';
import type { CreateDraftChange, Draft, DraftPatch, DraftPatchFileChange, LocalDraft } from '../../../gk/models/drafts';
import type { GkRepositoryId } from '../../../gk/models/repositoryIdentities';
import { executeCommand, registerCommand } from '../../../system/command';
import { configuration } from '../../../system/configuration';
import { setContext } from '../../../system/context';
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 type { IpcMessage } from '../../../webviews/protocol';
import { onIpc } from '../../../webviews/protocol';
import type { WebviewController, WebviewProvider } from '../../../webviews/webviewController';
import type { WebviewShowOptions } from '../../../webviews/webviewsController';
import { showPatchesView } from '../../drafts/actions';
import type { ShowInCommitGraphCommandArgs } from '../graph/protocol';
import type {
ApplyPatchParams,
Change,
CreatePatchParams,
DidExplainParams,
FileActionParams,
Mode,
Preferences,
State,
SwitchModeParams,
UpdateablePreferences,
UpdateCreatePatchMetadataParams,
UpdateCreatePatchRepositoryCheckedStateParams,
} from './protocol';
import {
ApplyPatchCommandType,
CopyCloudLinkCommandType,
CreatePatchCommandType,
DidChangeCreateNotificationType,
DidChangeDraftNotificationType,
DidChangeNotificationType,
DidChangePreferencesNotificationType,
DidExplainCommandType,
ExplainCommandType,
OpenFileCommandType,
OpenFileComparePreviousCommandType,
OpenFileCompareWorkingCommandType,
OpenInCommitGraphCommandType,
SwitchModeCommandType,
UpdateCreatePatchMetadataCommandType,
UpdateCreatePatchRepositoryCheckedStateCommandType,
UpdatePreferencesCommandType,
} from './protocol';
import type { CreateDraft, PatchDetailsWebviewShowingArgs } from './registration';
import type { RepositoryChangeset } from './repositoryChangeset';
import { RepositoryRefChangeset, RepositoryWipChangeset } from './repositoryChangeset';
interface Context {
mode: Mode;
draft: LocalDraft | Draft | undefined;
create:
| {
title?: string;
description?: string;
changes: Map<string, RepositoryChangeset>;
showingAllRepos: boolean;
}
| undefined;
preferences: Preferences;
}
export class PatchDetailsWebviewProvider
implements WebviewProvider<State, Serialized<State>, PatchDetailsWebviewShowingArgs>
{
private _context: Context;
private readonly _disposable: Disposable;
constructor(
private readonly container: Container,
private readonly host: WebviewController<State, Serialized<State>, PatchDetailsWebviewShowingArgs>,
) {
this._context = {
mode: 'create',
draft: undefined,
create: undefined,
preferences: this.getPreferences(),
};
this.setHostTitle();
this.host.description = 'PREVIEW ☁️';
this._disposable = Disposable.from(
configuration.onDidChangeAny(this.onAnyConfigurationChanged, this),
container.git.onDidChangeRepositories(this.onRepositoriesChanged, this),
);
}
dispose() {
this._disposable.dispose();
}
async onShowing(
_loading: boolean,
options: WebviewShowOptions,
...args: PatchDetailsWebviewShowingArgs
): Promise<boolean> {
const [arg] = args;
if (arg?.mode === 'view' && arg.draft != null) {
this.updateViewDraftState(arg.draft);
} 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<Serialized<State>> {
return this.getState(this._context);
}
registerCommands(): Disposable[] {
return [
registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)),
registerCommand(`${this.host.id}.close`, () => this.closeView()),
];
}
onMessageReceived(e: IpcMessage) {
switch (e.method) {
case ApplyPatchCommandType.method:
onIpc(ApplyPatchCommandType, e, params => this.applyPatch(params));
break;
case CopyCloudLinkCommandType.method:
onIpc(CopyCloudLinkCommandType, e, () => this.copyCloudLink());
break;
// case CreateFromLocalPatchCommandType.method:
// onIpc(CreateFromLocalPatchCommandType, e, () => this.shareLocalPatch());
// break;
case CreatePatchCommandType.method:
onIpc(CreatePatchCommandType, e, params => this.createDraft(params));
break;
case ExplainCommandType.method:
onIpc(ExplainCommandType, e, () => this.explainPatch(e.completionId));
break;
case OpenFileComparePreviousCommandType.method:
onIpc(
OpenFileComparePreviousCommandType,
e,
params => void this.openFileComparisonWithPrevious(params),
);
break;
case OpenFileCompareWorkingCommandType.method:
onIpc(OpenFileCompareWorkingCommandType, e, params => void this.openFileComparisonWithWorking(params));
break;
case OpenFileCommandType.method:
onIpc(OpenFileCommandType, e, params => void this.openFile(params));
break;
case OpenInCommitGraphCommandType.method:
onIpc(
OpenInCommitGraphCommandType,
e,
params =>
void executeCommand<ShowInCommitGraphCommandArgs>(Commands.ShowInCommitGraph, {
ref: createReference(params.ref, params.repoPath, { refType: 'revision' }),
}),
);
break;
// case SelectPatchBaseCommandType.method:
// onIpc(SelectPatchBaseCommandType, e, () => void this.selectPatchBase());
// break;
// case SelectPatchRepoCommandType.method:
// onIpc(SelectPatchRepoCommandType, e, () => void this.selectPatchRepo());
// break;
case SwitchModeCommandType.method:
onIpc(SwitchModeCommandType, e, params => this.switchMode(params));
break;
case UpdateCreatePatchMetadataCommandType.method:
onIpc(UpdateCreatePatchMetadataCommandType, e, params => this.updateCreateMetadata(params));
break;
case UpdateCreatePatchRepositoryCheckedStateCommandType.method:
onIpc(UpdateCreatePatchRepositoryCheckedStateCommandType, e, params =>
this.updateCreateCheckedState(params),
);
break;
case UpdatePreferencesCommandType.method:
onIpc(UpdatePreferencesCommandType, e, params => this.updatePreferences(params));
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.changedAny<CoreConfiguration>(e, 'workbench.tree.renderIndentGuides') ||
configuration.changedAny<CoreConfiguration>(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.getAny<CoreConfiguration, Preferences['indentGuides']>(
'workbench.tree.renderIndentGuides',
) ?? 'onHover',
indent: configuration.getAny<CoreConfiguration, Preferences['indent']>('workbench.tree.indent'),
};
}
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,
{ baseSha: 'HEAD', sha: uncommitted },
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', mode);
if (!silent) {
this.updateState(true);
}
}
private setHostTitle(mode: Mode = this._context.mode) {
this.host.title = mode === 'create' ? 'Create Cloud Patch' : 'Cloud Patch Details';
}
private 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) return;
if (this._context.draft.draftType === 'local') return;
const draft = this._context.draft;
const changeset = draft.changesets?.[0];
if (changeset == null) return;
console.log(changeset);
}
private closeView() {
void setContext('gitlens:views:patchDetails:mode', undefined);
}
private copyCloudLink() {
if (this._context.draft?.draftType !== 'cloud') return;
void env.clipboard.writeText(this._context.draft.deepLinkUrl);
}
private async createDraft({ title, changesets, description }: CreatePatchParams): Promise<void> {
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, sha: uncommittedStaged };
}
createChanges.push({
repository: repository,
revision: revision,
});
}
if (createChanges == null) return;
try {
const draft = await this.container.drafts.createDraft(
'patch',
title,
createChanges,
description ? { description: description } : undefined,
);
async function showNotification() {
const view = { title: 'View Patch' };
const copy = { title: 'Copy Link' };
while (true) {
const result = await window.showInformationMessage(
'Cloud Patch successfully created \u2014 link copied to the clipboard',
view,
copy,
);
if (result === copy) {
void env.clipboard.writeText(draft.deepLinkUrl);
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 explainPatch(completionId?: string) {
if (this._context.draft?.draftType !== 'cloud') 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) return;
const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId);
if (commit == null) return;
const summary = await this.container.ai.explainCommit(commit, {
progress: { location: { viewId: this.host.id } },
});
params = { summary: summary };
} catch (ex) {
debugger;
params = { error: { message: ex.message } };
}
void this.host.notify(DidExplainCommandType, params, completionId);
}
private async openPatchContents(_params: FileActionParams) {
// TODO@eamodio Open the patch contents for the selected repo in an untitled editor
}
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;
void this.notifyDidChangeCreateDraftState();
}
// 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<Serialized<State>> {
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<State>({
...this.host.baseWebviewState,
mode: current.mode,
create: create,
draft: draft,
preferences: current.preferences,
});
return state;
}
private async notifyDidChangeState() {
this._notifyDidChangeStateDebounced?.cancel();
return this.host.notify(DidChangeNotificationType, { state: await this.getState(this._context) });
}
private updateCreateDraftState(create: CreateDraft) {
let changesetByRepo: Map<string, RepositoryChangeset>;
let allRepos = false;
if (create.changes != null) {
changesetByRepo = new Map<string, RepositoryChangeset>();
const updated = new Set<string>();
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,
{
baseSha: 'HEAD',
sha: uncommitted,
},
this.onRepositoryWipChanged.bind(this),
false,
true, // TODO revisit
),
]),
);
}
this._context.create = {
title: create.title,
description: create.description,
changes: changesetByRepo,
showingAllRepos: allRepos,
};
this.setMode('create', true);
void this.notifyDidChangeCreateDraftState();
}
private async getCreateDraftState(current: Context): Promise<State['create'] | undefined> {
const { create } = current;
if (create == null) return undefined;
const repoChanges: Record<string, Change> = {};
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,
};
}
private async notifyDidChangeCreateDraftState() {
return this.host.notify(DidChangeCreateNotificationType, {
mode: this._context.mode,
create: await this.getCreateDraftState(this._context),
});
}
private updateViewDraftState(draft: LocalDraft | Draft | undefined) {
this._context.draft = draft;
this.setMode('view', true);
void this.notifyDidChangeViewDraftState();
}
// eslint-disable-next-line @typescript-eslint/require-await
private async getViewDraftState(current: Context): Promise<State['draft'] | undefined> {
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 (
draft.changesets == null ||
some(draft.changesets, cs =>
cs.patches.some(p => p.contents == null || p.files == null || p.repository == null),
)
) {
setTimeout(async () => {
if (draft.changesets == null) {
draft.changesets = await this.container.drafts.getChangesets(draft.id);
}
const patches = draft.changesets
.flatMap(cs => cs.patches)
.filter(p => p.contents == null || p.files == null || p.repository == null);
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;
}
}
}
void this.notifyDidChangeViewDraftState();
}, 0);
}
return {
draftType: 'cloud',
id: draft.id,
createdAt: draft.createdAt.getTime(),
updatedAt: draft.updatedAt.getTime(),
author: draft.author,
title: draft.title,
description: draft.description,
patches: serialize(
draft.changesets![0].patches.map(p => ({
...p,
contents: undefined,
commit: undefined,
repository: {
id: p.gkRepositoryId,
name: p.repository?.name ?? '',
},
})),
),
};
}
return undefined;
}
private async notifyDidChangeViewDraftState() {
return this.host.notify(DidChangeDraftNotificationType, {
mode: this._context.mode,
draft: serialize(await this.getViewDraftState(this._context)),
});
}
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(DidChangePreferencesNotificationType, { preferences: this._context.preferences });
}
private async getDraftPatch(draft: Draft, gkRepositoryId?: GkRepositoryId): Promise<DraftPatch | undefined> {
if (draft.changesets == null) {
const changesets = await this.container.drafts.getChangesets(draft.id);
draft.changesets = changesets;
}
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: FileActionParams,
): Promise<
| [commit: GitCommit, file: GitFileChange, revision?: Required<Omit<PatchRevisionRange, 'branchName'>>]
| 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.sha ?? uncommitted);
if (
change.revision.sha === change.revision.baseSha ||
change.revision.sha === change.revision.baseSha.substring(0, change.revision.baseSha.length - 1)
) {
return [commit];
}
return [commit, change.revision];
} else if (change.type === 'wip') {
return [await this.container.git.getCommit(file.repoPath, change.revision.sha ?? uncommitted)];
}
return [undefined];
}
async getOrCreateCommitForPatch(gkRepositoryId: GkRepositoryId): Promise<GitCommit | undefined> {
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) {
if (!isRepository(patch.repository)) {
const repo = await this.container.repositoryIdentity.getRepository(patch.repository, {
openIfNeeded: true,
prompt: true,
});
if (repo == null) return undefined; // TODO
patch.repository = repo;
}
try {
const commit = await this.container.git.createUnreachableCommitForPatch(
patch.repository.uri,
patch.contents!,
patch.baseRef ?? 'HEAD',
draft.title,
);
patch.commit = commit;
} catch (ex) {
void window.showErrorMessage(`Unable preview the patch on base '${patch.baseRef}': ${ex.message}`);
patch.baseRef = undefined!;
}
}
return patch?.commit;
}
private async openFile(params: FileActionParams) {
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 async openFileComparisonWithPrevious(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file, revision] = result;
void openChanges(
file,
revision != null
? { repoPath: commit.repoPath, ref1: revision.sha ?? uncommitted, ref2: revision.baseSha }
: commit,
{
preserveFocus: true,
preview: true,
...params.showOptions,
rhsTitle: this.mode === 'view' ? `${basename(file.path)} (Patch)` : undefined,
},
);
this.container.events.fire('file:selected', { uri: file.uri }, { source: this.host.id });
}
private async openFileComparisonWithWorking(params: FileActionParams) {
const result = await this.getFileCommitFromParams(params);
if (result == null) return;
const [commit, file, revision] = result;
void openChangesWithWorking(
file,
revision != null ? { repoPath: commit.repoPath, ref: revision.baseSha } : commit,
{
preserveFocus: true,
preview: true,
...params.showOptions,
lhsTitle: this.mode === 'view' ? `${basename(file.path)} (Patch)` : undefined,
},
);
}
}

+ 256
- 0
src/plus/webviews/patchDetails/protocol.ts View File

@ -0,0 +1,256 @@
import type { TextDocumentShowOptions } from 'vscode';
import type { Config } from '../../../config';
import type { WebviewIds, WebviewViewIds } from '../../../constants';
import type { GitFileChangeShape } from '../../../git/models/file';
import type { PatchRevisionRange } from '../../../git/models/patch';
import type { DraftPatch, DraftPatchFileChange } from '../../../gk/models/drafts';
import type { GkRepositoryId } from '../../../gk/models/repositoryIdentities';
import type { DateTimeFormat } from '../../../system/date';
import type { Serialized } from '../../../system/serialize';
import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol';
export const messageHeadlineSplitterToken = '\x00\n\x00';
export type FileShowOptions = TextDocumentShowOptions;
type PatchDetails = Serialized<
Omit<DraftPatch, 'commit' | 'contents' | 'repository'> & { repository: { id: GkRepositoryId; name: string } }
>;
interface LocalDraftDetails {
draftType: 'local';
id?: never;
author?: never;
createdAt?: never;
updatedAt?: never;
title?: string;
description?: string;
patches?: PatchDetails[];
// files?: GitFileChangeShape[];
// stats?: GitCommitStats;
// repoPath?: string;
// repoName?: string;
// baseRef?: string;
// commit?: string;
}
interface CloudDraftDetails {
draftType: 'cloud';
id: string;
createdAt: number;
updatedAt: number;
author: {
id: string;
name: string;
email: string | undefined;
avatar?: string;
};
title: string;
description?: string;
patches?: PatchDetails[];
// commit?: string;
// files?: GitFileChangeShape[];
// stats?: GitCommitStats;
// repoPath: string;
// repoName?: string;
// baseRef?: string;
}
export type DraftDetails = LocalDraftDetails | CloudDraftDetails;
// export interface RangeRef {
// baseSha: string;
// sha: string | undefined;
// branchName: string;
// // shortSha: string;
// // summary: string;
// // message: string;
// // author: GitCommitIdentityShape & { avatar: string | undefined };
// // committer: GitCommitIdentityShape & { avatar: string | undefined };
// // parents: string[];
// // repoPath: string;
// // stashNumber?: 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<Pick<Preferences, 'files'>>;
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 RepoCommitChange {
// type: 'commit';
// repoName: string;
// repoUri: string;
// change: Change;
// checked: boolean;
// expanded: boolean;
// }
// export interface RepoWipChange {
// type: 'wip';
// repoName: string;
// repoUri: string;
// change: Change | undefined;
// checked: boolean | 'staged';
// expanded: boolean;
// }
// export type RepoChangeSet = RepoCommitChange | RepoWipChange;
export interface State {
webviewId: WebviewIds | WebviewViewIds;
timestamp: number;
mode: Mode;
preferences: Preferences;
draft?: DraftDetails;
create?: {
title?: string;
description?: string;
changes: Record<string, Change>;
creationError?: string;
};
}
export type ShowCommitDetailsViewCommandArgs = string[];
// COMMANDS
export interface ApplyPatchParams {
details: DraftDetails;
targetRef?: string; // a branch name. default to HEAD if not supplied
selected: PatchDetails['id'][];
}
export const ApplyPatchCommandType = new IpcCommandType<ApplyPatchParams>('patch/apply');
export interface CreatePatchParams {
title: string;
description?: string;
changesets: Record<string, Change>;
}
export const CreatePatchCommandType = new IpcCommandType<CreatePatchParams>('patch/create');
export interface OpenInCommitGraphParams {
repoPath: string;
ref: string;
}
export const OpenInCommitGraphCommandType = new IpcCommandType<OpenInCommitGraphParams>('patch/openInGraph');
export interface SelectPatchRepoParams {
repoPath: string;
}
export const SelectPatchRepoCommandType = new IpcCommandType<undefined>('patch/selectRepo');
export const SelectPatchBaseCommandType = new IpcCommandType<undefined>('patch/selectBase');
export interface FileActionParams extends DraftPatchFileChange {
showOptions?: TextDocumentShowOptions;
}
export const FileActionsCommandType = new IpcCommandType<FileActionParams>('patch/file/actions');
export const OpenFileCommandType = new IpcCommandType<FileActionParams>('patch/file/open');
export const OpenFileOnRemoteCommandType = new IpcCommandType<FileActionParams>('patch/file/openOnRemote');
export const OpenFileCompareWorkingCommandType = new IpcCommandType<FileActionParams>('patch/file/compareWorking');
export const OpenFileComparePreviousCommandType = new IpcCommandType<FileActionParams>('patch/file/comparePrevious');
export const ExplainCommandType = new IpcCommandType<undefined>('patch/explain');
export type UpdatePreferenceParams = UpdateablePreferences;
export const UpdatePreferencesCommandType = new IpcCommandType<UpdatePreferenceParams>('patch/preferences/update');
export interface SwitchModeParams {
repoPath?: string;
mode: Mode;
}
export const SwitchModeCommandType = new IpcCommandType<SwitchModeParams>('patch/switchMode');
export const CopyCloudLinkCommandType = new IpcCommandType<undefined>('patch/cloud/copyLink');
export const CreateFromLocalPatchCommandType = new IpcCommandType<undefined>('patch/local/createPatch');
export interface UpdateCreatePatchRepositoryCheckedStateParams {
repoUri: string;
checked: boolean | 'staged';
}
export const UpdateCreatePatchRepositoryCheckedStateCommandType =
new IpcCommandType<UpdateCreatePatchRepositoryCheckedStateParams>('patch/create/repository/check');
export interface UpdateCreatePatchMetadataParams {
title: string;
description: string | undefined;
}
export const UpdateCreatePatchMetadataCommandType = new IpcCommandType<UpdateCreatePatchMetadataParams>(
'patch/update/create/metadata',
);
// NOTIFICATIONS
export interface DidChangeParams {
state: Serialized<State>;
}
export const DidChangeNotificationType = new IpcNotificationType<DidChangeParams>('patch/didChange', true);
export type DidChangeCreateParams = Pick<Serialized<State>, 'create' | 'mode'>;
export const DidChangeCreateNotificationType = new IpcNotificationType<DidChangeCreateParams>('patch/create/didChange');
export type DidChangeDraftParams = Pick<Serialized<State>, 'draft' | 'mode'>;
export const DidChangeDraftNotificationType = new IpcNotificationType<DidChangeDraftParams>('patch/draft/didChange');
export type DidChangePreferencesParams = Pick<Serialized<State>, 'preferences'>;
export const DidChangePreferencesNotificationType = new IpcNotificationType<DidChangePreferencesParams>(
'patch/preferences/didChange',
);
export type DidExplainParams =
| {
summary: string | undefined;
error?: undefined;
}
| { error: { message: string } };
export const DidExplainCommandType = new IpcNotificationType<DidExplainParams>('patch/didExplain');

+ 63
- 0
src/plus/webviews/patchDetails/registration.ts View File

@ -0,0 +1,63 @@
import type { DraftSelectedEvent } from '../../../eventBus';
import type { Repository } from '../../../git/models/repository';
import { setContext } from '../../../system/context';
import type { Serialized } from '../../../system/serialize';
import type { WebviewsController } from '../../../webviews/webviewsController';
import type { Change, State } from './protocol';
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 = DraftSelectedEvent['data']['draft'];
export type ShowCreateDraft = {
mode: 'create';
create?: CreateDraft;
};
export type ShowViewDraft = {
mode: 'view';
draft: ViewDraft;
};
export type PatchDetailsWebviewShowingArgs = [ShowCreateDraft | ShowViewDraft];
export function registerPatchDetailsWebviewView(controller: WebviewsController) {
return controller.registerWebviewView<State, Serialized<State>, 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: "patchDetails" */ './patchDetailsWebview'
);
return new PatchDetailsWebviewProvider(container, host);
},
async (...args) => {
const arg = args[0];
if (arg == null) return;
await setContext('gitlens:views:patchDetails:mode', 'state' in arg ? arg.state.mode : arg.mode);
},
);
}

+ 233
- 0
src/plus/webviews/patchDetails/repositoryChangeset.ts View File

@ -0,0 +1,233 @@
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<Change>;
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<Change> {
// 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() {
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<Change> {
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 };
}
}

+ 3
- 2
src/plus/workspaces/models.ts View File

@ -1,6 +1,7 @@
import type { Disposable } from '../../api/gitlens';
import type { Container } from '../../container';
import type { Repository } from '../../git/models/repository';
import type { GkProviderId } from '../../gk/models/repositoryIdentities';
export type WorkspaceType = 'cloud' | 'local';
export type WorkspaceAutoAddSetting = 'disabled' | 'enabled' | 'prompt';
@ -149,7 +150,7 @@ export interface CloudWorkspaceRepositoryDescriptor {
name: string;
description: string;
repository_id: string;
provider: string | null;
provider: GkProviderId | null;
provider_project_name: string | null;
provider_organization_id: string;
provider_organization_name: string | null;
@ -317,7 +318,7 @@ export interface CloudWorkspaceRepositoryData {
name: string;
description: string;
repository_id: string;
provider: string | null;
provider: GkProviderId | null;
provider_project_name: string | null;
provider_organization_id: string;
provider_organization_name: string | null;

+ 9
- 0
src/system/brand.ts View File

@ -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<Base, B> = { [__brand]: B; [__base]: Base };
export type Branded<Base, B> = Base & _Brand<Base, B>;
export type Brand<B extends Branded<any, any>> = B extends Branded<any, any> ? B : never;
export type Unbrand<T> = T extends _Brand<infer Base, any> ? Base : never;

+ 21
- 9
src/system/iterable.ts View File

@ -38,19 +38,18 @@ export function* chunkByStringLength(source: string[], maxLength: number): Itera
}
}
export function count<T>(source: IterableIterator<T>, predicate?: (item: T) => boolean): number {
let count = 0;
let next: IteratorResult<T>;
while (true) {
next = source.next();
if (next.done) break;
export function count<T>(
source: Iterable<T> | IterableIterator<T> | 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;
}
@ -120,6 +119,19 @@ export function first(source: Iterable | IterableIterator): T | undefin
return source[Symbol.iterator]().next().value as T | undefined;
}
export function flatCount<T>(
source: Iterable<T> | IterableIterator<T> | 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<T, TMapped>(
source: Iterable<T> | IterableIterator<T>,
mapper: (item: T) => Iterable<TMapped>,

+ 11
- 1
src/system/serialize.ts View File

@ -1,14 +1,24 @@
import type { Branded } from './brand';
export type Serialized<T> = T extends Function
? never
: T extends Date
? number
: T extends Branded<infer U, any>
? U
: T extends any[]
? Serialized<T[number]>[]
: T extends object
? {
[K in keyof T]: T[K] extends Date ? number : Serialized<T[K]>;
}
: T;
export function serialize<T extends object>(obj: T): Serialized<T> {
export function serialize<T extends object>(obj: T): Serialized<T>;
export function serialize<T extends object>(obj: T | undefined): Serialized<T> | undefined;
export function serialize<T extends object>(obj: T | undefined): Serialized<T> | undefined {
if (obj == null) return undefined;
try {
function replacer(this: any, key: string, value: unknown) {
if (value instanceof Date) return value.getTime();

+ 2
- 0
src/telemetry/usageTracker.ts View File

@ -16,6 +16,7 @@ export type TrackedUsageFeatures =
| 'commitDetailsView'
| 'commitsView'
| 'contributorsView'
| 'draftsView'
| 'fileHistoryView'
| 'focusWebview'
| 'graphDetailsView'
@ -23,6 +24,7 @@ export type TrackedUsageFeatures =
| 'graphWebview'
| 'homeView'
| 'lineHistoryView'
| 'patchDetailsView'
| 'rebaseEditor'
| 'remotesView'
| 'repositoriesView'

+ 54
- 2
src/uris/deepLinks/deepLink.ts View File

@ -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 { Draft } from '../../gk/models/drafts';
export type UriTypes = 'link';
@ -9,11 +10,15 @@ export enum DeepLinkType {
Branch = 'b',
Commit = 'c',
Comparison = 'compare',
Draft = 'drafts',
Repository = 'r',
Tag = 't',
Workspace = 'workspace',
}
export const AccountDeepLinkTypes = [DeepLinkType.Draft, DeepLinkType.Workspace];
export const PaidDeepLinkTypes = [];
export function deepLinkTypeToString(type: DeepLinkType): string {
switch (type) {
case DeepLinkType.Branch:
@ -22,6 +27,8 @@ export function deepLinkTypeToString(type: DeepLinkType): string {
return 'Commit';
case DeepLinkType.Comparison:
return 'Comparison';
case DeepLinkType.Draft:
return 'Cloud Patch';
case DeepLinkType.Repository:
return 'Repository';
case DeepLinkType.Tag:
@ -49,7 +56,7 @@ export function refTypeToDeepLinkType(refType: GitReference['refType']): DeepLin
export interface DeepLink {
type: DeepLinkType;
mainId: string;
mainId?: string;
remoteUrl?: string;
repoPath?: string;
targetId?: string;
@ -116,6 +123,21 @@ export function parseDeepLinkUri(uri: Uri): DeepLink | undefined {
secondaryRemoteUrl: secondaryRemoteUrl,
};
}
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,
};
}
case DeepLinkType.Workspace:
return {
type: DeepLinkType.Workspace,
@ -129,6 +151,8 @@ export function parseDeepLinkUri(uri: Uri): DeepLink | undefined {
export const enum DeepLinkServiceState {
Idle,
AccountCheck,
PlanCheck,
TypeMatch,
RepoMatch,
CloneOrAddRepo,
@ -141,23 +165,29 @@ export const enum DeepLinkServiceState {
FetchedTargetMatch,
OpenGraph,
OpenComparison,
OpenDraft,
OpenWorkspace,
}
export const enum DeepLinkServiceAction {
AccountCheckPassed,
DeepLinkEventFired,
DeepLinkCancelled,
DeepLinkResolved,
DeepLinkStored,
DeepLinkErrored,
LinkIsRepoType,
LinkIsDraftType,
LinkIsWorkspaceType,
OpenRepo,
PlanCheckPassed,
RepoMatched,
RepoMatchedInLocalMapping,
RepoMatchedForDraft,
RepoMatchFailed,
RepoAdded,
RepoOpened,
RepoOpenedForDraft,
RemoteMatched,
RemoteMatchFailed,
RemoteMatchUnneeded,
@ -185,17 +215,29 @@ export interface DeepLinkServiceContext {
targetType?: DeepLinkType | undefined;
targetSha?: string | undefined;
secondaryTargetSha?: string | undefined;
targetDraft?: Draft | undefined;
}
export const deepLinkStateTransitionTable: Record<string, Record<string, DeepLinkServiceState>> = {
[DeepLinkServiceState.Idle]: {
[DeepLinkServiceAction.DeepLinkEventFired]: DeepLinkServiceState.TypeMatch,
[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.RepoMatch,
[DeepLinkServiceAction.LinkIsWorkspaceType]: DeepLinkServiceState.OpenWorkspace,
},
[DeepLinkServiceState.RepoMatch]: {
@ -203,16 +245,19 @@ export const deepLinkStateTransitionTable: Record
[DeepLinkServiceAction.RepoMatched]: DeepLinkServiceState.RemoteMatch,
[DeepLinkServiceAction.RepoMatchedInLocalMapping]: DeepLinkServiceState.CloneOrAddRepo,
[DeepLinkServiceAction.RepoMatchFailed]: DeepLinkServiceState.CloneOrAddRepo,
[DeepLinkServiceAction.RepoMatchedForDraft]: DeepLinkServiceState.OpenDraft,
},
[DeepLinkServiceState.CloneOrAddRepo]: {
[DeepLinkServiceAction.OpenRepo]: DeepLinkServiceState.OpeningRepo,
[DeepLinkServiceAction.RepoOpened]: DeepLinkServiceState.RemoteMatch,
[DeepLinkServiceAction.RepoOpenedForDraft]: DeepLinkServiceState.OpenDraft,
[DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle,
[DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle,
[DeepLinkServiceAction.DeepLinkStored]: DeepLinkServiceState.Idle,
},
[DeepLinkServiceState.OpeningRepo]: {
[DeepLinkServiceAction.RepoAdded]: DeepLinkServiceState.AddedRepoMatch,
[DeepLinkServiceAction.RepoOpenedForDraft]: DeepLinkServiceState.OpenDraft,
[DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle,
[DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle,
},
@ -255,6 +300,10 @@ export const deepLinkStateTransitionTable: Record
[DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle,
[DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle,
},
[DeepLinkServiceState.OpenDraft]: {
[DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle,
[DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle,
},
[DeepLinkServiceState.OpenWorkspace]: {
[DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle,
[DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle,
@ -268,6 +317,8 @@ export interface DeepLinkProgress {
export const deepLinkStateToProgress: Record<string, DeepLinkProgress> = {
[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 },
@ -280,5 +331,6 @@ export const deepLinkStateToProgress: Record = {
[DeepLinkServiceState.FetchedTargetMatch]: { message: 'Finding a matching target...', increment: 90 },
[DeepLinkServiceState.OpenGraph]: { message: 'Opening graph...', increment: 95 },
[DeepLinkServiceState.OpenComparison]: { message: 'Opening comparison...', increment: 95 },
[DeepLinkServiceState.OpenDraft]: { message: 'Opening cloud patch...', increment: 95 },
[DeepLinkServiceState.OpenWorkspace]: { message: 'Opening workspace...', increment: 95 },
};

+ 155
- 12
src/uris/deepLinks/deepLinkService.ts View File

@ -5,7 +5,12 @@ import type { Container } from '../../container';
import { getBranchNameWithoutRemote } from '../../git/models/branch';
import type { GitReference } from '../../git/models/reference';
import { createReference, isSha } from '../../git/models/reference';
import type { Repository } from '../../git/models/repository';
import { isRepository } from '../../git/models/repository';
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 { executeCommand } from '../../system/command';
import { configuration } from '../../system/configuration';
@ -16,6 +21,7 @@ import type { OpenWorkspaceLocation } from '../../system/utils';
import { openWorkspace } from '../../system/utils';
import type { DeepLink, DeepLinkProgress, DeepLinkRepoOpenType, DeepLinkServiceContext, UriTypes } from './deepLink';
import {
AccountDeepLinkTypes,
DeepLinkServiceAction,
DeepLinkServiceState,
deepLinkStateToProgress,
@ -25,8 +31,6 @@ import {
parseDeepLinkUri,
} from './deepLink';
const missingRepositoryId = '-';
export class DeepLinkService implements Disposable {
private readonly _disposables: Disposable[] = [];
private _context: DeepLinkServiceContext;
@ -47,7 +51,7 @@ export class DeepLinkService implements Disposable {
await this.container.git.isDiscoveringRepositories;
}
if (!link.type || (!link.mainId && !link.remoteUrl && !link.repoPath)) {
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;
@ -104,6 +108,7 @@ export class DeepLinkService implements Disposable {
secondaryRemoteUrl: undefined,
targetType: undefined,
targetSha: undefined,
targetDraft: undefined,
};
}
@ -142,7 +147,10 @@ export class DeepLinkService implements Disposable {
const repo = await this.container.git.getOrOpenRepository(repoOpenUri, { detectNested: false });
if (repo != null) {
this._context.repo = repo;
action = DeepLinkServiceAction.RepoOpened;
action =
this._context.targetType === DeepLinkType.Draft
? DeepLinkServiceAction.RepoOpenedForDraft
: DeepLinkServiceAction.RepoOpened;
}
} catch {}
}
@ -425,8 +433,61 @@ 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(
`Account required to open ${deepLinkTypeToString(targetType)} link`,
this.container,
))
) {
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 (!AccountDeepLinkTypes.includes(targetType)) {
action = DeepLinkServiceAction.PlanCheckPassed;
break;
}
if (
!(await ensurePaidPlan(
`Paid plan required to open ${deepLinkTypeToString(targetType)} link`,
this.container,
))
) {
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;
@ -439,16 +500,64 @@ export class DeepLinkService implements Disposable {
}
case DeepLinkServiceState.RepoMatch:
case DeepLinkServiceState.AddedRepoMatch: {
if (!mainId && !remoteUrl && !repoPath) {
if (!mainId && !remoteUrl && !repoPath && targetType !== DeepLinkType.Draft) {
action = DeepLinkServiceAction.DeepLinkErrored;
message = 'No repository id, remote url or path was provided.';
break;
}
let repoIdentity: RepositoryIdentity | undefined;
let draftRepo: Repository | undefined;
if (targetType === DeepLinkType.Draft) {
if (this._context.targetDraft == null && targetId != null) {
try {
this._context.targetDraft = await this.container.drafts.getDraft(targetId);
} catch (ex) {
action = DeepLinkServiceAction.DeepLinkErrored;
message = `Unable to fetch draft${ex.message ? ` - ${ex.message}` : ''}`;
break;
}
}
if (
this._context.targetDraft?.changesets?.length &&
this._context.targetDraft?.changesets[0].patches?.length
) {
// TODO@axosoft-ramint Look at this
// draftRepoData = this._context.targetDraft.changesets[0].patches[0].repoData;
const repoOrIdentity = this._context.targetDraft.changesets[0].patches[0].repository;
if (isRepository(repoOrIdentity)) {
draftRepo = repoOrIdentity;
if (draftRepo == null) {
const gkId = this._context.targetDraft.changesets[0].patches[0].gkRepositoryId;
draftRepo = await this.container.repositoryIdentity.getRepository(gkId);
}
} else {
repoIdentity = repoOrIdentity;
}
}
}
if (draftRepo != null && targetType === DeepLinkType.Draft) {
action = DeepLinkServiceAction.RepoMatchedForDraft;
break;
}
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.
@ -474,10 +583,10 @@ export class DeepLinkService implements Disposable {
}
}
if (mainId != null && mainId !== missingRepositoryId) {
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, mainId)) {
if (await this.container.git.validateReference(repo.path, mainIdToSearch)) {
this._context.repo = repo;
action = DeepLinkServiceAction.RepoMatched;
break;
@ -487,7 +596,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) {
@ -509,6 +618,14 @@ export class DeepLinkService implements Disposable {
}
}
if (targetType === DeepLinkType.Draft && this._context.repo != null) {
action = DeepLinkServiceAction.RepoMatchedForDraft;
if (this._context.targetDraft?.changesets?.[0]?.patches?.[0] != null) {
this._context.targetDraft.changesets[0].patches[0].repository = this._context.repo;
}
break;
}
if (!this._context.repo) {
if (state === DeepLinkServiceState.RepoMatch) {
action = DeepLinkServiceAction.RepoMatchFailed;
@ -521,7 +638,7 @@ export class DeepLinkService implements Disposable {
break;
}
case DeepLinkServiceState.CloneOrAddRepo: {
if (!mainId && !remoteUrl && !repoPath) {
if (!mainId && !remoteUrl && !repoPath && targetType !== DeepLinkType.Draft) {
action = DeepLinkServiceAction.DeepLinkErrored;
message = 'Missing repository id, remote url and path.';
break;
@ -555,6 +672,11 @@ export class DeepLinkService implements Disposable {
});
}
if (repoOpenType === 'current' && targetType === DeepLinkType.Draft) {
action = DeepLinkServiceAction.RepoOpenedForDraft;
break;
}
if (!repoOpenType) {
action = DeepLinkServiceAction.DeepLinkCancelled;
break;
@ -649,7 +771,13 @@ export class DeepLinkService implements Disposable {
case DeepLinkServiceState.OpeningRepo: {
this._disposables.push(
once(this.container.git.onDidChangeRepositories)(() => {
queueMicrotask(() => this.processDeepLink(DeepLinkServiceAction.RepoAdded));
queueMicrotask(() =>
this.processDeepLink(
targetType === DeepLinkType.Draft
? DeepLinkServiceAction.RepoOpenedForDraft
: DeepLinkServiceAction.RepoAdded,
),
);
}),
);
return;
@ -887,6 +1015,21 @@ export class DeepLinkService implements Disposable {
action = DeepLinkServiceAction.DeepLinkResolved;
break;
}
case DeepLinkServiceState.OpenDraft: {
if (!targetId) {
action = DeepLinkServiceAction.DeepLinkErrored;
message = 'Missing cloud patch id.';
break;
}
void (await executeCommand(Commands.OpenCloudPatch, {
id: targetId,
patchId: secondaryTargetId,
draft: this._context.targetDraft,
}));
action = DeepLinkServiceAction.DeepLinkResolved;
break;
}
case DeepLinkServiceState.OpenWorkspace: {
if (!mainId) {
action = DeepLinkServiceAction.DeepLinkErrored;

+ 169
- 0
src/views/draftsView.ts View File

@ -0,0 +1,169 @@
import type { CancellationToken, Disposable } from 'vscode';
import { TreeItem, TreeItemCollapsibleState, window } from 'vscode';
import type { RepositoriesViewConfig } from '../config';
import { Commands } from '../constants';
import type { Container } from '../container';
import { unknownGitUri } from '../git/gitUri';
import type { Draft } from '../gk/models/drafts';
import { showPatchesView } from '../plus/drafts/actions';
import { ensurePlusFeaturesEnabled } from '../plus/gk/utils';
import { executeCommand } from '../system/command';
import { gate } from '../system/decorators/gate';
import { CacheableChildrenViewNode } from './nodes/abstract/cacheableChildrenViewNode';
import type { ViewNode } from './nodes/abstract/viewNode';
import { DraftNode } from './nodes/draftNode';
import { ViewBase } from './viewBase';
import { registerViewCommand } from './viewCommands';
export class DraftsViewNode extends CacheableChildrenViewNode<'drafts', DraftsView, DraftNode> {
constructor(view: DraftsView) {
super('drafts', unknownGitUri, view);
}
async getChildren(): Promise<ViewNode[]> {
if (this.children == null) {
const children: DraftNode[] = [];
const drafts = await this.view.container.drafts.getDrafts();
drafts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
for (const draft of drafts) {
children.push(new DraftNode(this.uri, this.view, this, draft));
}
this.children = children;
}
return this.children;
}
getTreeItem(): TreeItem {
const item = new TreeItem('Drafts', TreeItemCollapsibleState.Expanded);
return item;
}
}
export class DraftsView extends ViewBase<'drafts', DraftsViewNode, RepositoriesViewConfig> {
protected readonly configKey = 'drafts';
private _disposable: Disposable | undefined;
constructor(container: Container) {
super(container, 'drafts', 'Cloud Patches', 'draftsView');
this.description = `PREVIEW\u00a0\u00a0☁️`;
}
override dispose() {
this._disposable?.dispose();
super.dispose();
}
override get canSelectMany(): boolean {
return false;
}
protected getRoot() {
return new DraftsViewNode(this);
}
override async show(options?: { preserveFocus?: boolean | undefined }): Promise<void> {
if (!(await ensurePlusFeaturesEnabled())) return;
// if (this._disposable == null) {
// this._disposable = Disposable.from(
// this.container.drafts.onDidResetDrafts(() => void this.ensureRoot().triggerChange(true)),
// );
// }
return super.show(options);
}
override get canReveal(): boolean {
return false;
}
protected registerCommands(): Disposable[] {
void this.container.viewCommands;
return [
// registerViewCommand(
// this.getQualifiedCommand('info'),
// () => env.openExternal(Uri.parse('https://help.gitkraken.com/gitlens/side-bar/#drafts-☁%ef%b8%8f')),
// 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 draft '${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('open'),
async (node: DraftNode) => {
let draft = node.draft;
if (draft.changesets == null) {
draft = await this.container.drafts.getDraft(node.draft.id);
}
void showPatchesView({ mode: 'view', draft: draft });
},
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) 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;
}
}

+ 6
- 0
src/views/nodes/abstract/viewNode.ts View File

@ -10,6 +10,7 @@ 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,
@ -65,6 +66,7 @@ export const enum ContextValues {
Contributor = 'gitlens:contributor',
Contributors = 'gitlens:contributors',
DateMarker = 'gitlens:date-marker',
Draft = 'gitlens:draft',
File = 'gitlens:file',
FileHistory = 'gitlens:history:file',
Folder = 'gitlens:folder',
@ -116,6 +118,7 @@ export interface AmbientContext {
readonly comparisonId?: string;
readonly comparisonFiltered?: boolean;
readonly contributor?: GitContributor;
readonly draft?: Draft;
readonly file?: GitFile;
readonly reflog?: GitReflogRecord;
readonly remote?: GitRemote;
@ -191,6 +194,9 @@ export function getViewNodeId(type: string, context: AmbientContext): string {
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}`;
}

+ 60
- 0
src/views/nodes/draftNode.ts View File

@ -0,0 +1,60 @@
import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
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 { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode';
export class DraftNode extends ViewNode<'draft', DraftsView> {
constructor(
uri: GitUri,
view: 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.draft.title ?? this.draft.description ?? '';
}
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';
const showUpdated = this.draft.updatedAt.getTime() - this.draft.createdAt.getTime() >= 1000;
item.id = this.id;
item.contextValue = ContextValues.Draft;
item.iconPath = new ThemeIcon('cloud');
item.tooltip = new MarkdownString(
`${label}${this.draft.description ? `\\\n${this.draft.description}` : ''}\n\nCreated ${fromNow(
this.draft.createdAt,
)} &nbsp; _(${formatDate(this.draft.createdAt, dateFormat)})_${
showUpdated
? ` \\\nLast updated ${fromNow(this.draft.updatedAt)} &nbsp; _(${formatDate(
this.draft.updatedAt,
dateFormat,
)})_`
: ''
}`,
);
item.description = fromNow(this.draft.updatedAt);
return item;
}
}

+ 3
- 1
src/views/viewBase.ts View File

@ -42,6 +42,7 @@ 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/abstract/viewNode';
@ -58,6 +59,7 @@ export type View =
| BranchesView
| CommitsView
| ContributorsView
| DraftsView
| FileHistoryView
| LineHistoryView
| RemotesView
@ -79,7 +81,7 @@ export type ViewsWithRepositories = RepositoriesView | WorkspacesView;
export type ViewsWithRepositoriesNode = RepositoriesView | WorkspacesView;
export type ViewsWithRepositoryFolders = Exclude<
View,
FileHistoryView | LineHistoryView | RepositoriesView | WorkspacesView
DraftsView | FileHistoryView | LineHistoryView | RepositoriesView | WorkspacesView
>;
export type ViewsWithStashes = StashesView | ViewsWithCommits;
export type ViewsWithStashesNode = RepositoriesView | StashesView | WorkspacesView;

+ 17
- 455
src/webviews/apps/commitDetails/commitDetails.scss View File

@ -1,469 +1,31 @@
@use '../shared/styles/theme';
@use '../shared/styles/details-base';
:root {
--gitlens-gutter-width: 20px;
--gitlens-scrollbar-gutter-width: 10px;
}
.vscode-high-contrast,
.vscode-dark {
--color-background--level-05: var(--color-background--lighten-05);
--color-background--level-075: var(--color-background--lighten-075);
--color-background--level-10: var(--color-background--lighten-10);
--color-background--level-15: var(--color-background--lighten-15);
--color-background--level-30: var(--color-background--lighten-30);
}
.vscode-high-contrast-light,
.vscode-light {
--color-background--level-05: var(--color-background--darken-05);
--color-background--level-075: var(--color-background--darken-075);
--color-background--level-10: var(--color-background--darken-10);
--color-background--level-15: var(--color-background--darken-15);
--color-background--level-30: var(--color-background--darken-30);
}
// generic resets
html {
font-size: 62.5%;
// box-sizing: border-box;
font-family: var(--font-family);
}
*,
*:before,
*:after {
// TODO: "change-list__action" should be a separate component
.change-list__action {
box-sizing: border-box;
}
body {
--gk-badge-outline-color: var(--vscode-badge-foreground);
--gk-badge-filled-background-color: var(--vscode-badge-background);
--gk-badge-filled-color: var(--vscode-badge-foreground);
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;
}
}
}
::-webkit-scrollbar-corner {
background-color: transparent !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);
}
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
.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;
}
}
.button--busy {
code-icon {
margin-right: 0.5rem;
}
&[aria-busy='true'] {
opacity: 0.5;
}
&:not([aria-busy='true']) {
code-icon {
display: none;
}
}
}
.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;
width: 2rem;
height: 2rem;
border-radius: 0.25em;
color: inherit;
padding: 0.2rem;
padding: 2px;
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);
}
.vscode-light & {
background-color: var(--color-background--darken-15);
}
}
&.is-active {
.vscode-dark & {
background-color: var(--color-background--lighten-10);
}
.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;
}
.message-block {
font-size: 1.3rem;
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
padding: 0.5rem;
&__text {
margin: 0;
overflow-y: auto;
overflow-x: hidden;
max-height: 9rem;
> * {
white-space: break-spaces;
}
strong {
font-weight: 600;
font-size: 1.4rem;
}
}
}
.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;
}
&--highlight {
margin-left: 0.25em;
padding: 0 4px 2px 4px;
border: 1px solid var(--color-background--level-15);
border-radius: 0.3rem;
font-family: var(--vscode-editor-font-family);
}
&.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);
}
}
}
&__sha {
margin: 0 0.5rem 0 0.25rem;
}
&__authors {
flex-basis: 100%;
padding-top: 0.5rem;
}
&__author {
& + & {
margin-top: 0.5rem;
}
}
}
.issue > :not(:first-child) {
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;
}
.change-list__action:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.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;
}
&:empty {
display: none;
}
&__summary {
margin: 0;
overflow-y: auto;
overflow-x: hidden;
// max-height: 9rem;
white-space: break-spaces;
.has-error & {
white-space: normal;
}
}
.change-list__action:hover {
color: inherit;
background-color: var(--vscode-toolbar-hoverBackground);
text-decoration: none;
}
.wip-details {
display: flex;
padding: 0.4rem 0.8rem;
background: var(--color-alert-infoBackground);
border-left: 0.3rem solid var(--color-alert-infoBorder);
align-items: center;
justify-content: space-between;
.wip-changes {
display: inline-flex;
align-items: baseline;
}
.wip-branch {
display: inline-block;
padding: 0 0.3rem 0.2rem;
margin-left: 0.4rem;
// background: var(--color-background--level-05);
border: 1px solid var(--color-foreground--50);
border-radius: 0.3rem;
}
gl-button {
padding: 0.2rem 0.8rem;
opacity: 0.8;
}
.change-list__action:active {
background-color: var(--vscode-toolbar-activeBackground);
}
.details-tab {
display: flex;
justify-content: stretch;
align-items: center;
margin-bottom: 0.4rem;
gap: 0.2rem;
& > * {
flex: 1;
}
&__item {
appearance: none;
padding: 0.4rem;
color: var(--color-foreground--85);
background-color: transparent;
border: none;
border-bottom: 0.2rem solid transparent;
cursor: pointer;
// background-color: #00000030;
line-height: 1.8rem;
gk-badge {
line-height: 1.36rem;
}
&:hover {
color: var(--color-foreground);
// background-color: var(--vscode-button-hoverBackground);
background-color: #00000020;
}
&.is-active {
color: var(--color-foreground);
border-bottom-color: var(--vscode-button-hoverBackground);
}
}
.change-list__action.is-disabled {
opacity: 0.5;
}

+ 19
- 0
src/webviews/apps/commitDetails/commitDetails.ts View File

@ -5,6 +5,7 @@ import type { CommitActionsParams, Mode, State } from '../../commitDetails/proto
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
CreatePatchFromWipCommandType,
DidChangeNotificationType,
DidChangeWipStateNotificationType,
DidExplainCommandType,
@ -91,6 +92,7 @@ export class CommitDetailsApp extends App> {
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-action="create-patch"]', 'click', e => this.onCreatePatchFromWip(e)),
DOM.on<WebviewPane, WebviewPaneExpandedChangeEventDetail>(
'[data-region="rich-pane"]',
'expanded-change',
@ -154,6 +156,23 @@ export class CommitDetailsApp extends App> {
}
}
private onCreatePatchFromWip(e: MouseEvent) {
if (this.state.wip?.changes == null) return;
const wipCheckedParam = ((e.target as HTMLElement)?.closest('[data-wip-checked]') as HTMLElement | undefined)
?.dataset.wipChecked;
let wipChecked: boolean | 'staged';
if (wipCheckedParam == null) {
wipChecked = true;
} else if (wipCheckedParam === 'staged') {
wipChecked = wipCheckedParam;
} else {
wipChecked = wipCheckedParam === 'true';
}
this.sendCommand(CreatePatchFromWipCommandType, { changes: this.state.wip?.changes, checked: wipChecked });
}
private onCommandClickedCore(action?: string) {
const command = action?.startsWith('command:') ? action.slice(8) : action;
if (command == null) return;

+ 2
- 2
src/webviews/apps/commitDetails/components/gl-commit-details.ts View File

@ -93,7 +93,7 @@ export class GlCommitDetails extends GlDetailsBase {
<button class="button button--full" type="button" data-action="wip">Show Working Changes</button>
</p>
<p class="button-container">
<span class="button-group">
<span class="button-group button-group--single">
<button class="button button--full" type="button" data-action="pick-commit">
Choose Commit...
</button>
@ -326,7 +326,7 @@ export class GlCommitDetails extends GlDetailsBase {
@click=${this.onExplainChanges}
@keydown=${this.onExplainChanges}
>
<code-icon icon="loading" modifier="spin"></code-icon>Explain this Commit
<code-icon icon="loading" modifier="spin"></code-icon>Explain Changes
</button>
</span>
</p>

+ 37
- 4
src/webviews/apps/commitDetails/components/gl-details-base.ts View File

@ -24,6 +24,25 @@ export class GlDetailsBase extends LitElement {
@property({ attribute: 'empty-text' })
emptyText? = 'No Files';
private renderWipCategory(staged = true, hasFiles = true) {
const label = staged ? 'Staged Changes' : 'Unstaged Changes';
const shareLabel = `Share ${label}`;
return html`
<list-item tree branch hide-icon>
${label}
<span slot="actions"
><a
class="change-list__action ${!hasFiles ? 'is-disabled' : ''}"
href="#"
title="${shareLabel}"
aria-label="${shareLabel}"
data-action="create-patch"
data-wip-checked="${staged ? 'staged' : 'true'}"
><code-icon icon="live-share"></code-icon></a></span
></list-item>
`;
}
private renderFileList(mode: Mode, files: Files) {
let items;
let classes;
@ -34,7 +53,8 @@ export class GlDetailsBase extends LitElement {
const staged = files.filter(f => f.staged);
if (staged.length) {
items.push(html`<list-item tree branch hide-icon>Staged Changes</list-item>`);
// items.push(html`<list-item tree branch hide-icon>Staged Changes</list-item>`);
items.push(this.renderWipCategory(true, true));
for (const f of staged) {
items.push(this.renderFile(mode, f, 2, true));
@ -43,7 +63,8 @@ export class GlDetailsBase extends LitElement {
const unstaged = files.filter(f => !f.staged);
if (unstaged.length) {
items.push(html`<list-item tree branch hide-icon>Unstaged Changes</list-item>`);
// items.push(html`<list-item tree branch hide-icon>Unstaged Changes</list-item>`);
items.push(this.renderWipCategory(false, true));
for (const f of unstaged) {
items.push(this.renderFile(mode, f, 2, true));
@ -66,13 +87,15 @@ export class GlDetailsBase extends LitElement {
const staged = files.filter(f => f.staged);
if (staged.length) {
items.push(html`<list-item tree branch hide-icon>Staged Changes</list-item>`);
// items.push(html`<list-item tree branch hide-icon>Staged Changes</list-item>`);
items.push(this.renderWipCategory(true, true));
items.push(...this.renderFileSubtree(mode, staged, 1, compact));
}
const unstaged = files.filter(f => !f.staged);
if (unstaged.length) {
items.push(html`<list-item tree branch hide-icon>Unstaged Changes</list-item>`);
// items.push(html`<list-item tree branch hide-icon>Unstaged Changes</list-item>`);
items.push(this.renderWipCategory(false, true));
items.push(...this.renderFileSubtree(mode, unstaged, 1, compact));
}
} else {
@ -197,6 +220,16 @@ export class GlDetailsBase extends LitElement {
`;
}
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;
}

+ 10
- 0
src/webviews/apps/commitDetails/components/gl-wip-details.ts View File

@ -42,6 +42,16 @@ export class GlWipDetails extends GlDetailsBase {
<a
class="commit-action"
href="#"
data-action="create-patch"
aria-label="Share as Cloud Patch"
title="Share as Cloud Patch"
>
<code-icon icon="live-share"></code-icon>
<span class="top-details__sha">Share</span>
</a>
<a
class="commit-action"
href="#"
data-action="commit-actions"
data-action-type="scm"
aria-label="Open SCM view"

+ 692
- 0
src/webviews/apps/plus/patchDetails/components/gl-draft-details.ts View File

@ -0,0 +1,692 @@
import { defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components';
import { html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { when } from 'lit/directives/when.js';
import type { TextDocumentShowOptions } from 'vscode';
import type { DraftPatchFileChange } from '../../../../../gk/models/drafts';
import type { DraftDetails, FileActionParams, State } from '../../../../../plus/webviews/patchDetails/protocol';
import { makeHierarchical } from '../../../../../system/array';
import { flatCount } from '../../../../../system/iterable';
import type {
TreeItemActionDetail,
TreeItemBase,
TreeItemCheckedDetail,
TreeItemSelectionDetail,
TreeModel,
} from '../../../shared/components/tree/base';
import { GlTreeBase } from './gl-tree-base';
import '../../../shared/components/actions/action-item';
import '../../../shared/components/actions/action-nav';
import '../../../shared/components/button-container';
import '../../../shared/components/button';
import '../../../shared/components/code-icon';
import '../../../shared/components/commit/commit-identity';
import '../../../shared/components/tree/tree-generator';
import '../../../shared/components/webview-pane';
// Can only import types from 'vscode'
const BesideViewColumn = -2; /*ViewColumn.Beside*/
interface ExplainState {
cancelled?: boolean;
error?: { message: string };
summary?: string;
}
export interface ApplyPatchDetail {
draft: DraftDetails;
target?: 'current' | 'branch' | 'worktree';
base?: string;
selectedPatches?: string[];
// [key: string]: unknown;
}
export interface ChangePatchBaseDetail {
draft: DraftDetails;
// [key: string]: unknown;
}
export interface SelectPatchRepoDetail {
draft: DraftDetails;
repoPath?: string;
// [key: string]: unknown;
}
export interface ShowPatchInGraphDetail {
draft: DraftDetails;
// [key: string]: unknown;
}
@customElement('gl-draft-details')
export class GlDraftDetails extends GlTreeBase {
@property({ type: Object })
state!: State;
@state()
explainBusy = false;
@property({ type: Object })
explain?: ExplainState;
@state()
selectedPatches: string[] = [];
@state()
validityMessage?: string;
get canSubmit() {
return this.selectedPatches.length > 0;
// return this.state.draft?.repoPath != null && this.state.draft?.baseRef != null;
}
constructor() {
super();
defineGkElement(Popover, Menu, MenuItem);
}
override updated(changedProperties: Map<string, any>) {
if (changedProperties.has('explain')) {
this.explainBusy = false;
this.querySelector('[data-region="ai-explanation"]')?.scrollIntoView();
}
if (changedProperties.has('state')) {
const patches = this.state?.draft?.patches;
if (!patches?.length) {
this.selectedPatches = [];
} else {
this.selectedPatches = patches.map(p => p.id);
}
// } else if (patches?.length === 1) {
// this.selectedPatches = [patches[0].id];
// } else {
// this.selectedPatches = this.selectedPatches.filter(id => {
// return patches.find(p => p.id === id) != null;
// });
// }
}
}
private renderEmptyContent() {
return html`
<div class="section section--empty" id="empty">
<button-container>
<gl-button full href="command:gitlens.openPatch">Open Patch...</gl-button>
</button-container>
</div>
`;
}
private renderPatchMessage() {
if (this.state?.draft?.title == null) {
return undefined;
}
const title = this.state.draft.title;
const description = this.state.draft.draftType === 'cloud' ? this.state.draft.description : undefined;
return html`
<div class="section section--message">
<div class="message-block">
${when(
description == null,
() =>
html`<p class="message-block__text scrollable" data-region="message">
<strong>${unsafeHTML(title)}</strong>
</p>`,
() =>
html`<p class="message-block__text scrollable" data-region="message">
<strong>${unsafeHTML(title)}</strong><br /><span>${unsafeHTML(description)}</span>
</p>`,
)}
</div>
</div>
`;
}
private renderExplainAi() {
// TODO: add loading and response states
return html`
<webview-pane collapsable data-region="explain-pane">
<span slot="title">Explain (AI)</span>
<span slot="subtitle"><code-icon icon="beaker" size="12"></code-icon></span>
<action-nav slot="actions">
<action-item data-action="switch-ai" label="Switch AI Model" icon="hubot"></action-item>
</action-nav>
<div class="section">
<p>Let AI assist in understanding the changes made with this patch.</p>
<p class="button-container">
<span class="button-group button-group--single">
<gl-button
full
class="button--busy"
data-action="ai-explain"
aria-busy="${ifDefined(this.explainBusy ? 'true' : undefined)}"
@click=${this.onExplainChanges}
@keydown=${this.onExplainChanges}
><code-icon icon="loading" modifier="spin"></code-icon>Explain Changes</gl-button
>
</span>
</p>
${when(
this.explain,
() => html`
<div
class="ai-content${this.explain?.error ? ' has-error' : ''}"
data-region="ai-explanation"
>
${when(
this.explain?.error,
() =>
html`<p class="ai-content__summary scrollable">
${this.explain!.error!.message ?? 'Error retrieving content'}
</p>`,
)}
${when(
this.explain?.summary,
() => html`<p class="ai-content__summary scrollable">${this.explain!.summary}</p>`,
)}
</div>
`,
)}
</div>
</webview-pane>
`;
}
// private renderCommitStats() {
// if (this.state?.draft?.stats?.changedFiles == null) {
// return undefined;
// }
// if (typeof this.state.draft.stats.changedFiles === 'number') {
// return html`<commit-stats
// .added=${undefined}
// modified="${this.state.draft.stats.changedFiles}"
// .removed=${undefined}
// ></commit-stats>`;
// }
// const { added, deleted, changed } = this.state.draft.stats.changedFiles;
// return html`<commit-stats added="${added}" modified="${changed}" removed="${deleted}"></commit-stats>`;
// }
private renderChangedFiles() {
const layout = this.state?.preferences?.files?.layout ?? 'auto';
return html`
<webview-pane collapsable expanded>
<span slot="title">Files changed </span>
<!-- <span slot="subtitle" data-region="stats">\${this.renderCommitStats()}</span> -->
<action-nav slot="actions">${this.renderLayoutAction(layout)}</action-nav>
${when(
this.validityMessage != null,
() =>
html`<div class="section">
<div class="alert alert--error">
<code-icon icon="error"></code-icon>
<p class="alert__content">${this.validityMessage}</p>
</div>
</div>`,
)}
<div class="change-list" data-region="files">
${when(
this.state?.draft?.patches == null,
() => this.renderLoading(),
() => this.renderTreeView(this.treeModel, this.state?.preferences?.indentGuides),
)}
</div>
</webview-pane>
`;
}
get treeModel(): TreeModel[] {
if (this.state?.draft?.patches == null) return [];
const {
draft: { patches },
} = this.state;
const layout = this.state?.preferences?.files?.layout ?? 'auto';
let isTree = false;
const fileCount = flatCount(patches, p => p?.files?.length ?? 0);
if (layout === 'auto') {
isTree = fileCount > (this.state.preferences?.files?.threshold ?? 5);
} else {
isTree = layout === 'tree';
}
// checkable only for multi-repo
const options = { checkable: patches.length > 1 };
const models = patches?.map(p =>
this.draftPatchToTreeModel(p, isTree, this.state.preferences?.files?.compact, options),
);
return models;
}
renderPatches() {
// // const path = this.state.draft?.repoPath;
// const repo = this.state.draft?.repoName;
// const base = this.state.draft?.baseRef;
// const getActions = () => {
// if (!repo) {
// return html`
// <a href="#" class="commit-action" data-action="select-patch-repo" @click=${this.onSelectPatchRepo}
// ><code-icon icon="repo" title="Repository" aria-label="Repository"></code-icon
// ><span class="top-details__sha">Select base repo</span></a
// >
// <a href="#" class="commit-action is-disabled"><code-icon icon="gl-graph"></code-icon></a>
// `;
// }
// if (!base) {
// return html`
// <a href="#" class="commit-action" data-action="select-patch-repo" @click=${this.onSelectPatchRepo}
// ><code-icon icon="repo" title="Repository" aria-label="Repository"></code-icon
// ><span class="top-details__sha">${repo}</span></a
// >
// <a href="#" class="commit-action" data-action="select-patch-base" @click=${this.onChangePatchBase}
// ><code-icon icon="git-commit" title="Repository" aria-label="Repository"></code-icon
// ><span class="top-details__sha">Select base</span></a
// >
// <a href="#" class="commit-action is-disabled"><code-icon icon="gl-graph"></code-icon></a>
// `;
// }
// return html`
// <a href="#" class="commit-action" data-action="select-patch-repo" @click=${this.onSelectPatchRepo}
// ><code-icon icon="repo" title="Repository" aria-label="Repository"></code-icon
// ><span class="top-details__sha">${repo}</span></a
// >
// <a href="#" class="commit-action" data-action="select-patch-base" @click=${this.onChangePatchBase}
// ><code-icon icon="git-commit"></code-icon
// ><span class="top-details__sha">${base?.substring(0, 7)}</span></a
// >
// <a href="#" class="commit-action" data-action="patch-base-in-graph" @click=${this.onShowInGraph}
// ><code-icon icon="gl-graph"></code-icon
// ></a>
// `;
// };
// <div class="section">
// <div class="patch-base">${getActions()}</div>
// </div>
return html`
<webview-pane expanded>
<span slot="title">Apply</span>
<div class="section section--sticky-actions">
<p class="button-container">
<span class="button-group button-group--single">
<gl-button full @click=${this.onApplyPatch}>Apply Cloud Patch</gl-button>
</span>
</p>
</div>
</webview-pane>
`;
}
// renderCollaborators() {
// return html`
// <webview-pane collapsable expanded>
// <span slot="title">Collaborators</span>
// <div class="h-spacing">
// <list-container>
// <list-item>
// <code-icon
// slot="icon"
// icon="account"
// title="Collaborator"
// aria-label="Collaborator"
// ></code-icon>
// justin.roberts@gitkraken.com
// </list-item>
// <list-item>
// <code-icon
// slot="icon"
// icon="account"
// title="Collaborator"
// aria-label="Collaborator"
// ></code-icon>
// eamodio@gitkraken.com
// </list-item>
// <list-item>
// <code-icon
// slot="icon"
// icon="account"
// title="Collaborator"
// aria-label="Collaborator"
// ></code-icon>
// keith.daulton@gitkraken.com
// </list-item>
// </list-container>
// </div>
// </webview-pane>
// `;
// }
override render() {
if (this.state?.draft == null) {
return html` <div class="commit-detail-panel scrollable">${this.renderEmptyContent()}</div>`;
}
return html`
<div class="pane-groups">
<div class="pane-groups__group-fixed">
<div class="top-details">
<div class="top-details__top-menu">
<div class="top-details__actionbar">
<div class="top-details__actionbar-group"></div>
<div class="top-details__actionbar-group">
${when(
this.state?.draft?.draftType === 'cloud',
() => html`
<a class="commit-action" href="#" @click=${this.onCopyCloudLink}>
<code-icon icon="link"></code-icon>
<span class="top-details__sha">Copy Link</span></a
>
`,
() => html`
<a
class="commit-action"
href="#"
aria-label="Share Patch"
title="Share Patch"
@click=${this.onShareLocalPatch}
>Share</a
>
`,
)}
<a
class="commit-action"
href="#"
aria-label="Show Patch Actions"
title="Show Patch Actions"
><code-icon icon="kebab-vertical"></code-icon
></a>
</div>
</div>
${when(
this.state.draft?.draftType === 'cloud' && this.state.draft?.author.name != null,
() => html`
<ul class="top-details__authors" aria-label="Authors">
<li class="top-details__author" data-region="author">
<commit-identity
name="${this.state.draft!.author!.name}"
email="${ifDefined(this.state.draft!.author!.email)}"
date="${this.state.draft!.createdAt!}"
dateFormat="${this.state.preferences.dateFormat}"
avatarUrl="${this.state.draft!.author!.avatar ?? ''}"
?showavatar=${this.state.preferences?.avatars ?? true}
.actionLabel=${'created'}
></commit-identity>
</li>
</ul>
`,
)}
</div>
</div>
${this.renderPatchMessage()}
</div>
<div class="pane-groups__group">${this.renderChangedFiles()}</div>
<div class="pane-groups__group-fixed pane-groups__group--bottom">
${this.renderExplainAi()}${this.renderPatches()}
</div>
</div>
`;
}
protected override createRenderRoot() {
return this;
}
onExplainChanges(e: MouseEvent | KeyboardEvent) {
if (this.explainBusy === true || (e instanceof KeyboardEvent && e.key !== 'Enter')) {
e.preventDefault();
e.stopPropagation();
return;
}
this.explainBusy = true;
}
override onTreeItemActionClicked(e: CustomEvent<TreeItemActionDetail>) {
if (!e.detail.context || !e.detail.action) return;
const action = e.detail.action;
switch (action.action) {
// repo actions
case 'apply-patch':
this.onApplyPatch();
break;
case 'change-patch-base':
this.onChangePatchBase();
break;
case 'show-patch-in-graph':
this.onShowInGraph();
break;
// file actions
case 'file-open':
this.onOpenFile(e);
break;
case 'file-compare-working':
this.onCompareWorking(e);
break;
}
}
fireFileEvent(name: string, file: DraftPatchFileChange, showOptions?: TextDocumentShowOptions) {
const event = new CustomEvent(name, {
detail: { ...file, showOptions: showOptions },
});
this.dispatchEvent(event);
}
onCompareWorking(e: CustomEvent<TreeItemActionDetail>) {
if (!e.detail.context) return;
const [file] = e.detail.context;
this.fireEvent('gl-patch-file-compare-working', {
...file,
showOptions: {
preview: false,
viewColumn: e.detail.altKey ? BesideViewColumn : undefined,
},
});
}
onOpenFile(e: CustomEvent<TreeItemActionDetail>) {
if (!e.detail.context) return;
const [file] = e.detail.context;
this.fireEvent('gl-patch-file-open', {
...file,
showOptions: {
preview: false,
viewColumn: e.detail.altKey ? BesideViewColumn : undefined,
},
});
}
override onTreeItemChecked(e: CustomEvent<TreeItemCheckedDetail>) {
if (!e.detail.context) return;
const [gkRepositoryId] = e.detail.context;
const patch = this.state.draft?.patches?.find(p => p.gkRepositoryId === gkRepositoryId);
if (!patch) return;
const selectedIndex = this.selectedPatches.indexOf(patch?.id);
if (e.detail.checked) {
if (selectedIndex === -1) {
this.selectedPatches.push(patch.id);
this.validityMessage = undefined;
}
} else if (selectedIndex > -1) {
this.selectedPatches.splice(selectedIndex, 1);
}
// const [repoPath] = e.detail.context;
// const event = new CustomEvent('repo-checked', {
// detail: {
// path: repoPath,
// },
// });
// this.dispatchEvent(event);
}
override onTreeItemSelected(e: CustomEvent<TreeItemSelectionDetail>) {
if (!e.detail.context) return;
const [file] = e.detail.context;
this.fireEvent('gl-patch-file-compare-previous', { ...file });
}
onApplyPatch(e?: MouseEvent | KeyboardEvent, target: 'current' | 'branch' | 'worktree' = 'current') {
if (this.canSubmit === false) {
this.validityMessage = 'Please select changes to apply';
return;
}
this.validityMessage = undefined;
this.fireEvent('gl-patch-apply-patch', {
draft: this.state.draft!,
target: target,
selectedPatches: this.selectedPatches,
});
}
onSelectApplyOption(e: CustomEvent<{ target: MenuItem }>) {
if (this.canSubmit === false) return;
const target = e.detail?.target;
if (target?.dataset?.value != null) {
this.onApplyPatch(undefined, target.dataset.value as 'current' | 'branch' | 'worktree');
}
}
onChangePatchBase(_e?: MouseEvent | KeyboardEvent) {
const evt = new CustomEvent<ChangePatchBaseDetail>('change-patch-base', {
detail: {
draft: this.state.draft!,
},
});
this.dispatchEvent(evt);
}
onSelectPatchRepo(_e?: MouseEvent | KeyboardEvent) {
const evt = new CustomEvent<SelectPatchRepoDetail>('select-patch-repo', {
detail: {
draft: this.state.draft!,
},
});
this.dispatchEvent(evt);
}
onShowInGraph(_e?: MouseEvent | KeyboardEvent) {
this.fireEvent('gl-patch-details-graph-show-patch', { draft: this.state.draft! });
}
onCopyCloudLink() {
this.fireEvent('gl-patch-details-copy-cloud-link', { draft: this.state.draft! });
}
onShareLocalPatch() {
this.fireEvent('gl-patch-details-share-local-patch', { draft: this.state.draft! });
}
draftPatchToTreeModel(
patch: NonNullable<DraftDetails['patches']>[0],
isTree = false,
compact = true,
options?: Partial<TreeItemBase>,
): TreeModel {
const model = this.repoToTreeModel(patch.repository.name, patch.gkRepositoryId, options);
if (!patch.files?.length) return model;
const children = [];
if (isTree) {
const fileTree = makeHierarchical(
patch.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: 2 });
children.push(childModel);
}
}
} else {
for (const file of patch.files) {
const child = this.fileToTreeModel(file, { level: 2, branch: false }, true);
children.push(child);
}
}
if (children.length > 0) {
model.branch = true;
model.children = children;
}
return model;
}
// override getRepoActions(_name: string, _path: string, _options?: Partial<TreeItemBase>) {
// return [
// {
// icon: 'cloud-download',
// label: 'Apply...',
// action: 'apply-patch',
// },
// // {
// // icon: 'git-commit',
// // label: 'Change Base',
// // action: 'change-patch-base',
// // },
// {
// icon: 'gl-graph',
// label: 'Open in Commit Graph',
// action: 'show-patch-in-graph',
// },
// ];
// }
override getFileActions(_file: DraftPatchFileChange, _options?: Partial<TreeItemBase>) {
return [
{
icon: 'go-to-file',
label: 'Open file',
action: 'file-open',
},
{
icon: 'git-compare',
label: 'Open Changes with Working File',
action: 'file-compare-working',
},
];
}
}
declare global {
interface HTMLElementTagNameMap {
'gl-patch-details': GlDraftDetails;
}
interface WindowEventMap {
'gl-patch-apply-patch': CustomEvent<ApplyPatchDetail>;
'gl-patch-details-graph-show-patch': CustomEvent<{ draft: DraftDetails }>;
'gl-patch-details-share-local-patch': CustomEvent<{ draft: DraftDetails }>;
'gl-patch-details-copy-cloud-link': CustomEvent<{ draft: DraftDetails }>;
'gl-patch-file-compare-previous': CustomEvent<FileActionParams>;
'gl-patch-file-compare-working': CustomEvent<FileActionParams>;
'gl-patch-file-open': CustomEvent<FileActionParams>;
}
}

+ 523
- 0
src/webviews/apps/plus/patchDetails/components/gl-patch-create.ts View File

@ -0,0 +1,523 @@
import { defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components';
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import type { GitFileChangeShape } from '../../../../../git/models/file';
import type { Change, FileActionParams, State } from '../../../../../plus/webviews/patchDetails/protocol';
import { flatCount } from '../../../../../system/iterable';
import type { Serialized } from '../../../../../system/serialize';
import type {
TreeItemActionDetail,
TreeItemBase,
TreeItemCheckedDetail,
TreeItemSelectionDetail,
TreeModel,
} from '../../../shared/components/tree/base';
import { GlTreeBase } from './gl-tree-base';
import '../../../shared/components/actions/action-nav';
import '../../../shared/components/button';
import '../../../shared/components/code-icon';
import '../../../shared/components/commit/commit-stats';
import '../../../shared/components/webview-pane';
export interface CreatePatchEventDetail {
title: string;
description?: string;
changesets: Record<string, Change>;
}
export interface CreatePatchMetadataEventDetail {
title: string;
description: string | undefined;
}
export interface CreatePatchCheckRepositoryEventDetail {
repoUri: string;
checked: boolean | 'staged';
}
// Can only import types from 'vscode'
const BesideViewColumn = -2; /*ViewColumn.Beside*/
export type GlPatchCreateEvents = {
[K in Extract<keyof WindowEventMap, `gl-patch-${string}` | `gl-patch-create-${string}`>]: WindowEventMap[K];
};
@customElement('gl-patch-create')
export class GlPatchCreate extends GlTreeBase<GlPatchCreateEvents> {
@property({ type: Object }) state?: Serialized<State>;
// @state()
// patchTitle = this.create.title ?? '';
// @state()
// description = this.create.description ?? '';
@query('#title')
titleInput!: HTMLInputElement;
@query('#desc')
descInput!: HTMLInputElement;
@state()
validityMessage?: string;
get create() {
return this.state!.create!;
}
get createChanges() {
return Object.values(this.create.changes);
}
get createEntries() {
return Object.entries(this.create.changes);
}
get hasWipChanges() {
return this.createChanges.some(change => change?.type === 'wip');
}
get selectedChanges(): [string, Change][] {
if (this.createChanges.length === 1) return this.createEntries;
return this.createEntries.filter(([, change]) => change.checked !== false);
}
get canSubmit() {
return this.create.title != null && this.create.title.length > 0 && this.selectedChanges.length > 0;
}
get fileLayout() {
return this.state?.preferences?.files?.layout ?? 'auto';
}
get isCompact() {
return this.state?.preferences?.files?.compact ?? true;
}
get filesModified() {
return flatCount(this.createChanges, c => c.files?.length ?? 0);
}
constructor() {
super();
defineGkElement(Menu, MenuItem, Popover);
}
renderForm() {
return html`
<div class="section">
${when(
this.state?.create?.creationError != null,
() =>
html` <div class="alert alert--error">
<code-icon icon="error"></code-icon>
<p class="alert__content">${this.state!.create!.creationError}</p>
</div>`,
)}
<div class="message-input">
<input id="title" type="text" class="message-input__control" placeholder="Title (required)" .value=${
this.create.title ?? ''
} @input=${this.onTitleInput}></textarea>
</div>
<div class="message-input">
<textarea id="desc" class="message-input__control" placeholder="Description (optional)" .value=${
this.create.description ?? ''
} @input=${this.onDescriptionInput}></textarea>
</div>
<p class="button-container">
<span class="button-group button-group--single">
<gl-button full @click=${this.onCreateAll}>Create Cloud Patch</gl-button>
</span>
</p>
<!-- <p class="h-deemphasize"><code-icon icon="account"></code-icon> Requires an account <a href="#">sign-in</a></p>
<p class="h-deemphasize"><code-icon icon="info"></code-icon> <a href="#">Learn more about cloud patches</a></p> -->
</div>
`;
}
// <gl-create-details
// .repoChanges=${this.repoChanges}
// .preferences=${this.state?.preferences}
// .isUncommitted=${true}
// @changeset-repo-checked=${this.onRepoChecked}
// @changeset-unstaged-checked=${this.onUnstagedChecked}
// >
// </gl-create-details>
override render() {
return html`
<div class="pane-groups">
<div class="pane-groups__group">${this.renderChangedFiles()}</div>
<div class="pane-groups__group-fixed pane-groups__group--bottom">
<webview-pane expanded
><span slot="title">Create Patch</span
><span slot="subtitle">PREVIEW </span>${this.renderForm()}</webview-pane
>
</div>
</div>
`;
}
private renderChangedFiles() {
return html`
<webview-pane expanded>
<span slot="title">Changes to Include</span>
<action-nav slot="actions">${this.renderLayoutAction(this.fileLayout)}</action-nav>
${when(
this.validityMessage != null,
() =>
html`<div class="section">
<div class="alert alert--error">
<code-icon icon="error"></code-icon>
<p class="alert__content">${this.validityMessage}</p>
</div>
</div>`,
)}
<div class="change-list" data-region="files">
${when(
this.create.changes == null,
() => this.renderLoading(),
() => this.renderTreeViewWithModel(),
)}
</div>
</webview-pane>
`;
}
// private renderChangeStats() {
// if (this.filesModified == null) return undefined;
// return html`<commit-stats
// .added=${undefined}
// modified="${this.filesModified}"
// .removed=${undefined}
// ></commit-stats>`;
// }
override onTreeItemChecked(e: CustomEvent<TreeItemCheckedDetail>) {
console.log(e);
// this.onRepoChecked()
if (e.detail.context == null || e.detail.context.length < 1) return;
const [repoUri, type] = e.detail.context;
let checked: boolean | 'staged' = e.detail.checked;
if (type === 'unstaged') {
checked = e.detail.checked ? true : 'staged';
}
const change = this.getChangeForRepo(repoUri);
if (change == null) {
debugger;
return;
}
if (change.checked === checked) return;
change.checked = checked;
this.requestUpdate('state');
this.fireEvent('gl-patch-create-repo-checked', {
repoUri: repoUri,
checked: checked,
});
}
override onTreeItemSelected(e: CustomEvent<TreeItemSelectionDetail>) {
if (!e.detail.context) return;
const [file] = e.detail.context;
this.fireEvent('gl-patch-file-compare-previous', { ...file });
}
private renderTreeViewWithModel() {
if (this.createChanges == null || this.createChanges.length === 0) {
return this.renderTreeView([
{
label: 'No changes',
path: '',
level: 1,
branch: false,
checkable: false,
expanded: true,
checked: false,
},
]);
}
const treeModel: TreeModel[] = [];
// for knowing if we need to show repos
const isCheckable = this.createChanges.length > 1;
const isTree = this.isTree(this.filesModified ?? 0);
const compact = this.isCompact;
if (isCheckable) {
for (const changeset of this.createChanges) {
const tree = this.getTreeForChange(changeset, true, isTree, compact);
if (tree != null) {
treeModel.push(...tree);
}
}
} else {
const changeset = this.createChanges[0];
const tree = this.getTreeForChange(changeset, false, isTree, compact);
if (tree != null) {
treeModel.push(...tree);
}
}
return this.renderTreeView(treeModel, this.state?.preferences?.indentGuides);
}
private getTreeForChange(change: Change, isMulti = false, isTree = false, compact = true): TreeModel[] | undefined {
if (change.files == null || change.files.length === 0) return undefined;
const children = [];
if (change.type === 'wip') {
const staged: Change['files'] = [];
const unstaged: Change['files'] = [];
for (const f of change.files) {
if (f.staged) {
staged.push(f);
} else {
unstaged.push(f);
}
}
if (staged.length === 0 || unstaged.length === 0) {
children.push(...this.renderFiles(change.files, isTree, compact, isMulti ? 2 : 1));
} else {
if (unstaged.length) {
children.push({
label: 'Unstaged Changes',
path: '',
level: isMulti ? 2 : 1,
branch: true,
checkable: true,
expanded: true,
checked: change.checked === true,
context: [change.repository.uri, 'unstaged'],
children: this.renderFiles(unstaged, isTree, compact, isMulti ? 3 : 2),
});
}
if (staged.length) {
children.push({
label: 'Staged Changes',
path: '',
level: isMulti ? 2 : 1,
branch: true,
checkable: true,
expanded: true,
checked: change.checked !== false,
disableCheck: true,
children: this.renderFiles(staged, isTree, compact, isMulti ? 3 : 2),
});
}
}
} else {
children.push(...this.renderFiles(change.files, isTree, compact));
}
if (!isMulti) {
return children;
}
const repoModel = this.repoToTreeModel(change.repository.name, change.repository.uri, {
branch: true,
checkable: true,
checked: change.checked !== false,
});
repoModel.children = children;
return [repoModel];
}
private isTree(count: number) {
if (this.fileLayout === 'auto') {
return count > (this.state?.preferences?.files?.threshold ?? 5);
}
return this.fileLayout === 'tree';
}
private createPatch() {
if (!this.canSubmit) {
// TODO: show error
if (this.titleInput.value.length === 0) {
this.titleInput.setCustomValidity('Title is required');
this.titleInput.reportValidity();
this.titleInput.focus();
} else {
this.titleInput.setCustomValidity('');
}
if (this.selectedChanges == null || this.selectedChanges.length === 0) {
this.validityMessage = 'Check at least one change';
} else {
this.validityMessage = undefined;
}
return;
}
this.validityMessage = undefined;
this.titleInput.setCustomValidity('');
const changes = this.selectedChanges.reduce<Record<string, Change>>((a, [id, change]) => {
a[id] = change;
return a;
}, {});
const patch = {
title: this.create.title ?? '',
description: this.create.description,
changesets: changes,
};
this.fireEvent('gl-patch-create-patch', patch);
}
private onCreateAll(_e: Event) {
// const change = this.create.[0];
// if (change == null) {
// return;
// }
// this.createPatch([change]);
this.createPatch();
}
private onSelectCreateOption(_e: CustomEvent<{ target: MenuItem }>) {
// const target = e.detail?.target;
// const value = target?.dataset?.value as 'staged' | 'unstaged' | undefined;
// const currentChange = this.create.[0];
// if (value == null || currentChange == null) {
// return;
// }
// const change = {
// ...currentChange,
// files: currentChange.files.filter(file => {
// const staged = file.staged ?? false;
// return (staged && value === 'staged') || (!staged && value === 'unstaged');
// }),
// };
// this.createPatch([change]);
}
private getChangeForRepo(repoUri: string): Change | undefined {
return this.create.changes[repoUri];
// for (const [id, change] of this.createEntries) {
// if (change.repository.uri === repoUri) return change;
// }
// return undefined;
}
// private onRepoChecked(e: CustomEvent<{ repoUri: string; checked: boolean }>) {
// const [_, changeset] = this.getRepoChangeSet(e.detail.repoUri);
// if ((changeset as RepoWipChangeSet).checked === e.detail.checked) {
// return;
// }
// (changeset as RepoWipChangeSet).checked = e.detail.checked;
// this.requestUpdate('state');
// }
// private onUnstagedChecked(e: CustomEvent<{ repoUri: string; checked: boolean | 'staged' }>) {
// const [_, changeset] = this.getRepoChangeSet(e.detail.repoUri);
// if ((changeset as RepoWipChangeSet).checked === e.detail.checked) {
// return;
// }
// (changeset as RepoWipChangeSet).checked = e.detail.checked;
// this.requestUpdate('state');
// }
private onTitleInput(e: InputEvent) {
this.create.title = (e.target as HTMLInputElement).value;
this.fireEvent('gl-patch-create-update-metadata', {
title: this.create.title,
description: this.create.description,
});
}
private onDescriptionInput(e: InputEvent) {
this.create.description = (e.target as HTMLInputElement).value;
this.fireEvent('gl-patch-create-update-metadata', {
title: this.create.title!,
description: this.create.description,
});
}
protected override createRenderRoot() {
return this;
}
override onTreeItemActionClicked(e: CustomEvent<TreeItemActionDetail>) {
if (!e.detail.context || !e.detail.action) return;
const action = e.detail.action;
switch (action.action) {
case 'show-patch-in-graph':
this.onShowInGraph(e);
break;
case 'file-open':
this.onOpenFile(e);
break;
}
}
onOpenFile(e: CustomEvent<TreeItemActionDetail>) {
if (!e.detail.context) return;
const [file] = e.detail.context;
this.fireEvent('gl-patch-file-open', {
...file,
showOptions: {
preview: false,
viewColumn: e.detail.altKey ? BesideViewColumn : undefined,
},
});
}
onShowInGraph(_e: CustomEvent<TreeItemActionDetail>) {
// this.fireEvent('gl-patch-details-graph-show-patch', { draft: this.state!.create! });
}
override getFileActions(_file: GitFileChangeShape, _options?: Partial<TreeItemBase>) {
return [
{
icon: 'go-to-file',
label: 'Open file',
action: 'file-open',
},
];
}
override getRepoActions(_name: string, _path: string, _options?: Partial<TreeItemBase>) {
return [
{
icon: 'gl-graph',
label: 'Open in Commit Graph',
action: 'show-patch-in-graph',
},
];
}
}
declare global {
interface HTMLElementTagNameMap {
'gl-patch-create': GlPatchCreate;
}
interface WindowEventMap {
'gl-patch-create-repo-checked': CustomEvent<CreatePatchCheckRepositoryEventDetail>;
'gl-patch-create-patch': CustomEvent<CreatePatchEventDetail>;
'gl-patch-create-update-metadata': CustomEvent<CreatePatchMetadataEventDetail>;
'gl-patch-file-compare-previous': CustomEvent<FileActionParams>;
'gl-patch-file-compare-working': CustomEvent<FileActionParams>;
'gl-patch-file-open': CustomEvent<FileActionParams>;
// 'gl-patch-details-graph-show-patch': CustomEvent<{ draft: State['create'] }>;
}
}

+ 209
- 0
src/webviews/apps/plus/patchDetails/components/gl-tree-base.ts View File

@ -0,0 +1,209 @@
import { html, nothing } from 'lit';
import type { GitFileChangeShape } from '../../../../../git/models/file';
import type { HierarchicalItem } from '../../../../../system/array';
import { makeHierarchical } from '../../../../../system/array';
import type { GlEvents } from '../../../shared/components/element';
import { GlElement } from '../../../shared/components/element';
import type {
TreeItemAction,
TreeItemActionDetail,
TreeItemBase,
TreeItemCheckedDetail,
TreeItemSelectionDetail,
TreeModel,
} from '../../../shared/components/tree/base';
import '../../../shared/components/tree/tree-generator';
import '../../../shared/components/skeleton-loader';
import '../../../shared/components/actions/action-item';
export class GlTreeBase<Events extends GlEvents = GlEvents> extends GlElement<Events> {
protected onTreeItemActionClicked?(_e: CustomEvent<TreeItemActionDetail>): void;
protected onTreeItemChecked?(_e: CustomEvent<TreeItemCheckedDetail>): void;
protected onTreeItemSelected?(_e: CustomEvent<TreeItemSelectionDetail>): void;
protected renderLoading() {
return html`
<div class="section section--skeleton">
<skeleton-loader></skeleton-loader>
</div>
<div class="section section--skeleton">
<skeleton-loader></skeleton-loader>
</div>
<div class="section section--skeleton">
<skeleton-loader></skeleton-loader>
</div>
`;
}
protected renderLayoutAction(layout: string) {
if (!layout) return nothing;
let value = 'tree';
let icon = 'list-tree';
let label = 'View as Tree';
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;
}
return html`<action-item data-switch-value="${value}" label="${label}" icon="${icon}"></action-item>`;
}
protected renderTreeView(treeModel: TreeModel[], guides: 'none' | 'onHover' | 'always' = 'none') {
return html`<gl-tree-generator
.model=${treeModel}
.guides=${guides}
@gl-tree-generated-item-action-clicked=${this.onTreeItemActionClicked}
@gl-tree-generated-item-checked=${this.onTreeItemChecked}
@gl-tree-generated-item-selected=${this.onTreeItemSelected}
></gl-tree-generator>`;
}
protected renderFiles(files: GitFileChangeShape[], isTree = false, compact = false, level = 2): TreeModel[] {
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: level });
children.push(childModel);
}
}
} else {
for (const file of files) {
const child = this.fileToTreeModel(file, { level: level, branch: false }, true);
children.push(child);
}
}
return children;
}
protected walkFileTree(
item: HierarchicalItem<GitFileChangeShape>,
options: Partial<TreeItemBase> = { 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) {
model.branch = true;
model.children = children;
}
}
return model;
}
protected folderToTreeModel(name: string, options?: Partial<TreeItemBase>): TreeModel {
return {
branch: false,
expanded: true,
path: name,
level: 1,
checkable: false,
checked: false,
icon: 'folder',
label: name,
...options,
};
}
protected getRepoActions(_name: string, _path: string, _options?: Partial<TreeItemBase>): TreeItemAction[] {
return [];
}
protected emptyTreeModel(name: string, options?: Partial<TreeItemBase>): TreeModel {
return {
branch: false,
expanded: true,
path: '',
level: 1,
checkable: true,
checked: true,
icon: undefined,
label: name,
...options,
};
}
protected repoToTreeModel(name: string, path: string, options?: Partial<TreeItemBase>): TreeModel<string[]> {
return {
branch: false,
expanded: true,
path: path,
level: 1,
checkable: true,
checked: true,
icon: 'repo',
label: name,
context: [path],
actions: this.getRepoActions(name, path, options),
...options,
};
}
protected getFileActions(_file: GitFileChangeShape, _options?: Partial<TreeItemBase>): TreeItemAction[] {
return [];
}
protected fileToTreeModel(
file: GitFileChangeShape,
options?: Partial<TreeItemBase>,
flat = false,
glue = '/',
): TreeModel<GitFileChangeShape[]> {
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: 'file', //{ type: 'status', name: file.status },
label: fileName,
description: flat === true ? filePath : undefined,
context: [file],
actions: this.getFileActions(file, options),
decorators: [{ type: 'text', label: file.status }],
...options,
};
}
}

+ 182
- 0
src/webviews/apps/plus/patchDetails/components/patch-details-app.ts View File

@ -0,0 +1,182 @@
import { Badge, defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components';
import { html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import type { DraftDetails, Mode, State } from '../../../../../plus/webviews/patchDetails/protocol';
import { GlElement } from '../../../shared/components/element';
import type { PatchDetailsApp } from '../patchDetails';
import './gl-draft-details';
import './gl-patch-create';
interface ExplainState {
cancelled?: boolean;
error?: { message: string };
summary?: string;
}
export interface ApplyPatchDetail {
draft: DraftDetails;
target?: 'current' | 'branch' | 'worktree';
base?: string;
// [key: string]: unknown;
}
export interface ChangePatchBaseDetail {
draft: DraftDetails;
// [key: string]: unknown;
}
export interface SelectPatchRepoDetail {
draft: DraftDetails;
repoPath?: string;
// [key: string]: unknown;
}
export interface ShowPatchInGraphDetail {
draft: DraftDetails;
// [key: string]: unknown;
}
export type GlPatchDetailsAppEvents = {
[K in Extract<keyof WindowEventMap, `gl-patch-details-${string}`>]: WindowEventMap[K];
};
@customElement('gl-patch-details-app')
export class GlPatchDetailsApp extends GlElement<GlPatchDetailsAppEvents> {
@property({ type: Object })
state!: State;
@property({ type: Object })
explain?: ExplainState;
@property({ attribute: false, type: Object })
app?: PatchDetailsApp;
constructor() {
super();
defineGkElement(Badge, Popover, Menu, MenuItem);
}
get wipChangesCount() {
if (this.state?.create == null) return 0;
return Object.values(this.state.create.changes).reduce((a, c) => {
a += c.files?.length ?? 0;
return a;
}, 0);
}
get wipChangeState() {
if (this.state?.create == null) return undefined;
const state = Object.values(this.state.create.changes).reduce(
(a, c) => {
if (c.files != null) {
a.files += c.files.length;
a.on.add(c.repository.uri);
}
return a;
},
{ files: 0, on: new Set<string>() },
);
// return file length total and repo/branch names
return {
count: state.files,
branches: Array.from(state.on).join(', '),
};
}
get mode(): Mode {
return this.state?.mode ?? 'view';
}
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`);
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
if (changedProperties.has('state')) {
this.updateDocumentProperties();
}
}
private renderTabs() {
return nothing;
// return html`
// <nav class="details-tab">
// <button
// class="details-tab__item ${this.mode === 'view' ? ' is-active' : ''}"
// data-action="mode"
// data-action-value="view"
// >
// Patch
// </button>
// <button
// class="details-tab__item ${this.mode === 'create' ? ' is-active' : ''}"
// data-action="mode"
// data-action-value="create"
// title="${this.wipChangeState != null
// ? `${pluralize('file change', this.wipChangeState.count, {
// plural: 'file changes',
// })} on ${this.wipChangeState.branches}`
// : nothing}"
// >
// Create${this.wipChangeState
// ? html` &nbsp;<gk-badge variant="filled">${this.wipChangeState.count}</gk-badge>`
// : ''}
// </button>
// </nav>
// `;
}
override render() {
return html`
<div class="commit-detail-panel scrollable">
${this.renderTabs()}
<main id="main" tabindex="-1">
${when(
this.mode === 'view',
() => html`<gl-draft-details .state=${this.state} .explain=${this.explain}></gl-draft-details>`,
() => html`<gl-patch-create .state=${this.state}></gl-patch-create>`,
)}
</main>
</div>
`;
}
// onShowInGraph(e: CustomEvent<ShowPatchInGraphDetail>) {
// this.fireEvent('gl-patch-details-graph-show-patch', e.detail);
// }
// private onShareLocalPatch(_e: CustomEvent<undefined>) {
// this.fireEvent('gl-patch-details-share-local-patch');
// }
// private onCopyCloudLink(_e: CustomEvent<undefined>) {
// this.fireEvent('gl-patch-details-copy-cloud-link');
// }
protected override createRenderRoot() {
return this;
}
}
declare global {
interface HTMLElementTagNameMap {
'gl-patch-details-app': GlPatchDetailsApp;
}
// interface WindowEventMap {
// 'gl-patch-details-graph-show-patch': CustomEvent<ShowPatchInGraphDetail>;
// 'gl-patch-details-share-local-patch': CustomEvent<undefined>;
// 'gl-patch-details-copy-cloud-link': CustomEvent<undefined>;
// }
}

+ 27
- 0
src/webviews/apps/plus/patchDetails/patchDetails.html View File

@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style nonce="#{cspNonce}">
@font-face {
font-family: 'codicon';
font-display: block;
src: url('#{webroot}/codicon.ttf?2ab61cbaefbdf4c7c5589068100bee0c') format('truetype');
}
@font-face {
font-family: 'glicons';
font-display: block;
src: url('#{root}/dist/glicons.woff2?8e33f5a80a91b05940d687a08305c156') format('woff2');
}
</style>
</head>
<body
class="preload"
data-placement="#{placement}"
data-vscode-context='{ "preventDefaultContextMenuItems": true, "webview": "#{webviewId}" }'
>
<gl-patch-details-app id="app"></gl-patch-details-app>
#{endOfBody}
</body>
</html>

+ 164
- 0
src/webviews/apps/plus/patchDetails/patchDetails.scss View File

@ -0,0 +1,164 @@
@use '../../shared/styles/details-base';
.message-block__text strong:not(:only-child) {
display: inline-block;
margin-bottom: 0.52rem;
}
// TODO: "change-list__action" should be a separate component
.change-list__action {
box-sizing: border-box;
display: inline-flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.25em;
color: inherit;
padding: 2px;
vertical-align: text-bottom;
text-decoration: none;
}
.change-list__action:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.change-list__action:hover {
color: inherit;
background-color: var(--vscode-toolbar-hoverBackground);
text-decoration: none;
}
.change-list__action:active {
background-color: var(--vscode-toolbar-activeBackground);
}
.patch-base {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 0.4rem;
padding: {
top: 0.1rem;
bottom: 0.1rem;
}
:first-child {
margin-right: auto;
}
}
textarea.message-input__control {
resize: vertical;
min-height: 4rem;
max-height: 40rem;
}
.message-input {
padding-top: 0.8rem;
&__control {
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;
&::placeholder {
color: var(--vscode-input-placeholderForeground);
}
&:invalid {
border-color: var(--vscode-inputValidation-errorBorder);
background-color: var(--vscode-inputValidation-errorBackground);
}
&:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
}
}
.h {
&-spacing {
margin-bottom: 1.5rem;
}
&-deemphasize {
margin: 0.8rem 0 0.4rem;
opacity: 0.8;
}
}
.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);
code-icon {
margin-right: 0.4rem;
vertical-align: baseline;
}
&__content {
font-size: 1.2rem;
line-height: 1.2;
text-align: left;
margin: 0;
}
}
.commit-detail-panel {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.details-tab {
flex: none;
}
main {
flex: 1 1 auto;
overflow: hidden;
}
gl-patch-create {
display: contents;
}
.pane-groups {
display: flex;
flex-direction: column;
height: 100%;
&__group {
min-height: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
webview-pane {
flex: none;
&[expanded] {
min-height: 0;
flex: 1;
}
}
}
&__group-fixed {
flex: none;
}
}

+ 337
- 0
src/webviews/apps/plus/patchDetails/patchDetails.ts View File

@ -0,0 +1,337 @@
/*global*/
import type { TextDocumentShowOptions } from 'vscode';
import type { ViewFilesLayout } from '../../../../config';
import type { DraftPatchFileChange } from '../../../../gk/models/drafts';
import type { State, SwitchModeParams } from '../../../../plus/webviews/patchDetails/protocol';
import {
ApplyPatchCommandType,
CopyCloudLinkCommandType,
CreateFromLocalPatchCommandType,
CreatePatchCommandType,
DidChangeCreateNotificationType,
DidChangeDraftNotificationType,
DidChangeNotificationType,
DidChangePreferencesNotificationType,
DidExplainCommandType,
ExplainCommandType,
FileActionsCommandType,
OpenFileCommandType,
OpenFileComparePreviousCommandType,
OpenFileCompareWorkingCommandType,
OpenFileOnRemoteCommandType,
SelectPatchBaseCommandType,
SelectPatchRepoCommandType,
SwitchModeCommandType,
UpdateCreatePatchMetadataCommandType,
UpdateCreatePatchRepositoryCheckedStateCommandType,
UpdatePreferencesCommandType,
} from '../../../../plus/webviews/patchDetails/protocol';
import type { Serialized } from '../../../../system/serialize';
import type { IpcMessage } from '../../../protocol';
import { ExecuteCommandType, onIpc } from '../../../protocol';
import { App } from '../../shared/appBase';
import { DOM } from '../../shared/dom';
import type { ApplyPatchDetail, GlDraftDetails } from './components/gl-draft-details';
import type {
CreatePatchCheckRepositoryEventDetail,
CreatePatchEventDetail,
CreatePatchMetadataEventDetail,
GlPatchCreate,
} from './components/gl-patch-create';
import type {
ChangePatchBaseDetail,
GlPatchDetailsApp,
SelectPatchRepoDetail,
ShowPatchInGraphDetail,
} from './components/patch-details-app';
import './patchDetails.scss';
import './components/patch-details-app';
export const uncommittedSha = '0000000000000000000000000000000000000000';
export interface FileChangeListItemDetail extends DraftPatchFileChange {
showOptions?: TextDocumentShowOptions;
}
export class PatchDetailsApp extends App<Serialized<State>> {
constructor() {
super('PatchDetailsApp');
}
override onInitialize() {
this.attachState();
}
override onBind() {
const disposables = [
// DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-open-on-remote', e =>
// this.onOpenFileOnRemote(e.detail),
// ),
// DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-compare-working', e =>
// this.onCompareFileWithWorking(e.detail),
// ),
// DOM.on<FileChangeListItem, FileChangeListItemDetail>('file-change-list-item', 'file-more-actions', e =>
// this.onFileMoreActions(e.detail),
// ),
DOM.on('[data-switch-value]', 'click', e => this.onToggleFilesLayout(e)),
DOM.on('[data-action="ai-explain"]', 'click', e => this.onAIExplain(e)),
DOM.on('[data-action="switch-ai"]', 'click', e => this.onSwitchAIModel(e)),
DOM.on('[data-action="mode"]', 'click', e => this.onModeClicked(e)),
DOM.on<GlDraftDetails, ApplyPatchDetail>('gl-draft-details', 'gl-patch-apply-patch', e =>
this.onApplyPatch(e.detail),
),
DOM.on<GlPatchDetailsApp, ChangePatchBaseDetail>('gl-patch-details-app', 'change-patch-base', e =>
this.onChangePatchBase(e.detail),
),
DOM.on<GlPatchDetailsApp, SelectPatchRepoDetail>('gl-patch-details-app', 'select-patch-repo', e =>
this.onSelectPatchRepo(e.detail),
),
DOM.on<GlPatchDetailsApp, ShowPatchInGraphDetail>(
'gl-patch-details-app',
'gl-patch-details-graph-show-patch',
e => this.onShowPatchInGraph(e.detail),
),
DOM.on<GlPatchDetailsApp, CreatePatchEventDetail>('gl-patch-details-app', 'gl-patch-create-patch', e =>
this.onCreatePatch(e.detail),
),
DOM.on<GlPatchDetailsApp, undefined>('gl-patch-details-app', 'gl-patch-share-local-patch', () =>
this.onShareLocalPatch(),
),
DOM.on<GlPatchDetailsApp, undefined>('gl-patch-details-app', 'gl-patch-copy-cloud-link', () =>
this.onCopyCloudLink(),
),
DOM.on<GlPatchCreate, CreatePatchCheckRepositoryEventDetail>(
'gl-patch-create',
'gl-patch-create-repo-checked',
e => this.onCreateCheckRepo(e.detail),
),
DOM.on<GlPatchCreate, CreatePatchMetadataEventDetail>(
'gl-patch-create',
'gl-patch-create-update-metadata',
e => this.onCreateUpdateMetadata(e.detail),
),
DOM.on<GlPatchCreate, FileChangeListItemDetail>(
'gl-patch-create,gl-draft-details',
'gl-patch-file-compare-previous',
e => this.onCompareFileWithPrevious(e.detail),
),
DOM.on<GlPatchCreate, FileChangeListItemDetail>(
'gl-patch-create,gl-draft-details',
'gl-patch-file-compare-working',
e => this.onCompareFileWithWorking(e.detail),
),
DOM.on<GlDraftDetails, FileChangeListItemDetail>(
'gl-patch-create,gl-draft-details',
'gl-patch-file-open',
e => this.onOpenFile(e.detail),
),
];
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<typeof params>(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<State>(params.state);
this.state = params.state;
this.setState(this.state);
this.attachState();
});
break;
case DidChangeCreateNotificationType.method:
onIpc(DidChangeCreateNotificationType, msg, params => {
// assertsSerialized<State>(params.state);
this.state = { ...this.state, ...params };
this.setState(this.state);
this.attachState(true);
});
break;
case DidChangeDraftNotificationType.method:
onIpc(DidChangeDraftNotificationType, msg, params => {
// assertsSerialized<State>(params.state);
this.state = { ...this.state, ...params };
this.setState(this.state);
this.attachState(true);
});
break;
case DidChangePreferencesNotificationType.method:
onIpc(DidChangePreferencesNotificationType, msg, params => {
// assertsSerialized<State>(params.state);
this.state = { ...this.state, ...params };
this.setState(this.state);
this.attachState(true);
});
break;
default:
super.onMessageReceived?.(e);
}
}
private onCreateCheckRepo(e: CreatePatchCheckRepositoryEventDetail) {
this.sendCommand(UpdateCreatePatchRepositoryCheckedStateCommandType, e);
}
private onCreateUpdateMetadata(e: CreatePatchMetadataEventDetail) {
this.sendCommand(UpdateCreatePatchMetadataCommandType, e);
}
private onShowPatchInGraph(_e: ShowPatchInGraphDetail) {
// this.sendCommand(OpenInCommitGraphCommandType, { });
}
private onCreatePatch(e: CreatePatchEventDetail) {
this.sendCommand(CreatePatchCommandType, e);
}
private onShareLocalPatch() {
this.sendCommand(CreateFromLocalPatchCommandType, undefined);
}
private onCopyCloudLink() {
this.sendCommand(CopyCloudLinkCommandType, undefined);
}
private onModeClicked(e: Event) {
const mode = ((e.target as HTMLElement)?.dataset.actionValue as SwitchModeParams['mode']) ?? undefined;
if (mode === this.state.mode) return;
this.sendCommand(SwitchModeCommandType, { mode: mode });
}
private onApplyPatch(e: ApplyPatchDetail) {
console.log('onApplyPatch', e);
if (e.selectedPatches == null || e.selectedPatches.length === 0) return;
this.sendCommand(ApplyPatchCommandType, { details: e.draft, selected: e.selectedPatches });
}
private onChangePatchBase(e: ChangePatchBaseDetail) {
console.log('onChangePatchBase', e);
this.sendCommand(SelectPatchBaseCommandType, undefined);
}
private onSelectPatchRepo(e: SelectPatchRepoDetail) {
console.log('onSelectPatchRepo', e);
this.sendCommand(SelectPatchRepoCommandType, undefined);
}
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 onAIExplain(_e: MouseEvent) {
try {
const result = await this.sendCommandWithCompletion(ExplainCommandType, undefined, DidExplainCommandType);
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;
}
} catch (ex) {
this.component.explain = { error: { message: 'Error retrieving content' } };
}
}
private onToggleFilesLayout(e: MouseEvent) {
const layout = ((e.target as HTMLElement)?.dataset.switchValue as ViewFilesLayout) ?? undefined;
if (layout === this.state.preferences.files?.layout) return;
const files: State['preferences']['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(UpdatePreferencesCommandType, { files: files });
}
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 _component?: GlPatchDetailsApp;
private get component() {
if (this._component == null) {
this._component = (document.getElementById('app') as GlPatchDetailsApp)!;
this._component.app = this;
}
return this._component;
}
attachState(_force?: boolean) {
this.component.state = this.state!;
// if (force) {
// this.component.requestUpdate('state');
// }
}
}
function assertsSerialized<T>(obj: unknown): asserts obj is Serialized<T> {}
new PatchDetailsApp();

+ 48
- 46
src/webviews/apps/shared/components/actions/action-nav.ts View File

@ -1,67 +1,69 @@
import { css, customElement, FASTElement, html, observable, slotted } from '@microsoft/fast-element';
import '../code-icon';
import { css, html, LitElement } from 'lit';
import { customElement, queryAssignedElements } from 'lit/decorators.js';
const template = html<ActionNav>`<template role="navigation"><slot ${slotted('actionNodes')}></slot></template>`;
@customElement('action-nav')
export class ActionNav extends LitElement {
static override styles = css`
:host {
display: flex;
align-items: center;
user-select: none;
}
`;
private _slotSubscriptionsDisposer?: () => void;
@queryAssignedElements({ flatten: true })
private actionNodes!: HTMLElement[];
const styles = css`
:host {
display: flex;
align-items: center;
user-select: none;
override firstUpdated() {
this.role = 'navigation';
}
override disconnectedCallback() {
this._slotSubscriptionsDisposer?.();
}
`;
@customElement({ name: 'action-nav', template: template, styles: styles })
export class ActionNav extends FASTElement {
@observable
actionNodes?: HTMLElement[];
override render() {
return html`<slot @slotchange=${this.handleSlotChange}></slot>`;
}
actionNodesDisposer?: () => void;
actionNodesChanged(_oldValue?: HTMLElement[], newValue?: HTMLElement[]) {
this.actionNodesDisposer?.();
private handleSlotChange(_e: Event) {
this._slotSubscriptionsDisposer?.();
if (!newValue?.length) {
return;
}
if (this.actionNodes.length < 2) return;
const handleKeydown = this.handleKeydown.bind(this);
const nodeEvents = newValue
?.filter(node => node.nodeType === 1)
.map((node, i) => {
node.setAttribute('tabindex', i === 0 ? '0' : '-1');
node.addEventListener('keydown', handleKeydown, false);
return {
dispose: () => {
node?.removeEventListener('keydown', handleKeydown, false);
},
};
});
const size = `${this.actionNodes.length}`;
const subs = this.actionNodes.map((element, i) => {
element.setAttribute('aria-posinset', `${i + 1}`);
element.setAttribute('aria-setsize', size);
element.setAttribute('tabindex', i === 0 ? '0' : '-1');
element.addEventListener('keydown', handleKeydown, false);
return {
dispose: () => {
element?.removeEventListener('keydown', handleKeydown, false);
},
};
});
this.actionNodesDisposer = () => {
nodeEvents?.forEach(({ dispose }) => dispose());
this._slotSubscriptionsDisposer = () => {
subs?.forEach(({ dispose }) => dispose());
};
}
override disconnectedCallback() {
this.actionNodesDisposer?.();
}
handleKeydown(e: KeyboardEvent) {
private handleKeydown(e: KeyboardEvent) {
if (!e.target || this.actionNodes == null || this.actionNodes.length < 2) return;
const target = e.target as HTMLElement;
const posinset = parseInt(target.getAttribute('aria-posinset') ?? '0', 10);
let $next: HTMLElement | null = null;
if (e.key === 'ArrowLeft') {
$next = target.previousElementSibling as HTMLElement;
if ($next == null) {
const filteredNodes = this.actionNodes.filter(node => node.nodeType === 1);
$next = filteredNodes[filteredNodes.length - 1] ?? null;
}
const next = posinset === 1 ? this.actionNodes.length - 1 : posinset - 2;
$next = this.actionNodes[next];
} else if (e.key === 'ArrowRight') {
$next = target.nextElementSibling as HTMLElement;
if ($next == null) {
$next = this.actionNodes.find(node => node.nodeType === 1) ?? null;
}
const next = posinset === this.actionNodes.length ? 0 : posinset;
$next = this.actionNodes[next];
}
if ($next == null || $next === target) {
return;

+ 27
- 0
src/webviews/apps/shared/components/button.ts View File

@ -1,3 +1,4 @@
import type { PropertyValueMap } from 'lit';
import { css, html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { focusOutline } from './styles/lit/a11y.css';
@ -13,6 +14,7 @@ export class GlButton extends LitElement {
--button-background: var(--color-button-background);
--button-hover-background: var(--vscode-button-hoverBackground);
--button-padding: 0.4rem 1.1rem;
--button-compact-padding: 0.4rem 0.4rem;
--button-line-height: 1.694;
--button-border: var(--vscode-button-border, transparent);
@ -96,12 +98,28 @@ export class GlButton extends LitElement {
display: block;
width: max-content;
}
:host([density='compact']) {
padding: var(--button-compact-padding);
}
:host([disabled]) {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
`,
];
@property({ type: Boolean, reflect: true })
full = false;
@property({ type: Boolean, reflect: true })
disabled = false;
@property({ reflect: true })
density?: 'compact';
@property()
href?: string;
@ -116,6 +134,15 @@ export class GlButton extends LitElement {
@property({ type: Number, reflect: true })
override tabIndex = 0;
protected override updated(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
super.updated(changedProperties);
if (changedProperties.has('disabled')) {
this.tabIndex = this.disabled ? -1 : 0;
this.setAttribute('aria-disabled', this.disabled.toString());
}
}
override render() {
const main = html`<slot></slot>`;
return this.href != null ? html`<a href=${this.href}>${main}</a>` : main;

+ 2
- 1
src/webviews/apps/shared/components/commit/commit-identity.ts View File

@ -68,9 +68,10 @@ export class CommitIdentity extends LitElement {
actionLabel = 'committed';
override render() {
const showAvatar = this.showAvatar && this.avatarUrl != null && this.avatarUrl.length > 0;
return html`
<a class="avatar" href="${this.email ? `mailto:${this.email}` : '#'}">
${this.showAvatar
${showAvatar
? html`<img class="thumb" src="${this.avatarUrl}" alt="${this.name}" />`
: html`<code-icon icon="person" size="32"></code-icon>`}
</a>

+ 15
- 0
src/webviews/apps/shared/components/element.ts View File

@ -0,0 +1,15 @@
import { LitElement } from 'lit';
export type GlEvents<Prefix extends string = ''> = Record<`gl-${Prefix}${string}`, CustomEvent>;
type GlEventsUnwrapped<Events extends GlEvents> = {
[P in Extract<keyof Events, `gl-${string}`>]: UnwrapCustomEvent<Events[P]>;
};
export abstract class GlElement<Events extends GlEvents = GlEvents> extends LitElement {
fireEvent<T extends keyof GlEventsUnwrapped<Events>>(
name: T,
detail?: GlEventsUnwrapped<Events>[T] | undefined,
): boolean {
return this.dispatchEvent(new CustomEvent<GlEventsUnwrapped<Events>[T]>(name, { detail: detail }));
}
}

+ 4
- 5
src/webviews/apps/shared/components/list/file-change-list-item.ts View File

@ -1,6 +1,7 @@
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import type { TextDocumentShowOptions } from 'vscode';
import type { GitFileChangeShape, GitFileStatus } from '../../../../../git/models/file';
import type { ListItem, ListItemSelectedEvent } from './list-item';
import '../code-icon';
import '../status/git-status';
@ -8,11 +9,7 @@ import '../status/git-status';
// Can only import types from 'vscode'
const BesideViewColumn = -2; /*ViewColumn.Beside*/
export interface FileChangeListItemDetail {
path: string;
repoPath: string;
staged: boolean | undefined;
export interface FileChangeListItemDetail extends GitFileChangeShape {
showOptions?: TextDocumentShowOptions;
}
@ -264,6 +261,8 @@ export class FileChangeListItem extends LitElement {
return {
path: this.path,
repoPath: this.repo,
status: this.status as GitFileStatus,
// originalPath: this.originalPath,
staged: this.staged,
showOptions: showOptions,
};

+ 101
- 1
src/webviews/apps/shared/components/list/list-item.ts View File

@ -1,6 +1,6 @@
import type { PropertyValues } from 'lit';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import type { TextDocumentShowOptions } from 'vscode';
import '../converters/number-converter';
import '../code-icon';
@ -162,6 +162,58 @@ export class ListItem extends LitElement {
display: flex;
align-items: center;
}
.checkbox {
position: relative;
display: inline-flex;
width: 1.6rem;
aspect-ratio: 1 / 1;
text-align: center;
color: var(--vscode-checkbox-foreground);
background: var(--vscode-checkbox-background);
border: 1px solid var(--vscode-checkbox-border);
border-radius: 0.3rem;
// overflow: hidden;
}
.checkbox:has(:checked) {
color: var(--vscode-inputOption-activeForeground);
border-color: var(--vscode-inputOption-activeBorder);
background-color: var(--vscode-inputOption-activeBackground);
}
.checkbox:has(:disabled) {
opacity: 0.4;
}
.checkbox__input {
position: absolute;
top: 0;
left: 0;
appearance: none;
width: 1.4rem;
aspect-ratio: 1 / 1;
margin: 0;
cursor: pointer;
border-radius: 0.3rem;
}
.checkbox__input:disabled {
cursor: default;
}
.checkbox__check {
width: 1.6rem;
aspect-ratio: 1 / 1;
opacity: 0;
transition: opacity 0.1s linear;
color: var(--vscode-checkbox-foreground);
pointer-events: none;
}
.checkbox__input:checked + .checkbox__check {
opacity: 1;
}
`;
@property({ type: Boolean, reflect: true }) tree = false;
@ -174,6 +226,10 @@ export class ListItem extends LitElement {
@property({ type: Number }) level = 1;
@property({ type: Boolean, reflect: true }) checkable = false;
@property({ type: Boolean, reflect: true }) checked = false;
@property({ type: Boolean, attribute: 'disable-check', reflect: true }) disableCheck = false;
@property({ type: Boolean })
active = false;
@ -197,11 +253,24 @@ export class ListItem extends LitElement {
return 'false';
}
@query('#checkbox')
checkboxEl?: HTMLInputElement;
onItemClick(e: MouseEvent) {
if (this.checkable && e.target === this.checkboxEl) {
e.preventDefault();
e.stopPropagation();
return;
}
this.select(e.altKey ? { viewColumn: BesideViewColumn } : undefined);
}
onDblItemClick(e: MouseEvent) {
if (this.checkable && e.target === this.checkboxEl) {
e.preventDefault();
e.stopPropagation();
return;
}
this.select({
preview: false,
viewColumn: e.altKey || e.ctrlKey || e.metaKey ? BesideViewColumn : undefined,
@ -263,6 +332,22 @@ export class ListItem extends LitElement {
this.setAttribute('aria-hidden', this.isHidden);
}
renderCheckbox() {
if (!this.checkable) {
return nothing;
}
return html`<span class="checkbox"
><input
class="checkbox__input"
id="checkbox"
type="checkbox"
.checked=${this.checked}
?disabled=${this.disableCheck}
@change=${this.onCheckedChange}
@click=${this.onCheckedClick} /><code-icon icon="check" size="14" class="checkbox__check"></code-icon
></span>`;
}
override render() {
return html`
<button
@ -283,6 +368,7 @@ export class ListItem extends LitElement {
></code-icon
></span>`
: nothing}
${this.renderCheckbox()}
${this.hideIcon ? nothing : html`<span class="icon"><slot name="icon"></slot></span>`}
<span class="text">
<span class="main"><slot></slot></span>
@ -292,4 +378,18 @@ export class ListItem extends LitElement {
<nav class="actions"><slot name="actions"></slot></nav>
`;
}
onCheckedClick(e: Event) {
console.log('onCheckedClick', e);
e.stopPropagation();
}
onCheckedChange(e: Event) {
console.log('onCheckedChange', e);
e.preventDefault();
e.stopPropagation();
this.checked = (e.target as HTMLInputElement).checked;
this.dispatchEvent(new CustomEvent('list-item-checked', { detail: { checked: this.checked } }));
}
}

+ 35
- 0
src/webviews/apps/shared/components/styles/lit/base.css.ts View File

@ -27,3 +27,38 @@ export const linkBase = css`
text-decoration: underline;
}
`;
export const scrollableBase = css`
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-corner {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: transparent;
border-color: inherit;
border-right-style: inset;
border-right-width: calc(100vw + 100vh);
border-radius: unset !important;
}
::-webkit-scrollbar-thumb:hover {
border-color: var(--vscode-scrollbarSlider-hoverBackground);
}
::-webkit-scrollbar-thumb:active {
border-color: var(--vscode-scrollbarSlider-activeBackground);
}
.scrollable {
border-color: transparent;
transition: border-color 1s linear;
}
:host(:hover) .scrollable,
:host(:focus-within) .scrollable {
border-color: var(--vscode-scrollbarSlider-background);
transition: none;
}
`;

+ 91
- 0
src/webviews/apps/shared/components/tree/base.ts View File

@ -0,0 +1,91 @@
import type { GitFileStatus } from '../../../../../git/models/file';
import type { DraftPatchFileChange } from '../../../../../gk/models/drafts';
export interface TreeItemBase {
// node properties
branch: boolean;
expanded: boolean;
path: string;
// parent
parentPath?: string;
parentExpanded?: boolean;
// depth
level: number;
// checkbox
checkable: boolean;
checked?: boolean;
disableCheck?: boolean;
}
// TODO: add support for modifiers (ctrl, alt, shift, meta)
export interface TreeItemAction {
icon: string;
label: string;
action: string;
arguments?: any[];
}
export interface TreeItemDecoratorBase {
type: string;
label: string;
}
export interface TreeItemDecoratorIcon extends TreeItemDecoratorBase {
type: 'icon';
icon: string;
}
export interface TreeItemDecoratorText extends TreeItemDecoratorBase {
type: 'text';
}
export interface TreeItemDecoratorStatus extends TreeItemDecoratorBase {
type: 'indicator' | 'badge';
status: string;
}
export type TreeItemDecorator = TreeItemDecoratorText | TreeItemDecoratorIcon | TreeItemDecoratorStatus;
interface TreeModelBase<Context = any[]> extends TreeItemBase {
label: string;
icon?: string | { type: 'status'; name: GitFileStatus };
description?: string;
context?: Context;
actions?: TreeItemAction[];
decorators?: TreeItemDecorator[];
contextData?: unknown;
}
export interface TreeModel<Context = any[]> extends TreeModelBase<Context> {
children?: TreeModel<Context>[];
}
export interface TreeModelFlat extends TreeModelBase {
size: number;
position: number;
}
export interface TreeItemSelectionDetail {
node: TreeItemBase;
context?: DraftPatchFileChange[];
dblClick: boolean;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}
export interface TreeItemActionDetail extends TreeItemSelectionDetail {
action: TreeItemAction;
}
export interface TreeItemCheckedDetail {
node: TreeItemBase;
context?: string[];
checked: boolean;
}
// export function toStashTree(files: GitFileChangeShape[]): TreeModel {}
// export function toWipTrees(files: GitFileChangeShape[]): TreeModel[] {}

+ 225
- 0
src/webviews/apps/shared/components/tree/tree-generator.ts View File

@ -0,0 +1,225 @@
import { css, html, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import { GlElement } from '../element';
import type { GlGitStatus } from '../status/git-status';
import type {
TreeItemAction,
TreeItemActionDetail,
TreeItemCheckedDetail,
TreeItemSelectionDetail,
TreeModel,
TreeModelFlat,
} from './base';
import '../actions/action-item';
import '../status/git-status';
import '../code-icon';
import './tree';
import './tree-item';
export type GlTreeGeneratorEvents = {
[K in Extract<keyof WindowEventMap, `gl-tree-generated-item-${string}`>]: WindowEventMap[K];
};
@customElement('gl-tree-generator')
export class GlTreeGenerator extends GlElement<GlTreeGeneratorEvents> {
static override styles = css`
:host {
display: contents;
}
`;
@state()
treeItems?: TreeModelFlat[] = undefined;
@property({ reflect: true })
guides?: 'none' | 'onHover' | 'always';
_model?: TreeModel[];
@property({ type: Array, attribute: false })
set model(value: TreeModel[] | undefined) {
if (this._model === value) return;
this._model = value;
let treeItems: TreeModelFlat[] | undefined;
if (this._model != null) {
const size = this._model.length;
treeItems = this._model.reduce<TreeModelFlat[]>((acc, node, index) => {
acc.push(...flattenTree(node, size, index + 1));
return acc;
}, []);
}
this.treeItems = treeItems;
}
get model() {
return this._model;
}
private renderIcon(icon?: string | { type: 'status'; name: string }) {
if (icon == null) return nothing;
if (typeof icon === 'string') {
return html`<code-icon slot="icon" icon=${icon}></code-icon>`;
}
if (icon.type !== 'status') {
return nothing;
}
return html`<gl-git-status slot="icon" .status=${icon.name as GlGitStatus['status']}></gl-git-status>`;
}
private renderActions(model: TreeModelFlat) {
const actions = model.actions;
if (actions == null || actions.length === 0) return nothing;
return actions.map(action => {
return html`<action-item
slot="actions"
.icon=${action.icon}
.label=${action.label}
@click=${(e: MouseEvent) => this.onTreeItemActionClicked(e, model, action)}
></action-item>`;
});
}
private renderDecorators(model: TreeModelFlat) {
const decorators = model.decorators;
if (decorators == null || decorators.length === 0) return nothing;
return decorators.map(decorator => {
if (decorator.type === 'icon') {
return html`<code-icon
slot="decorators"
title="${decorator.label}"
aria-label="${decorator.label}"
.icon=${decorator.icon}
></code-icon>`;
}
if (decorator.type === 'text') {
return html`<span slot="decorators">${decorator.label}</span>`;
}
// TODO: implement badge and indicator decorators
return undefined;
});
}
private renderTreeItem(model: TreeModelFlat) {
return html`<gl-tree-item
.branch=${model.branch}
.expanded=${model.expanded}
.path=${model.path}
.parentPath=${model.parentPath}
.parentExpanded=${model.parentExpanded}
.level=${model.level}
.size=${model.size}
.position=${model.position}
.checkable=${model.checkable}
.checked=${model.checked ?? false}
.disableCheck=${model.disableCheck ?? false}
.showIcon=${model.icon != null}
@gl-tree-item-selected=${(e: CustomEvent<TreeItemSelectionDetail>) => this.onTreeItemSelected(e, model)}
@gl-tree-item-checked=${(e: CustomEvent<TreeItemCheckedDetail>) => this.onTreeItemChecked(e, model)}
>
${this.renderIcon(model.icon)}
${model.label}${when(
model.description != null,
() => html`<span slot="description">${model.description}</span>`,
)}
${this.renderActions(model)} ${this.renderDecorators(model)}
</gl-tree-item>`;
}
private renderTree(nodes?: TreeModelFlat[]) {
return nodes?.map(node => this.renderTreeItem(node));
}
override render() {
return html`<gl-tree>${this.renderTree(this.treeItems)}</gl-tree>`;
}
private onTreeItemSelected(e: CustomEvent<TreeItemSelectionDetail>, model: TreeModelFlat) {
e.stopPropagation();
this.fireEvent('gl-tree-generated-item-selected', {
...e.detail,
node: model,
context: model.context,
});
}
private onTreeItemChecked(e: CustomEvent<TreeItemCheckedDetail>, model: TreeModelFlat) {
e.stopPropagation();
this.fireEvent('gl-tree-generated-item-checked', {
...e.detail,
node: model,
context: model.context,
});
}
private onTreeItemActionClicked(e: MouseEvent, model: TreeModelFlat, action: TreeItemAction) {
e.stopPropagation();
this.fireEvent('gl-tree-generated-item-action-clicked', {
node: model,
context: model.context,
action: action,
dblClick: false,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
});
}
}
function flattenTree(tree: TreeModel, children: number = 1, position: number = 1): TreeModelFlat[] {
// const node = Object.keys(tree).reduce<TreeModelFlat>(
// (acc, key) => {
// if (key !== 'children') {
// const value = tree[key as keyof TreeModel];
// if (value != null) {
// acc[key] = value;
// }
// }
// return acc;
// },
// { size: children, position: position },
// );
const node: Partial<TreeModelFlat> = {
size: children,
position: position,
};
for (const [key, value] of Object.entries(tree)) {
if (value == null || key === 'children') continue;
node[key as keyof TreeModelFlat] = value;
}
const nodes = [node as TreeModelFlat];
if (tree.children != null && tree.children.length > 0) {
const childSize = tree.children.length;
for (let i = 0; i < childSize; i++) {
nodes.push(...flattenTree(tree.children[i], childSize, i + 1));
}
}
return nodes;
}
declare global {
interface HTMLElementTagNameMap {
'gl-tree-generator': GlTreeGenerator;
}
interface WindowEventMap {
'gl-tree-generated-item-action-clicked': CustomEvent<TreeItemActionDetail>;
'gl-tree-generated-item-selected': CustomEvent<TreeItemSelectionDetail>;
'gl-tree-generated-item-checked': CustomEvent<TreeItemCheckedDetail>;
}
}

+ 271
- 0
src/webviews/apps/shared/components/tree/tree-item.ts View File

@ -0,0 +1,271 @@
import { html, nothing } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import { GlElement } from '../element';
import type { TreeItemCheckedDetail, TreeItemSelectionDetail } from './base';
import { treeItemStyles } from './tree.css';
import '../actions/action-nav';
import '../code-icon';
export type GlTreeItemEvents = {
[K in Extract<keyof WindowEventMap, `gl-tree-item-${string}`>]: WindowEventMap[K];
};
@customElement('gl-tree-item')
export class GlTreeItem extends GlElement<GlTreeItemEvents> {
static override styles = treeItemStyles;
// node properties
@property({ type: Boolean })
branch = false;
@property({ type: Boolean })
expanded = true;
@property({ type: String })
path = '';
// parent
@property({ type: String, attribute: 'parent-path' })
parentPath?: string;
@property({ type: Boolean, attribute: 'parent-expanded' })
parentExpanded?: boolean;
// depth and siblings
@property({ type: Number })
level = 0;
@property({ type: Number })
size = 1;
@property({ type: Number })
position = 1;
// checkbox
@property({ type: Boolean })
checkable = false;
@property({ type: Boolean })
checked = false;
@property({ type: Boolean })
disableCheck = false;
@property({ type: Boolean })
showIcon = true;
// state
@state()
selected = false;
@state()
focused = false;
@query('#button')
buttonEl!: HTMLButtonElement;
get isHidden() {
return this.parentExpanded === false || (!this.branch && !this.expanded);
}
override connectedCallback() {
super.connectedCallback();
this.addEventListener('click', this.onComponentClickBound);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this.onComponentClickBound);
}
private onComponentClick(e: MouseEvent) {
this.selectCore({
dblClick: false,
altKey: e.altKey,
});
this.buttonEl.focus();
}
private onComponentClickBound = this.onComponentClick.bind(this);
private updateAttrs(changedProperties: Map<string, any>, force = false) {
if (changedProperties.has('expanded') || force) {
this.setAttribute('aria-expanded', this.expanded.toString());
}
if (changedProperties.has('parentExpanded') || force) {
this.setAttribute('aria-hidden', this.isHidden.toString());
}
if (changedProperties.has('selected') || force) {
this.setAttribute('aria-selected', this.selected.toString());
}
if (changedProperties.has('size') || force) {
this.setAttribute('aria-setsize', this.size.toString());
}
if (changedProperties.has('position') || force) {
this.setAttribute('aria-posinset', this.position.toString());
}
if (changedProperties.has('level') || force) {
this.setAttribute('aria-level', this.level.toString());
}
}
override firstUpdated() {
this.role = 'treeitem';
}
override updated(changedProperties: Map<string, any>) {
this.updateAttrs(changedProperties);
}
private renderBranching() {
const connectors = this.level - 1;
if (connectors < 1 && !this.branch) {
return nothing;
}
const branching = [];
if (connectors > 0) {
for (let i = 0; i < connectors; i++) {
branching.push(html`<span class="node node--connector"><code-icon name="blank"></code-icon></span>`);
}
}
if (this.branch) {
branching.push(
html`<code-icon class="branch" icon="${this.expanded ? 'chevron-down' : 'chevron-right'}"></code-icon>`,
);
}
return branching;
}
private renderCheckbox() {
if (!this.checkable) {
return nothing;
}
return html`<span class="checkbox"
><input
class="checkbox__input"
id="checkbox"
type="checkbox"
.checked=${this.checked}
?disabled=${this.disableCheck}
@change=${this.onCheckboxChange}
@click=${this.onCheckboxClick} /><code-icon icon="check" size="14" class="checkbox__check"></code-icon
></span>`;
}
private renderActions() {
return html`<action-nav class="actions"><slot name="actions"></slot></action-nav>`;
}
private renderDecorators() {
return html`<slot name="decorators" class="decorators"></slot>`;
}
override render() {
return html`
${this.renderBranching()}${this.renderCheckbox()}
<button
id="button"
class="item"
type="button"
@click=${this.onButtonClick}
@dblclick=${this.onButtonDblClick}
>
${when(this.showIcon, () => html`<slot name="icon" class="icon"></slot>`)}
<span class="text">
<slot class="main"></slot>
<slot name="description" class="description"></slot>
</span>
</button>
${this.renderActions()}${this.renderDecorators()}
`;
}
private selectCore(
modifiers?: { dblClick: boolean; altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean },
quiet = false,
) {
this.fireEvent('gl-tree-item-select');
if (this.branch) {
this.expanded = !this.expanded;
}
this.selected = true;
if (!quiet) {
window.requestAnimationFrame(() => {
this.fireEvent('gl-tree-item-selected', {
node: this,
dblClick: modifiers?.dblClick ?? false,
altKey: modifiers?.altKey ?? false,
ctrlKey: modifiers?.ctrlKey ?? false,
metaKey: modifiers?.metaKey ?? false,
});
});
}
}
select() {
this.selectCore(undefined, true);
}
deselect() {
this.selected = false;
}
override focus() {
this.buttonEl.focus();
}
onButtonClick(e: MouseEvent) {
console.log('onButtonClick', e);
e.stopPropagation();
this.selectCore({
dblClick: false,
altKey: e.altKey,
});
}
onButtonDblClick(e: MouseEvent) {
console.log('onButtonDblClick', e);
e.stopPropagation();
this.selectCore({
dblClick: true,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
});
}
onCheckboxClick(e: Event) {
console.log('onCheckboxClick', e);
e.stopPropagation();
}
onCheckboxChange(e: Event) {
console.log('onCheckboxChange', e);
e.preventDefault();
e.stopPropagation();
this.checked = (e.target as HTMLInputElement).checked;
this.fireEvent('gl-tree-item-checked', { node: this, checked: this.checked });
}
}
declare global {
interface HTMLElementTagNameMap {
'gl-tree-item': GlTreeItem;
}
interface WindowEventMap {
'gl-tree-item-select': CustomEvent<undefined>;
'gl-tree-item-selected': CustomEvent<TreeItemSelectionDetail>;
'gl-tree-item-checked': CustomEvent<TreeItemCheckedDetail>;
}
}

+ 217
- 0
src/webviews/apps/shared/components/tree/tree.css.ts View File

@ -0,0 +1,217 @@
import { css } from 'lit';
import { elementBase } from '../styles/lit/base.css';
export const treeStyles = [elementBase, css``];
export const treeItemStyles = [
elementBase,
css`
:host {
--tree-connector-spacing: 0.6rem;
--tree-connector-size: var(--gitlens-tree-indent, 1.6rem);
box-sizing: border-box;
padding-left: var(--gitlens-gutter-width);
padding-right: var(--gitlens-scrollbar-gutter-width);
padding-top: 0.1rem;
padding-bottom: 0.1rem;
line-height: 2.2rem;
height: 2.2rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
font-size: var(--vscode-font-size);
color: var(--vscode-sideBar-foreground);
content-visibility: auto;
contain-intrinsic-size: auto 2.2rem;
cursor: pointer;
}
:host([aria-hidden='true']) {
display: none;
}
:host(:hover) {
color: var(--vscode-list-hoverForeground);
background-color: var(--vscode-list-hoverBackground);
}
:host([aria-selected='true']) {
color: var(--vscode-list-inactiveSelectionForeground);
background-color: var(--vscode-list-inactiveSelectionBackground);
}
/* TODO: these should be :has(.input:focus) instead of :focus-within */
:host(:focus-within) {
outline: 1px solid var(--vscode-list-focusOutline);
outline-offset: -0.1rem;
}
:host([aria-selected='true']:focus-within) {
color: var(--vscode-list-activeSelectionForeground);
background-color: var(--vscode-list-activeSelectionBackground);
}
.item {
appearance: none;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0;
text-decoration: none;
color: inherit;
background: none;
border: none;
outline: none;
cursor: pointer;
min-width: 0;
}
/* FIXME: remove, this is for debugging
.item:focus {
outline: 1px solid var(--vscode-list-focusOutline);
outline-offset: -0.1rem;
}
*/
.icon {
display: inline-block;
width: 1.6rem;
text-align: center;
}
slot[name='icon']::slotted(*) {
width: 1.6rem;
aspect-ratio: 1;
vertical-align: text-bottom;
}
.node {
display: inline-block;
width: var(--tree-connector-size);
text-align: center;
flex: none;
}
.node:last-of-type {
margin-right: 0.3rem;
}
.node--connector {
position: relative;
}
.node--connector::before {
content: '';
position: absolute;
height: 2.2rem;
border-left: 1px solid transparent;
top: 50%;
transform: translate(-1px, -50%);
left: 0.8rem;
width: 0.1rem;
transition: border-color 0.1s linear;
opacity: 0.4;
}
:host-context([guides='always']) .node--connector::before,
:host-context([guides='onHover']:focus-within) .node--connector::before,
:host-context([guides='onHover']:hover) .node--connector::before {
border-color: var(--vscode-tree-indentGuidesStroke);
}
.branch {
margin-right: 0.6rem;
}
.text {
line-height: 1.6rem;
overflow: hidden;
white-space: nowrap;
text-align: left;
text-overflow: ellipsis;
flex: 1;
}
.main {
display: inline;
}
.description {
display: inline;
opacity: 0.7;
margin-left: 0.3rem;
}
.actions {
flex: none;
user-select: none;
color: var(--vscode-icon-foreground);
}
:host(:focus-within) .actions {
color: var(--vscode-list-activeSelectionIconForeground);
}
:host(:not(:hover):not(:focus-within)) .actions {
display: none;
}
.checkbox {
position: relative;
display: inline-flex;
width: 1.6rem;
aspect-ratio: 1 / 1;
text-align: center;
color: var(--vscode-checkbox-foreground);
background: var(--vscode-checkbox-background);
border: 1px solid var(--vscode-checkbox-border);
border-radius: 0.3rem;
// overflow: hidden;
margin-right: 0.6rem;
}
.checkbox:has(:checked) {
color: var(--vscode-inputOption-activeForeground);
border-color: var(--vscode-inputOption-activeBorder);
background-color: var(--vscode-inputOption-activeBackground);
}
.checkbox:has(:disabled) {
opacity: 0.4;
}
.checkbox__input {
position: absolute;
top: 0;
left: 0;
appearance: none;
width: 1.4rem;
aspect-ratio: 1 / 1;
margin: 0;
cursor: pointer;
border-radius: 0.3rem;
}
.checkbox__input:disabled {
cursor: default;
}
.checkbox__check {
width: 1.6rem;
aspect-ratio: 1 / 1;
opacity: 0;
transition: opacity 0.1s linear;
color: var(--vscode-checkbox-foreground);
pointer-events: none;
}
.checkbox__input:checked + .checkbox__check {
opacity: 1;
}
`,
];

+ 120
- 0
src/webviews/apps/shared/components/tree/tree.ts View File

@ -0,0 +1,120 @@
import { html, LitElement } from 'lit';
import { customElement, property, queryAssignedElements } from 'lit/decorators.js';
import type { TreeItemSelectionDetail } from './base';
import type { GlTreeItem } from './tree-item';
import { treeStyles } from './tree.css';
@customElement('gl-tree')
export class GlTree extends LitElement {
static override styles = treeStyles;
@property({ reflect: true })
guides?: 'none' | 'onHover' | 'always';
private _slotSubscriptionsDisposer?: () => void;
private _lastSelected?: GlTreeItem;
@queryAssignedElements({ flatten: true })
private treeItems!: GlTreeItem[];
override disconnectedCallback() {
super.disconnectedCallback();
this._slotSubscriptionsDisposer?.();
}
override firstUpdated() {
this.setAttribute('role', 'tree');
}
override render() {
return html`<slot @slotchange=${this.handleSlotChange}></slot>`;
}
private handleSlotChange() {
console.log('handleSlotChange');
if (!this.treeItems?.length) return;
const keyHandler = this.handleKeydown.bind(this);
const beforeSelectHandler = this.handleBeforeSelected.bind(this) as EventListenerOrEventListenerObject;
const selectHandler = this.handleSelected.bind(this) as EventListenerOrEventListenerObject;
const subscriptions = this.treeItems.map(node => {
node.addEventListener('keydown', keyHandler, false);
node.addEventListener('gl-tree-item-select', beforeSelectHandler, false);
node.addEventListener('gl-tree-item-selected', selectHandler, false);
return {
dispose: function () {
node?.removeEventListener('keydown', keyHandler, false);
node?.removeEventListener('gl-tree-item-select', beforeSelectHandler, false);
node?.removeEventListener('gl-tree-item-selected', selectHandler, false);
},
};
});
this._slotSubscriptionsDisposer = () => {
subscriptions?.forEach(({ dispose }) => dispose());
};
}
private handleKeydown(e: KeyboardEvent) {
if (!e.target) return;
const target = e.target as HTMLElement;
if (e.key === 'ArrowUp') {
const $previous = target.previousElementSibling as HTMLElement | null;
$previous?.focus();
} else if (e.key === 'ArrowDown') {
const $next = target.nextElementSibling as HTMLElement | null;
$next?.focus();
}
}
private handleBeforeSelected(e: CustomEvent) {
if (!e.target) return;
const target = e.target as GlTreeItem;
if (this._lastSelected != null && this._lastSelected !== target) {
this._lastSelected.deselect();
}
this._lastSelected = target;
}
private handleSelected(e: CustomEvent<TreeItemSelectionDetail>) {
if (!e.target || !e.detail.node.branch) return;
function getParent(el: GlTreeItem) {
const currentLevel = el.level;
let prev = el.previousElementSibling as GlTreeItem | null;
while (prev) {
const prevLevel = prev.level;
if (prevLevel < currentLevel) return prev;
prev = prev.previousElementSibling as GlTreeItem | null;
}
return undefined;
}
const target = e.target as GlTreeItem;
const level = target.level;
let nextElement = target.nextElementSibling as GlTreeItem | null;
while (nextElement) {
if (level === nextElement.level) break;
const parentElement = getParent(nextElement);
nextElement.parentExpanded = parentElement?.expanded !== false;
nextElement.expanded = e.detail.node.expanded;
nextElement = nextElement.nextElementSibling as GlTreeItem;
}
}
}
declare global {
interface HTMLElementTagNameMap {
'gl-tree': GlTree;
}
}

+ 97
- 76
src/webviews/apps/shared/components/webview-pane.ts View File

@ -1,7 +1,9 @@
import { css, html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { scrollableBase } from './styles/lit/base.css';
import './code-icon';
import './actions/action-nav';
import './progress';
export interface WebviewPaneExpandedChangeEventDetail {
expanded: boolean;
@ -9,85 +11,98 @@ export interface WebviewPaneExpandedChangeEventDetail {
@customElement('webview-pane')
export class WebviewPane extends LitElement {
static override styles = css`
:host {
display: flex;
flex-direction: column;
background-color: var(--vscode-sideBar-background);
}
* {
box-sizing: border-box;
}
.header {
flex: none;
display: flex;
background-color: var(--vscode-sideBarSectionHeader-background);
color: var(--vscode-sideBarSectionHeader-foreground);
border-top: 1px solid var(--vscode-sideBarSectionHeader-border);
position: relative;
}
.header:focus-within {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.label {
appearance: none;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
padding: 0;
border: none;
text-align: left;
font-family: var(--font-family);
font-size: 1.1rem;
line-height: 2.2rem;
height: 2.2rem;
background: transparent;
color: inherit;
cursor: pointer;
outline: none;
text-overflow: ellipsis;
}
.title {
font-weight: bold;
text-transform: uppercase;
}
.subtitle {
margin-left: 1rem;
opacity: 0.6;
}
.icon {
font-weight: normal;
margin: 0 0.2rem;
}
.content {
overflow: auto;
/*
static override styles = [
scrollableBase,
css`
:host {
display: flex;
flex-direction: column;
background-color: var(--vscode-sideBar-background);
}
* {
box-sizing: border-box;
}
.header {
flex: none;
display: flex;
background-color: var(--vscode-sideBarSectionHeader-background);
color: var(--vscode-sideBarSectionHeader-foreground);
border-top: 1px solid var(--vscode-sideBarSectionHeader-border);
position: relative;
}
.header:focus-within {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.label {
appearance: none;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
padding: 0;
border: none;
text-align: left;
font-family: var(--font-family);
font-size: 1.1rem;
line-height: 2.2rem;
height: 2.2rem;
background: transparent;
color: inherit;
outline: none;
text-overflow: ellipsis;
user-select: none;
}
:host([collapsable]) .label {
cursor: pointer;
}
.title {
font-weight: bold;
text-transform: uppercase;
}
:host(:not([collapsable])) .title {
margin-left: 0.8rem;
}
.subtitle {
margin-left: 1rem;
opacity: 0.6;
}
.icon {
font-weight: normal;
margin: 0 0.2rem;
}
.content {
flex: 1;
overflow: auto;
min-height: 0;
/*
scrollbar-gutter: stable;
box-shadow: #000000 0 0.6rem 0.6rem -0.6rem inset;
*/
padding-top: 0.6rem;
}
padding-top: 0.6rem;
}
:host([collapsable]:not([expanded])) .content,
:host([collapsable][expanded='false']) .content {
display: none;
}
:host([collapsable]:not([expanded])) .content,
:host([collapsable][expanded='false']) .content {
display: none;
}
slot[name='actions']::slotted(*) {
flex: none;
margin-left: auto;
}
`;
slot[name='actions']::slotted(*) {
flex: none;
margin-left: auto;
}
`,
];
@property({ type: Boolean, reflect: true })
collapsable = false;
@ -123,9 +138,9 @@ export class WebviewPane extends LitElement {
<header class="header">
${this.renderTitle()}
<slot name="actions"></slot>
<progress-indicator active="${this.loading}"></progress-indicator>
<progress-indicator ?active="${this.loading}"></progress-indicator>
</header>
<div id="content" role="region" class="content">
<div id="content" role="region" class="content scrollable">
<slot></slot>
</div>
`;
@ -145,3 +160,9 @@ export class WebviewPane extends LitElement {
);
}
}
declare global {
interface HTMLElementTagNameMap {
'webview-pane': WebviewPane;
}
}

+ 445
- 0
src/webviews/apps/shared/styles/details-base.scss View File

@ -0,0 +1,445 @@
@use './theme';
:root {
--gitlens-gutter-width: 20px;
--gitlens-scrollbar-gutter-width: 10px;
}
.vscode-high-contrast,
.vscode-dark {
--color-background--level-05: var(--color-background--lighten-05);
--color-background--level-075: var(--color-background--lighten-075);
--color-background--level-10: var(--color-background--lighten-10);
--color-background--level-15: var(--color-background--lighten-15);
--color-background--level-30: var(--color-background--lighten-30);
}
.vscode-high-contrast-light,
.vscode-light {
--color-background--level-05: var(--color-background--darken-05);
--color-background--level-075: var(--color-background--darken-075);
--color-background--level-10: var(--color-background--darken-10);
--color-background--level-15: var(--color-background--darken-15);
--color-background--level-30: var(--color-background--darken-30);
}
// generic resets
html {
font-size: 62.5%;
// box-sizing: border-box;
font-family: var(--font-family);
}
*,
*:before,
*:after {
box-sizing: border-box;
}
body {
--gk-badge-outline-color: var(--vscode-badge-foreground);
--gk-badge-filled-background-color: var(--vscode-badge-background);
--gk-badge-filled-color: var(--vscode-badge-foreground);
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;
}
}
}
::-webkit-scrollbar-corner {
background-color: transparent !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);
}
}
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
.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;
}
}
.button--busy {
code-icon {
margin-right: 0.5rem;
}
&[aria-busy='true'] {
opacity: 0.5;
}
&:not([aria-busy='true']) {
code-icon {
display: none;
}
}
}
.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;
&--single {
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);
}
.vscode-light & {
background-color: var(--color-background--darken-15);
}
}
&.is-active {
.vscode-dark & {
background-color: var(--color-background--lighten-10);
}
.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;
}
.message-block {
font-size: 1.3rem;
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
padding: 0.5rem;
&__text {
margin: 0;
overflow-y: auto;
overflow-x: hidden;
max-height: 9rem;
> * {
white-space: break-spaces;
}
strong {
font-weight: 600;
font-size: 1.4rem;
}
}
}
.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;
}
&--highlight {
margin-left: 0.25em;
padding: 0 4px 2px 4px;
border: 1px solid var(--color-background--level-15);
border-radius: 0.3rem;
font-family: var(--vscode-editor-font-family);
}
&.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);
}
}
}
&__sha {
margin: 0 0.5rem 0 0.25rem;
}
&__authors {
flex-basis: 100%;
padding-top: 0.5rem;
}
&__author {
& + & {
margin-top: 0.5rem;
}
}
}
.issue > :not(:first-child) {
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;
}
}
.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;
}
&:empty {
display: none;
}
&__summary {
margin: 0;
overflow-y: auto;
overflow-x: hidden;
resize: vertical;
max-height: 20rem;
white-space: break-spaces;
.has-error & {
white-space: normal;
}
}
}
.details-tab {
display: flex;
justify-content: stretch;
align-items: center;
margin-bottom: 0.4rem;
gap: 0.2rem;
& > * {
flex: 1;
}
&__item {
appearance: none;
padding: 0.4rem;
color: var(--color-foreground--85);
background-color: transparent;
border: none;
border-bottom: 0.2rem solid transparent;
cursor: pointer;
// background-color: #00000030;
line-height: 1.8rem;
gk-badge {
line-height: 1.2;
}
&:hover {
color: var(--color-foreground);
// background-color: var(--vscode-button-hoverBackground);
background-color: #00000020;
}
&.is-active {
color: var(--color-foreground);
border-bottom-color: var(--vscode-button-hoverBackground);
}
}
}

+ 28
- 0
src/webviews/commitDetails/commitDetailsWebview.ts View File

@ -29,7 +29,9 @@ import { createReference, getReferenceFromRevision, shortenRevision } from '../.
import type { GitRemote } from '../../git/models/remote';
import type { Repository } from '../../git/models/repository';
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository';
import { showPatchesView } from '../../plus/drafts/actions';
import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol';
import type { Change } from '../../plus/webviews/patchDetails/protocol';
import { pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/cancellation';
import { executeCommand, executeCoreCommand, registerCommand } from '../../system/command';
import { configuration } from '../../system/configuration';
@ -53,6 +55,7 @@ import type { WebviewShowOptions } from '../webviewsController';
import { isSerializedState } from '../webviewsController';
import type {
CommitDetails,
CreatePatchFromWipParams,
DidExplainParams,
FileActionParams,
Mode,
@ -66,6 +69,7 @@ import type {
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
CreatePatchFromWipCommandType,
DidChangeNotificationType,
DidChangeWipStateNotificationType,
DidExplainCommandType,
@ -340,6 +344,9 @@ export class CommitDetailsWebviewProvider
case UnstageFileCommandType.method:
onIpc(UnstageFileCommandType, e, params => this.unstageFile(params));
break;
case CreatePatchFromWipCommandType.method:
onIpc(CreatePatchFromWipCommandType, e, params => this.createPatchFromWip(params));
break;
}
}
@ -486,6 +493,27 @@ export class CommitDetailsWebviewProvider
}, 100);
}
private createPatchFromWip(e: CreatePatchFromWipParams) {
if (e.changes == null) return;
const change: Change = {
type: 'wip',
repository: {
name: e.changes.repository.name,
path: e.changes.repository.path,
uri: e.changes.repository.uri,
},
files: e.changes.files,
revision: {
baseSha: 'HEAD',
sha: uncommitted,
},
checked: e.checked,
};
void showPatchesView({ mode: 'create', create: { changes: [change] } });
}
private onActiveEditorLinesChanged(e: LinesChangeEvent) {
if (e.pending || e.editor == null || e.suspended) return;

+ 7
- 5
src/webviews/commitDetails/protocol.ts View File

@ -83,11 +83,7 @@ export interface CommitActionsParams {
}
export const CommitActionsCommandType = new IpcCommandType<CommitActionsParams>('commit/actions');
export interface FileActionParams {
path: string;
repoPath: string;
staged: boolean | undefined;
export interface FileActionParams extends GitFileChangeShape {
showOptions?: TextDocumentShowOptions;
}
export const FileActionsCommandType = new IpcCommandType<FileActionParams>('commit/file/actions');
@ -125,6 +121,12 @@ export const NavigateCommitCommandType = new IpcCommandType('com
export type UpdatePreferenceParams = UpdateablePreferences;
export const UpdatePreferencesCommandType = new IpcCommandType<UpdatePreferenceParams>('commit/preferences/update');
export interface CreatePatchFromWipParams {
changes: WipChange;
checked: boolean | 'staged';
}
export const CreatePatchFromWipCommandType = new IpcCommandType<CreatePatchFromWipParams>('commit/wip/createPatch');
// NOTIFICATIONS
export interface DidChangeParams {

+ 1
- 1
src/webviews/webviewController.ts View File

@ -299,7 +299,7 @@ export class WebviewController<
loading: boolean,
options?: WebviewShowOptions,
...args: WebviewShowingArgs<ShowingArgs, SerializedState>
) {
): Promise<void> {
if (options == null) {
options = {};
}

+ 8
- 2
src/webviews/webviewsController.ts View File

@ -128,6 +128,7 @@ export class WebviewsController implements Disposable {
container: Container,
controller: WebviewController<State, SerializedState, ShowingArgs>,
) => Promise<WebviewProvider<State, SerializedState, ShowingArgs>>,
onBeforeShow?: (...args: WebviewShowingArgs<ShowingArgs, SerializedState>) => void | Promise<void>,
): WebviewViewProxy<ShowingArgs, SerializedState> {
const scope = getNewLogScope(`WebviewView(${descriptor.id})`);
@ -212,7 +213,7 @@ export class WebviewsController implements Disposable {
refresh: function (force?: boolean) {
return registration.controller != null ? registration.controller.refresh(force) : Promise.resolve();
},
show: function (
show: async function (
options?: WebviewViewShowOptions,
...args: WebviewShowingArgs<ShowingArgs, SerializedState>
) {
@ -223,7 +224,12 @@ export class WebviewsController implements Disposable {
}
registration.pendingShowArgs = [options, args];
return Promise.resolve(void executeCoreCommand(`${descriptor.id}.focus`, options));
if (onBeforeShow != null) {
await onBeforeShow?.(...args);
}
return void executeCoreCommand(`${descriptor.id}.focus`, options);
},
} satisfies WebviewViewProxy<ShowingArgs, SerializedState>;
}

+ 2
- 0
webpack.config.js View File

@ -330,6 +330,7 @@ function getWebviewsConfig(mode, env) {
getHtmlPlugin('welcome', false, mode, env),
getHtmlPlugin('focus', true, mode, env),
getHtmlPlugin('account', true, mode, env),
getHtmlPlugin('patchDetails', true, mode, env),
getCspHtmlPlugin(mode, env),
new InlineChunkHtmlPlugin(HtmlPlugin, mode === 'production' ? ['\\.css$'] : []),
new CopyPlugin({
@ -394,6 +395,7 @@ function getWebviewsConfig(mode, env) {
welcome: './welcome/welcome.ts',
focus: './plus/focus/focus.ts',
account: './plus/account/account.ts',
patchDetails: './plus/patchDetails/patchDetails.ts',
},
mode: mode,
target: 'web',

Loading…
Cancel
Save