diff --git a/CHANGELOG.md b/CHANGELOG.md index d987059..937f176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.0 + +- Commit graph context menu overhaul (grouping + orange/green tone highlights). +- Drop commits (single or multi-select) with confirmation + automatic rollback on failure. +- Reveal-in-Finder folder action in file lists (details + squash preview), with fallback to nearest existing parent folder. +- Uncommitted changes: discard icon always visible per file; commit button selects all on first click when message is present. +- Diff opens in a floating window; closing the floating window no longer leaves a stray diff tab behind. +- Copy icon to copy commit title/subject in the right-hand details view. +- Move mode: clicking in the editor now cancels move mode (equivalent to Escape). + ## 0.1.1 - Generalize commit error handling UI (red border/background for any failure). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..26c8aae --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# Third-Party Notices + +This project bundles third-party assets under their respective licenses. + +## JetBrains IntelliJ Platform Icons (2023+ UI, Auto) + +- **Source**: `ardonplay.vscode-jetbrains-icon-theme` (VS Code extension) +- **License text**: see `media/icons/LICENSE-2.0.txt` + +If you believe any bundled icon has different licensing requirements, please open an issue with the icon filename and its upstream source. + diff --git a/media/icons/CMake.svg b/media/icons/CMake.svg new file mode 100644 index 0000000..4972017 --- /dev/null +++ b/media/icons/CMake.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/media/icons/actionScript.svg b/media/icons/actionScript.svg new file mode 100644 index 0000000..de57c7a --- /dev/null +++ b/media/icons/actionScript.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/angularJS.svg b/media/icons/angularJS.svg new file mode 100644 index 0000000..0f1b14e --- /dev/null +++ b/media/icons/angularJS.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/anyType.svg b/media/icons/anyType.svg new file mode 100644 index 0000000..eee0e6b --- /dev/null +++ b/media/icons/anyType.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/application.svg b/media/icons/application.svg new file mode 100644 index 0000000..71f3a85 --- /dev/null +++ b/media/icons/application.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/archive.svg b/media/icons/archive.svg new file mode 100644 index 0000000..ab3cae2 --- /dev/null +++ b/media/icons/archive.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/media/icons/beam.svg b/media/icons/beam.svg new file mode 100644 index 0000000..c6cc27c --- /dev/null +++ b/media/icons/beam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/cargo.svg b/media/icons/cargo.svg new file mode 100644 index 0000000..9139e71 --- /dev/null +++ b/media/icons/cargo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/media/icons/cargoLock.svg b/media/icons/cargoLock.svg new file mode 100644 index 0000000..ab5c308 --- /dev/null +++ b/media/icons/cargoLock.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/media/icons/cassandra.svg b/media/icons/cassandra.svg new file mode 100644 index 0000000..d16da70 --- /dev/null +++ b/media/icons/cassandra.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/media/icons/clojure.svg b/media/icons/clojure.svg new file mode 100644 index 0000000..75befe9 --- /dev/null +++ b/media/icons/clojure.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/config.svg b/media/icons/config.svg new file mode 100644 index 0000000..7e652e5 --- /dev/null +++ b/media/icons/config.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/media/icons/cs.svg b/media/icons/cs.svg new file mode 100644 index 0000000..0249b5a --- /dev/null +++ b/media/icons/cs.svg @@ -0,0 +1,10 @@ + + Csharp(GrayDark) + + + + + + + + diff --git a/media/icons/cshtml.svg b/media/icons/cshtml.svg new file mode 100644 index 0000000..bce3db6 --- /dev/null +++ b/media/icons/cshtml.svg @@ -0,0 +1,11 @@ + + Razor(GrayDark) + + + + + + + + + diff --git a/media/icons/csproj.svg b/media/icons/csproj.svg new file mode 100644 index 0000000..809d086 --- /dev/null +++ b/media/icons/csproj.svg @@ -0,0 +1,18 @@ + + CsharpProject(GrayDark) + + + + + + + + + + + + + + + + diff --git a/media/icons/csv.svg b/media/icons/csv.svg new file mode 100644 index 0000000..d038177 --- /dev/null +++ b/media/icons/csv.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/media/icons/dart.svg b/media/icons/dart.svg new file mode 100644 index 0000000..2d0e595 --- /dev/null +++ b/media/icons/dart.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/media/icons/docker.svg b/media/icons/docker.svg index 76c5f02..f8a6d4c 100644 --- a/media/icons/docker.svg +++ b/media/icons/docker.svg @@ -1,4 +1,4 @@ - + diff --git a/media/icons/dockerCompose.svg b/media/icons/dockerCompose.svg new file mode 100644 index 0000000..7ebb58b --- /dev/null +++ b/media/icons/dockerCompose.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/media/icons/dune.svg b/media/icons/dune.svg new file mode 100644 index 0000000..ee6e036 --- /dev/null +++ b/media/icons/dune.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/editorConfig.svg b/media/icons/editorConfig.svg new file mode 100644 index 0000000..528d02e --- /dev/null +++ b/media/icons/editorConfig.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/icons/eex.svg b/media/icons/eex.svg new file mode 100644 index 0000000..ddcaff5 --- /dev/null +++ b/media/icons/eex.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/media/icons/elixir.svg b/media/icons/elixir.svg new file mode 100644 index 0000000..7c8ce9c --- /dev/null +++ b/media/icons/elixir.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/icons/erbFile.svg b/media/icons/erbFile.svg new file mode 100644 index 0000000..b3e727d --- /dev/null +++ b/media/icons/erbFile.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/erlang.svg b/media/icons/erlang.svg new file mode 100644 index 0000000..375a926 --- /dev/null +++ b/media/icons/erlang.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/eslint.svg b/media/icons/eslint.svg new file mode 100644 index 0000000..ac72f26 --- /dev/null +++ b/media/icons/eslint.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/folder.svg b/media/icons/folder.svg index 7ece6b7..0af492b 100644 --- a/media/icons/folder.svg +++ b/media/icons/folder.svg @@ -1,4 +1,4 @@ - + diff --git a/media/icons/font.svg b/media/icons/font.svg new file mode 100644 index 0000000..94fbd32 --- /dev/null +++ b/media/icons/font.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/gitignore.svg b/media/icons/gitignore.svg new file mode 100644 index 0000000..1527df8 --- /dev/null +++ b/media/icons/gitignore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/gleam.svg b/media/icons/gleam.svg new file mode 100644 index 0000000..5175ff6 --- /dev/null +++ b/media/icons/gleam.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/media/icons/gomodsum.svg b/media/icons/gomodsum.svg new file mode 100644 index 0000000..18b9289 --- /dev/null +++ b/media/icons/gomodsum.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/gradle.svg b/media/icons/gradle.svg new file mode 100644 index 0000000..f169520 --- /dev/null +++ b/media/icons/gradle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/graphql.svg b/media/icons/graphql.svg new file mode 100644 index 0000000..2fa86b6 --- /dev/null +++ b/media/icons/graphql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/h.svg b/media/icons/h.svg new file mode 100644 index 0000000..62d4619 --- /dev/null +++ b/media/icons/h.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/icons/haskell.svg b/media/icons/haskell.svg new file mode 100644 index 0000000..64f0389 --- /dev/null +++ b/media/icons/haskell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/hcl.svg b/media/icons/hcl.svg new file mode 100644 index 0000000..b1740f1 --- /dev/null +++ b/media/icons/hcl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/html.svg b/media/icons/html.svg index c446cf3..9e10d57 100644 --- a/media/icons/html.svg +++ b/media/icons/html.svg @@ -1,5 +1,5 @@ - - + + diff --git a/media/icons/http.svg b/media/icons/http.svg new file mode 100644 index 0000000..e89963e --- /dev/null +++ b/media/icons/http.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/media/icons/ignored.svg b/media/icons/ignored.svg new file mode 100644 index 0000000..1cbad7a --- /dev/null +++ b/media/icons/ignored.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/image.svg b/media/icons/image.svg new file mode 100644 index 0000000..4f189a0 --- /dev/null +++ b/media/icons/image.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/java.svg b/media/icons/java.svg new file mode 100644 index 0000000..155295f --- /dev/null +++ b/media/icons/java.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/javaScript.svg b/media/icons/javaScript.svg index d8bff96..043bb73 100644 --- a/media/icons/javaScript.svg +++ b/media/icons/javaScript.svg @@ -1,6 +1,6 @@ - - - + + + diff --git a/media/icons/json.svg b/media/icons/json.svg index d8f707d..29371ab 100644 --- a/media/icons/json.svg +++ b/media/icons/json.svg @@ -1,5 +1,5 @@ - - + + diff --git a/media/icons/jupyter.svg b/media/icons/jupyter.svg new file mode 100644 index 0000000..d1b8fec --- /dev/null +++ b/media/icons/jupyter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/kotlin.svg b/media/icons/kotlin.svg new file mode 100644 index 0000000..10edacb --- /dev/null +++ b/media/icons/kotlin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/less.svg b/media/icons/less.svg new file mode 100644 index 0000000..09d461d --- /dev/null +++ b/media/icons/less.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/lua.svg b/media/icons/lua.svg new file mode 100644 index 0000000..6dd7e80 --- /dev/null +++ b/media/icons/lua.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/makefile.svg b/media/icons/makefile.svg new file mode 100644 index 0000000..e1ca58b --- /dev/null +++ b/media/icons/makefile.svg @@ -0,0 +1,3 @@ + + + diff --git a/media/icons/markdown.svg b/media/icons/markdown.svg index c603fd2..2d86cd3 100644 --- a/media/icons/markdown.svg +++ b/media/icons/markdown.svg @@ -1,5 +1,5 @@ - - + + diff --git a/media/icons/mdx.svg b/media/icons/mdx.svg new file mode 100644 index 0000000..6e7e5d3 --- /dev/null +++ b/media/icons/mdx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/ml.svg b/media/icons/ml.svg new file mode 100644 index 0000000..0f0c222 --- /dev/null +++ b/media/icons/ml.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/mli.svg b/media/icons/mli.svg new file mode 100644 index 0000000..32309da --- /dev/null +++ b/media/icons/mli.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/opam.svg b/media/icons/opam.svg new file mode 100644 index 0000000..b4711f5 --- /dev/null +++ b/media/icons/opam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/php.svg b/media/icons/php.svg new file mode 100644 index 0000000..a96a947 --- /dev/null +++ b/media/icons/php.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/pnpm.svg b/media/icons/pnpm.svg new file mode 100644 index 0000000..0d32042 --- /dev/null +++ b/media/icons/pnpm.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/media/icons/postcss.svg b/media/icons/postcss.svg new file mode 100644 index 0000000..3b55428 --- /dev/null +++ b/media/icons/postcss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/projectProperties.svg b/media/icons/projectProperties.svg new file mode 100644 index 0000000..e4e1b03 --- /dev/null +++ b/media/icons/projectProperties.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/properties.svg b/media/icons/properties.svg new file mode 100644 index 0000000..528d02e --- /dev/null +++ b/media/icons/properties.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/icons/protobuf.svg b/media/icons/protobuf.svg new file mode 100644 index 0000000..ff37164 --- /dev/null +++ b/media/icons/protobuf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/rakeTask.svg b/media/icons/rakeTask.svg new file mode 100644 index 0000000..d7aeb48 --- /dev/null +++ b/media/icons/rakeTask.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/react.svg b/media/icons/react.svg index 7059146..46c0ac8 100644 --- a/media/icons/react.svg +++ b/media/icons/react.svg @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/media/icons/rego.svg b/media/icons/rego.svg new file mode 100644 index 0000000..e45ca5c --- /dev/null +++ b/media/icons/rego.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/ruby.svg b/media/icons/ruby.svg new file mode 100644 index 0000000..acb164c --- /dev/null +++ b/media/icons/ruby.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/icons/rubyGems.svg b/media/icons/rubyGems.svg new file mode 100644 index 0000000..312dd5c --- /dev/null +++ b/media/icons/rubyGems.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/rustFile.svg b/media/icons/rustFile.svg new file mode 100644 index 0000000..86a824f --- /dev/null +++ b/media/icons/rustFile.svg @@ -0,0 +1,3 @@ + + + diff --git a/media/icons/scala.svg b/media/icons/scala.svg new file mode 100644 index 0000000..3fd07cc --- /dev/null +++ b/media/icons/scala.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/scss.svg b/media/icons/scss.svg new file mode 100644 index 0000000..715212f --- /dev/null +++ b/media/icons/scss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/shell.svg b/media/icons/shell.svg new file mode 100644 index 0000000..7022d0c --- /dev/null +++ b/media/icons/shell.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/slim.svg b/media/icons/slim.svg new file mode 100644 index 0000000..9fa4f38 --- /dev/null +++ b/media/icons/slim.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/icons/solution.svg b/media/icons/solution.svg new file mode 100644 index 0000000..aa8acc9 --- /dev/null +++ b/media/icons/solution.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/media/icons/sql.svg b/media/icons/sql.svg new file mode 100644 index 0000000..85d9593 --- /dev/null +++ b/media/icons/sql.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/media/icons/svelte.svg b/media/icons/svelte.svg new file mode 100644 index 0000000..4c6bc79 --- /dev/null +++ b/media/icons/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/swift.svg b/media/icons/swift.svg new file mode 100644 index 0000000..69d7c07 --- /dev/null +++ b/media/icons/swift.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/tailwind.svg b/media/icons/tailwind.svg new file mode 100644 index 0000000..96ccfa5 --- /dev/null +++ b/media/icons/tailwind.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/terraform.svg b/media/icons/terraform.svg new file mode 100644 index 0000000..c953fbb --- /dev/null +++ b/media/icons/terraform.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/media/icons/text.svg b/media/icons/text.svg index 110139c..8646827 100644 --- a/media/icons/text.svg +++ b/media/icons/text.svg @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/media/icons/toml.svg b/media/icons/toml.svg new file mode 100644 index 0000000..b9e6aea --- /dev/null +++ b/media/icons/toml.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/media/icons/typeScript.svg b/media/icons/typeScript.svg index 957790f..bcac440 100644 --- a/media/icons/typeScript.svg +++ b/media/icons/typeScript.svg @@ -1,5 +1,5 @@ - + diff --git a/media/icons/vite.svg b/media/icons/vite.svg new file mode 100644 index 0000000..6ceec21 --- /dev/null +++ b/media/icons/vite.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/media/icons/vueJs.svg b/media/icons/vueJs.svg new file mode 100644 index 0000000..bbd74f1 --- /dev/null +++ b/media/icons/vueJs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/icons/xml.svg b/media/icons/xml.svg new file mode 100644 index 0000000..8a2eb77 --- /dev/null +++ b/media/icons/xml.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/icons/yaml.svg b/media/icons/yaml.svg index 8bffda4..31e1126 100644 --- a/media/icons/yaml.svg +++ b/media/icons/yaml.svg @@ -1,5 +1,5 @@ - - + + diff --git a/media/icons/yarn.svg b/media/icons/yarn.svg new file mode 100644 index 0000000..1e7a312 --- /dev/null +++ b/media/icons/yarn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f933fd0..0d7958f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitbit", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitbit", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.44", diff --git a/package.json b/package.json index cda0858..3b2c9fe 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "name": "Filip Strand" }, "license": "MIT", - "version": "0.1.1", + "version": "0.2.0", "repository": { "type": "git", "url": "https://github.com/filipstrand/gitbit" diff --git a/src/extension/GitGraphViewProvider.ts b/src/extension/GitGraphViewProvider.ts index 8e1d73e..f11aa51 100644 --- a/src/extension/GitGraphViewProvider.ts +++ b/src/extension/GitGraphViewProvider.ts @@ -20,6 +20,9 @@ export class GitGraphViewProvider implements vscode.WebviewViewProvider { private _disposables: vscode.Disposable[] = []; private _startTime: string = new Date().toISOString(); private _repoChangedTimer: NodeJS.Timeout | undefined; + private _ephemeralDiffKeys = new Set(); + private _ephemeralDiffCloser?: vscode.Disposable; + private _moveModeActive = false; constructor(private readonly _extensionUri: vscode.Uri) { this._outputChannel = vscode.window.createOutputChannel('GitBit'); @@ -58,6 +61,37 @@ export class GitGraphViewProvider implements vscode.WebviewViewProvider { }); } + private _ensureEphemeralDiffCloser() { + if (this._ephemeralDiffCloser) return; + this._ephemeralDiffCloser = vscode.window.tabGroups.onDidChangeTabs(() => { + void this._closeEphemeralDiffTabs(); + }); + this._disposables.push(this._ephemeralDiffCloser); + } + + private async _closeEphemeralDiffTabs() { + if (this._ephemeralDiffKeys.size === 0) return; + + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input; + if (input instanceof vscode.TabInputTextDiff) { + const key = `${input.original.toString()}@@${input.modified.toString()}`; + if (this._ephemeralDiffKeys.has(key)) { + try { + // Close the tab without stealing focus. + await vscode.window.tabGroups.close(tab, true); + } catch (err: any) { + this._outputChannel.appendLine(`Failed to auto-close returned diff tab: ${err?.message ?? String(err)}`); + } finally { + this._ephemeralDiffKeys.delete(key); + } + } + } + } + } + } + private async _discoverRepos(): Promise { if (this._reposCache) return this._reposCache; @@ -178,6 +212,11 @@ export class GitGraphViewProvider implements vscode.WebviewViewProvider { this._outputChannel.appendLine(`Received message: ${message.type} (${message.requestId})`); try { switch (message.type) { + case 'ui/moveMode': { + this._moveModeActive = !!message.payload?.active; + this._sendResponse(message.requestId, 'ok'); + break; + } case 'repos/list': { const base = await this._discoverRepos(); const enriched = await Promise.all( @@ -459,6 +498,7 @@ export class GitGraphViewProvider implements vscode.WebviewViewProvider { } this._outputChannel.appendLine(`Opening diff: ${leftUri.toString()} <-> ${rightUri.toString()}`); + const diffKey = `${leftUri.toString()}@@${rightUri.toString()}`; // Find first diff line to jump to let diffSelection: vscode.Range | undefined; @@ -492,11 +532,80 @@ export class GitGraphViewProvider implements vscode.WebviewViewProvider { // Attempt to move the diff to a new floating window try { await vscode.commands.executeCommand('workbench.action.moveEditorToNewWindow'); + // VS Code will "return" moved editors back into the original window when the floating window closes. + // We track this diff and auto-close it if it reappears as a tab. + this._ensureEphemeralDiffCloser(); + this._ephemeralDiffKeys.add(diffKey); + setTimeout(() => this._ephemeralDiffKeys.delete(diffKey), 5 * 60 * 1000).unref?.(); } catch (err) { this._outputChannel.appendLine(`Failed to move to new window: ${err}`); // Fallback: stay in the current column if moving fails } break; + case 'file/revealInOS': { + if (!this._gitRunner) return; + const relPathRaw: unknown = message.payload?.path; + const oldPathRaw: unknown = message.payload?.oldPath; + const relPath = typeof relPathRaw === 'string' ? relPathRaw : ''; + const oldRelPath = typeof oldPathRaw === 'string' ? oldPathRaw : ''; + + const repoRoot = this._gitRunner!.cwd; + + const exists = async (fsPath: string) => { + try { + await fs.promises.stat(fsPath); + return true; + } catch { + return false; + } + }; + + const revealPathOrParent = async (repoRelativePath: string) => { + const full = path.join(repoRoot, repoRelativePath); + if (await exists(full)) { + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(full)); + return true; + } + + // If the file doesn't exist in the working tree (common when viewing older commits), + // fall back to revealing the nearest existing parent folder. + let dir = path.dirname(full); + while (dir && dir !== repoRoot && dir.startsWith(repoRoot + path.sep)) { + if (await exists(dir)) { + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(dir)); + return true; + } + const next = path.dirname(dir); + if (next === dir) break; + dir = next; + } + + // Final fallback: reveal repo root. + if (await exists(repoRoot)) { + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(repoRoot)); + return true; + } + + return false; + }; + + if (!relPath) { + vscode.window.showWarningMessage('Reveal in Finder: missing file path.'); + break; + } + + // Prefer current path, fall back to old path (renames). + const ok = await revealPathOrParent(relPath); + if (!ok && oldRelPath) { + const okOld = await revealPathOrParent(oldRelPath); + if (okOld) break; + } + + if (!ok) { + vscode.window.showWarningMessage('Cannot reveal: failed to resolve a path to reveal.'); + } + break; + } case 'git/reword': { if (!this._gitRunner) return; const rewordSha = message.payload.sha; @@ -1553,11 +1662,16 @@ export class GitGraphViewProvider implements vscode.WebviewViewProvider { // Restore previously staged changes (best effort). if (stagedPatchFile) { - const applyRes = await this._gitRunner.run(['apply', '--cached', '--whitespace=nowarn', stagedPatchFile]); + // First attempt: 3-way apply (more robust when history/index moved, e.g. after a soft reset). + let applyRes = await this._gitRunner.run(['apply', '--cached', '--3way', '--whitespace=nowarn', stagedPatchFile]); + if (applyRes.exitCode !== 0) { + // Fallback: plain apply (some patch types don't support 3-way). + applyRes = await this._gitRunner.run(['apply', '--cached', '--whitespace=nowarn', stagedPatchFile]); + } if (applyRes.exitCode !== 0) { this._outputChannel.appendLine(`Warning: failed to restore previously staged changes: ${applyRes.stderr}`); vscode.window.showWarningMessage( - 'Checkpoint commit succeeded, but restoring previously staged changes failed. You may need to re-stage them manually.' + 'Commit succeeded, but GitBit could not restore your previously staged changes. You may need to re-stage them manually.' ); } } @@ -1785,6 +1899,162 @@ export class GitGraphViewProvider implements vscode.WebviewViewProvider { } } break; + case 'git/drop': + if (!this._gitRunner) return; + { + const dropShas: string[] = Array.isArray(message.payload?.shas) + ? (message.payload.shas as any[]).map(s => String(s)).filter(s => s && s !== GitGraphViewProvider.UNCOMMITTED_SHA) + : []; + + + + if (dropShas.length < 1) { + this._sendError(message.requestId, 'Invalid selection for drop'); + break; + } + + if (!(await this._ensureClean('Dropping commits rewrites history. You have local changes. Continue?'))) { + this._sendError(message.requestId, 'Drop cancelled'); + break; + } + + const branchRes = await this._gitRunner.run(['symbolic-ref', '--quiet', '--short', 'HEAD']); + const originalBranch = branchRes.exitCode === 0 ? branchRes.stdout.trim() : null; + if (!originalBranch) { + this._sendError(message.requestId, 'Drop is not supported in detached HEAD state. Checkout a branch first.'); + break; + } + + const tipRes = await this._gitRunner.run(['rev-parse', 'HEAD']); + const originalTip = tipRes.exitCode === 0 ? tipRes.stdout.trim() : ''; + if (!originalTip) { + this._sendError(message.requestId, 'Failed to determine current HEAD'); + break; + } + + // Use first-parent log for consistent ordering with the UI. + const logRes = await this._gitRunner.run(['log', '--first-parent', '--format=%H']); + if (logRes.exitCode !== 0) { + this._sendError(message.requestId, 'Failed to fetch log for drop', logRes.stderr); + break; + } + const allNewestFirst = logRes.stdout.trim().split('\n').filter(Boolean); + const allOldestFirst = [...allNewestFirst].reverse(); + + const selectedPositions = dropShas.map(sha => allOldestFirst.indexOf(sha)); + if (selectedPositions.some(p => p === -1)) { + this._sendError(message.requestId, 'Drop failed: one or more selected commits are not on the current branch history.'); + break; + } + + const startPos = Math.min(...selectedPositions); + const rangeOldestFirst = allOldestFirst.slice(startPos); + const startCommit = rangeOldestFirst[0]; + + // Determine base (parent of range start). If range start is root, we currently don't support this. + const parentsRes = await this._gitRunner.run(['rev-list', '--parents', '-n', '1', startCommit]); + const parts = parentsRes.exitCode === 0 ? parentsRes.stdout.trim().split(' ') : []; + if (parts.length < 2) { + this._sendError(message.requestId, 'Cannot drop commits when the operation includes the root commit (not supported yet).'); + break; + } + const baseSha = parts[1]; + + // For now, only support linear history (no merge commits) in the rewritten range. + let hasMergeCommit = false; + for (const sha of rangeOldestFirst) { + const p = await this._gitRunner.run(['rev-list', '--parents', '-n', '1', sha]); + const toks = p.exitCode === 0 ? p.stdout.trim().split(' ').filter(Boolean) : []; + if (toks.length > 2) { + this._sendError(message.requestId, 'Drop is not supported for merge commits yet.'); + hasMergeCommit = true; + break; + } + } + if (hasMergeCommit) break; + + const selectedSet = new Set(dropShas); + const remainingSeq = rangeOldestFirst.filter(sha => !selectedSet.has(sha)); + const dropCount = rangeOldestFirst.length - remainingSeq.length; + + if (dropCount <= 0) { + this._sendResponse(message.requestId, { newHead: originalTip }); + break; + } + + const confirm = await vscode.window.showWarningMessage( + `Drop ${dropCount} commit(s) and rewrite history on ${originalBranch}? This will rewrite ${remainingSeq.length} commit(s) that come after the oldest dropped commit.`, + { modal: true }, + 'Drop' + ); + if (confirm !== 'Drop') { + this._sendError(message.requestId, 'Drop cancelled'); + break; + } + + const tmpBranch = `cgg-tmp-drop-${Date.now()}`; + await this._gitRunner.run(['branch', tmpBranch, originalTip]); + + const restoreOriginal = async () => { + await this._gitRunner!.run(['checkout', originalBranch]); + await this._gitRunner!.run(['reset', '--hard', originalTip]); + }; + + try { + const checkoutBase = await this._gitRunner.run(['checkout', '--detach', baseSha]); + if (checkoutBase.exitCode !== 0) { + this._sendError(message.requestId, 'Failed to checkout base for drop', checkoutBase.stderr); + await restoreOriginal(); + break; + } + + let cherryFailed = false; + for (const sha of remainingSeq) { + const cherryRes = await this._gitRunner.run(['cherry-pick', sha], 600000); + if (cherryRes.exitCode !== 0) { + await this._gitRunner.run(['cherry-pick', '--abort']); + await restoreOriginal(); + this._sendError( + message.requestId, + 'Drop failed due to conflicts while rewriting history. Your branch was restored.', + cherryRes.stderr + ); + cherryFailed = true; + break; + } + } + if (cherryFailed) break; + + const newTipRes = await this._gitRunner.run(['rev-parse', 'HEAD']); + const newTip = newTipRes.exitCode === 0 ? newTipRes.stdout.trim() : ''; + if (!newTip) { + await restoreOriginal(); + this._sendError(message.requestId, 'Drop failed: could not resolve new HEAD.'); + break; + } + + const checkoutBranch = await this._gitRunner.run(['checkout', originalBranch]); + if (checkoutBranch.exitCode !== 0) { + await restoreOriginal(); + this._sendError(message.requestId, 'Drop failed: could not return to branch.', checkoutBranch.stderr); + break; + } + + const resetBranch = await this._gitRunner.run(['reset', '--hard', newTip]); + if (resetBranch.exitCode !== 0) { + await restoreOriginal(); + this._sendError(message.requestId, 'Drop failed: could not move branch to new history.', resetBranch.stderr); + break; + } + + this._notifyRepoChanged('drop'); + this._sendResponse(message.requestId, { newHead: newTip }); + } finally { + // Best-effort cleanup of temp branch. + await this._gitRunner.run(['branch', '-D', tmpBranch]); + } + } + break; case 'git/moveCommits': if (!this._gitRunner) return; { @@ -2210,6 +2480,13 @@ export class GitGraphViewProvider implements vscode.WebviewViewProvider { if (e.focused) notify('focus'); })); + // If the user clicks in an editor while commits are in "move mode", treat it as Escape (cancel move mode). + this._disposables.push(vscode.window.onDidChangeTextEditorSelection(e => { + if (!this._moveModeActive) return; + if (e.kind !== vscode.TextEditorSelectionChangeKind.Mouse) return; + this._view?.webview.postMessage({ type: 'ui/escape' }); + })); + // 2. Watch .git changes (HEAD, refs, index) as a fallback / for non-working-tree events. // Note: we intentionally do NOT watch the entire workspace. Dev builds commonly write to `dist/` // on every save, which would otherwise cause a noisy refresh loop. diff --git a/src/webview/components/ContextMenu.tsx b/src/webview/components/ContextMenu.tsx index 5de6787..4357a8a 100644 --- a/src/webview/components/ContextMenu.tsx +++ b/src/webview/components/ContextMenu.tsx @@ -6,8 +6,9 @@ interface ContextMenuProps { onClose: () => void; actions: { label?: string; - onClick?: () => void; + onClick?: () => void | Promise; danger?: boolean; + tone?: 'warning' | 'success'; icon?: string; disabled?: boolean; primary?: boolean; @@ -51,6 +52,15 @@ export const ContextMenu: React.FC = ({ x, y, onClose, actions const disabled = !!action.disabled; const label = action.label || ''; + const baseColor = + action.danger + ? 'var(--vscode-errorForeground)' + : action.tone === 'warning' + ? 'var(--vscode-editorWarning-foreground, #d19a66)' + : action.tone === 'success' + ? 'var(--vscode-gitDecoration-addedResourceForeground, #73c991)' + : 'inherit'; + const isToned = !!action.danger || !!action.tone; return (
= ({ x, y, onClose, actions padding: '6px 12px', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.45 : 1, - color: action.danger ? 'var(--vscode-errorForeground)' : 'inherit', + color: baseColor, fontSize: '12px', display: 'flex', alignItems: 'center', @@ -75,11 +85,11 @@ export const ContextMenu: React.FC = ({ x, y, onClose, actions onMouseEnter={(e) => { if (disabled) return; e.currentTarget.style.backgroundColor = 'var(--vscode-menu-selectionBackground)'; - e.currentTarget.style.color = action.danger ? 'var(--vscode-errorForeground)' : 'var(--vscode-menu-selectionForeground)'; + e.currentTarget.style.color = isToned ? baseColor : 'var(--vscode-menu-selectionForeground)'; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; - e.currentTarget.style.color = action.danger ? 'var(--vscode-errorForeground)' : 'inherit'; + e.currentTarget.style.color = baseColor; }} > {action.icon ? ( diff --git a/src/webview/components/DetailsPane.tsx b/src/webview/components/DetailsPane.tsx index 1db2041..511de20 100644 --- a/src/webview/components/DetailsPane.tsx +++ b/src/webview/components/DetailsPane.tsx @@ -69,6 +69,18 @@ export const DetailsPane: React.FC = ({ sha }) => { }); }; + const handleRevealInOS = (change: Change) => { + vscode.postMessage({ + type: 'file/revealInOS', + requestId: `reveal-${Date.now()}`, + payload: { + path: change.path, + oldPath: change.oldPath, + status: change.status + } + }); + }; + const handleFileClick = (change: Change) => { if (!details) return; @@ -128,6 +140,16 @@ export const DetailsPane: React.FC = ({ sha }) => { } }; + const copySubject = () => { + if (!details || details.sha === 'UNCOMMITTED') return; + const text = String(details.subject || '').trim(); + if (!text) return; + vscode.postMessage({ + type: 'app/copyToClipboard', + payload: { text } + }); + }; + const isUncommitted = details?.sha === 'UNCOMMITTED'; const allFilePaths = isUncommitted ? changes.map(c => c.path) : []; const hasExtendedMessage = !!details && !isUncommitted && details.message.trim() !== details.subject.trim(); @@ -182,6 +204,28 @@ export const DetailsPane: React.FC = ({ sha }) => { return 'No files selected'; }, [selectedPaths.size]); + const handleCommitClick = () => { + // UX: if the user typed a message but forgot to select files, first click selects all, + // second click commits. + if (selectedPaths.size === 0) { + if (!commitMessage.trim()) return; + if (allFilePaths.length === 0) return; + selectAll(); + return; + } + performCommit(); + }; + + const handleAmendClick = () => { + // Same UX for amend (message optional): first click selects all if nothing is selected. + if (selectedPaths.size === 0) { + if (allFilePaths.length === 0) return; + selectAll(); + return; + } + performCommit({ amend: true }); + }; + const hasCommitBox = isUncommitted || (hasExtendedMessage && showFullMessage); const formatDateYYYYMMDD = (iso: string) => { @@ -220,6 +264,25 @@ export const DetailsPane: React.FC = ({ sha }) => { /> )} {details.subject} + {!isUncommitted && ( + { + e.stopPropagation(); + copySubject(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + copySubject(); + } + }} + /> + )}
{!isUncommitted && (
@@ -277,15 +340,15 @@ export const DetailsPane: React.FC = ({ sha }) => {