diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ac7828..a4aa49a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [0.2.0] - 2026-08-05 + +### Added + +- Independent repository layout and sort controls, including activity, dirty-file, and alphabetical sorting inside folder trees +- Bidirectional selection between the repository navigator and the graph repository picker +- Commit search across messages, authors, hashes, branches, tags, and stash labels +- Worktree visibility, repository terminal, fetch, settings, and icon-only refresh actions in the graph header +- Inline terminal and fetch actions on repository rows +- Stash history and labels in the show-all graph; tag history remains visible + +### Changed + +- Kept graph controls anchored above the worktree rail at narrow panel widths +- Capped repository and branch picker widths with ellipsis and full-value tooltips +- Made collapse-all available only in folder-tree layout +- Made the clean-repository eye show the current visibility state + ## [0.1.0] - 2026-08-05 The first Git Fleet release, forked from Neo Git Graph 0.5.0. @@ -32,5 +50,6 @@ The first Git Fleet release, forked from Neo Git Graph 0.5.0. Git Fleet began from Neo Git Graph 0.5.0. Its earlier changelog remains available in the [upstream repository](https://github.com/asispts/neo-git-graph/blob/main/CHANGELOG.md). -[Unreleased]: https://github.com/wrgrant/git-fleet/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/wrgrant/git-fleet/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/wrgrant/git-fleet/releases/tag/v0.2.0 [0.1.0]: https://github.com/wrgrant/git-fleet/releases/tag/v0.1.0 diff --git a/README.md b/README.md index e7f4e979..6497ff91 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Git Fleet is a VS Code extension for people working across more repositories and - A dedicated Activity Bar pane instead of another toolbar inside Source Control - Repository discovery below every workspace folder, including nested projects - Optional global search roots for repositories outside the current workspace -- Recent-activity, dirty-file, and folder-tree arrangements +- Independent list/folder-tree layouts with recent-activity, dirty-file, or alphabetical sorting - A persistent eye control that hides clean repositories - A clickable Uncommitted Changes row with the same file tree and diff flow as a commit - A worktree rail showing each checkout's HEAD, dirty state, branch, and inferred base @@ -32,7 +32,7 @@ The repository navigator is intentionally read-only. Repository-level actions— Git Fleet is not yet listed in the VS Code Marketplace. Download the latest `.vsix` from [GitHub Releases](https://github.com/wrgrant/git-fleet/releases), then run: ```sh -code --install-extension git-fleet-v0.1.0.vsix +code --install-extension git-fleet-v0.2.0.vsix ``` Or choose **Extensions: Install from VSIX...** from the VS Code Command Palette. diff --git a/ROADMAP.md b/ROADMAP.md index 9d2f9dc6..6daa64da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,6 @@ Git Fleet 0.1 focuses on visibility: find repositories, see which ones need atte ## Next -- Separate repository **layout** from **sort order**. Layout becomes flat list or folder tree; sort becomes recent activity, dirty-file count, or alphabetical. Tree sorting applies within each folder. - Let users remove, rename, temporarily disable, and reorder configured repository search roots from the navigator. - Add stale-branch, ahead/behind, and fetch-state signals without turning the navigator into another Source Control action surface. - Improve worktree base inference and make the confirmed-versus-inferred distinction even easier to scan. @@ -12,7 +11,8 @@ Git Fleet 0.1 focuses on visibility: find repositories, see which ones need atte ## Later -- Safe, contextual repository actions from the navigator +- Configurable graph visibility for individual ref classes and richer Git operation settings +- Optional LazyGit launcher beside the built-in repository terminal action - Saved fleet scopes for work, personal, client, or monorepo groupings - Optional notifications or health summaries for abandoned dirty repositories - Marketplace and Open VSX distribution diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 7cfef0b6..54748df7 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -4,7 +4,16 @@ "Repo": "Repo", "Branch": "Branch", "Show Remote Branches": "Show Remote Branches", + "Search commits": "Search commits", + "Show or hide worktrees": "Show or hide worktrees", + "Open terminal in repository": "Open terminal in repository", + "Fetch repository": "Fetch repository", + "Open Git Fleet settings": "Open Git Fleet settings", "Refresh": "Refresh", + "Search commit messages": "Search commit messages", + "Previous match": "Previous match", + "Next match": "Next match", + "Close search": "Close search", "Worktrees": "Worktrees", "Unable to load Git Fleet": "Unable to load Git Fleet", "Either the current workspace does not contain a Git repository, or the Git repository is not configured correctly.": "Either the current workspace does not contain a Git repository, or the Git repository is not configured correctly.", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 00496c55..f6439a86 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -4,7 +4,16 @@ "Repo": "仓库", "Branch": "分支", "Show Remote Branches": "显示远程分支", + "Search commits": "搜索提交", + "Show or hide worktrees": "显示或隐藏工作树", + "Open terminal in repository": "在仓库中打开终端", + "Fetch repository": "获取仓库", + "Open Git Fleet settings": "打开 Git Fleet 设置", "Refresh": "刷新", + "Search commit messages": "搜索提交消息", + "Previous match": "上一个匹配项", + "Next match": "下一个匹配项", + "Close search": "关闭搜索", "Worktrees": "工作树", "Loading ...": "加载中 ...", "Load More Commits": "加载更多提交", diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index 35a48543..54338497 100644 --- a/l10n/bundle.l10n.zh-tw.json +++ b/l10n/bundle.l10n.zh-tw.json @@ -4,7 +4,16 @@ "Repo": "存放庫", "Branch": "分支", "Show Remote Branches": "顯示遠端分支", + "Search commits": "搜尋提交", + "Show or hide worktrees": "顯示或隱藏工作樹", + "Open terminal in repository": "在存放庫中開啟終端機", + "Fetch repository": "擷取存放庫", + "Open Git Fleet settings": "開啟 Git Fleet 設定", "Refresh": "重新整理", + "Search commit messages": "搜尋提交訊息", + "Previous match": "上一個相符項目", + "Next match": "下一個相符項目", + "Close search": "關閉搜尋", "Worktrees": "工作樹", "Loading ...": "載入中 ...", "Load More Commits": "載入更多提交", diff --git a/media/dropdown.css b/media/dropdown.css index 9a7d8b83..b47a9443 100644 --- a/media/dropdown.css +++ b/media/dropdown.css @@ -21,6 +21,10 @@ cursor: pointer; user-select: none; white-space: nowrap; + overflow: hidden; + padding-right: 24px; + text-overflow: ellipsis; + width: 100%; } .dropdownCurrentValue:hover { background-color: rgba(128, 128, 128, 0.2); @@ -54,6 +58,8 @@ overflow-x: hidden; margin-top: -1px; white-space: nowrap; + min-width: 100%; + max-width: min(520px, 90vw); } .dropdownOption { diff --git a/media/main.css b/media/main.css index 322a9ca0..ab7534ee 100644 --- a/media/main.css +++ b/media/main.css @@ -87,6 +87,9 @@ body.worktreesVisible #footer { #commitTable tr.commit:hover td { cursor: pointer; } +#commitTable tr.commit.commitSearchMatch td:not(:first-child) { + background-color: var(--vscode-editor-findMatchHighlightBackground, rgba(234, 196, 0, 0.35)); +} #commitTable tr.commit:hover td:first-child, #commitTable tr.commit.contextMenuActive td:first-child, #commitTable tr.commit.dialogActive td:first-child, @@ -197,7 +200,7 @@ body.worktreesVisible #footer { /* Worktree history rail */ #worktreeRail { position: fixed; - top: 44px; + top: var(--git-fleet-worktree-top, 52px); right: 8px; bottom: 8px; z-index: 4; @@ -353,7 +356,7 @@ body.worktreesVisible #footer { @media (max-width: 760px) { body.worktreesVisible #content, body.worktreesVisible #footer { - margin-right: 0; + margin-right: 234px; } #worktreeRail { width: 218px; @@ -590,35 +593,95 @@ svg.fileIcon { /* Controls */ #controls { - display: flex; - align-items: center; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; padding: 4px 8px; border-bottom: 1px solid rgba(128, 128, 128, 0.5); line-height: 32px; font-weight: 700; font-size: 13px; } +#controlFields { + display: flex; + align-items: center; + flex-wrap: wrap; + min-width: 0; +} +#controlActions { + display: flex; + align-items: center; + gap: 2px; + padding-top: 3px; +} #repoControl, #branchControl, #showRemoteBranchesControl { white-space: nowrap; margin: 0 10px; } +#repoControl, +#branchControl { + display: inline-flex; + align-items: center; + min-width: 0; +} #repoSelect, #branchSelect { margin-left: 3px; + min-width: 130px; + max-width: min(34vw, 360px); + width: 260px; +} +#branchSelect { + max-width: min(42vw, 460px); + width: 340px; } #showRemoteBranchesCheckbox { width: 14px; vertical-align: text-top; } -#refreshBtn { - margin-left: auto; - width: 80px; - height: 22px; - line-height: 22px; - border-radius: 11px; +.iconBtn { + min-width: 26px; + height: 26px; + padding: 0 6px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--vscode-foreground); + font: 13px/24px var(--vscode-editor-font-family); + cursor: pointer; +} +.iconBtn:hover, +.iconBtn:focus-visible, +.iconBtn.active { + background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.18)); + outline: none; +} +#commitSearch { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-bottom: 1px solid rgba(128, 128, 128, 0.35); +} +#commitSearch[hidden] { + display: none; +} +#commitSearchInput { + min-width: 140px; + max-width: 520px; + flex: 1; + height: 26px; + padding: 3px 7px; + border: 1px solid var(--vscode-input-border, rgba(128, 128, 128, 0.5)); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); +} +#commitSearchStatus { + color: var(--vscode-descriptionForeground); + font-size: 11px; + white-space: nowrap; } #loadMoreCommitsBtn { width: 180px; @@ -635,6 +698,16 @@ svg.fileIcon { #controls label { cursor: pointer; } +@media (max-width: 720px) { + #repoSelect, + #branchSelect { + width: 190px; + max-width: 42vw; + } + #showRemoteBranchesControl { + flex-basis: 100%; + } +} /* Context Menu */ #contextMenu { diff --git a/package.json b/package.json index a5627cb7..5eb42cb5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "git-fleet", "displayName": "Git Fleet", - "version": "0.1.0", + "version": "0.2.0", "description": "%description%", "categories": [ "SCM Providers", @@ -109,20 +109,32 @@ }, { "category": "Git Fleet", - "command": "git-fleet.changeRepositoryNavigatorMode", + "command": "git-fleet.changeRepositoryNavigatorLayout", "icon": "$(list-tree)", - "title": "%command.changeRepositoryNavigatorMode%" + "title": "%command.changeRepositoryNavigatorLayout%" + }, + { + "category": "Git Fleet", + "command": "git-fleet.changeRepositoryNavigatorSort", + "icon": "$(sort-precedence)", + "title": "%command.changeRepositoryNavigatorSort%" + }, + { + "category": "Git Fleet", + "command": "git-fleet.collapseRepositoryFolders", + "icon": "$(collapse-all)", + "title": "%command.collapseRepositoryFolders%" }, { "category": "Git Fleet", "command": "git-fleet.hideCleanRepositories", - "icon": "$(eye-closed)", + "icon": "$(eye)", "title": "%command.hideCleanRepositories%" }, { "category": "Git Fleet", "command": "git-fleet.showAllRepositories", - "icon": "$(eye)", + "icon": "$(eye-closed)", "title": "%command.showAllRepositories%" }, { @@ -136,6 +148,18 @@ "command": "git-fleet.openRepositorySearchRootsSettings", "icon": "$(settings-gear)", "title": "%command.openRepositorySearchRootsSettings%" + }, + { + "category": "Git Fleet", + "command": "git-fleet.openRepositoryTerminal", + "icon": "$(terminal)", + "title": "%command.openRepositoryTerminal%" + }, + { + "category": "Git Fleet", + "command": "git-fleet.fetchRepository", + "icon": "$(cloud-download)", + "title": "%command.fetchRepository%" } ], "viewsContainers": { @@ -172,27 +196,47 @@ "when": "view == git-fleet.repositoryNavigator" }, { - "command": "git-fleet.changeRepositoryNavigatorMode", + "command": "git-fleet.changeRepositoryNavigatorLayout", "group": "navigation@2", "when": "view == git-fleet.repositoryNavigator" }, { - "command": "git-fleet.hideCleanRepositories", + "command": "git-fleet.changeRepositoryNavigatorSort", "group": "navigation@3", + "when": "view == git-fleet.repositoryNavigator" + }, + { + "command": "git-fleet.collapseRepositoryFolders", + "group": "navigation@4", + "when": "view == git-fleet.repositoryNavigator && gitFleet.repositoryLayoutTree" + }, + { + "command": "git-fleet.hideCleanRepositories", + "group": "navigation@5", "when": "view == git-fleet.repositoryNavigator && !gitFleet.hideCleanRepositories" }, { "command": "git-fleet.showAllRepositories", - "group": "navigation@3", + "group": "navigation@5", "when": "view == git-fleet.repositoryNavigator && gitFleet.hideCleanRepositories" }, { "command": "git-fleet.refreshRepositoryNavigator", - "group": "navigation@4", + "group": "navigation@6", "when": "view == git-fleet.repositoryNavigator" } ], "view/item/context": [ + { + "command": "git-fleet.openRepositoryTerminal", + "group": "inline@1", + "when": "view == git-fleet.repositoryNavigator && viewItem == repositoryNavigator.repository" + }, + { + "command": "git-fleet.fetchRepository", + "group": "inline@2", + "when": "view == git-fleet.repositoryNavigator && viewItem == repositoryNavigator.repository" + }, { "command": "git-fleet.openRepositoryGraph", "group": "navigation", diff --git a/package.nls.json b/package.nls.json index 26dd31e8..54545a91 100644 --- a/package.nls.json +++ b/package.nls.json @@ -4,8 +4,12 @@ "command.clearAvatarCache": "Clear Avatar Cache", "command.openRepositoryGraph": "Open Repository Graph", "command.refreshRepositoryNavigator": "Refresh Repository Navigator", - "command.changeRepositoryNavigatorMode": "Change Repository Arrangement", + "command.changeRepositoryNavigatorLayout": "Change Repository Layout", + "command.changeRepositoryNavigatorSort": "Change Repository Sort", + "command.collapseRepositoryFolders": "Collapse Repository Folders", "command.hideCleanRepositories": "Hide Clean Repositories", + "command.openRepositoryTerminal": "Open Terminal", + "command.fetchRepository": "Fetch Repository", "command.showAllRepositories": "Show Clean Repositories", "command.addRepositorySearchRoot": "Add Repository Search Root", "command.openRepositorySearchRootsSettings": "Manage Repository Search Roots", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index e2fe8564..0d17695e 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -4,7 +4,11 @@ "command.clearAvatarCache": "清除头像缓存", "command.openRepositoryGraph": "打开仓库图形", "command.refreshRepositoryNavigator": "刷新仓库导航器", - "command.changeRepositoryNavigatorMode": "更改仓库导航器布局", + "command.changeRepositoryNavigatorLayout": "更改仓库布局", + "command.changeRepositoryNavigatorSort": "更改仓库排序", + "command.collapseRepositoryFolders": "折叠仓库文件夹", + "command.openRepositoryTerminal": "打开终端", + "command.fetchRepository": "获取仓库", "command.hideCleanRepositories": "隐藏无更改的仓库", "command.showAllRepositories": "显示无更改的仓库", "command.addRepositorySearchRoot": "添加仓库搜索根目录", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 60b45687..37a1e046 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -4,7 +4,11 @@ "command.clearAvatarCache": "清除頭像快取", "command.openRepositoryGraph": "開啟儲存庫圖形", "command.refreshRepositoryNavigator": "重新整理儲存庫導覽器", - "command.changeRepositoryNavigatorMode": "變更儲存庫導覽器版面配置", + "command.changeRepositoryNavigatorLayout": "變更儲存庫版面配置", + "command.changeRepositoryNavigatorSort": "變更儲存庫排序", + "command.collapseRepositoryFolders": "摺疊儲存庫資料夾", + "command.openRepositoryTerminal": "開啟終端機", + "command.fetchRepository": "擷取儲存庫", "command.hideCleanRepositories": "隱藏無變更的儲存庫", "command.showAllRepositories": "顯示無變更的儲存庫", "command.addRepositorySearchRoot": "新增儲存庫搜尋根目錄", diff --git a/plans/content-discovery-queue.md b/plans/content-discovery-queue.md index 33ec61f3..ed9df2f1 100644 --- a/plans/content-discovery-queue.md +++ b/plans/content-discovery-queue.md @@ -1,5 +1,19 @@ # Content discovery queue +## 2026-08-05 — Separate repository structure from urgency + +- Why it matters: Folder hierarchy and operational priority answer different questions. A fleet view should let someone keep spatial context while still sorting repositories by recent activity or uncommitted work. +- Evidence: Git Fleet 0.2 stores layout and sort independently, migrates the 0.1 combined mode, and applies the chosen sort inside each folder. +- Files changed: `src/extension/repoNavigator.ts`, `src/extensionState.ts`, `package.json` +- Suggested content angle: The useful abstraction was not another repository mode; it was two orthogonal controls. + +## 2026-08-05 — Keep cross-repository navigation synchronized with deep history + +- Why it matters: A left-side fleet and right-side graph feel like separate products if selecting a repository in one does not reveal it in the other. +- Evidence: Repository selection now flows through one extension callback and uses the native TreeView reveal API to select and scroll the matching repository, including inside folder layouts. +- Files changed: `src/extension/initExtension.ts`, `src/extension/messageHandler.ts`, `src/extension/repoNavigator.ts` +- Suggested content angle: Bidirectional selection is the small piece of plumbing that turns two useful panes into one application. + ## 2026-08-05 — Audit inherited network code before publishing a fork - Why it matters: An MIT license permits reuse, but a public release still needs its own credential and privacy audit. diff --git a/src/backend/queries/loadCommits.ts b/src/backend/queries/loadCommits.ts index feedae85..d7957b48 100644 --- a/src/backend/queries/loadCommits.ts +++ b/src/backend/queries/loadCommits.ts @@ -27,7 +27,10 @@ async function getRefs(git: SimpleGit, showRemoteBranches: boolean): Promise ""); + } const refData: GitRefData = { head: null, refs: [] }; const lines = stdout.split(eolRegex); for (let i = 0; i < lines.length - 1; i++) { @@ -47,6 +50,8 @@ async function getRefs(git: SimpleGit, showRemoteBranches: boolean): Promise true, + () => false + ) + ) { + args.push("refs/stash"); + } } try { const stdout = await git.raw(args); diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts index 9e9841ee..300971a9 100644 --- a/src/extension/initExtension.ts +++ b/src/extension/initExtension.ts @@ -12,7 +12,10 @@ import { EXTENSION_NAME } from "@/extension/constant/const"; import { createMaxDepthTracker } from "@/extension/maxDepthTracker"; import { registerMessageHandlers } from "@/extension/messageHandler"; import { createRepoManager, RepoManager } from "@/extension/repoManager"; -import { registerRepositoryNavigator } from "@/extension/repoNavigator"; +import { + registerRepositoryNavigator, + repositoryPathFromCommandArgument +} from "@/extension/repoNavigator"; import { getRepositorySearchRoots } from "@/extension/repositorySearchRoots"; import { logger } from "@/extension/utils/logger"; import { WebviewBridge, webviewBridgeFactory } from "@/extension/webviewBridge"; @@ -28,7 +31,8 @@ function registerViewCommand( repoManager: RepoManager, extensionState: ExtensionState, avatarManager: AvatarManager, - gitClient: GitClient + gitClient: GitClient, + onRepositorySelected: (repo: string) => void ) { let currentPanel: WebviewPanel | undefined; let selectRepo: ((repo: string) => void) | undefined; @@ -71,7 +75,8 @@ function registerViewCommand( repoManager, extensionState, avatarManager, - repoFileWatcher + repoFileWatcher, + onRepositorySelected }); selectRepo = handlers.selectRepo; if (repo) { @@ -98,9 +103,12 @@ function registerViewCommand( vscode.commands.registerCommand("git-fleet.view", () => openGraph(undefined, vscode.window.activeTextEditor?.viewColumn) ), - vscode.commands.registerCommand("git-fleet.openRepositoryGraph", (repo: string) => - openGraph(repo, currentPanel ? undefined : vscode.ViewColumn.Beside) - ) + vscode.commands.registerCommand("git-fleet.openRepositoryGraph", (argument: unknown) => { + const repo = repositoryPathFromCommandArgument(argument); + if (repo) { + openGraph(repo, currentPanel ? undefined : vscode.ViewColumn.Beside); + } + }) ); } @@ -142,8 +150,15 @@ export function initExtension( repoManager.setRepos(repoDirs); repoManager.sendRepos(); }; - registerViewCommand(ctx, repoManager, extensionState, avatarManager, gitClient); - registerRepositoryNavigator(ctx, repoManager, extensionState, rescanRepositories); + const navigator = registerRepositoryNavigator( + ctx, + repoManager, + extensionState, + rescanRepositories + ); + registerViewCommand(ctx, repoManager, extensionState, avatarManager, gitClient, (repo) => { + void navigator.revealRepository(repo); + }); const gitWatcher = vscode.workspace.createFileSystemWatcher("**/.git"); ctx.subscriptions.push( diff --git a/src/extension/messageHandler.ts b/src/extension/messageHandler.ts index 0b3df04e..345ef178 100644 --- a/src/extension/messageHandler.ts +++ b/src/extension/messageHandler.ts @@ -92,9 +92,18 @@ export function registerMessageHandlers( extensionState: ExtensionState; avatarManager: AvatarManager; repoFileWatcher: RepoFileWatcher; + onRepositorySelected?: (repo: string) => void; } ) { - const { config, gitClient, repoManager, extensionState, avatarManager, repoFileWatcher } = deps; + const { + config, + gitClient, + repoManager, + extensionState, + avatarManager, + repoFileWatcher, + onRepositorySelected + } = deps; let currentRepo: string | null = null; @@ -106,6 +115,7 @@ export function registerMessageHandlers( gitClient.setRepo(repo); extensionState.setLastActiveRepo(repo); repoFileWatcher.start(repo); + onRepositorySelected?.(repo); } function registerAction( @@ -190,6 +200,34 @@ export function registerMessageHandlers( selectRepo(msg.repo); }); + bridge.onMessage("openTerminal", (msg) => { + vscode.window + .createTerminal({ cwd: msg.repo, name: `Git Fleet: ${path.basename(msg.repo)}` }) + .show(); + }); + + bridge.onMessage("fetchRepository", async (msg) => { + selectRepo(msg.repo); + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: `Fetching ${path.basename(msg.repo)}` }, + () => gitClient.getInstance().fetch(["--all", "--prune"]) + ); + bridge.post({ command: "refresh" }); + } catch (error) { + void vscode.window.showErrorMessage( + `Git Fleet could not fetch ${path.basename(msg.repo)}: ${error instanceof Error ? error.message : String(error)}` + ); + } + }); + + bridge.onMessage("openSettings", () => { + void vscode.commands.executeCommand( + "workbench.action.openSettings", + "@ext:wgrant-dev.git-fleet" + ); + }); + bridge.onMessage("loadRepos", async (msg) => { if (!msg.check || !(await repoManager.checkReposExist())) { bridge.post({ diff --git a/src/extension/repoNavigator.ts b/src/extension/repoNavigator.ts index b1a32691..4d8e37dc 100644 --- a/src/extension/repoNavigator.ts +++ b/src/extension/repoNavigator.ts @@ -7,12 +7,13 @@ import { evalPromises } from "@/backend/utils/promise"; import { config } from "@/config"; import { getRepositorySearchRoots } from "@/extension/repositorySearchRoots"; import { ExtensionState } from "@/extensionState"; -import { RepositoryNavigatorMode } from "@/types"; +import { RepositoryNavigatorLayout, RepositoryNavigatorSort } from "@/types"; import { RepoManager } from "./repoManager"; const VIEW_ID = "git-fleet.repositoryNavigator"; const HIDE_CLEAN_CONTEXT = "gitFleet.hideCleanRepositories"; +const TREE_LAYOUT_CONTEXT = "gitFleet.repositoryLayoutTree"; export type RepositorySummary = { branch: string; @@ -33,6 +34,7 @@ export function filterRepositorySummaries( type RepositoryNode = { kind: "repository"; + parent?: FolderNode; summary: RepositorySummary; }; @@ -40,18 +42,40 @@ type FolderNode = { children: NavigatorNode[]; kind: "folder"; label: string; + parent?: FolderNode; }; type NavigatorNode = FolderNode | RepositoryNode; type MutableFolder = FolderNode & { folders: Map }; -const MODE_LABELS: Record = { +const SORT_LABELS: Record = { activity: "Recent activity", - dirty: "Dirty files", + alphabetical: "Alphabetical", + dirty: "Dirty files" +}; + +const LAYOUT_LABELS: Record = { + list: "List", tree: "Folder tree" }; +export function repositoryPathFromCommandArgument(argument: unknown): string | undefined { + if (typeof argument === "string") { + return argument; + } + if ( + typeof argument === "object" && + argument !== null && + "kind" in argument && + argument.kind === "repository" && + "summary" in argument + ) { + return (argument as RepositoryNode).summary.path; + } + return undefined; +} + function formatAge(timestamp: number): string { if (timestamp === 0) { return "no commits"; @@ -113,23 +137,48 @@ async function loadRepositorySummary(repoPath: string): Promise { if (a.kind !== b.kind) { return a.kind === "folder" ? -1 : 1; } - const aLabel = a.kind === "folder" ? a.label : a.summary.name; - const bLabel = b.kind === "folder" ? b.label : b.summary.name; - return aLabel.localeCompare(bLabel); + if (a.kind === "folder" && b.kind === "folder") { + return a.label.localeCompare(b.label); + } + if (a.kind === "repository" && b.kind === "repository") { + return compareRepositories(a.summary, b.summary, sort); + } + return 0; }); for (const node of nodes) { if (node.kind === "folder") { - sortNodes(node.children); + sortNodes(node.children, sort); } } } -function buildFolderTree(summaries: RepositorySummary[]): NavigatorNode[] { +function buildFolderTree( + summaries: RepositorySummary[], + sort: RepositoryNavigatorSort +): NavigatorNode[] { const searchRoots = getRepositorySearchRoots(); const root: MutableFolder = { children: [], folders: new Map(), kind: "folder", label: "" }; @@ -155,16 +204,26 @@ function buildFolderTree(summaries: RepositorySummary[]): NavigatorNode[] { for (const segment of segments.slice(0, -1)) { let folder = parent.folders.get(segment); if (!folder) { - folder = { children: [], folders: new Map(), kind: "folder", label: segment }; + folder = { + children: [], + folders: new Map(), + kind: "folder", + label: segment, + parent: parent === root ? undefined : parent + }; parent.folders.set(segment, folder); parent.children.push(folder); } parent = folder; } - parent.children.push({ kind: "repository", summary }); + parent.children.push({ + kind: "repository", + parent: parent === root ? undefined : parent, + summary + }); } - sortNodes(root.children); + sortNodes(root.children, sort); return root.children; } @@ -172,6 +231,7 @@ class RepositoryNavigatorProvider implements vscode.TreeDataProvider(); private loading: Promise | undefined; private summaries: RepositorySummary[] | undefined; + private rootNodes: NavigatorNode[] | undefined; public readonly onDidChangeTreeData = this.changeEmitter.event; @@ -180,27 +240,40 @@ class RepositoryNavigatorProvider implements vscode.TreeDataProvider b.dirtyCount - a.dirtyCount || b.latestCommitAt - a.latestCommitAt); - } else { - sorted.sort((a, b) => b.latestCommitAt - a.latestCommitAt || a.name.localeCompare(b.name)); - } - return sorted.map((summary) => ({ kind: "repository", summary })); + sorted.sort((a, b) => compareRepositories(a, b, this.sort)); + this.rootNodes = sorted.map((summary) => ({ kind: "repository", summary })); + return this.rootNodes; + } + + public getParent(node: NavigatorNode): NavigatorNode | undefined { + return node.parent; + } + + public async findRepository(repoPath: string): Promise { + const visit = (nodes: NavigatorNode[]): RepositoryNode | undefined => { + for (const node of nodes) { + if (node.kind === "repository" && node.summary.path === repoPath) { + return node; + } + if (node.kind === "folder") { + const match = visit(node.children); + if (match) { + return match; + } + } + } + return undefined; + }; + return visit(await this.getChildren()); } private getSummaries(): Promise { @@ -291,15 +387,15 @@ export function registerRepositoryNavigator( repoManager: RepoManager, extensionState: ExtensionState, rescanRepositories: () => Promise = async () => {} -): void { +): { revealRepository(repoPath: string): Promise } { const provider = new RepositoryNavigatorProvider(repoManager, extensionState); const treeView = vscode.window.createTreeView(VIEW_ID, { - showCollapseAll: true, + showCollapseAll: false, treeDataProvider: provider }); const updateDescription = () => { - treeView.description = `${MODE_LABELS[provider.mode]}${provider.hideCleanRepositories ? " · Dirty only" : ""}`; + treeView.description = `${LAYOUT_LABELS[provider.layout]} · ${SORT_LABELS[provider.sort]}${provider.hideCleanRepositories ? " · Dirty only" : ""}`; }; updateDescription(); void vscode.commands.executeCommand( @@ -307,6 +403,11 @@ export function registerRepositoryNavigator( HIDE_CLEAN_CONTEXT, provider.hideCleanRepositories ); + void vscode.commands.executeCommand( + "setContext", + TREE_LAYOUT_CONTEXT, + provider.layout === "tree" + ); const setHideCleanRepositories = (hidden: boolean) => { provider.setHideCleanRepositories(hidden); @@ -322,38 +423,79 @@ export function registerRepositoryNavigator( await rescanRepositories(); provider.refresh(); }), - vscode.commands.registerCommand("git-fleet.changeRepositoryNavigatorMode", async () => { - const modes: Array<{ description: string; label: string; mode: RepositoryNavigatorMode }> = [ + vscode.commands.registerCommand("git-fleet.changeRepositoryNavigatorLayout", async () => { + const layouts: Array<{ + description: string; + label: string; + layout: RepositoryNavigatorLayout; + }> = [ { - description: "Newest commit first", - label: MODE_LABELS.activity, - mode: "activity" + description: "One compact repository list", + label: LAYOUT_LABELS.list, + layout: "list" }, { - description: "Most uncommitted files first", - label: MODE_LABELS.dirty, - mode: "dirty" - }, - { - description: "Mirror folders below the workspace", - label: MODE_LABELS.tree, - mode: "tree" + description: "Mirror folders below the search roots", + label: LAYOUT_LABELS.tree, + layout: "tree" } ]; - const selection = await vscode.window.showQuickPick(modes, { - placeHolder: "Choose how repositories are arranged" + const selection = await vscode.window.showQuickPick(layouts, { + placeHolder: "Choose the repository layout" }); if (selection) { - provider.setMode(selection.mode); + provider.setLayout(selection.layout); + void vscode.commands.executeCommand( + "setContext", + TREE_LAYOUT_CONTEXT, + selection.layout === "tree" + ); updateDescription(); } }), + vscode.commands.registerCommand("git-fleet.changeRepositoryNavigatorSort", async () => { + const sorts: Array<{ description: string; label: string; sort: RepositoryNavigatorSort }> = [ + { description: "Newest commit first", label: SORT_LABELS.activity, sort: "activity" }, + { description: "Most uncommitted files first", label: SORT_LABELS.dirty, sort: "dirty" }, + { description: "Repository name", label: SORT_LABELS.alphabetical, sort: "alphabetical" } + ]; + const selection = await vscode.window.showQuickPick(sorts, { + placeHolder: "Choose how repositories are sorted" + }); + if (selection) { + provider.setSort(selection.sort); + updateDescription(); + } + }), + vscode.commands.registerCommand("git-fleet.collapseRepositoryFolders", () => + vscode.commands.executeCommand(`workbench.actions.treeView.${VIEW_ID}.collapseAll`) + ), vscode.commands.registerCommand("git-fleet.hideCleanRepositories", () => setHideCleanRepositories(true) ), vscode.commands.registerCommand("git-fleet.showAllRepositories", () => setHideCleanRepositories(false) ), + vscode.commands.registerCommand("git-fleet.openRepositoryTerminal", (argument: unknown) => { + const repoPath = repositoryPathFromCommandArgument(argument); + if (!repoPath) { + return; + } + vscode.window + .createTerminal({ cwd: repoPath, name: `Git Fleet: ${path.basename(repoPath)}` }) + .show(); + }), + vscode.commands.registerCommand("git-fleet.fetchRepository", async (argument: unknown) => { + const repoPath = repositoryPathFromCommandArgument(argument); + if (!repoPath) { + return; + } + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: `Fetching ${path.basename(repoPath)}` }, + () => simpleGit({ baseDir: repoPath, binary: config.gitPath() }).fetch(["--all", "--prune"]) + ); + provider.refresh(); + }), vscode.workspace.onDidSaveTextDocument(() => provider.refresh()), vscode.window.onDidChangeWindowState((state) => { if (state.focused) { @@ -361,4 +503,13 @@ export function registerRepositoryNavigator( } }) ); + + return { + async revealRepository(repoPath: string) { + const node = await provider.findRepository(repoPath); + if (node) { + await treeView.reveal(node, { expand: true, focus: false, select: true }); + } + } + }; } diff --git a/src/extension/webviewHtml.ts b/src/extension/webviewHtml.ts index 874ff308..0eed23f4 100644 --- a/src/extension/webviewHtml.ts +++ b/src/extension/webviewHtml.ts @@ -62,11 +62,21 @@ export function buildWebviewHtml(opts: { if (numRepos > 0) { body = `
+
${vscode.l10n.t("Repo")}: ${vscode.l10n.t("Branch")}: -
${vscode.l10n.t("Refresh")}
+
+
+ + + + + + +
+
diff --git a/src/extensionState.ts b/src/extensionState.ts index 4d66f274..aa916a9f 100644 --- a/src/extensionState.ts +++ b/src/extensionState.ts @@ -3,13 +3,21 @@ import * as fs from "node:fs"; import { ExtensionContext, Memento } from "vscode"; import { getPathFromStr } from "./backend/utils/path"; -import { Avatar, AvatarCache, GitRepoSet, RepositoryNavigatorMode } from "./types"; +import { + Avatar, + AvatarCache, + GitRepoSet, + RepositoryNavigatorLayout, + RepositoryNavigatorSort +} from "./types"; const AVATAR_STORAGE_FOLDER = "/avatars"; const AVATAR_CACHE = "avatarCache"; const LAST_ACTIVE_REPO = "lastActiveRepo"; const REPO_STATES = "repoStates"; const REPOSITORY_NAVIGATOR_MODE = "repositoryNavigatorMode"; +const REPOSITORY_NAVIGATOR_LAYOUT = "repositoryNavigatorLayout"; +const REPOSITORY_NAVIGATOR_SORT = "repositoryNavigatorSort"; const HIDE_CLEAN_REPOSITORIES = "hideCleanRepositories"; export class ExtensionState { @@ -55,11 +63,26 @@ export class ExtensionState { } /* Repository Navigator */ - public getRepositoryNavigatorMode() { - return this.workspaceState.get(REPOSITORY_NAVIGATOR_MODE, "activity"); + public getRepositoryNavigatorLayout(): RepositoryNavigatorLayout { + const saved = this.workspaceState.get(REPOSITORY_NAVIGATOR_LAYOUT); + if (saved) { + return saved; + } + return this.workspaceState.get(REPOSITORY_NAVIGATOR_MODE) === "tree" ? "tree" : "list"; } - public setRepositoryNavigatorMode(mode: RepositoryNavigatorMode) { - this.workspaceState.update(REPOSITORY_NAVIGATOR_MODE, mode); + public setRepositoryNavigatorLayout(layout: RepositoryNavigatorLayout) { + this.workspaceState.update(REPOSITORY_NAVIGATOR_LAYOUT, layout); + } + public getRepositoryNavigatorSort(): RepositoryNavigatorSort { + const saved = this.workspaceState.get(REPOSITORY_NAVIGATOR_SORT); + if (saved) { + return saved; + } + const legacy = this.workspaceState.get(REPOSITORY_NAVIGATOR_MODE); + return legacy === "dirty" ? "dirty" : legacy === "tree" ? "alphabetical" : "activity"; + } + public setRepositoryNavigatorSort(sort: RepositoryNavigatorSort) { + this.workspaceState.update(REPOSITORY_NAVIGATOR_SORT, sort); } public getHideCleanRepositories() { return this.workspaceState.get(HIDE_CLEAN_REPOSITORIES, false); diff --git a/src/types.ts b/src/types.ts index 4a9fd220..c10396ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,7 +35,8 @@ export type AvatarCache = { [email: string]: Avatar }; export type DateFormat = "Date & Time" | "Date Only" | "Relative"; export type GraphStyle = "rounded" | "angular"; -export type RepositoryNavigatorMode = "activity" | "dirty" | "tree"; +export type RepositoryNavigatorLayout = "list" | "tree"; +export type RepositoryNavigatorSort = "activity" | "dirty" | "alphabetical"; /* Infrastructure Request / Response Messages */ @@ -56,6 +57,20 @@ export type RequestSelectRepo = { repo: string; }; +export type RequestOpenTerminal = { + command: "openTerminal"; + repo: string; +}; + +export type RequestFetchRepository = { + command: "fetchRepository"; + repo: string; +}; + +export type RequestOpenSettings = { + command: "openSettings"; +}; + export type RequestLoadRepos = { command: "loadRepos"; check: boolean; @@ -105,6 +120,9 @@ export type RequestMessage = | QueryRequest | RequestFetchAvatar | RequestSelectRepo + | RequestOpenTerminal + | RequestFetchRepository + | RequestOpenSettings | RequestLoadRepos | RequestSaveRepoState | RequestCopyToClipboard diff --git a/src/webview/dropdown.ts b/src/webview/dropdown.ts index 0c388bc5..ad8f3d75 100644 --- a/src/webview/dropdown.ts +++ b/src/webview/dropdown.ts @@ -127,6 +127,7 @@ export class Dropdown { private render() { this.elem.classList.add("loaded"); this.currentValueElem.innerHTML = escapeHtml(this.options[this.selectedOption].name); + this.currentValueElem.title = this.options[this.selectedOption].value; let html = ""; for (let i = 0; i < this.options.length; i++) { html += @@ -152,11 +153,7 @@ export class Dropdown { this.menuElem.style.cssText = "opacity:0; display:block;"; // Width must be at least 130px for the filter elements. Max height for the dropdown is [filter (31px) + 9.5 * dropdown item (28px) = 297px] // Don't need to add 12px if showing info icons and scrollbar isn't needed. The scrollbar isn't needed if: menuElem height + filter input (25px) < 297px - this.currentValueElem.style.width = - Math.max( - this.menuElem.offsetWidth + (this.showInfo && this.menuElem.offsetHeight < 272 ? 0 : 12), - 130 - ) + "px"; + this.currentValueElem.style.width = "100%"; this.menuElem.style.cssText = "right:0; overflow-y:auto; max-height:297px;"; if (this.dropdownVisible) { this.filter(); diff --git a/src/webview/global.d.ts b/src/webview/global.d.ts index 31b95586..9030f53c 100644 --- a/src/webview/global.d.ts +++ b/src/webview/global.d.ts @@ -116,6 +116,7 @@ declare global { expandedCommit: ExpandedCommit | null; worktrees?: GitWorktree[]; worktreeBaselineRef?: string | null; + worktreesVisible?: boolean; } } diff --git a/src/webview/main.ts b/src/webview/main.ts index 34fb54b6..fb99b2fc 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -30,6 +30,9 @@ class GitGraphView { private worktrees: GitWorktree[] = []; private worktreeBaselineRef: string | null = null; private activeWorktree: { worktree: GitWorktree; item: HTMLElement } | null = null; + private worktreesVisible: boolean = true; + private searchMatches: number[] = []; + private searchPosition: number = -1; private graph: Graph; private config: Config; @@ -91,6 +94,25 @@ class GitGraphView { document.getElementById("refreshBtn")!.addEventListener("click", () => { this.refresh(true); }); + document.getElementById("worktreesBtn")!.addEventListener("click", () => { + this.worktreesVisible = !this.worktreesVisible; + this.saveState(); + this.renderWorktrees(); + }); + document + .getElementById("terminalBtn")! + .addEventListener("click", () => + sendMessage({ command: "openTerminal", repo: this.currentRepo }) + ); + document + .getElementById("fetchBtn")! + .addEventListener("click", () => + sendMessage({ command: "fetchRepository", repo: this.currentRepo }) + ); + document + .getElementById("settingsBtn")! + .addEventListener("click", () => sendMessage({ command: "openSettings" })); + this.registerSearchControls(); this.observeWindowSizeChanges(); this.observeWebviewStyleChanges(); this.observeWebviewScroll(); @@ -99,6 +121,7 @@ class GitGraphView { if (prevState) { this.currentBranch = prevState.currentBranch; this.showRemoteBranches = prevState.showRemoteBranches; + this.worktreesVisible = prevState.worktreesVisible ?? true; this.showRemoteBranchesElem.checked = this.showRemoteBranches; if (typeof this.gitRepos[prevState.currentRepo] !== "undefined") { this.currentRepo = prevState.currentRepo; @@ -401,7 +424,8 @@ class GitGraphView { showRemoteBranches: this.showRemoteBranches, expandedCommit: this.expandedCommit, worktrees: this.worktrees, - worktreeBaselineRef: this.worktreeBaselineRef + worktreeBaselineRef: this.worktreeBaselineRef, + worktreesVisible: this.worktreesVisible }); } @@ -1110,6 +1134,7 @@ class GitGraphView { let windowWidth = window.outerWidth, windowHeight = window.outerHeight; window.addEventListener("resize", () => { + this.updateWorktreeTop(); if (windowWidth === window.outerWidth && windowHeight === window.outerHeight) { this.renderGraph(); } else { @@ -1146,7 +1171,10 @@ class GitGraphView { const header = document.getElementById("worktreeHeader")!; const list = document.getElementById("worktreeList")!; this.clearWorktreeConnections(); - if (this.worktrees.length === 0) { + const toggle = document.getElementById("worktreesBtn")!; + toggle.classList.toggle("active", this.worktreesVisible && this.worktrees.length > 0); + toggle.setAttribute("aria-pressed", String(this.worktreesVisible)); + if (this.worktrees.length === 0 || !this.worktreesVisible) { rail.hidden = true; document.body.classList.remove("worktreesVisible"); header.innerHTML = ""; @@ -1156,6 +1184,7 @@ class GitGraphView { rail.hidden = false; document.body.classList.add("worktreesVisible"); + this.updateWorktreeTop(); header.innerHTML = '
' + escapeHtml(l10n.worktrees) + @@ -1227,6 +1256,96 @@ class GitGraphView { } } + private updateWorktreeTop() { + const controls = document.getElementById("commitSearch")!.hidden + ? document.getElementById("controls")! + : document.getElementById("commitSearch")!; + document.documentElement.style.setProperty( + "--git-fleet-worktree-top", + `${Math.ceil(controls.getBoundingClientRect().bottom + 8)}px` + ); + } + + private registerSearchControls() { + const search = document.getElementById("commitSearch")!; + const input = document.getElementById("commitSearchInput") as HTMLInputElement; + const close = () => { + search.hidden = true; + this.searchMatches = []; + this.searchPosition = -1; + this.updateSearchStatus(); + this.updateWorktreeTop(); + }; + document.getElementById("searchBtn")!.addEventListener("click", () => { + search.hidden = !search.hidden; + if (!search.hidden) { + input.focus(); + input.select(); + this.updateCommitSearch(input.value); + } + this.updateWorktreeTop(); + }); + input.addEventListener("input", () => this.updateCommitSearch(input.value)); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + this.moveCommitSearch(event.shiftKey ? -1 : 1); + } + if (event.key === "Escape") { + close(); + } + }); + document + .getElementById("commitSearchPrevious")! + .addEventListener("click", () => this.moveCommitSearch(-1)); + document + .getElementById("commitSearchNext")! + .addEventListener("click", () => this.moveCommitSearch(1)); + document.getElementById("commitSearchClose")!.addEventListener("click", close); + } + + private updateCommitSearch(query: string) { + const normalized = query.trim().toLocaleLowerCase(); + this.searchMatches = normalized + ? this.commits.flatMap((commit, index) => + `${commit.message} ${commit.author} ${commit.hash} ${commit.refs.map((ref) => ref.name).join(" ")}` + .toLocaleLowerCase() + .includes(normalized) + ? [index] + : [] + ) + : []; + this.searchPosition = this.searchMatches.length > 0 ? 0 : -1; + this.updateSearchStatus(); + if (this.searchPosition >= 0) { + this.revealSearchMatch(); + } + } + + private moveCommitSearch(direction: number) { + if (this.searchMatches.length === 0) { + return; + } + this.searchPosition = + (this.searchPosition + direction + this.searchMatches.length) % this.searchMatches.length; + this.updateSearchStatus(); + this.revealSearchMatch(); + } + + private revealSearchMatch() { + const commit = this.commits[this.searchMatches[this.searchPosition]]; + const row = commit ? this.findCommitRow(commit.hash) : null; + row?.scrollIntoView?.({ block: "center", behavior: "smooth" }); + row?.classList.add("commitSearchMatch"); + window.setTimeout(() => row?.classList.remove("commitSearchMatch"), 900); + } + + private updateSearchStatus() { + document.getElementById("commitSearchStatus")!.textContent = + this.searchMatches.length === 0 + ? "No matches" + : `${this.searchPosition + 1} of ${this.searchMatches.length}`; + } + private findCommitRow(hash: string): HTMLElement | null { const rows = this.tableElem.getElementsByTagName("tr"); for (let i = 0; i < rows.length; i++) { diff --git a/tests/backend/queries/loadCommits/list.test.ts b/tests/backend/queries/loadCommits/list.test.ts index d0eb1186..50fa737b 100644 --- a/tests/backend/queries/loadCommits/list.test.ts +++ b/tests/backend/queries/loadCommits/list.test.ts @@ -177,6 +177,31 @@ describe("loadCommits", () => { expect(allRefs.every((r) => r.type !== "remote")).toBe(true); }); + it("includes the stash ref in show-all history", async () => { + const stashRepo = makeRepo(); + try { + fs.writeFileSync(path.join(stashRepo, "file"), "stashed change"); + git(["add", "file"], stashRepo); + git(["stash", "push", "-m", "fleet stash"], stashRepo); + const result = await loadCommits(simpleGit(stashRepo), { + branchName: "", + maxCommits: 300, + showRemoteBranches: false, + hard: false, + dateType: "Author Date", + showUncommittedChanges: false + }); + + expect(result.commits.flatMap((commit) => commit.refs)).toContainEqual({ + hash: expect.any(String), + name: "stash", + type: "tag" + }); + } finally { + fs.rmSync(stashRepo, { recursive: true, force: true }); + } + }); + it("uses commit date when dateType is Commit Date", async () => { const result = await loadCommits(simpleGit(repo), { branchName: "", diff --git a/tests/webview/setup.ts b/tests/webview/setup.ts index c609d221..aaf74de7 100644 --- a/tests/webview/setup.ts +++ b/tests/webview/setup.ts @@ -32,7 +32,19 @@ export function setupHtml(viewState: GG.GitGraphViewState) { Show Remote Branches
Refresh
+ + + + +
+
diff --git a/tests/webview/workingTreeAndWorktrees.test.ts b/tests/webview/workingTreeAndWorktrees.test.ts index 61b911b0..b3f7a46e 100644 --- a/tests/webview/workingTreeAndWorktrees.test.ts +++ b/tests/webview/workingTreeAndWorktrees.test.ts @@ -97,4 +97,33 @@ describe("working tree and worktree rendering", () => { expect(document.querySelector(".worktreeItem")!.textContent).toContain("main"); expect(document.body.classList.contains("worktreesVisible")).toBe(true); }); + + it("toggles worktree visibility without removing worktree data", () => { + document.getElementById("worktreesBtn")!.click(); + expect(document.getElementById("worktreeRail")!.hidden).toBe(true); + expect(document.body.classList.contains("worktreesVisible")).toBe(false); + + document.getElementById("worktreesBtn")!.click(); + expect(document.getElementById("worktreeRail")!.hidden).toBe(false); + }); + + it("sends repository-scoped toolbar actions", () => { + vscodeMock.clearMessages(); + document.getElementById("terminalBtn")!.click(); + document.getElementById("fetchBtn")!.click(); + document.getElementById("settingsBtn")!.click(); + + expect(vscodeMock.sentMessages).toContainEqual({ command: "openTerminal", repo: REPO }); + expect(vscodeMock.sentMessages).toContainEqual({ command: "fetchRepository", repo: REPO }); + expect(vscodeMock.sentMessages).toContainEqual({ command: "openSettings" }); + }); + + it("searches loaded commit messages and reports the match", () => { + document.getElementById("searchBtn")!.click(); + const input = document.getElementById("commitSearchInput") as HTMLInputElement; + input.value = "Initial"; + input.dispatchEvent(new Event("input", { bubbles: true })); + + expect(document.getElementById("commitSearchStatus")!.textContent).toBe("1 of 1"); + }); });