diff --git a/.gitignore b/.gitignore index a583b32..2203a27 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,10 @@ out/ out-test/ *.vsix .DS_Store +graphify-out/* +docs/codex-plan.md +.vscode/launch.json +.vscode/tasks.json +.claude/settings.json +.gitignore +CLAUDE.md diff --git a/.vscodeignore b/.vscodeignore index 68fb932..c2e2243 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -9,3 +9,9 @@ tsconfig.test.json **/*.map **/*.ts .gitignore + +# Local tooling / dev files — not part of the published extension +.github/** +.claude/** +CLAUDE.md +graphify-out/** diff --git a/README.md b/README.md index 0c02e06..0c66113 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A full-featured, Git client for VS Code. VsGit spawns the `git` binary directly — no libgit2, no reimplementation of git in JavaScript — and -surfaces **160+ commands** across every Git workflow through dedicated views, +surfaces **170+ commands** across every Git workflow through dedicated views, webviews, and an interactive commit graph. ![VsGit Git Graph](docs/git-graph.png) @@ -67,14 +67,15 @@ config, hooks, credential helpers, and aliases apply. Colour-coded lanes, `HEAD → main` / remote / tag ref pills, a selected-row commit-details panel (metadata on the left, changed files on the right), and Eclipse Git-style columns: **Graph · Description · Author · Authored Date · Committer · -Committed Date** (each toggleable). +Committed Date · Commit**. Metadata columns are toggleable from the graph toolbar; +Authored Date and Committer are hidden by default. ### Activity bar & trees ![Sidebar views](docs/sidebar.png) -The VsGit container uses its own commit-graph logo (distinct from the built-in -Source Control icon) and hosts the Repositories and Staging trees, among others. +The VsGit container uses a git-merge style logo and hosts the Repositories +and Staging trees, among others. --- @@ -84,13 +85,18 @@ Source Control icon) and hosts the Repositories and Staging trees, among others. - Multi-root workspace support with per-repo ahead/behind indicators. - Full tree of branches, remotes, tags, stashes, submodules, and worktrees. - Inline checkout, push, pull, fetch, merge, and rebase from tree nodes. +- **Reset HEAD** submenu with all five git modes — soft, mixed, hard, keep, and + merge — each picking the target ref and confirming before a hard reset. - **Switch To** quick picker (`⌘⇧G B` / `Ctrl+Shift+G B`) across all branches and tags. - Sequencer controls (Continue / Skip / Abort) appear automatically during an in-progress rebase, merge, cherry-pick, or revert. ### Staging view & Commit webview - Staged / Changes / Conflicts groups. +- Changed files render as a collapsible **tree or flat list** (toggle persisted + across sessions), with a descriptive status label per file. - Hunk-level stage and unstage (forward/reverse patch apply to the index). +- **Reset Changes** on any unstaged file to revert it. - Commit with **GPG sign** (`-S`) and **DCO sign-off** options, or **amend** the last commit (message prefilled). @@ -99,8 +105,8 @@ Source Control icon) and hosts the Repositories and Staging trees, among others. unit-tested graph layout — branch lanes stay connected across rows. - Filter by branch, author, or message; restrict by date range. - Per-commit context menu: checkout (detached), create branch/tag, cherry-pick, - revert, reset (soft / mixed / hard), compare with HEAD or another commit, - copy SHA, and show full details. + revert, reset (soft / mixed / hard / keep / merge), compare with HEAD or + another commit, copy SHA, and show full details. - **Compare Branches** mode for a symmetric `A...B` diff. - Commits are loaded in `--topo-order` so a child always precedes its parents, which is what the lane layout needs to draw a correct graph. @@ -151,6 +157,7 @@ Source Control icon) and hosts the Repositories and Staging trees, among others. | **Submodules** | Add, update, sync, deinit | | **Maintenance** | `git gc`, prune, fsck, and repo maintenance helpers | | **Blame** | Toggleable inline blame annotations (`⌘⇧G A`) | +| **Tags** | Webview **Create Tag** dialog — name, message, and annotate / sign / force / push options in one form | | **GitHub** | Fetch Pull Requests — pulls `refs/pull/*/head` as local refs | Interactive rebase and commit-message editing are routed back into VS Code via a @@ -173,7 +180,7 @@ rebase -i` opens a native editor instead of a terminal vi session. | `⌘⇧G K` | `Ctrl+Shift+G K` | Cherry-Pick Commit | | `⌘⇧G ,` | `Ctrl+Shift+G ,` | Open Git Config Panel | -All 160+ commands are also available from the Command Palette under the +All 170+ commands are also available from the Command Palette under the **Git (VsGit)** category. --- @@ -205,10 +212,12 @@ All settings live under the `vsgit.*` namespace. | Setting | Default | Description | |---|---|---| +| `vsgit.showAdvancedViews` | `false` | Show advanced sidebar sections such as Staging, Reflog, Synchronize, Worktrees, Conflicts, and Compare. | | `vsgit.git.path` | `""` | Custom path to the `git` executable; empty uses `$PATH`. | | `vsgit.autoRefresh` | `true` | Refresh views automatically when the repo changes. | | `vsgit.autoFetch.enabled` | `false` | Periodically fetch from all remotes. | | `vsgit.autoFetch.intervalMinutes` | `3` | Minutes between automatic fetches. | +| `vsgit.autoFetch.notify` | `true` | Notify when auto-fetch discovers new incoming commits. | | `vsgit.fetch.pruneOnFetch` | `true` | Prune deleted remote-tracking branches on fetch. | | `vsgit.defaultPullMode` | `merge` | Pull strategy: `merge` or `rebase`. | | `vsgit.confirmDestructiveActions` | `true` | Confirm hard reset, clean, force-push, etc. | @@ -222,15 +231,15 @@ All settings live under the `vsgit.*` namespace. | `vsgit.graph.sortOrder` | `date` | Commit sort order for the History view. | | `vsgit.graph.style` | `rounded` | Branch line style: `rounded` curves or `angular` elbows. | | `vsgit.graph.colours` | 12-colour palette | Branch lane colours cycled through in the graph. | -| `vsgit.graph.dateFormat` | `relative` | Date format in the graph (`relative` / `iso` / `standard`). | +| `vsgit.graph.dateFormat` | `standard` | Date format in the graph (`relative` / `iso` / `standard`). | | `vsgit.graph.showRemoteBranches` | `true` | Show remote branches in the graph by default. | | `vsgit.graph.showSidebar` | `true` | Show the graph's left sidebar tree. | | `vsgit.graph.showStatusBarItem` | `true` | Show a *Git Graph* button in the status bar. | | `vsgit.graph.bottomPanelMode` | `editor` | How the graph opens a changed file's diff. | | `vsgit.graph.showIdColumn` | `true` | Show the Id (hash) column. | | `vsgit.graph.showAuthorColumn` | `true` | Show the Author column. | -| `vsgit.graph.showAuthoredDateColumn` | `true` | Show the Authored Date column. | -| `vsgit.graph.showCommitterColumn` | `true` | Show the Committer column. | +| `vsgit.graph.showAuthoredDateColumn` | `false` | Show the Authored Date column. | +| `vsgit.graph.showCommitterColumn` | `false` | Show the Committer column. | | `vsgit.graph.showCommittedDateColumn` | `true` | Show the Committed Date column. | There's also a graphical **Git Config editor** (`⌘⇧G ,`) for editing local / @@ -253,9 +262,10 @@ src/ (log, graphLog, status, refs, diff, blame, config, reflog, rebaseTodo, worktree) views/ tree data providers (Repositories, Staging, …) - webviews/ webview panels (Graph, History, Commit, pickers) + webviews/ webview panels (Graph, History, Commit, Create Tag, + pickers) services/ auto-fetch, file-system watcher, status bar - util/ IPC servers (askpass / editor) + helpers + util/ IPC servers (askpass / editor) + helpers (html escape) resources/ graphLayout.js shared, unit-tested commit-graph layout (UMD) graph.js / graph.css Git Graph panel client @@ -309,11 +319,13 @@ required. They cover the pure logic that's most worth pinning down: - every output parser under `src/git/parsers/` (log, graph-log, status, refs, diff, blame, config, reflog, rebase-todo, worktree), -- the shared commit-graph layout (`resources/graphLayout.test.js`), -- the argument guards and the IPC token comparison. +- the shared commit-graph layout (`resources/graphLayout.test.js`) and the + Git Graph webview client (`resources/manifest.test.js`), +- the `GitExecutor` argv assembly and the `Repository` command builders, +- the argument guards, the HTML-escape helper, and the IPC token comparison. ```bash -npm test # 60 tests +npm test # 142 tests ``` CI (GitHub Actions) runs type-check, build, the test suite, and packages the diff --git a/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md similarity index 77% rename from IMPLEMENTATION_PLAN.md rename to docs/IMPLEMENTATION_PLAN.md index ee20890..a91d097 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -317,99 +317,83 @@ Implement an interactive, visual git graph similar to git-graph extension and So --- -## Phase 8: Auto-Fetch & Background Operations ⏰ TODO +## Phase 8: Auto-Fetch & Background Operations ✅ COMPLETE ### Objectives Implement automatic background fetching and repository monitoring. -### Tasks -- [ ] **Auto-Fetch Service** - - Create `src/services/AutoFetchService.ts` - - Read `vsgit.autoFetch.enabled` and `vsgit.autoFetch.intervalMinutes` - - Start timer on extension activation - - Fetch all remotes for all repositories - - Show subtle notification on new commits - - Pause during active operations (merge, rebase, etc.) - -- [ ] **File System Watcher** - - Watch `.git/` folder for external changes - - Detect: - - External commits (from command line) - - Branch switches - - Merge/rebase progress - - Auto-refresh views on change - -- [ ] **Ahead/Behind Indicators** - - Show ahead/behind count in Repositories view - - Update after fetch - - Show in status bar (optional setting) - -- [ ] **Pull Notifications** - - Show notification when remote has new commits - - Quick action: "Pull Now" button - - Respect `vsgit.autoFetch.notify` setting +### Completed Tasks +- ✅ **Auto-Fetch Service** + - Created `src/services/AutoFetchService.ts` + - Reads `vsgit.autoFetch.enabled`, `vsgit.autoFetch.intervalMinutes`, and `vsgit.autoFetch.notify` + - Starts/stops the interval from extension activation and config changes + - Fetches all remotes for all repositories + - Skips repositories with merge/rebase/cherry-pick/revert operations in progress + - Shows optional notifications with a "Pull Now" action when new incoming commits are found + +- ✅ **File System Watcher** + - Created `src/services/GitWatcherService.ts` + - Watches `.git` sentinel files for branch switches, merge/rebase/cherry-pick/revert state, and external commits + - Debounces refreshes and respects `vsgit.autoRefresh` + +- ✅ **Ahead/Behind Indicators** + - Refreshes ahead/behind state after fetch + - Shows pull/push badges in the Git Graph toolbar + - Shows last-fetch time in the auto-fetch status bar item + +- ✅ **Pull Notifications** + - Shows notifications when remote commits arrive + - Supports the "Pull Now" action + - Respects `vsgit.autoFetch.notify` ### Implementation Notes - Use `vscode.workspace.createFileSystemWatcher()` for `.git/**` changes - Debounce rapid file changes (500ms) - Use `setInterval()` for auto-fetch timer - Clear timer on extension deactivation -- Add status bar item showing last fetch time +- Status bar item shows last fetch time and can trigger `vsgit.autoFetch.fetchNow` --- -## Phase 9: Configuration & Settings UI 🎨 TODO +## Phase 9: Configuration & Settings UI ✅ COMPLETE ### Objectives Provide in-app configuration UI for common git settings. -### Tasks -- [ ] **Git Config Editor** - - Webview panel showing user.name, user.email, etc. - - Three levels: Local (repo), Global (user), System - - Edit common settings: - - User identity (name, email) - - Default branch name - - Pull strategy (merge/rebase) - - Push default (simple/matching/current) - - GPG signing - - Show all settings in advanced mode (key-value list) - -- [ ] **Repository Settings Panel** - - Per-repository configuration - - Quick actions: - - Set upstream branch - - Configure remote URLs - - Set pull strategy - - Enable/disable GPG signing - - Set commit template path - -- [ ] **Extension Settings Integration** - - Show current `vsgit.*` settings - - Quick toggle buttons for boolean settings - - Link to VS Code settings editor +### Completed Tasks +- ✅ **Git Config Editor** + - `src/commands/config.ts` opens a webview panel for local, global, and system scopes + - Local/global scopes support add, edit, and unset + - System scope is displayed as read-only + - Advanced key/value entries are loaded from `git config --list --show-origin --show-scope` + +- ✅ **Repository Settings Panel** + - Remotes tab lists fetch/push URLs + - Supports adding and removing remotes + - Per-repository config can be edited through local scope + +- ✅ **Extension Settings Integration** + - Extension tab shows common `vsgit.*` settings + - Supports toggles for auto refresh, auto fetch, auto-fetch notifications, and destructive-action confirmations + - Supports auto-fetch interval and default pull-mode edits ### Implementation Notes -- Create `src/webviews/config/` folder +- Uses `src/webviews/configHtml.ts` - Use `git config --list --show-origin --show-scope` to read - Use `git config --local/--global --add/--unset` to write -- Add command `vsgit.config.openPanel` +- Command `vsgit.config.openPanel` is registered as the keybinding/palette alias --- -## Phase 10: Integration & Polish 🎯 TODO +## Phase 10: Integration & Polish 🔄 IN PROGRESS ### Objectives Final integration, performance optimization, accessibility, and testing. ### Tasks -- [ ] **Keyboard Shortcuts** - - Define default keybindings for common operations - - Examples: - - `Cmd+Shift+G C` — Commit - - `Cmd+Shift+G P` — Push - - `Cmd+Shift+G L` — Show History - - `Cmd+Shift+G F` — Fetch +- [x] **Keyboard Shortcuts** + - Default keybindings are contributed in `package.json` + - Covered commands include Commit, Push, History, Fetch, Switch To, Inline Blame, Git Graph, Cherry-Pick, and Git Config - [ ] **Accessibility (a11y)** - Ensure all tree views have proper ARIA labels @@ -423,35 +407,33 @@ Final integration, performance optimization, accessibility, and testing. - Lazy-load views - Cache git output where possible -- [ ] **Error Handling** - - Graceful degradation when git not found - - Clear error messages for common git failures - - Suggest fixes for common issues (e.g., merge conflicts) +- [x] **Error Handling** + - `src/commands/shared.ts` humanizes common git failures + - Command flows use shared progress/error wrappers where practical + - Conflict and destructive-operation paths surface explicit confirmations or follow-up actions - [ ] **Testing** - - Unit tests for parsers (status, log, blame, etc.) - - Integration tests for commands - - Mock git execution for tests - - Test coverage > 70% + - ✅ Unit tests for parsers (status, log, blame, refs, reflog, config, diff, worktree, etc.) + - ✅ Mock-git Repository security/argv tests + - ✅ Static manifest/docs regression tests + - [ ] Integration tests for command flows inside VS Code + - [ ] Coverage threshold / report - [ ] **Documentation** - - README with feature overview - - GIFs/screenshots for key features - - CHANGELOG tracking all releases - - CONTRIBUTING guide - - Keyboard shortcuts reference + - ✅ README with feature overview, screenshots, settings, architecture, and keyboard shortcuts + - ✅ CHANGELOG and LICENSE + - [ ] CONTRIBUTING guide + - [ ] GIFs or updated live screenshots for key workflows - [ ] **Marketplace Preparation** - - Extension icon and banner - - Detailed description - - Categories and keywords - - Pricing (free/paid features) - - License file (MIT) + - ✅ Extension icon, banner, categories, keywords, and license metadata + - ✅ Detailed README description + - [ ] Final VSIX inspection and marketplace publish checklist ### Implementation Notes -- Add `contributes.keybindings` to `package.json` +- `contributes.keybindings` is present in `package.json` - Use `@axe-core/playwright` for a11y testing -- Add `test/` folder with `mocha` or `vitest` +- Current tests use Node's built-in test runner - Create `.github/workflows/ci.yml` for CI/CD - Use `vsce package` to create `.vsix` for manual testing @@ -471,6 +453,7 @@ Final integration, performance optimization, accessibility, and testing. - `vsgit.autoRefresh` — Auto-refresh views on change - `vsgit.autoFetch.enabled` — Enable auto-fetch - `vsgit.autoFetch.intervalMinutes` — Fetch interval +- `vsgit.autoFetch.notify` — Notify when auto-fetch discovers incoming commits - `vsgit.graph.pageSize` — History page size (Phase 3) - `vsgit.graph.sortOrder` — Commit sort order - `vsgit.confirmDestructiveActions` — Confirm destructive ops @@ -481,7 +464,7 @@ Final integration, performance optimization, accessibility, and testing. ## Current Status -### ✅ Completed (70%) +### ✅ Completed (~90%) - Phase 1: Explorer context menu (Team menu) — 100% - Phase 2: Branch & tag enhancements — 100% - Phase 3: History view enhancements — 100% @@ -489,22 +472,22 @@ Final integration, performance optimization, accessibility, and testing. - Phase 5: SCM view menus & safety layer — 100% - Phase 6: Advanced operations (LFS, Notes, Archive, Subtree) — 100% - Phase 7: Git graph interactive visualization — 100% +- Phase 8: Auto-fetch & background operations — 100% +- Phase 9: Configuration & settings UI — 100% ### 🔄 In Progress -- None +- Phase 10: Integration & polish -### 📋 Remaining (30%) -- Phase 8: Auto-fetch & background operations — 0% -- Phase 9: Configuration & settings UI — 0% -- Phase 10: Integration & polish — 0% +### 📋 Remaining +- Phase 10: Accessibility audit, deeper command integration tests, CI workflow, CONTRIBUTING guide, and marketplace packaging polish --- -## Extension Statistics (After Phase 7) +## Extension Statistics (Current) -- **Total Commands**: 125+ commands -- **Total Views**: 7 tree views + 1 webview panel (Git Graph) -- **Command Files**: 27+ modules +- **Total Commands**: 167 contributed commands +- **Total Views**: 8 sidebar views + 1 Commit webview + 1 Git Graph panel +- **Command Files**: 29+ modules - **Repository Methods**: 92+ git operations - **Lines of Code**: ~11,500+ lines @@ -512,6 +495,6 @@ Final integration, performance optimization, accessibility, and testing. ## Next Steps -1. **Immediate**: Phase 8 — Auto-fetch & background operations -2. **High Priority**: Phase 9 — Configuration & settings UI -3. **Final**: Phase 10 — Integration, polish, and release preparation +1. **Immediate**: Phase 10 — add CI and higher-level command integration tests +2. **High Priority**: Accessibility and keyboard-navigation audit for webviews +3. **Final**: Marketplace packaging polish and release preparation diff --git a/package.json b/package.json index 4566eea..802494c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vsgit-vscode", "displayName": "VsGit", "description": "VsGit-style Git client for VS Code: Repositories, Staging, History, Graph, Compare, Worktrees, LFS, Gerrit, and more.", - "version": "0.0.1", + "version": "0.0.2", "publisher": "framedparadox", "license": "MIT", "repository": { @@ -839,6 +839,31 @@ "title": "Reset HEAD...", "category": "VsGit" }, + { + "command": "vsgit.repo.reset.soft", + "title": "Soft (keep index & working tree)...", + "category": "VsGit" + }, + { + "command": "vsgit.repo.reset.mixed", + "title": "Mixed (reset index, keep working tree)...", + "category": "VsGit" + }, + { + "command": "vsgit.repo.reset.hard", + "title": "Hard (reset index & working tree)...", + "category": "VsGit" + }, + { + "command": "vsgit.repo.reset.keep", + "title": "Keep (reset index, keep local changes)...", + "category": "VsGit" + }, + { + "command": "vsgit.repo.reset.merge", + "title": "Merge (reset index, keep merge changes)...", + "category": "VsGit" + }, { "command": "vsgit.branch.compareTo", "title": "Compare Branch with...", @@ -1079,6 +1104,10 @@ "id": "vsgit.repo.commit", "label": "Commit" }, + { + "id": "vsgit.repo.reset", + "label": "Reset" + }, { "id": "vsgit.repo.remote", "label": "Remote" @@ -1348,6 +1377,28 @@ "group": "3@2" } ], + "vsgit.repo.reset": [ + { + "command": "vsgit.repo.reset.soft", + "group": "1@1" + }, + { + "command": "vsgit.repo.reset.mixed", + "group": "1@2" + }, + { + "command": "vsgit.repo.reset.hard", + "group": "1@3" + }, + { + "command": "vsgit.repo.reset.keep", + "group": "2@1" + }, + { + "command": "vsgit.repo.reset.merge", + "group": "2@2" + } + ], "vsgit.repo.remote": [ { "command": "vsgit.remote.add", @@ -2090,7 +2141,7 @@ "group": "1b@2" }, { - "command": "vsgit.branch.reset", + "submenu": "vsgit.repo.reset", "when": "(view == vsgit.repositoriesList || view == vsgit.repositories) && viewItem == vsgit.repo", "group": "1b@3" }, @@ -2882,6 +2933,11 @@ "minimum": 1, "description": "Interval (minutes) between automatic fetches." }, + "vsgit.autoFetch.notify": { + "type": "boolean", + "default": true, + "description": "Show a notification when automatic fetch discovers new incoming commits." + }, "vsgit.graph.pageSize": { "type": "number", "default": 200, @@ -2973,10 +3029,20 @@ "default": true, "description": "Show the Author column in the Git Graph." }, + "vsgit.graph.showAuthoredDateColumn": { + "type": "boolean", + "default": false, + "description": "Show the Authored Date column in the Git Graph." + }, + "vsgit.graph.showCommitterColumn": { + "type": "boolean", + "default": false, + "description": "Show the Committer column in the Git Graph." + }, "vsgit.graph.showCommittedDateColumn": { "type": "boolean", "default": true, - "description": "Show the Date column in the Git Graph." + "description": "Show the Committed Date column in the Git Graph." }, "vsgit.graph.dateFormat": { "type": "string", diff --git a/resources/commit.css b/resources/commit.css index 09683a8..ca1f6fa 100644 --- a/resources/commit.css +++ b/resources/commit.css @@ -18,6 +18,41 @@ body { /* ─── message editor ─────────────────────────────────────────────────────── */ #message-box { padding: 8px; } +#commit-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +#commit-title { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +#commit-title .title { + font-size: 12px; + font-weight: 600; +} + +#branch-name { + color: var(--vscode-descriptionForeground); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#commit-actions { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + #message { width: 100%; min-height: 56px; @@ -53,6 +88,32 @@ body { } #commit-btn:hover { background-color: var(--vscode-button-hoverBackground); } +.header-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--vscode-icon-foreground, var(--vscode-foreground)); + cursor: pointer; + font-size: 13px; + line-height: 1; +} +.header-btn:hover { background-color: var(--vscode-toolbar-hoverBackground, rgba(128,128,128,0.2)); } +.header-btn.active { + background-color: var(--vscode-inputOption-activeBackground, rgba(14,99,156,0.35)); + border-color: var(--vscode-inputOption-activeBorder, transparent); +} +.header-icon { + display: block; + width: 16px; + height: 16px; + fill: currentColor; +} + .opt { display: inline-flex; align-items: center; @@ -120,30 +181,91 @@ body { } .file-row:hover { background-color: var(--vscode-list-hoverBackground); } -.file-status { +.tree-folder { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 10px 2px 12px; + cursor: pointer; + user-select: none; + font-size: 13px; +} +.tree-folder:hover { background-color: var(--vscode-list-hoverBackground); } +.tree-folder .chev { + display: inline-flex; + align-items: center; + justify-content: center; width: 14px; + height: 14px; + flex-shrink: 0; + color: var(--vscode-icon-foreground, var(--vscode-foreground)); + font-size: 10px; + transition: transform 0.1s ease; +} +.tree-folder .chev.expanded { transform: rotate(90deg); } +.tree-folder .folder-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tree-children.collapsed { display: none; } + +/* LEFT extension chip. */ +.file-ext { + min-width: 30px; + max-width: 48px; + padding: 0 4px; + height: 15px; + line-height: 15px; text-align: center; font-family: var(--vscode-editor-font-family, monospace); - font-weight: 700; - font-size: 11px; + font-weight: 600; + font-size: 10px; + border-radius: 3px; flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background-color: rgba(128, 128, 128, 0.16); } -.file-status.s-A { color: #4ec94e; } -.file-status.s-M { color: #e2c08d; } -.file-status.s-D { color: #f14c4c; } -.file-status.s-R { color: #4daafc; } -.file-status.s-U { color: #e2c08d; } -.file-status.s-C { color: #f14c4c; } +.file-ext.s-A { color: #4ec94e; } +.file-ext.s-M { color: #e2c08d; } +.file-ext.s-D { color: #f14c4c; } +.file-ext.s-R { color: #4daafc; } +.file-ext.s-U { color: #e2c08d; } +.file-ext.s-C { color: #f14c4c; } -.file-name { flex-shrink: 0; } -.file-dir { +.file-name { flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-dir { color: var(--vscode-descriptionForeground); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex-shrink: 0; + max-width: 45%; +} + +/* RIGHT change label (Modified, Added, …). */ +.file-change { + flex-shrink: 0; + font-size: 11px; + color: var(--vscode-descriptionForeground); } +.file-change.s-A { color: #4ec94e; } +.file-change.s-M { color: #e2c08d; } +.file-change.s-D { color: #f14c4c; } +.file-change.s-R { color: #4daafc; } +.file-change.s-U { color: #e2c08d; } +.file-change.s-C { color: #f14c4c; } +/* The hover actions overlay the change label, so hide it on hover. */ +.file-row:hover .file-change { display: none; } .file-actions { margin-left: auto; diff --git a/resources/commit.js b/resources/commit.js index 79a0577..e8304f3 100644 --- a/resources/commit.js +++ b/resources/commit.js @@ -10,6 +10,7 @@ const vscode = acquireVsCodeApi(); let state = { active: false }; +let viewMode = (vscode.getState() || {}).commitViewMode || 'tree'; const el = (id) => document.getElementById(id); const post = (type, data) => vscode.postMessage({ type, data }); @@ -23,6 +24,8 @@ function render() { } el('empty').style.display = 'none'; el('root').style.display = 'block'; + el('branch-name').textContent = state.branch || ''; + syncViewButtons(); const groups = el('groups'); groups.innerHTML = ''; @@ -64,7 +67,11 @@ function renderGroup(title, group, files) { return wrap; } - files.forEach((f) => wrap.appendChild(renderFile(f, group))); + if (viewMode === 'tree') { + wrap.appendChild(renderTree(group, files)); + } else { + files.forEach((f) => wrap.appendChild(renderFile(f, group))); + } return wrap; } @@ -77,33 +84,107 @@ function groupAction(title, glyph, action) { return b; } -function renderFile(f, group) { +function renderTree(group, files) { + const root = { dirs: new Map(), files: [] }; + files.forEach((f) => { + const parts = String(f.path || '').split('/').filter(Boolean); + let node = root; + for (let i = 0; i < parts.length - 1; i++) { + const segment = parts[i]; + if (!node.dirs.has(segment)) node.dirs.set(segment, { dirs: new Map(), files: [] }); + node = node.dirs.get(segment); + } + node.files.push({ file: f, name: parts[parts.length - 1] || f.name || f.path }); + }); + + const wrap = document.createElement('div'); + renderTreeLevel(wrap, root, group, 0); + return wrap; +} + +function renderTreeLevel(parent, node, group, depth) { + Array.from(node.dirs.keys()).sort((a, b) => a.localeCompare(b)).forEach((name) => { + const dir = node.dirs.get(name); + const folder = document.createElement('div'); + folder.className = 'tree-folder'; + folder.style.paddingLeft = (12 + depth * 14) + 'px'; + folder.innerHTML = + '' + + '' + escapeHtml(name) + ''; + + const children = document.createElement('div'); + children.className = 'tree-children'; + renderTreeLevel(children, dir, group, depth + 1); + + folder.addEventListener('click', () => { + const collapsed = children.classList.toggle('collapsed'); + folder.querySelector('.chev').classList.toggle('expanded', !collapsed); + }); + parent.appendChild(folder); + parent.appendChild(children); + }); + + node.files.sort((a, b) => a.name.localeCompare(b.name)).forEach(({ file, name }) => { + parent.appendChild(renderFile(file, group, depth, name)); + }); +} + +// Full change label shown on the right of each row, keyed by status code. +const STATUS_LABELS = { + A: 'Added', + M: 'Modified', + D: 'Deleted', + R: 'Renamed', + C: 'Conflicted', + U: 'Conflicted', + '?': 'Untracked', +}; + +// File extension (lowercase, no dot) shown on the left of each row, or '•' when +// the file has no extension. +function fileExt(name) { + const base = String(name || ''); + const dot = base.lastIndexOf('.'); + if (dot <= 0 || dot === base.length - 1) return '•'; + return base.slice(dot + 1).toLowerCase(); +} + +function renderFile(f, group, depth = 0, label = f.name) { const row = document.createElement('div'); row.className = 'file-row'; row.title = f.path; + if (viewMode === 'tree') row.style.paddingLeft = (26 + depth * 14) + 'px'; const code = f.conflicted ? 'C' : (f.state || 'modified').charAt(0).toUpperCase(); - const badge = document.createElement('span'); - badge.className = 'file-status s-' + code; - badge.textContent = code; - row.appendChild(badge); + + // LEFT: file extension chip. + const ext = document.createElement('span'); + ext.className = 'file-ext s-' + code; + ext.textContent = fileExt(f.name); + row.appendChild(ext); const name = document.createElement('span'); name.className = 'file-name'; - name.textContent = f.name; + name.textContent = label; row.appendChild(name); - if (f.dir) { + if (viewMode !== 'tree' && f.dir) { const dir = document.createElement('span'); dir.className = 'file-dir'; dir.textContent = f.dir; row.appendChild(dir); } + // RIGHT: full change label (Modified, Added, …). + const change = document.createElement('span'); + change.className = 'file-change s-' + code; + change.textContent = STATUS_LABELS[code] || 'Modified'; + row.appendChild(change); + const actions = document.createElement('span'); actions.className = 'file-actions'; if (group === 'unstaged' || group === 'conflicted') { - actions.appendChild(fileAction('Discard Changes', '↶', (e) => { + actions.appendChild(fileAction('Reset Changes', '↺', (e) => { e.stopPropagation(); post('discard', { path: f.path, group }); })); @@ -123,6 +204,27 @@ function renderFile(f, group) { return row; } +function setViewMode(mode) { + if (viewMode === mode) return; + viewMode = mode; + vscode.setState({ ...(vscode.getState() || {}), commitViewMode: mode }); + render(); +} + +function syncViewButtons() { + el('view-tree').classList.toggle('active', viewMode === 'tree'); + el('view-list').classList.toggle('active', viewMode === 'list'); +} + +function escapeHtml(value) { + return String(value == null ? '' : value).replace(/[&<>"]/g, (ch) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + }[ch])); +} + function fileAction(title, glyph, handler) { const b = document.createElement('button'); b.className = 'file-action'; @@ -153,6 +255,8 @@ function wire() { } }); el('commit-btn').addEventListener('click', doCommit); + el('view-tree').addEventListener('click', () => setViewMode('tree')); + el('view-list').addEventListener('click', () => setViewMode('list')); } // ─── messages ──────────────────────────────────────────────────────────────── diff --git a/resources/graph.css b/resources/graph.css index 12c7cc3..96d7ef4 100644 --- a/resources/graph.css +++ b/resources/graph.css @@ -52,11 +52,41 @@ body { } .tb-btn:hover { background-color: var(--vscode-toolbar-hoverBackground, rgba(128,128,128,0.2)); } .tb-btn.active { background-color: var(--vscode-inputOption-activeBackground, rgba(14,99,156,0.4)); } -.tb-ico { display: inline-flex; align-items: center; width: 16px; height: 16px; } +.tb-btn.icon-only[data-label]::after { + content: attr(data-label); + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 80; + display: none; + padding: 3px 7px; + border-radius: 3px; + background-color: var(--vscode-editorHoverWidget-background, var(--vscode-menu-background, #252526)); + color: var(--vscode-editorHoverWidget-foreground, var(--vscode-menu-foreground, #ccc)); + border: 1px solid var(--vscode-editorHoverWidget-border, rgba(128, 128, 128, 0.5)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.24); + font-size: 11px; + font-weight: 400; + line-height: 16px; + white-space: nowrap; + pointer-events: none; +} +.tb-btn.icon-only[data-label]:hover::after, +.tb-btn.icon-only[data-label]:focus-visible::after { + display: block; +} +.tb-ico { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} /* The toolbar octicons ship without an explicit fill (so they'd render solid black). Tint them with the button's themed colour so they track the active VS Code colour theme instead of staying black. */ -.tb-ico svg { display: block; fill: currentColor; } +.tb-ico svg { display: block; fill: currentColor; width: 16px; height: 16px; } .tb-ico svg path { fill: currentColor; } .tb-badge { position: absolute; @@ -77,6 +107,50 @@ body { .tb-sep { width: 1px; align-self: stretch; margin: 3px 4px; background-color: var(--vscode-panel-border); } .tb-spacer { flex: 1; } +.columns-menu { + position: absolute; + top: calc(100% - 2px); + right: 8px; + z-index: 65; + min-width: 170px; + padding: 4px 0; + background-color: var(--vscode-menu-background, #252526); + color: var(--vscode-menu-foreground, #ccc); + border: 1px solid rgba(128, 128, 128, 0.5); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.28); +} +.column-option { + display: flex; + align-items: center; + gap: 7px; + min-height: 26px; + padding: 4px 10px; + cursor: pointer; + user-select: none; + white-space: nowrap; +} +.column-option:hover { + background-color: var(--vscode-menu-selectionBackground, #094771); + color: var(--vscode-menu-selectionForeground, #fff); +} +.column-option input { display: none; } +.column-option input:checked + .customCheckbox { + background-color: var(--vscode-button-background, #0e639c); + border-color: var(--vscode-button-background, #0e639c); +} +.column-option input:checked + .customCheckbox::after { + content: ""; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid var(--vscode-button-foreground, #fff); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + /* control-bar labels + repo/branch controls */ #repoControl, #branchControl { display: inline-flex; @@ -243,17 +317,23 @@ body { /* ─── graph (scrollable) ───────────────────────────────────────────────── */ #main { flex: 1; - overflow: auto; + /* Scroll vertically only — the table is sized to the window width (see + updateDescriptionColumnWidth), so it must never scroll horizontally. */ + overflow-x: hidden; + overflow-y: auto; position: relative; min-height: 0; + width: 100%; } #main.loading { opacity: 0.45; pointer-events: none; } #graph-table { width: 100%; + min-width: 100%; border-collapse: collapse; table-layout: fixed; } +#col-desc { width: 100%; } #graph-table th { position: sticky; top: 0; @@ -322,7 +402,7 @@ td.col-desc { white-space: nowrap; font-size: 12px; } -td.col-id, td.col-author, td.col-cdate { +td.col-id, td.col-author, td.col-adate, td.col-committer, td.col-cdate { padding: 0 6px; vertical-align: middle; white-space: nowrap; @@ -340,9 +420,10 @@ tr.commit-row.uncommitted td.col-desc { font-style: italic; color: var(--vscode- /* flow tracing (dim everything except the traced flow) */ .graph-line, .graph-node { transition: opacity 0.12s ease; } .graph-line.dim, .graph-node.dim { opacity: 0.13; } -#graph-table tr.commit-row.dimmed { opacity: 0.4; } +#graph-table tr.commit-row.dimmed td:not(.col-graph) { opacity: 0.4; } #graph-table tr.commit-row.dimmed .gitRef { opacity: 0.5; } #graph-table tr.commit-row.traced td.col-desc { font-weight: 600; } +#graph-svg .graph-line:not(.dim) { stroke-width: 2.6; } /* ─── inline ref labels (gitRef pills) — exact vscode-git-graph styling ─── */ .gitRef { @@ -507,19 +588,45 @@ tr.cdv-row > td.cdv-cell { font-size: 12px; } .file-row:hover { background-color: var(--vscode-list-hoverBackground); } -.file-status { - width: 14px; +/* LEFT extension chip. */ +.file-ext { + min-width: 30px; + max-width: 48px; + padding: 0 4px; + height: 15px; + line-height: 15px; text-align: center; font-family: var(--vscode-editor-font-family, monospace); - font-weight: 700; + font-weight: 600; + font-size: 10px; + border-radius: 3px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background-color: rgba(128, 128, 128, 0.16); +} +.file-ext.A { color: #4ec94e; } +.file-ext.M { color: #e2c08d; } +.file-ext.D { color: #f14c4c; } +.file-ext.R { color: #4daafc; } +.file-ext.C { color: #4daafc; } +.file-ext.T { color: #e2c08d; } +.file-ext.U { color: #f14c4c; } +.file-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* RIGHT change label. */ +.file-change { flex-shrink: 0; + font-size: 11px; + color: var(--vscode-descriptionForeground); } -.file-status.A { color: #4ec94e; } -.file-status.M { color: #e2c08d; } -.file-status.D { color: #f14c4c; } -.file-status.R { color: #4daafc; } -.file-status.C { color: #4daafc; } -.file-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-change.A { color: #4ec94e; } +.file-change.M { color: #e2c08d; } +.file-change.D { color: #f14c4c; } +.file-change.R { color: #4daafc; } +.file-change.C { color: #4daafc; } +.file-change.T { color: #e2c08d; } +.file-change.U { color: #f14c4c; } /* ─── changed-file tree view ──────────────────────────────────────────── */ .tree-children.collapsed { display: none; } @@ -606,6 +713,117 @@ tr.cdv-row > td.cdv-cell { .context-menu-separator { height: 1px; background-color: var(--vscode-menu-separatorBackground, #454545); margin: 4px 0; } .context-menu-title { padding: 4px 16px; font-size: 11px; color: var(--vscode-descriptionForeground); cursor: default; } +/* ─── modal dialogs ────────────────────────────────────────────────────── */ +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.42); +} +.modal-backdrop[hidden] { display: none; } +.modal { + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + background-color: var(--vscode-editorWidget-background, #252526); + color: var(--vscode-editorWidget-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-editorWidget-border, #454545); + border-radius: 4px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); +} +.modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px 8px; + border-bottom: 1px solid var(--vscode-editorWidget-border, #454545); +} +.modal-header h2 { + flex: 1; + margin: 0; + font-size: 14px; + font-weight: 600; +} +.modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + font-size: 16px; + line-height: 1; +} +.modal-close:hover { background-color: var(--vscode-toolbar-hoverBackground, rgba(128,128,128,0.2)); } +.modal-body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + overflow: auto; +} +.field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} +.field span, +.check-row span { + color: var(--vscode-descriptionForeground); +} +.field input, +.field textarea { + width: 100%; + box-sizing: border-box; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, #555); + border-radius: 2px; + padding: 5px 7px; + font-family: var(--vscode-font-family); + font-size: 12px; +} +.field textarea { + resize: vertical; + min-height: 72px; +} +.field input[readonly], +.field textarea:disabled { + opacity: 0.72; +} +.check-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 22px; + font-size: 12px; +} +.check-row input { margin: 0; } +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 10px 14px 12px; + border-top: 1px solid var(--vscode-editorWidget-border, #454545); +} +.tb-btn.primary { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +.tb-btn.primary:hover { + background-color: var(--vscode-button-hoverBackground); +} + /* ─── loading / empty ──────────────────────────────────────────────────── */ #loading, #empty-state { position: absolute; diff --git a/resources/graph.js b/resources/graph.js index aac64e7..4ede568 100644 --- a/resources/graph.js +++ b/resources/graph.js @@ -30,9 +30,16 @@ let CONFIG = { '#00d9cc','#e138e8','#85d900','#dc5b23','#6f24d6','#ffcc00'], style: 'rounded', dateFormat: 'standard', - // git-graph-style columns: Graph | Description | Author | Date | Commit. - // Graph/Description are always shown; Author, Date and Commit (id) toggle. - columns: { id: true, author: true, committedDate: true }, + // VsGit/EGit-style columns: Graph | Description | Author | Authored Date | + // Committer | Committed Date | Commit. Graph/Description are always shown; + // the metadata columns and Commit (id) are configurable. + columns: { + id: true, + author: true, + authoredDate: false, + committer: false, + committedDate: true, + }, showRemoteBranches: true, showSidebar: true, }; @@ -58,6 +65,7 @@ const SHOW_ALL_BRANCHES = ''; let repoDropdown = null; let branchDropdown = null; let currentBranches = null; // null / [SHOW_ALL] => all branches +let columnMenuOpen = false; let traceMode = persistedTraceMode(); let traceRoot = null; @@ -65,14 +73,23 @@ let parentMap = new Map(); let childMap = new Map(); function persistedTraceMode() { const p = vscode.getState() || {}; - return p.traceMode || 'ancestors'; + return p.traceMode || 'off'; } const persisted = vscode.getState() || {}; let colWidths = persisted.colWidths || {}; +let trackingEnabled = persisted.trackingEnabled === true; +let trackedSha = persisted.trackedSha || null; +let trackedRef = persisted.trackedRef || null; function savePersisted() { - vscode.setState({ ...vscode.getState(), colWidths }); + vscode.setState({ + ...vscode.getState(), + colWidths, + trackingEnabled, + trackedSha, + trackedRef, + }); } // ─── SVG icons (the exact Octicons used by vscode-git-graph) ───────────────── @@ -94,10 +111,12 @@ const SVG_ICONS = { fileList: '', fileTree: '', chevron: '', - pull: '', - push: '', - merge: '', - trace: '', + pull: '', + push: '', + merge: '', + columns: '', + tracking: '', + trace: '', }; // Toolbar data-icon aliases. SVG_ICONS.fetch = SVG_ICONS.download; @@ -323,7 +342,14 @@ function makeRefBadge(ref, colorIdx) { span.addEventListener('click', (e) => { e.stopPropagation(); const host = span.closest('tr.commit-row'); - if (host && host.dataset.sha) setTraceRoot(host.dataset.sha); + if (!host || !host.dataset.sha) return; + if (trackingEnabled) { + const row = layoutRows.find((r) => r.commit.sha === host.dataset.sha); + if (row) selectCommit(row.commit, host); + trackRef(ref.name); + } else if (traceMode !== 'off') { + setTraceRoot(host.dataset.sha); + } }); return span; } @@ -566,25 +592,113 @@ function applyTrace() { const sha = el.dataset.sha; el.classList.toggle('dim', dimAll && !lit.has(sha)); }); + updateTraceButton(); } +function clearTraceRoot() { traceRoot = null; applyTrace(); } function setTraceRoot(sha) { traceRoot = sha; applyTrace(); } function setTraceMode(mode) { traceMode = mode; vscode.setState({ ...vscode.getState(), traceMode: mode }); - const btn = document.getElementById('tb-trace'); - if (btn) btn.title = 'Trace flow: ' + traceModeLabel(); + if (traceMode === 'off') traceRoot = null; + else if (!traceRoot && selectedSha) traceRoot = selectedSha; applyTrace(); } +function updateTraceButton() { + const btn = document.getElementById('tb-trace'); + if (!btn) return; + const label = 'Trace flow: ' + traceModeLabel(); + btn.title = label; + btn.setAttribute('aria-label', label); + btn.classList.toggle('active', traceMode !== 'off'); + btn.dataset.traceMode = traceMode; +} function traceModeLabel() { if (traceMode === 'off') return 'off'; if (traceMode === 'both') return 'ancestors + descendants'; return 'ancestors'; } function cycleTraceMode() { - const order = ['ancestors', 'both', 'off']; + const order = ['off', 'ancestors', 'both']; setTraceMode(order[(order.indexOf(traceMode) + 1) % order.length]); } +function updateTrackingButton() { + const btn = document.getElementById('tb-tracking'); + if (!btn) return; + const target = trackedRef || (trackedSha ? trackedSha.slice(0, 8) : 'none'); + const label = trackingEnabled ? 'Tracking: ' + target : 'Tracking: off'; + btn.classList.toggle('active', trackingEnabled); + btn.title = label; + btn.setAttribute('aria-label', label); +} + +function setTrackingEnabled(enabled) { + trackingEnabled = enabled; + if (!trackingEnabled) { + trackedSha = null; + trackedRef = null; + } else if (selectedSha) { + trackedSha = selectedSha; + trackedRef = null; + } + savePersisted(); + updateTrackingButton(); +} + +function trackCommit(sha) { + if (!trackingEnabled || !sha || sha === '*uncommitted*') return; + trackedSha = sha; + trackedRef = null; + savePersisted(); + updateTrackingButton(); +} + +function trackRef(refName) { + if (!trackingEnabled || !refName) return; + trackedRef = refName; + const row = layoutRows.find((r) => (r.commit.refs || []).some((ref) => ref.name === refName)); + trackedSha = row ? row.commit.sha : null; + savePersisted(); + updateTrackingButton(); + if (row) applyTrackingSelection({ scroll: false }); +} + +function trackedRow() { + if (trackedRef) { + const byRef = layoutRows.find((r) => (r.commit.refs || []).some((ref) => ref.name === trackedRef)); + if (byRef) { + trackedSha = byRef.commit.sha; + return byRef; + } + } + return trackedSha ? layoutRows.find((r) => r.commit.sha === trackedSha) : null; +} + +function applyTrackingSelection({ scroll = false } = {}) { + if (!trackingEnabled) return false; + const row = trackedRow(); + document.querySelectorAll('#graph-body tr.selected, #graph-body tr.compareSelected').forEach((r) => + r.classList.remove('selected', 'compareSelected')); + compareSha = null; + if (!row) { + selectedSha = null; + updateTrackingButton(); + savePersisted(); + return false; + } + selectedSha = row.commit.sha; + const tr = document.querySelector('#graph-body tr[data-sha="' + cssEsc(selectedSha) + '"]'); + if (tr) { + tr.classList.add('selected'); + if (scroll) setTimeout(() => tr.scrollIntoView({ block: 'center' }), 0); + } + if (traceMode !== 'off') setTraceRoot(selectedSha); + else applyTrace(); + updateTrackingButton(); + savePersisted(); + return true; +} + // ─── table rendering (ported, + keyboard nav) ──────────────────────────────── function renderTable(rows) { const tbody = document.getElementById('graph-body'); @@ -609,8 +723,8 @@ function renderTable(rows) { tr.dataset.sha = commit.sha; if (commit.sha === selectedSha) tr.classList.add('selected'); - // Column order mirrors vscode-git-graph: Graph | Description | Author | - // Date | Commit. + // Column order mirrors VsGit / EGit history: Graph | Description | Author | + // Authored Date | Committer | Committed Date | Commit. // Graph: a fixed-size spacer cell; all lines + dots are drawn by one overlay // SVG appended to the first row's graph cell (see below), so the whole DAG @@ -636,6 +750,18 @@ function renderTable(rows) { tdAuthor.title = commit.author || ''; tr.appendChild(tdAuthor); + const tdADate = document.createElement('td'); + tdADate.className = 'col-adate'; + tdADate.textContent = commit.kind === 'uncommitted' ? '' : formatDate(commit.date); + tdADate.title = commit.date || ''; + tr.appendChild(tdADate); + + const tdCommitter = document.createElement('td'); + tdCommitter.className = 'col-committer'; + tdCommitter.textContent = commit.kind === 'uncommitted' ? '' : (commit.committer || ''); + tdCommitter.title = commit.committer || ''; + tr.appendChild(tdCommitter); + const tdCDate = document.createElement('td'); tdCDate.className = 'col-cdate'; tdCDate.textContent = commit.kind === 'uncommitted' ? '' : formatDate(commit.committerDate); @@ -666,7 +792,35 @@ function applyColumnWidths() { const set = (id, w) => { const el = document.getElementById(id); if (w && el) el.style.width = w + 'px'; }; set('col-id', colWidths.id || 80); set('col-author', colWidths.author || 130); + set('col-adate', colWidths.adate || 150); + set('col-committer', colWidths.committer || 130); set('col-cdate', colWidths.cdate || 150); + updateDescriptionColumnWidth(); +} +// `configKey` is the CONFIG.columns visibility key; `widthKey` is the (possibly +// different) key the resized width is persisted under in colWidths. +function visibleColumnWidth(configKey, widthKey, defaultWidth) { + return CONFIG.columns[configKey] === false ? 0 : (colWidths[widthKey] || defaultWidth); +} +function updateDescriptionColumnWidth() { + const main = document.getElementById('main'); + const desc = document.getElementById('col-desc'); + const graph = document.getElementById('col-graph'); + if (!main || !desc || !graph) return; + const graphWidth = parseFloat(graph.style.width) || 80; + const metadataWidth = + visibleColumnWidth('author', 'author', 130) + + visibleColumnWidth('authoredDate', 'adate', 150) + + visibleColumnWidth('committer', 'committer', 130) + + visibleColumnWidth('committedDate', 'cdate', 150) + + visibleColumnWidth('id', 'id', 80); + // Fit the table to the window: the Description column takes exactly the space + // left over so the row never extends past #main and forces a horizontal + // scrollbar. When the metadata columns alone overflow a narrow window we + // still floor at 0 (those columns keep their own px widths and scroll), but + // we never *add* an artificial minimum that would push the table wider. + const available = Math.floor(main.clientWidth - graphWidth - metadataWidth); + desc.style.width = Math.max(0, available) + 'px'; } function applyColumnVisibility() { const toggle = (cls, show) => { @@ -676,7 +830,64 @@ function applyColumnVisibility() { }; toggle('col-id', CONFIG.columns.id); toggle('col-author', CONFIG.columns.author); + toggle('col-adate', CONFIG.columns.authoredDate); + toggle('col-committer', CONFIG.columns.committer); toggle('col-cdate', CONFIG.columns.committedDate); + updateDescriptionColumnWidth(); + renderColumnsMenu(); +} + +const COLUMN_OPTIONS = [ + { key: 'id', label: 'Commit' }, + { key: 'author', label: 'Author' }, + { key: 'authoredDate', label: 'Authored Date' }, + { key: 'committer', label: 'Committer' }, + { key: 'committedDate', label: 'Committed Date' }, +]; + +function renderColumnsMenu() { + const menu = document.getElementById('columns-menu'); + if (!menu) return; + menu.innerHTML = ''; + COLUMN_OPTIONS.forEach((col) => { + const label = document.createElement('label'); + label.className = 'column-option'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = CONFIG.columns[col.key] !== false; + input.dataset.columnKey = col.key; + const mark = document.createElement('span'); + mark.className = 'customCheckbox'; + const text = document.createElement('span'); + text.textContent = col.label; + label.appendChild(input); + label.appendChild(mark); + label.appendChild(text); + menu.appendChild(label); + }); +} + +function closeColumnsMenu() { + columnMenuOpen = false; + const menu = document.getElementById('columns-menu'); + const btn = document.getElementById('tb-columns'); + if (menu) menu.hidden = true; + if (btn) btn.classList.remove('active'); +} + +function toggleColumnsMenu() { + columnMenuOpen = !columnMenuOpen; + const menu = document.getElementById('columns-menu'); + const btn = document.getElementById('tb-columns'); + if (menu) menu.hidden = !columnMenuOpen; + if (btn) btn.classList.toggle('active', columnMenuOpen); +} + +function setColumnVisibility(key, visible) { + if (!Object.prototype.hasOwnProperty.call(CONFIG.columns, key)) return; + CONFIG.columns[key] = visible; + applyColumnVisibility(); + vscode.postMessage({ type: 'setColumnVisibility', data: { column: key, visible } }); } // keyboard navigation over rows @@ -730,6 +941,8 @@ function selectCommit(commit, tr, ev) { if (selectedSha === commit.sha && !compareSha && document.getElementById('cdv-row')) { removeExpandedRow(); selectedSha = null; + compareSha = null; + clearTraceRoot(); document.querySelectorAll('#graph-body tr.selected').forEach((r) => r.classList.remove('selected')); return; } @@ -739,7 +952,9 @@ function selectCommit(commit, tr, ev) { document.querySelectorAll('#graph-body tr.selected, #graph-body tr.compareSelected').forEach((r) => r.classList.remove('selected', 'compareSelected')); if (tr) tr.classList.add('selected'); - if (commit.kind !== 'uncommitted') setTraceRoot(commit.sha); + if (trackingEnabled) trackCommit(commit.sha); + if (traceMode !== 'off' && commit.kind !== 'uncommitted') setTraceRoot(commit.sha); + else applyTrace(); openExpandedRow(commit, tr); vscode.postMessage({ type: 'requestFiles', data: commit.sha }); @@ -804,6 +1019,7 @@ function openExpandedRow(commit, tr) { td.appendChild(cdv); tr.insertAdjacentElement('afterend', row); + sizeExpandedPanel(); syncFileViewButtons(); cdv.querySelector('#cdvViewTree').addEventListener('click', (e) => { e.stopPropagation(); setFileViewMode('tree'); }); @@ -825,6 +1041,49 @@ function openExpandedRow(commit, tr) { // dots stay centred and the edges run continuously through the new gap (the // graph lines keep flowing past the open commit, exactly like git-graph). rebuildGraphOverlay(); + + // Bring the whole panel onto the screen: when the clicked commit sits near the + // bottom of the viewport the 248px details row would be clipped, so scroll the + // container just enough to reveal it — but never so far that the clicked + // commit's own row scrolls out of view above. + scrollExpandedRowIntoView(tr, row); +} + +// Pin the commit-details panel to the visible viewport width so its right pane +// (changed files) never runs off-screen. The panel lives in a td that spans the +// metadata columns, whose summed width can exceed the window; anchoring #cdv to +// (visible width − graph column) keeps both halves inside the screen with no +// horizontal overflow, regardless of column widths. +function sizeExpandedPanel() { + const cdv = document.getElementById('cdv'); + const main = document.getElementById('main'); + const graphCol = document.getElementById('col-graph'); + if (!cdv || !main) return; + const graphColW = graphCol ? (parseFloat(graphCol.style.width) || 0) : 0; + const width = Math.max(0, main.clientWidth - graphColW); + cdv.style.width = width + 'px'; +} + +// Scroll #main so the expanded details row (`cdvRow`) is fully visible while +// keeping its parent commit row (`commitRow`) on screen. Runs after layout so +// offset measurements are final. +function scrollExpandedRowIntoView(commitRow, cdvRow) { + const main = document.getElementById('main'); + if (!main || !commitRow || !cdvRow) return; + requestAnimationFrame(() => { + const viewTop = main.scrollTop; + const viewBottom = viewTop + main.clientHeight; + const commitTop = commitRow.offsetTop; + const panelBottom = cdvRow.offsetTop + cdvRow.offsetHeight; + if (panelBottom > viewBottom) { + // Scroll down to reveal the panel's bottom, but stop if that would push + // the commit row off the top — pin to the commit row in that case. + const target = Math.min(panelBottom - main.clientHeight, commitTop); + main.scrollTop = Math.max(0, target); + } else if (commitTop < viewTop) { + main.scrollTop = commitTop; + } + }); } // Tree/list view-type toggle for the changed-file pane. @@ -921,19 +1180,49 @@ function renderFilePane(host, files, onOpen, emptyText) { host.appendChild(cdvFileViewMode === 'tree' ? buildFileTree(files, onOpen) : buildFileList(files, onOpen)); } +// Full change label shown on the right of each row, keyed by status code. +const CDV_STATUS_LABELS = { + A: 'Added', + M: 'Modified', + D: 'Deleted', + R: 'Renamed', + C: 'Copied', + T: 'Type Changed', + U: 'Conflicted', +}; + +// File extension (lowercase, no dot) shown on the left of each row, or '•' when +// the file has no extension. +function cdvFileExt(p) { + const base = String(p || '').split('/').pop() || ''; + const dot = base.lastIndexOf('.'); + if (dot <= 0 || dot === base.length - 1) return '•'; + return base.slice(dot + 1).toLowerCase(); +} + function makeFileRow(f, onOpen, label) { const fileRow = document.createElement('div'); fileRow.className = 'file-row'; - const st = document.createElement('span'); const code = (f.status || 'M').charAt(0).toUpperCase(); - st.className = 'file-status ' + code; - st.textContent = code; + + // LEFT: file extension chip. + const ext = document.createElement('span'); + ext.className = 'file-ext ' + code; + ext.textContent = cdvFileExt(f.path); + const p = document.createElement('span'); p.className = 'file-path'; p.textContent = label; p.title = f.path; - fileRow.appendChild(st); + + // RIGHT: full change label (Modified, Added, …). + const change = document.createElement('span'); + change.className = 'file-change ' + code; + change.textContent = CDV_STATUS_LABELS[code] || 'Modified'; + + fileRow.appendChild(ext); fileRow.appendChild(p); + fileRow.appendChild(change); if (onOpen) fileRow.addEventListener('click', () => onOpen(f)); return fileRow; } @@ -1041,7 +1330,7 @@ function showCommitMenu(x, y, commit) { { title: commit.shortSha }, { label: 'Checkout Commit…', action: () => send('checkout', sha) }, { label: 'Create Branch Here…', action: () => send('createBranch', { sha }) }, - { label: 'Create Tag Here…', action: () => send('createTag', { sha }) }, + { label: 'Create Tag Here…', action: () => openCreateTagModal(commit) }, { sep: true }, { label: 'Merge into Current Branch…', action: () => send('merge', sha) }, { label: 'Rebase Current Branch onto This…', action: () => send('rebase', sha) }, @@ -1062,6 +1351,42 @@ function showCommitMenu(x, y, commit) { ]); placeMenu(menu, x, y); } + +function openCreateTagModal(commit) { + const backdrop = document.getElementById('create-tag-modal'); + const form = document.getElementById('create-tag-form'); + const name = document.getElementById('create-tag-name'); + const sha = document.getElementById('create-tag-sha'); + const annotated = document.getElementById('create-tag-annotated'); + const signed = document.getElementById('create-tag-signed'); + const message = document.getElementById('create-tag-message'); + const force = document.getElementById('create-tag-force'); + const push = document.getElementById('create-tag-push'); + + form.dataset.sha = commit.sha; + name.value = ''; + sha.value = commit.shortSha || commit.sha.slice(0, 8); + annotated.checked = false; + signed.checked = false; + message.value = ''; + message.disabled = true; + force.checked = false; + push.checked = false; + backdrop.hidden = false; + name.focus(); +} + +function closeCreateTagModal() { + document.getElementById('create-tag-modal').hidden = true; +} + +function syncCreateTagMessageState() { + const annotated = document.getElementById('create-tag-annotated'); + const signed = document.getElementById('create-tag-signed'); + const message = document.getElementById('create-tag-message'); + message.disabled = !annotated.checked && !signed.checked; + if (message.disabled) message.value = ''; +} function showRefMenu(x, y, ref) { const send = (type, data) => vscode.postMessage({ type, data }); let items = [{ title: ref.name }]; @@ -1123,7 +1448,7 @@ function runFind(term) { if (!q || !graphData) return; graphData.commits.forEach((c) => { if (c.kind === 'uncommitted') return; - const hay = (c.message + ' ' + (c.author || '') + ' ' + c.sha + ' ' + + const hay = (c.message + ' ' + (c.author || '') + ' ' + (c.committer || '') + ' ' + c.sha + ' ' + (c.refs || []).map((r) => r.name).join(' ')).toLowerCase(); if (hay.includes(q)) findMatches.push(c.sha); }); @@ -1214,6 +1539,9 @@ function wireResizers() { if (key !== 'desc') { colWidths[key] = w; document.querySelectorAll('td.col-' + key).forEach((td) => { td.style.width = w + 'px'; }); + // Re-fit Description so widening a metadata column shrinks it instead + // of pushing the table past the window. + updateDescriptionColumnWidth(); } }; const onUp = () => { @@ -1231,14 +1559,17 @@ function wireResizers() { window.addEventListener('message', (event) => { const msg = event.data; switch (msg.type) { - case 'config': + case 'config': { + const previousColumns = CONFIG.columns; CONFIG = Object.assign(CONFIG, msg.data || {}); + CONFIG.columns = Object.assign({}, previousColumns, (msg.data && msg.data.columns) || {}); if (graphData) { layoutRows = buildLayout(graphData.commits); renderTable(layoutRows); applyTrace(); } break; + } case 'empty': document.getElementById('loading').style.display = 'none'; document.getElementById('empty-state').style.display = 'block'; @@ -1259,19 +1590,23 @@ window.addEventListener('message', (event) => { buildAdjacency(graphData.commits); layoutRows = buildLayout(graphData.commits); renderTable(layoutRows); - // Re-open the previously expanded commit if it still exists, else clear. - const stillThere = selectedSha && layoutRows.find((r) => r.commit.sha === selectedSha); - if (stillThere) { - const tr = document.querySelector('#graph-body tr[data-sha="' + cssEsc(selectedSha) + '"]'); - if (tr) { - tr.classList.add('selected'); - if (hadExpanded) { - openExpandedRow(stillThere.commit, tr); - vscode.postMessage({ type: 'requestFiles', data: selectedSha }); + if (trackingEnabled) { + applyTrackingSelection({ scroll: true }); + } else { + // Re-open the previously expanded commit if it still exists, else clear. + const stillThere = selectedSha && layoutRows.find((r) => r.commit.sha === selectedSha); + if (stillThere) { + const tr = document.querySelector('#graph-body tr[data-sha="' + cssEsc(selectedSha) + '"]'); + if (tr) { + tr.classList.add('selected'); + if (hadExpanded) { + openExpandedRow(stillThere.commit, tr); + vscode.postMessage({ type: 'requestFiles', data: selectedSha }); + } } + } else { + selectedSha = null; } - } else { - selectedSha = null; } applyTrace(); document.getElementById('commit-count').textContent = @@ -1349,8 +1684,21 @@ function wireControls() { document.getElementById('tb-stash').addEventListener('click', () => send('toolbarStash')); document.getElementById('tb-find').addEventListener('click', openFind); + document.getElementById('tb-columns').addEventListener('click', (e) => { + e.stopPropagation(); + toggleColumnsMenu(); + }); + document.getElementById('columns-menu').addEventListener('click', (e) => { + e.stopPropagation(); + const input = e.target.closest('input[data-column-key]'); + if (input) setColumnVisibility(input.dataset.columnKey, input.checked); + }); + document.getElementById('tb-tracking').addEventListener('click', () => { + setTrackingEnabled(!trackingEnabled); + }); + updateTrackingButton(); document.getElementById('tb-trace').addEventListener('click', cycleTraceMode); - document.getElementById('tb-trace').title = 'Trace flow: ' + traceModeLabel(); + updateTraceButton(); document.getElementById('tb-refresh').addEventListener('click', () => { document.getElementById('main').classList.add('loading'); send('refresh'); @@ -1375,10 +1723,47 @@ function wireControls() { document.getElementById('find-next').addEventListener('click', () => findNext(1)); document.getElementById('find-close').addEventListener('click', closeFind); + const createTagModal = document.getElementById('create-tag-modal'); + const createTagForm = document.getElementById('create-tag-form'); + const createTagAnnotated = document.getElementById('create-tag-annotated'); + const createTagSigned = document.getElementById('create-tag-signed'); + createTagAnnotated.addEventListener('change', syncCreateTagMessageState); + createTagSigned.addEventListener('change', () => { + if (createTagSigned.checked) createTagAnnotated.checked = true; + syncCreateTagMessageState(); + }); + document.getElementById('create-tag-close').addEventListener('click', closeCreateTagModal); + document.getElementById('create-tag-cancel').addEventListener('click', closeCreateTagModal); + createTagModal.addEventListener('click', (e) => { + if (e.target === createTagModal) closeCreateTagModal(); + }); + createTagForm.addEventListener('submit', (e) => { + e.preventDefault(); + const name = document.getElementById('create-tag-name').value.trim(); + const message = document.getElementById('create-tag-message').value.trim(); + const signed = document.getElementById('create-tag-signed').checked; + if (!name) return; + if (signed && !message) { + document.getElementById('create-tag-message').focus(); + return; + } + send('createTag', { + sha: createTagForm.dataset.sha, + name, + message: message || undefined, + annotate: document.getElementById('create-tag-annotated').checked, + sign: signed, + force: document.getElementById('create-tag-force').checked, + push: document.getElementById('create-tag-push').checked, + }); + closeCreateTagModal(); + }); + // global keys document.addEventListener('keydown', (e) => { const typing = e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA'); - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { e.preventDefault(); openFind(); } + if (e.key === 'Escape' && !createTagModal.hidden) { e.preventDefault(); closeCreateTagModal(); } + else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { e.preventDefault(); openFind(); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'r') { e.preventDefault(); document.getElementById('main').classList.add('loading'); send('refresh'); } else if (!typing && e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1); } else if (!typing && e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1); } @@ -1388,6 +1773,12 @@ function wireControls() { document.addEventListener('click', () => { document.getElementById('context-menu').classList.remove('visible'); + closeColumnsMenu(); + }); + window.addEventListener('resize', () => { + updateDescriptionColumnWidth(); + // Keep an open commit-details panel pinned to the viewport width. + if (document.getElementById('cdv-row')) sizeExpandedPanel(); }); wireResizers(); diff --git a/resources/icon.svg b/resources/icon.svg index 0d0ce1b..5fcfd36 100644 --- a/resources/icon.svg +++ b/resources/icon.svg @@ -1,13 +1,3 @@ - - - - - - - - - - - + + diff --git a/resources/manifest.test.js b/resources/manifest.test.js new file mode 100644 index 0000000..e54d7f9 --- /dev/null +++ b/resources/manifest.test.js @@ -0,0 +1,197 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); + +function read(rel) { + return fs.readFileSync(path.join(root, rel), 'utf8'); +} + +function listCommandSource() { + const commandDir = path.join(root, 'src', 'commands'); + return [ + read('src/extension.ts'), + ...fs.readdirSync(commandDir) + .filter((name) => name.endsWith('.ts')) + .map((name) => fs.readFileSync(path.join(commandDir, name), 'utf8')), + ].join('\n'); +} + +function registeredCommands() { + const source = listCommandSource(); + const ids = new Set(); + for (const re of [ + /\bregisterCommand\(\s*[`'"]([^`'"]+)/g, + /\breg\(\s*[`'"]([^`'"]+)/g, + ]) { + for (const match of source.matchAll(re)) { + ids.add(match[1]); + } + } + return ids; +} + +function menuCommands(pkg) { + const ids = new Set(); + for (const entries of Object.values(pkg.contributes?.menus ?? {})) { + for (const item of entries) { + if (item.command) { + ids.add(item.command); + } + } + } + return ids; +} + +test('all contributed commands and menu entries are registered', () => { + const pkg = JSON.parse(read('package.json')); + const registered = registeredCommands(); + const required = new Set([ + ...(pkg.contributes?.commands ?? []).map((cmd) => cmd.command), + ...menuCommands(pkg), + ...(pkg.contributes?.keybindings ?? []).map((binding) => binding.command), + ]); + + const missing = [...required].filter((id) => !registered.has(id)).sort(); + assert.deepStrictEqual(missing, []); +}); + +test('README settings table matches contributed settings', () => { + const pkg = JSON.parse(read('package.json')); + const contributed = new Set(Object.keys(pkg.contributes?.configuration?.properties ?? {})); + const readme = read('README.md'); + const documented = new Set( + [...readme.matchAll(/^\| `(vsgit\.[^`]+)` \|/gm)].map((match) => match[1]), + ); + + const undocumented = [...contributed].filter((key) => !documented.has(key)).sort(); + const stale = [...documented].filter((key) => !contributed.has(key)).sort(); + + assert.deepStrictEqual(undocumented, []); + assert.deepStrictEqual(stale, []); +}); + +test('Git Graph column settings are contributed, sent, and rendered', () => { + const pkg = JSON.parse(read('package.json')); + const settingsObject = pkg.contributes?.configuration?.properties ?? {}; + const settings = new Set(Object.keys(settingsObject)); + const graphPanel = read('src/webviews/graph/GraphPanel.ts'); + const graphJs = read('resources/graph.js'); + + const columns = [ + ['vsgit.graph.showIdColumn', 'graph.showIdColumn', 'id', 'col-id', true], + ['vsgit.graph.showAuthorColumn', 'graph.showAuthorColumn', 'author', 'col-author', true], + ['vsgit.graph.showAuthoredDateColumn', 'graph.showAuthoredDateColumn', 'authoredDate', 'col-adate', false], + ['vsgit.graph.showCommitterColumn', 'graph.showCommitterColumn', 'committer', 'col-committer', false], + ['vsgit.graph.showCommittedDateColumn', 'graph.showCommittedDateColumn', 'committedDate', 'col-cdate', true], + ]; + + for (const [setting, configKey, payloadKey, cssClass, defaultValue] of columns) { + assert.ok(settings.has(setting), `${setting} is contributed`); + assert.strictEqual(settingsObject[setting].default, defaultValue, `${setting} default`); + assert.ok(graphPanel.includes(`"${configKey}"`), `${configKey} is read by GraphPanel`); + assert.ok(graphJs.includes(payloadKey), `${payloadKey} is handled by graph.js`); + assert.ok(graphJs.includes(cssClass), `${cssClass} is rendered by graph.js`); + } + + assert.ok(graphPanel.includes('case "setColumnVisibility"'), 'GraphPanel handles column updates'); + assert.ok(graphPanel.includes('vscode.ConfigurationTarget.Global'), 'column updates persist globally'); + assert.ok(graphJs.includes("type: 'setColumnVisibility'"), 'graph.js sends column updates'); + assert.ok(graphJs.includes('COLUMN_OPTIONS'), 'graph.js renders the Columns menu'); + assert.ok(graphJs.includes('tb-columns'), 'graph.js wires the Columns toolbar button'); +}); + +test('Git Graph trace defaults to explicit toolbar control', () => { + const graphJs = read('resources/graph.js'); + + assert.ok( + graphJs.includes("return p.traceMode || 'off';"), + 'trace mode defaults to off', + ); + assert.ok( + graphJs.includes("if (traceMode !== 'off' && commit.kind !== 'uncommitted') setTraceRoot(commit.sha);"), + 'normal commit selection does not force tracing while trace is off', + ); + assert.ok( + graphJs.includes('clearTraceRoot();'), + 'selection reset clears trace root', + ); + assert.ok( + graphJs.includes("const order = ['off', 'ancestors', 'both'];"), + 'trace toolbar cycles from off to explicit trace modes', + ); +}); + +test('Git Graph tracking is separate webview state', () => { + const graphPanel = read('src/webviews/graph/GraphPanel.ts'); + const graphJs = read('resources/graph.js'); + + assert.ok(graphPanel.includes('id="tb-tracking"'), 'Tracking toolbar button exists'); + assert.ok(graphPanel.includes('data-label="Tracking"'), 'Tracking button has hover label'); + assert.ok(graphPanel.includes('aria-label="Tracking"'), 'Tracking button has accessible label'); + assert.ok(graphPanel.includes('data-icon="tracking"'), 'Tracking button uses tracking icon'); + assert.ok(graphJs.includes('trackingEnabled'), 'tracking state is explicit'); + assert.ok(graphJs.includes('trackedSha'), 'tracked commit state is explicit'); + assert.ok(graphJs.includes('trackedRef'), 'tracked ref state is explicit'); + assert.ok(graphJs.includes('function setTrackingEnabled'), 'tracking can be toggled'); + assert.ok(graphJs.includes('function trackCommit'), 'commits can be tracked'); + assert.ok(graphJs.includes('function trackRef'), 'refs can be tracked'); + assert.ok(graphJs.includes('function applyTrackingSelection'), 'tracked item is restored'); + assert.ok(graphJs.includes('vscode.setState({'), 'tracking persists in webview state'); + assert.ok(graphJs.includes('traceMode') && graphJs.includes('trackingEnabled'), 'trace and tracking are separate states'); +}); + +test('Git Graph top bar buttons expose labels and table fills available width', () => { + const graphPanel = read('src/webviews/graph/GraphPanel.ts'); + const graphJs = read('resources/graph.js'); + const graphCss = read('resources/graph.css'); + const buttonIds = [ + 'tb-pull', + 'tb-push', + 'tb-fetch', + 'tb-commit', + 'tb-branch', + 'tb-merge', + 'tb-stash', + 'tb-find', + 'tb-columns', + 'tb-tracking', + 'tb-trace', + 'tb-refresh', + ]; + + for (const id of buttonIds) { + const button = graphPanel.match(new RegExp(`]+id="${id}"[^>]+>`)); + assert.ok(button, `${id} exists`); + assert.ok(button[0].includes('data-label='), `${id} has data-label`); + assert.ok(button[0].includes('aria-label='), `${id} has aria-label`); + } + + assert.ok(graphCss.includes('.tb-btn.icon-only[data-label]::after'), 'toolbar labels use CSS tooltip'); + assert.ok(graphCss.includes('#main') && graphCss.includes('width: 100%;'), '#main spans width'); + assert.ok(graphCss.includes('#graph-table') && graphCss.includes('min-width: 100%;'), 'table spans width'); + assert.ok(graphCss.includes('#col-desc { width: 100%; }'), 'Description column remains flexible'); + assert.ok(graphJs.includes('function updateDescriptionColumnWidth'), 'Description width is recalculated'); + assert.ok(graphJs.includes("CONFIG.columns[key] === false ? 0"), 'hidden columns free width for Description'); +}); + +test('Git Graph trace keeps the graph overlay bright', () => { + const graphCss = read('resources/graph.css'); + + assert.ok( + graphCss.includes('tr.commit-row.dimmed td:not(.col-graph)'), + 'row dimming skips the graph cell that hosts the overlay SVG', + ); + assert.ok( + graphCss.includes('#graph-svg .graph-line:not(.dim)'), + 'active traced graph lines are visually emphasized', + ); + assert.ok( + graphCss.includes('#graph-svg .graph-node:not(.dim)'), + 'active traced graph nodes are visually emphasized', + ); +}); diff --git a/src/commands/branch.ts b/src/commands/branch.ts index 14cc26e..8145b06 100644 --- a/src/commands/branch.ts +++ b/src/commands/branch.ts @@ -166,6 +166,8 @@ export function registerBranchCommands( ); } +type ResetMode = "soft" | "mixed" | "hard" | "keep" | "merge"; + /** Reset the current branch HEAD to a chosen ref. */ async function doBranchReset( manager: RepositoryManager, @@ -179,11 +181,33 @@ async function doBranchReset( { label: "Soft", description: "Move HEAD only; keep index and working tree" }, { label: "Mixed", description: "Move HEAD and reset index; keep working tree" }, { label: "Hard", description: "Move HEAD, reset index and working tree" }, + { label: "Keep", description: "Reset index; abort if a touched file has local changes" }, + { label: "Merge", description: "Reset index; keep local changes to untouched files" }, ], { placeHolder: "Reset mode" }, ); if (!mode) return; + await resetToRef(manager, repo, mode.label.toLowerCase() as ResetMode); +} + +/** Reset the current branch HEAD using a fixed mode, prompting only for the ref. */ +async function doResetWithMode( + manager: RepositoryManager, + node: unknown, + mode: ResetMode, +): Promise { + const repo = await resolveRepo(manager, node as VsgitNode); + if (!repo) return; + await resetToRef(manager, repo, mode); +} + +/** Shared ref picker + confirmation + execution for every reset entry point. */ +async function resetToRef( + manager: RepositoryManager, + repo: Repository, + mode: ResetMode, +): Promise { const refs = [ "HEAD~1", ...repo.localBranches.map((b) => b.shortName), @@ -191,11 +215,11 @@ async function doBranchReset( ...repo.tags.map((t) => t.shortName), ]; const ref = await vscode.window.showQuickPick(refs, { - placeHolder: "Reset to ref / SHA", + placeHolder: `Reset --${mode} to ref / SHA`, }); if (!ref) return; - if (mode.label === "Hard") { + if (mode === "hard") { const confirmed = await confirmDestructiveAction({ operation: DestructiveOperations.HARD_RESET, message: `Hard reset to ${ref}? All uncommitted changes will be lost.`, @@ -203,10 +227,8 @@ async function doBranchReset( if (!confirmed) return; } - await withProgress( - manager, - `Reset --${mode.label.toLowerCase()} to ${ref}`, - () => repo.reset(ref, mode.label.toLowerCase() as "soft" | "mixed" | "hard"), + await withProgress(manager, `Reset --${mode} to ${ref}`, () => + repo.reset(ref, mode), ); } @@ -220,6 +242,11 @@ export function registerBranchExtraCommands( // ── Branch reset ────────────────────────────────────────────────────── reg("vsgit.branch.reset", (node) => doBranchReset(manager, node)); + reg("vsgit.repo.reset.soft", (node) => doResetWithMode(manager, node, "soft")); + reg("vsgit.repo.reset.mixed", (node) => doResetWithMode(manager, node, "mixed")); + reg("vsgit.repo.reset.hard", (node) => doResetWithMode(manager, node, "hard")); + reg("vsgit.repo.reset.keep", (node) => doResetWithMode(manager, node, "keep")); + reg("vsgit.repo.reset.merge", (node) => doResetWithMode(manager, node, "merge")); // ── Compare branches ───────────────────────────────────────────────── diff --git a/src/commands/commitOps.ts b/src/commands/commitOps.ts index 03c0b63..23de1d0 100644 --- a/src/commands/commitOps.ts +++ b/src/commands/commitOps.ts @@ -340,7 +340,7 @@ export function registerCommitOpsCommands( const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); vscode.window.showInformationMessage( - `Resolve conflicts in ${picks.path}, then run "Mark Resolved" (vsgit.conflicts.markResolved).`, + `Resolve conflicts in ${picks.path}, then run "Mark Resolved" (vsgit.conflict.markResolved).`, ); } }); diff --git a/src/commands/config.ts b/src/commands/config.ts index e7d1baf..25b25a6 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -66,8 +66,10 @@ function openConfigEditor(repo: Repository, manager: RepositoryManager): void { panel.webview.postMessage({ type: "extensionSettings", settings: { + "autoRefresh": cfg.get("autoRefresh", true), "autoFetch.enabled": cfg.get("autoFetch.enabled", false), "autoFetch.intervalMinutes": cfg.get("autoFetch.intervalMinutes", 3), + "autoFetch.notify": cfg.get("autoFetch.notify", true), "confirmDestructiveActions": cfg.get("confirmDestructiveActions", true), "defaultPullMode": cfg.get("defaultPullMode", "merge"), }, diff --git a/src/commands/staging.ts b/src/commands/staging.ts index 0171cb7..4c393d3 100644 --- a/src/commands/staging.ts +++ b/src/commands/staging.ts @@ -60,13 +60,23 @@ export function registerStagingCommands( reg("vsgit.staging.stageAll", () => guard(async () => { - await staging.activeRepo?.stageAll(); + const repo = staging.activeRepo; + if (!repo) { + vscode.window.showWarningMessage("No active repository"); + return; + } + await repo.stageAll(); }, "Stage all"), ); reg("vsgit.staging.unstageAll", () => guard(async () => { - await staging.activeRepo?.unstageAll(); + const repo = staging.activeRepo; + if (!repo) { + vscode.window.showWarningMessage("No active repository"); + return; + } + await repo.unstageAll(); }, "Unstage all"), ); diff --git a/src/commands/tag.ts b/src/commands/tag.ts index c5b547b..c26b284 100644 --- a/src/commands/tag.ts +++ b/src/commands/tag.ts @@ -3,6 +3,7 @@ import { RepositoryManager } from "../git/RepositoryManager"; import { Repository } from "../git/Repository"; import { VsgitNode } from "../views/RepositoriesProvider"; import { resolveRepo, withProgress } from "./shared"; +import { showCreateTagDialog } from "../webviews/CreateTagDialog"; /** Tag operations: create (lightweight/annotated/signed), delete, checkout, push. */ export function registerTagCommands( @@ -17,15 +18,17 @@ export function registerTagCommands( if (!repo) { return; } - const name = await vscode.window.showInputBox({ prompt: "Tag name" }); + const request = await showCreateTagDialog(context.extensionUri, "HEAD"); + if (!request) { + return; + } + const name = request.name.trim(); if (!name) { return; } - - // If the tag already exists, offer a force re-tag (otherwise git would fail). + const message = request.message?.trim() || undefined; const exists = repo.tags.some((t) => t.shortName === name); - let force = false; - if (exists) { + if (exists && !request.force) { const choice = await vscode.window.showWarningMessage( `Tag '${name}' already exists. Force re-tag it to point at HEAD?`, { modal: true }, @@ -34,40 +37,10 @@ export function registerTagCommands( if (choice !== "Force Re-tag") { return; } - force = true; } - const kind = await vscode.window.showQuickPick( - ["Lightweight", "Annotated", "Signed (GPG)"], - { placeHolder: "Tag type" }, - ); - if (!kind) { - return; - } - let message: string | undefined; - if (kind !== "Lightweight") { - message = await vscode.window.showInputBox({ - prompt: "Tag message", - validateInput: (v) => - kind === "Signed (GPG)" && v.trim() === "" - ? "Signed tags need a message" - : undefined, - }); - if (message === undefined) { - return; - } - } - - // Optionally push the tag to a remote straight after creating it. - const pushChoice = await vscode.window.showQuickPick( - ["Create only", "Create and push"], - { placeHolder: "Push the tag to a remote?" }, - ); - if (!pushChoice) { - return; - } let remote: string | undefined; - if (pushChoice === "Create and push") { + if (request.push) { remote = await pickRemote(repo); if (!remote) { return; @@ -78,12 +51,12 @@ export function registerTagCommands( await repo.createTagAt( name, "HEAD", - message || undefined, - kind === "Signed (GPG)", - force, + request.sign || request.annotate ? message ?? name : undefined, + request.sign, + request.force || exists, ); if (remote) { - await repo.pushTag(remote, name, force); + await repo.pushTag(remote, name, request.force || exists); } }); }); diff --git a/src/commands/worktree.ts b/src/commands/worktree.ts index 5c23f87..090618e 100644 --- a/src/commands/worktree.ts +++ b/src/commands/worktree.ts @@ -4,7 +4,7 @@ import { RepositoryManager } from "../git/RepositoryManager"; import { WorktreeInfo } from "../git/Repository"; import { errMsg, withProgress } from "./shared"; -export type WorktreeNode = +type WorktreeNode = | { type: "worktreeRoot"; repo_root: string; manager: RepositoryManager } | { type: "worktree"; info: WorktreeInfo; repo_root: string; manager: RepositoryManager }; diff --git a/src/extension.ts b/src/extension.ts index 64aad69..232bbcc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,3 +1,8 @@ +/** + * Extension entry point: constructs the RepositoryManager, wires up every + * tree/webview view provider and command group, and registers them with + * VS Code's extension context for disposal on deactivation. + */ import * as vscode from "vscode"; import { RepositoryManager } from "./git/RepositoryManager"; import { RepositoriesProvider } from "./views/RepositoriesProvider"; @@ -86,6 +91,7 @@ export async function activate( // view's title bar. const reposListProvider = new RepositoriesProvider(manager, true); context.subscriptions.push( + reposListProvider, vscode.window.createTreeView("vsgit.repositoriesList", { treeDataProvider: reposListProvider, }), @@ -95,6 +101,7 @@ export async function activate( // Remote Branches, Tags, Remotes, Stashes, and Submodules. const repositoriesProvider = new RepositoriesProvider(manager); context.subscriptions.push( + repositoriesProvider, vscode.window.createTreeView("vsgit.repositories", { treeDataProvider: repositoriesProvider, showCollapseAll: true, @@ -103,6 +110,7 @@ export async function activate( const stagingProvider = new StagingProvider(manager); context.subscriptions.push( + stagingProvider, vscode.window.createTreeView("vsgit.staging", { treeDataProvider: stagingProvider, showCollapseAll: true, @@ -124,6 +132,7 @@ export async function activate( const reflogProvider = new ReflogProvider(manager); context.subscriptions.push( + reflogProvider, vscode.window.createTreeView("vsgit.reflog", { treeDataProvider: reflogProvider, }), @@ -131,6 +140,7 @@ export async function activate( const syncProvider = new SynchronizeProvider(manager); context.subscriptions.push( + syncProvider, vscode.window.createTreeView("vsgit.synchronize", { treeDataProvider: syncProvider, }), @@ -167,7 +177,7 @@ export async function activate( const compareView = vscode.window.createTreeView("vsgit.compare", { treeDataProvider: compareProvider, }); - context.subscriptions.push(compareView); + context.subscriptions.push(compareProvider, compareView); // Reveal the Compare view (called internally after starting a comparison) context.subscriptions.push( @@ -225,6 +235,7 @@ export async function activate( const worktreesProvider = new WorktreesProvider(manager); context.subscriptions.push( + worktreesProvider, vscode.window.createTreeView("vsgit.worktrees", { treeDataProvider: worktreesProvider, }), @@ -232,6 +243,7 @@ export async function activate( const conflictsProvider = new ConflictsProvider(manager); context.subscriptions.push( + conflictsProvider, vscode.window.createTreeView("vsgit.conflicts", { treeDataProvider: conflictsProvider, }), diff --git a/src/git/GitContentProvider.ts b/src/git/GitContentProvider.ts index 3ffdff6..03fee5b 100644 --- a/src/git/GitContentProvider.ts +++ b/src/git/GitContentProvider.ts @@ -19,12 +19,12 @@ export class GitContentProvider implements vscode.TextDocumentContentProvider { const repo = params.get("repo") ?? ""; const ref = params.get("ref") ?? "HEAD"; const relPath = params.get("path") ?? ""; - const spec = ref === "~index" ? `:${relPath}` : `${ref}:${relPath}`; // `git show ` takes no `--` separator for an object spec, so guard - // against a ref/spec that git would otherwise parse as an option. - if (isOptionLike(spec)) { + // against a ref or path that git would otherwise parse as an option. + if (isOptionLike(relPath) || (ref !== "~index" && isOptionLike(ref))) { return ""; } + const spec = ref === "~index" ? `:${relPath}` : `${ref}:${relPath}`; try { return await this.git.stdout(["show", spec], { cwd: repo }); } catch { diff --git a/src/git/GitExecutor.test.ts b/src/git/GitExecutor.test.ts new file mode 100644 index 0000000..2c3d20e --- /dev/null +++ b/src/git/GitExecutor.test.ts @@ -0,0 +1,76 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import * as os from "node:os"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { GitExecutor } from "./GitExecutor"; +import { GitError } from "./GitError"; + +function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "vsgit-executor-test-")); +} + +test("run() returns stdout/stderr/exitCode on success", async () => { + const git = new GitExecutor(); + const cwd = tmpDir(); + const result = await git.run(["--version"], { cwd }); + assert.strictEqual(result.exitCode, 0); + assert.match(result.stdout, /git version/); + assert.strictEqual(result.stderr, ""); +}); + +test("run() throws GitError on non-zero exit", async () => { + const git = new GitExecutor(); + const cwd = tmpDir(); + await assert.rejects( + () => git.run(["not-a-real-subcommand"], { cwd }), + (err: unknown) => { + assert.ok(err instanceof GitError); + assert.notStrictEqual(err.exitCode, 0); + return true; + }, + ); +}); + +test("run() treats okCodes as success", async () => { + const git = new GitExecutor(); + const cwd = tmpDir(); + fs.writeFileSync(path.join(cwd, "a.txt"), "a"); + await git.run(["init"], { cwd }); + // `git diff --no-index` exits 1 when files differ; allow it via okCodes. + const other = tmpDir(); + fs.writeFileSync(path.join(other, "b.txt"), "b"); + const result = await git.run( + ["diff", "--no-index", path.join(cwd, "a.txt"), path.join(other, "b.txt")], + { cwd, okCodes: [1] }, + ); + assert.strictEqual(result.exitCode, 1); +}); + +test("run() writes stdin to the process", async () => { + const git = new GitExecutor(); + const cwd = tmpDir(); + const result = await git.run(["hash-object", "--stdin"], { cwd, stdin: "hello\n" }); + assert.strictEqual(result.exitCode, 0); + assert.match(result.stdout.trim(), /^[0-9a-f]{40}$/); +}); + +test("run() rejects with GitError after timeoutMs elapses", async () => { + const git = new GitExecutor(); + const cwd = tmpDir(); + await assert.rejects( + () => git.run(["log"], { cwd, timeoutMs: 1 }), + (err: unknown) => { + assert.ok(err instanceof GitError); + assert.match(err.message, /timed out/); + return true; + }, + ); +}); + +test("stdout() returns only the stdout string", async () => { + const git = new GitExecutor(); + const cwd = tmpDir(); + const out = await git.stdout(["--version"], { cwd }); + assert.match(out, /git version/); +}); diff --git a/src/git/GitExecutor.ts b/src/git/GitExecutor.ts index 53b8199..de1d11c 100644 --- a/src/git/GitExecutor.ts +++ b/src/git/GitExecutor.ts @@ -10,6 +10,8 @@ export interface GitRunOptions { env?: NodeJS.ProcessEnv; /** Treat these exit codes as success (in addition to 0). */ okCodes?: number[]; + /** Kill the process if it hasn't exited after this many milliseconds. */ + timeoutMs?: number; } export interface GitResult { @@ -32,7 +34,7 @@ export class GitExecutor { const ok = result.exitCode === 0 || (options.okCodes ?? []).includes(result.exitCode); if (!ok) { throw new GitError( - `git ${args.join(" ")} failed (exit ${result.exitCode}): ${result.stderr.trim()}`, + `git ${args[0] ?? ""} failed (exit ${result.exitCode}): ${result.stderr.trim()}`, result.exitCode, result.stderr, result.stdout, @@ -59,8 +61,24 @@ export class GitExecutor { child.stdout.on("data", (d) => (stdout += d.toString())); child.stderr.on("data", (d) => (stderr += d.toString())); - child.on("error", reject); + let timedOut = false; + const timer = options.timeoutMs + ? setTimeout(() => { + timedOut = true; + child.kill(); + }, options.timeoutMs) + : undefined; + + child.on("error", (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); child.on("close", (code) => { + if (timer) clearTimeout(timer); + if (timedOut) { + reject(new GitError(`git ${args[0] ?? ""} timed out after ${options.timeoutMs}ms`, -1, stderr, stdout, args)); + return; + } resolve({ stdout, stderr, exitCode: code ?? -1 }); }); diff --git a/src/git/Repository.test.ts b/src/git/Repository.test.ts index b7fa21a..fa496cf 100644 --- a/src/git/Repository.test.ts +++ b/src/git/Repository.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert"; import { Repository } from "./Repository"; import { GitExecutor, GitResult, GitRunOptions } from "./GitExecutor"; import { GitError } from "./GitError"; +import { LOG_FORMAT } from "./parsers/log"; /** * These tests verify the SECURITY WIRING of Repository: that every method which @@ -541,6 +542,37 @@ test("log: rejects option-like revRange", async () => { assert.ok(git.calls[0].args.includes("main...feature")); }); +test("log: maps configured sort orders to git order flags", async () => { + const { repo, git } = makeRepo(); + git.calls = []; + await repo.log(); + assert.ok(git.calls[0].args.includes("--date-order")); + + git.calls = []; + await repo.log({ order: "author-date" }); + assert.ok(git.calls[0].args.includes("--author-date-order")); + + git.calls = []; + await repo.log({ order: "topo" }); + assert.ok(git.calls[0].args.includes("--topo-order")); +}); + +test("log: scopes file history after revision arguments", async () => { + const { repo, git } = makeRepo(); + await repo.log({ all: true, limit: 25, skip: 5, file: "src/example.ts" }); + assert.deepStrictEqual(git.calls[0].args, [ + "log", + `--format=${LOG_FORMAT}`, + "--date-order", + "--max-count=25", + "--skip=5", + "--all", + "--follow", + "--", + "src/example.ts", + ]); +}); + test("graphLog: rejects option-like branch in the branches list", async () => { const { repo, git } = makeRepo(); await assertRejectsBeforeGit( diff --git a/src/git/Repository.ts b/src/git/Repository.ts index 635d62a..f2259d4 100644 --- a/src/git/Repository.ts +++ b/src/git/Repository.ts @@ -1,3 +1,10 @@ +/** + * Repository wraps a single git working copy: it builds argv for every git + * subcommand we support (status, log, diff, branch, remote, stash, worktree, + * submodule, blame, reflog, config, etc.), runs it through GitExecutor, and + * parses the output into typed results. All ref/SHA/URL parameters are routed + * through argGuard so hostile input can't be smuggled into git's argv. + */ import * as path from "node:path"; import { GitExecutor } from "./GitExecutor"; import { safeRef, safeRemoteUrl } from "./argGuard"; @@ -533,8 +540,13 @@ export class Repository { /** Create archive from ref. Format: zip, tar, tar.gz, etc. */ async archive(ref: string, format: string, output: string, prefix?: string): Promise { - const args = ["archive", `--format=${format}`, `--output=${output}`, safeRef(ref)]; - if (prefix) args.push(`--prefix=${prefix}`); + const args = [ + "archive", + `--format=${safeRef(format, "archive format")}`, + `--output=${safeRef(output, "output path")}`, + safeRef(ref), + ]; + if (prefix) args.push(`--prefix=${safeRef(prefix, "archive prefix")}`); await this.git.run(args, { cwd: this.root }); } @@ -804,11 +816,18 @@ export class Repository { /** * Commit ordering. `topo` lists a child before all of its parents, which the * graph renderer needs to lay out lanes without backtracking edges; `date` - * (the default) is reverse-chronological for plain list views. + * (the default) is reverse-chronological for plain list views; `author-date` + * sorts by author timestamp when possible while preserving child-before-parent + * constraints. */ - order?: "date" | "topo"; + order?: "date" | "author-date" | "topo"; } = {}): Promise { - const orderFlag = options.order === "topo" ? "--topo-order" : "--date-order"; + const orderFlag = + options.order === "topo" + ? "--topo-order" + : options.order === "author-date" + ? "--author-date-order" + : "--date-order"; const args = ["log", `--format=${LOG_FORMAT}`, orderFlag]; if (options.limit !== undefined) { args.push(`--max-count=${options.limit}`); @@ -1017,7 +1036,10 @@ export class Repository { } } - async reset(sha: string, mode: "soft" | "mixed" | "hard"): Promise { + async reset( + sha: string, + mode: "soft" | "mixed" | "hard" | "keep" | "merge", + ): Promise { await this.git.run(["reset", `--${mode}`, safeRef(sha, "commit")], { cwd: this.root }); } diff --git a/src/git/RepositoryManager.ts b/src/git/RepositoryManager.ts index 5cca52b..d35f5c6 100644 --- a/src/git/RepositoryManager.ts +++ b/src/git/RepositoryManager.ts @@ -118,8 +118,17 @@ export class RepositoryManager implements vscode.Disposable { this.watchers.delete(root); } + private autoRefreshEnabled(): boolean { + return vscode.workspace + .getConfiguration("vsgit") + .get("autoRefresh", true); + } + /** Debounce bursts of filesystem events into a single refresh. */ private scheduleRefresh(): void { + if (!this.autoRefreshEnabled()) { + return; + } if (this.refreshTimer) { clearTimeout(this.refreshTimer); } diff --git a/src/git/parsers/status.ts b/src/git/parsers/status.ts index 8f72a59..ecb7870 100644 --- a/src/git/parsers/status.ts +++ b/src/git/parsers/status.ts @@ -67,19 +67,26 @@ export function parseStatusV2(output: string): StatusResult { // "1 XY sub mH mI mW hH hI path" — path is the 9th field onward. const xy = entry.slice(2, 4); const fields = entry.split(" "); + if (fields.length < 9) continue; const filePath = fields.slice(8).join(" "); changes.push(makeChange(xy, filePath)); } else if (type === "2") { // "2 XY sub mH mI mW hH hI Xscore path" then NUL then origPath const xy = entry.slice(2, 4); const fields = entry.split(" "); + if (fields.length < 10) { + i++; // still consume the paired origPath token + continue; + } const filePath = fields.slice(9).join(" "); const origPath = tokens[++i] ?? ""; const change = makeChange(xy, filePath); change.origPath = origPath; changes.push(change); } else if (type === "u") { - const filePath = entry.split(" ").slice(10).join(" "); + const fields = entry.split(" "); + if (fields.length < 11) continue; + const filePath = fields.slice(10).join(" "); changes.push({ path: filePath, conflicted: true, diff --git a/src/services/AutoFetchService.ts b/src/services/AutoFetchService.ts index 64b71ac..9b02ea5 100644 --- a/src/services/AutoFetchService.ts +++ b/src/services/AutoFetchService.ts @@ -1,3 +1,8 @@ +/** + * Background service that periodically fetches all remotes across known + * repositories and reflects status (last fetch time, behind count) in a + * status bar item. Restarts its timer when the autoFetch configuration changes. + */ import * as vscode from "vscode"; import { RepositoryManager } from "../git/RepositoryManager"; import { Credentials } from "../util/credentials"; @@ -75,7 +80,7 @@ export class AutoFetchService implements vscode.Disposable { const ab = await repo.aheadBehind(); if (ab) { const prev = this.behindCache.get(repo.root) ?? 0; - if (ab.behind > prev && ab.behind > 0) { + if (ab.behind > prev && ab.behind > 0 && this.shouldNotifyIncomingCommits()) { const action = await vscode.window.showInformationMessage( `${repo.name}: ${ab.behind} new commit(s) available from remote`, "Pull Now", @@ -113,6 +118,12 @@ export class AutoFetchService implements vscode.Disposable { this.statusBarItem.text = `$(sync) Fetched: ${label}`; } + private shouldNotifyIncomingCommits(): boolean { + return vscode.workspace + .getConfiguration("vsgit") + .get("autoFetch.notify", true); + } + dispose(): void { if (this.timer) { clearInterval(this.timer); diff --git a/src/services/GitWatcherService.ts b/src/services/GitWatcherService.ts index 396d856..303e2fa 100644 --- a/src/services/GitWatcherService.ts +++ b/src/services/GitWatcherService.ts @@ -26,6 +26,12 @@ export class GitWatcherService implements vscode.Disposable { } private scheduleRefresh(): void { + const autoRefresh = vscode.workspace + .getConfiguration("vsgit") + .get("autoRefresh", true); + if (!autoRefresh) { + return; + } if (this.debounceTimer) { clearTimeout(this.debounceTimer); } diff --git a/src/util/AskpassServer.ts b/src/util/AskpassServer.ts index 031ba83..54e0333 100644 --- a/src/util/AskpassServer.ts +++ b/src/util/AskpassServer.ts @@ -1,6 +1,7 @@ import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; +import * as fs from "node:fs"; import * as crypto from "node:crypto"; import * as vscode from "vscode"; import { safeEqual } from "./token"; @@ -64,6 +65,7 @@ export class AskpassServer implements vscode.Disposable { // Reject any connection that doesn't present this session's token. if (typeof token !== "string" || !safeEqual(token, this.token)) { socket.write(JSON.stringify({ ok: false }) + "\n"); + socket.destroy(); return; } authed = true; @@ -99,7 +101,7 @@ export class AskpassServer implements vscode.Disposable { this.server.close(); if (process.platform !== "win32") { try { - require("node:fs").unlinkSync(this.sockPath); + fs.unlinkSync(this.sockPath); } catch { // already gone } diff --git a/src/util/EditorServer.ts b/src/util/EditorServer.ts index 22bb02b..334e609 100644 --- a/src/util/EditorServer.ts +++ b/src/util/EditorServer.ts @@ -1,6 +1,7 @@ import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; +import * as fs from "node:fs"; import * as crypto from "node:crypto"; import * as vscode from "vscode"; import { safeEqual } from "./token"; @@ -77,6 +78,7 @@ export class EditorServer implements vscode.Disposable { }; if (typeof req.token !== "string" || !safeEqual(req.token, this.token)) { socket.write(JSON.stringify({ ok: false }) + "\n"); + socket.destroy(); return; } authed = true; @@ -107,7 +109,7 @@ export class EditorServer implements vscode.Disposable { this.server.close(); if (process.platform !== "win32") { try { - require("node:fs").unlinkSync(this.sockPath); + fs.unlinkSync(this.sockPath); } catch { // already gone } diff --git a/src/util/html.test.ts b/src/util/html.test.ts new file mode 100644 index 0000000..1ec418b --- /dev/null +++ b/src/util/html.test.ts @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { escapeHtml } from "./html"; + +test("escapeHtml escapes all HTML-significant characters", () => { + assert.strictEqual( + escapeHtml(` & 'ok'`), + "<script>alert("x")</script> & 'ok'", + ); +}); + +test("escapeHtml neutralizes an attribute-breakout attempt", () => { + const malicious = `">`; + const escaped = escapeHtml(malicious); + assert.ok(!escaped.includes('"')); + assert.ok(!escaped.includes("<")); + assert.ok(!escaped.includes(">")); +}); + +test("escapeHtml leaves plain text untouched", () => { + assert.strictEqual(escapeHtml("v1.0.0"), "v1.0.0"); + assert.strictEqual(escapeHtml("abcdef1"), "abcdef1"); +}); + +test("escapeHtml handles empty string", () => { + assert.strictEqual(escapeHtml(""), ""); +}); diff --git a/src/util/html.ts b/src/util/html.ts new file mode 100644 index 0000000..e189eed --- /dev/null +++ b/src/util/html.ts @@ -0,0 +1,8 @@ +/** Escapes text for safe interpolation into HTML markup (not attributes with single quotes). */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/views/CompareProvider.ts b/src/views/CompareProvider.ts index 77db72d..87b738b 100644 --- a/src/views/CompareProvider.ts +++ b/src/views/CompareProvider.ts @@ -1,3 +1,7 @@ +/** + * Tree view that compares two refs in a repository, showing the commits + * unique to each side plus the combined file diff between them. + */ import * as path from "node:path"; import * as vscode from "vscode"; import { RepositoryManager } from "../git/RepositoryManager"; @@ -16,9 +20,10 @@ interface ComparisonState { ref2: string; } -export class CompareProvider implements vscode.TreeDataProvider { +export class CompareProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChangeTreeData = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly subscription: vscode.Disposable; private currentComparison: ComparisonState | undefined; private cachedLeftCommits: Commit[] = []; @@ -26,11 +31,16 @@ export class CompareProvider implements vscode.TreeDataProvider private cachedFiles: CommitFile[] = []; constructor(manager: RepositoryManager) { - manager.onDidChange(() => { + this.subscription = manager.onDidChange(() => { this.refresh(); }); } + dispose(): void { + this.subscription.dispose(); + this._onDidChangeTreeData.dispose(); + } + refresh() { this.cachedLeftCommits = []; this.cachedRightCommits = []; diff --git a/src/views/ConflictsProvider.ts b/src/views/ConflictsProvider.ts index b814780..a4038ac 100644 --- a/src/views/ConflictsProvider.ts +++ b/src/views/ConflictsProvider.ts @@ -11,14 +11,20 @@ export type ConflictNode = * Conflicts view — shows all files in a conflicted state (merge, rebase, * cherry-pick). Automatically updates whenever the repository state changes. */ -export class ConflictsProvider implements vscode.TreeDataProvider { +export class ConflictsProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChangeTreeData = new vscode.EventEmitter< ConflictNode | undefined >(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly subscription: vscode.Disposable; constructor(private readonly manager: RepositoryManager) { - manager.onDidChange(() => this._onDidChangeTreeData.fire(undefined)); + this.subscription = manager.onDidChange(() => this._onDidChangeTreeData.fire(undefined)); + } + + dispose(): void { + this.subscription.dispose(); + this._onDidChangeTreeData.dispose(); } getTreeItem(node: ConflictNode): vscode.TreeItem { diff --git a/src/views/ReflogProvider.ts b/src/views/ReflogProvider.ts index 85d2148..a7d1c18 100644 --- a/src/views/ReflogProvider.ts +++ b/src/views/ReflogProvider.ts @@ -11,17 +11,23 @@ export type ReflogNode = * Reflog view for the active repository's HEAD. Each entry exposes * checkout/reset actions so lost commits can be recovered. */ -export class ReflogProvider implements vscode.TreeDataProvider { +export class ReflogProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChangeTreeData = new vscode.EventEmitter< ReflogNode | undefined >(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly subscription: vscode.Disposable; private entries: ReflogEntry[] = []; private repo: Repository | undefined; constructor(private readonly manager: RepositoryManager) { - manager.onDidChange(() => void this.refresh()); + this.subscription = manager.onDidChange(() => void this.refresh()); + } + + dispose(): void { + this.subscription.dispose(); + this._onDidChangeTreeData.dispose(); } get activeRepo(): Repository | undefined { diff --git a/src/views/RepositoriesProvider.ts b/src/views/RepositoriesProvider.ts index 1f41d02..90b1b57 100644 --- a/src/views/RepositoriesProvider.ts +++ b/src/views/RepositoriesProvider.ts @@ -35,12 +35,13 @@ const GROUP_LABELS: Record = { }; export class RepositoriesProvider - implements vscode.TreeDataProvider + implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChangeTreeData = new vscode.EventEmitter< VsgitNode | undefined >(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly subscription: vscode.Disposable; private abCache = new Map(); @@ -54,7 +55,12 @@ export class RepositoriesProvider private readonly manager: RepositoryManager, private readonly flat = false, ) { - manager.onDidChange(() => this.refresh()); + this.subscription = manager.onDidChange(() => this.refresh()); + } + + dispose(): void { + this.subscription.dispose(); + this._onDidChangeTreeData.dispose(); } private refresh(): void { diff --git a/src/views/StagingProvider.ts b/src/views/StagingProvider.ts index 5f946a5..159f82f 100644 --- a/src/views/StagingProvider.ts +++ b/src/views/StagingProvider.ts @@ -15,14 +15,20 @@ export type StagingNode = * "active" repository is the first discovered repo (multi-repo selection comes * in a later phase). */ -export class StagingProvider implements vscode.TreeDataProvider { +export class StagingProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChangeTreeData = new vscode.EventEmitter< StagingNode | undefined >(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly subscription: vscode.Disposable; constructor(private readonly manager: RepositoryManager) { - manager.onDidChange(() => this._onDidChangeTreeData.fire(undefined)); + this.subscription = manager.onDidChange(() => this._onDidChangeTreeData.fire(undefined)); + } + + dispose(): void { + this.subscription.dispose(); + this._onDidChangeTreeData.dispose(); } get activeRepo(): Repository | undefined { @@ -64,19 +70,21 @@ export class StagingProvider implements vscode.TreeDataProvider { const item = new vscode.TreeItem(path.basename(change.path)); item.description = path.dirname(change.path) === "." ? "" : path.dirname(change.path); item.resourceUri = vscode.Uri.file(path.join(node.repo.root, change.path)); - - // Context value and icon based on group and conflict status + // Use the language-specific file icon from the active icon theme (e.g. a + // TypeScript glyph for *.ts). The git status is conveyed separately via the + // colored letter badge from VsgitFileDecorationProvider, matching VS Code's + // built-in SCM view. + item.iconPath = vscode.ThemeIcon.File; + + // Context value based on group and conflict status. if (node.group === "conflicted" || change.conflicted) { item.contextValue = "vsgit.conflictedFile"; - item.iconPath = new vscode.ThemeIcon("warning", new vscode.ThemeColor("list.errorForeground")); item.tooltip = `${change.path} — CONFLICTED`; } else if (node.group === "staged") { item.contextValue = "vsgit.stagedFile"; - item.iconPath = new vscode.ThemeIcon(statusIcon(change, node.group)); item.tooltip = `${change.path} — ${describeState(change, node.group)}`; } else { item.contextValue = "vsgit.unstagedFile"; - item.iconPath = new vscode.ThemeIcon(statusIcon(change, node.group)); item.tooltip = `${change.path} — ${describeState(change, node.group)}`; } @@ -130,26 +138,6 @@ export class StagingProvider implements vscode.TreeDataProvider { } } -function statusIcon(change: FileChange, group: "staged" | "unstaged"): string { - if (change.conflicted) { - return "warning"; - } - const state = group === "staged" ? change.indexState : change.worktreeState; - switch (state) { - case "added": - case "untracked": - return "diff-added"; - case "deleted": - return "diff-removed"; - case "renamed": - return "diff-renamed"; - case "ignored": - return "circle-slash"; - default: - return "diff-modified"; - } -} - function describeState(change: FileChange, group: "staged" | "unstaged"): string { if (change.conflicted) { return "conflicted"; diff --git a/src/views/SynchronizeProvider.ts b/src/views/SynchronizeProvider.ts index f11a95d..e8e4d9f 100644 --- a/src/views/SynchronizeProvider.ts +++ b/src/views/SynchronizeProvider.ts @@ -12,11 +12,12 @@ export type SyncNode = * Synchronize view: incoming (behind upstream) and outgoing (ahead of upstream) * changesets for the active repository's current branch. */ -export class SynchronizeProvider implements vscode.TreeDataProvider { +export class SynchronizeProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChangeTreeData = new vscode.EventEmitter< SyncNode | undefined >(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly subscription: vscode.Disposable; private repo: Repository | undefined; private incoming: Commit[] = []; @@ -24,7 +25,12 @@ export class SynchronizeProvider implements vscode.TreeDataProvider { private hasUpstream = false; constructor(private readonly manager: RepositoryManager) { - manager.onDidChange(() => void this.refresh()); + this.subscription = manager.onDidChange(() => void this.refresh()); + } + + dispose(): void { + this.subscription.dispose(); + this._onDidChangeTreeData.dispose(); } async refresh(): Promise { diff --git a/src/views/WorktreesProvider.ts b/src/views/WorktreesProvider.ts index 4ea11d5..3d3293c 100644 --- a/src/views/WorktreesProvider.ts +++ b/src/views/WorktreesProvider.ts @@ -1,3 +1,7 @@ +/** + * Tree view listing each repository's git worktrees, with commands to + * create, open, and remove them. + */ import * as path from "node:path"; import * as vscode from "vscode"; import { RepositoryManager } from "../git/RepositoryManager"; @@ -7,21 +11,27 @@ export type WorktreeTreeNode = | { type: "repoWorktrees"; repo: Repository } | { type: "worktree"; info: WorktreeInfo; repo: Repository; manager: RepositoryManager }; -export class WorktreesProvider implements vscode.TreeDataProvider { +export class WorktreesProvider implements vscode.TreeDataProvider, vscode.Disposable { private readonly _onDidChangeTreeData = new vscode.EventEmitter< WorktreeTreeNode | undefined >(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly subscription: vscode.Disposable; private worktreeCache = new Map(); constructor(private readonly manager: RepositoryManager) { - manager.onDidChange(() => { + this.subscription = manager.onDidChange(() => { this.worktreeCache.clear(); this._onDidChangeTreeData.fire(undefined); }); } + dispose(): void { + this.subscription.dispose(); + this._onDidChangeTreeData.dispose(); + } + getTreeItem(node: WorktreeTreeNode): vscode.TreeItem { if (node.type === "repoWorktrees") { const item = new vscode.TreeItem( diff --git a/src/webviews/CreateTagDialog.ts b/src/webviews/CreateTagDialog.ts new file mode 100644 index 0000000..a31bb1a --- /dev/null +++ b/src/webviews/CreateTagDialog.ts @@ -0,0 +1,288 @@ +/** + * Webview-backed dialog for creating a tag: collects name, message, and + * annotate/sign/force/push options, then resolves with the user's choices + * (or undefined if cancelled). + */ +import * as vscode from "vscode"; +import { escapeHtml } from "../util/html"; + +export interface CreateTagDialogResult { + name: string; + message?: string; + annotate: boolean; + sign: boolean; + force: boolean; + push: boolean; +} + +export async function showCreateTagDialog( + extensionUri: vscode.Uri, + shaLabel = "HEAD", +): Promise { + const panel = vscode.window.createWebviewPanel( + "vsgit.createTag", + "Create Tag", + vscode.ViewColumn.Active, + { + enableScripts: true, + localResourceRoots: [extensionUri], + }, + ); + panel.webview.html = createTagHtml(getNonce(), panel.webview.cspSource, shaLabel); + + return new Promise((resolve) => { + let settled = false; + const finish = (result: CreateTagDialogResult | undefined) => { + if (settled) return; + settled = true; + resolve(result); + panel.dispose(); + }; + + panel.onDidDispose(() => finish(undefined)); + panel.webview.onDidReceiveMessage((message) => { + if (message.type === "cancel") { + finish(undefined); + } else if (message.type === "create") { + finish(message.data as CreateTagDialogResult); + } + }); + }); +} + +function createTagHtml(nonce: string, cspSource: string, shaLabel: string): string { + const escapedSha = escapeHtml(shaLabel); + return /* html */ ` + + + + + + + + +
+
+
+

Create Tag

+ +
+
+ + + + + + + +
+
+ + +
+
+
+ + +`; +} + +function getNonce(): string { + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let text = ""; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} diff --git a/src/webviews/HistoryView.ts b/src/webviews/HistoryView.ts index 140db1c..3150a39 100644 --- a/src/webviews/HistoryView.ts +++ b/src/webviews/HistoryView.ts @@ -14,6 +14,7 @@ interface HistoryState { loadedCommits: Commit[]; hasMore: boolean; currentBranch: string | "all"; + filePath?: string; compareMode: { ref1: string; ref2: string } | null; filters: { search: string; @@ -53,9 +54,9 @@ export class HistoryView { return; } if (file) { - // File-specific history - this.state.filters.search = file; - this.state.filters.searchBy = "message"; // Will be filtered by file path in query + this.state.filePath = file; + } else { + this.state.filePath = undefined; } if (this.panel) { this.panel.reveal(); @@ -190,7 +191,27 @@ export class HistoryView { this.state.filters.until = opts.until; } try { - const pageSize = vscode.workspace.getConfiguration("vsgit").get("graph.pageSize", 200); + const config = vscode.workspace.getConfiguration("vsgit"); + const pageSize = Math.max(1, config.get("graph.pageSize", 200)); + const maxCommits = Math.max(1, config.get("history.maxCommits", 500)); + const sortOrder = config.get<"date" | "author-date" | "topo">( + "graph.sortOrder", + "date", + ); + const remaining = maxCommits - this.state.loadedCommits.length; + if (remaining <= 0) { + this.state.hasMore = false; + this.post({ + type: "commits", + commits: this.state.loadedCommits, + hasMore: false, + currentBranch: this.state.currentBranch, + filePath: this.state.filePath, + compareMode: this.state.compareMode, + }); + return; + } + const limit = Math.min(pageSize, remaining); let revRange: string | undefined; if (this.state.compareMode) { @@ -201,20 +222,20 @@ export class HistoryView { const commits = await this.repo.log({ all: opts.branch === "all" && !this.state.compareMode, - limit: pageSize, + limit, skip: this.state.loadedCommits.length, revRange, search: opts.search || undefined, searchBy: opts.searchBy, + file: this.state.filePath, since: opts.since, until: opts.until, - // The history graph lays out lanes from this ordering, so a child must - // always precede its parents. - order: "topo", + order: sortOrder, }); this.state.loadedCommits.push(...commits); - this.state.hasMore = commits.length === pageSize; + this.state.hasMore = + this.state.loadedCommits.length < maxCommits && commits.length === limit; for (const c of this.state.loadedCommits) { this.commitsBySha.set(c.sha, c); @@ -225,6 +246,7 @@ export class HistoryView { commits: this.state.loadedCommits, hasMore: this.state.hasMore, currentBranch: this.state.currentBranch, + filePath: this.state.filePath, compareMode: this.state.compareMode, }); } catch (e) { diff --git a/src/webviews/commit/CommitViewProvider.ts b/src/webviews/commit/CommitViewProvider.ts index 5f5ba42..2a060bd 100644 --- a/src/webviews/commit/CommitViewProvider.ts +++ b/src/webviews/commit/CommitViewProvider.ts @@ -110,23 +110,31 @@ export class CommitViewProvider implements vscode.WebviewViewProvider { await vscode.commands.executeCommand("vsgit.staging.unstageAll"); return; case "openDiff": { - const group = (msg.data as { group: StagingGroup }).group; + const data = msg.data as { group?: unknown } | undefined; + const group = isStagingGroup(data?.group) ? data.group : "unstaged"; await vscode.commands.executeCommand( "vsgit.staging.openDiff", this.fileNode(msg.data, group), ); return; } - case "commit": - await this.commit( - repo, - msg.data as { - message: string; - amend: boolean; - signoff: boolean; - gpg: boolean; - }, - ); + case "commit": { + const data = msg.data as Partial<{ + message: string; + amend: boolean; + signoff: boolean; + gpg: boolean; + }> | undefined; + await this.commit(repo, { + message: typeof data?.message === "string" ? data.message : "", + amend: data?.amend === true, + signoff: data?.signoff === true, + gpg: data?.gpg === true, + }); + return; + } + default: + console.warn(`CommitViewProvider: unhandled message type "${msg.type}"`); return; } } @@ -134,9 +142,9 @@ export class CommitViewProvider implements vscode.WebviewViewProvider { /** Re-hydrate a webview file reference into a StagingNode for the commands. */ private fileNode(data: unknown, group: StagingGroup): StagingNode | undefined { const repo = this.repo; - const d = data as { path?: string; group?: StagingGroup }; - if (!repo || !d?.path) return undefined; - const g = d.group ?? group; + const d = data as { path?: string; group?: unknown } | undefined; + if (!repo || typeof d?.path !== "string") return undefined; + const g = isStagingGroup(d.group) ? d.group : group; const pool = g === "staged" ? repo.stagedChanges @@ -216,9 +224,27 @@ export class CommitViewProvider implements vscode.WebviewViewProvider {
No Git repository is active.