From 7b58b3394dede58df6dcdee65a7390d029cfc274 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 20 Jun 2026 16:00:23 +0100 Subject: [PATCH 1/7] feat(worktrees): add sidebar worktree panel --- electron/fileViewer/gitDiffService.ts | 218 +++++++++++++ electron/main.ts | 18 ++ electron/preload.ts | 7 + src/App.tsx | 268 +++++++++++++--- src/components/git-panel/WorktreesPanel.tsx | 327 ++++++++++++++++++++ src/components/git-panel/gitPanel.css | 171 ++++++++++ src/terminalSettings.ts | 40 +++ src/types/settings.ts | 2 + src/types/terminay.ts | 27 ++ 9 files changed, 1029 insertions(+), 49 deletions(-) create mode 100644 src/components/git-panel/WorktreesPanel.tsx diff --git a/electron/fileViewer/gitDiffService.ts b/electron/fileViewer/gitDiffService.ts index 6d0f214..b436f0b 100644 --- a/electron/fileViewer/gitDiffService.ts +++ b/electron/fileViewer/gitDiffService.ts @@ -8,6 +8,8 @@ import type { GitChangeEntry, GitFileState, GitPanelStatus, + GitWorktreeStatus, + WorktreePanelStatus, } from '../../src/types/terminay' import { getGitWorkingDirectory } from './pathUtils' import type { FileBufferService } from './fileBufferService' @@ -27,6 +29,13 @@ function isMissingGitError(error: unknown): boolean { return candidate?.code === 'ENOENT' } +function isNotWorkingTreeError(error: unknown): boolean { + const candidate = error as { stderr?: unknown; message?: unknown } + const stderr = typeof candidate.stderr === 'string' ? candidate.stderr : '' + const message = typeof candidate.message === 'string' ? candidate.message : '' + return `${stderr}\n${message}`.includes('is not a working tree') +} + export class GitDiffService { constructor(private readonly fileBufferService: FileBufferService) {} @@ -129,6 +138,130 @@ export class GitDiffService { } } + async getWorktreePanelStatus(rawPath: string): Promise { + const info = await this.fileBufferService.getFileInfo(rawPath) + const workingDirectory = getGitWorkingDirectory(info.path, info.isDirectory) + + let repoRoot: string | null = null + + try { + const result = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: workingDirectory }) + repoRoot = result.stdout.trim() || null + } catch (error) { + if (isMissingGitError(error)) { + return { + gitAvailable: false, + repoRoot: null, + worktrees: [], + } + } + + return { + gitAvailable: true, + repoRoot: null, + worktrees: [], + } + } + + if (!repoRoot) { + return { + gitAvailable: true, + repoRoot: null, + worktrees: [], + } + } + + const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { + cwd: workingDirectory, + }) + + const worktrees = parseWorktreeList(stdout, repoRoot) + + const withEntries = await Promise.all( + worktrees.map(async (worktree): Promise => { + if (worktree.isBare || worktree.isPrunable) { + return worktree + } + + const worktreeInfo = await this.fileBufferService.getFileInfo(worktree.path) + if (!worktreeInfo.exists || !worktreeInfo.isDirectory) { + return { + ...worktree, + isPrunable: true, + errorMessage: 'Worktree path no longer exists.', + entries: [], + } + } + + try { + const { stdout: statusOutput } = await execFileAsync( + 'git', + ['status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all', '--ignored=no'], + { cwd: worktree.path }, + ) + const { branch, entries } = parsePanelEntries(statusOutput, worktree.path) + const resolvedBranch = + branch === null + ? await this.resolveDetachedBranch(worktree.path) + : branch || worktree.branch + const aheadOfMainCount = await this.getAheadOfMainCount(worktree.path) + + return { + ...worktree, + aheadOfMainCount, + branch: resolvedBranch, + entries, + isDirtyBranch: aheadOfMainCount !== null && aheadOfMainCount > 0, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ...worktree, + errorMessage: message, + entries: [], + } + } + }), + ) + + return { + gitAvailable: true, + repoRoot, + worktrees: withEntries, + } + } + + async moveWorktree(rawRepoPath: string, rawWorktreePath: string, rawNewPath: string): Promise { + const cwd = await this.resolveGitCommandCwd(rawRepoPath) + const worktreePath = this.fileBufferService.normalizePath(rawWorktreePath) + const newPath = this.fileBufferService.normalizePath(rawNewPath) + + await execFileAsync('git', ['worktree', 'move', worktreePath, newPath], { cwd }) + } + + async removeWorktree(rawRepoPath: string, rawWorktreePath: string, force: boolean): Promise { + const cwd = await this.resolveGitCommandCwd(rawRepoPath) + const worktreePath = this.fileBufferService.normalizePath(rawWorktreePath) + const args = force + ? ['worktree', 'remove', '--force', worktreePath] + : ['worktree', 'remove', worktreePath] + + try { + await execFileAsync('git', args, { cwd }) + } catch (error) { + if (!isNotWorkingTreeError(error)) { + throw error + } + + await execFileAsync('git', ['worktree', 'prune'], { cwd }) + } + } + + private async resolveGitCommandCwd(rawPath: string): Promise { + const info = await this.fileBufferService.getFileInfo(rawPath) + return getGitWorkingDirectory(info.path, info.isDirectory) + } + private async resolveDetachedBranch(cwd: string): Promise { try { const result = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd }) @@ -138,6 +271,16 @@ export class GitDiffService { } } + private async getAheadOfMainCount(cwd: string): Promise { + try { + const result = await execFileAsync('git', ['rev-list', '--count', 'main..HEAD'], { cwd }) + const count = Number.parseInt(result.stdout.trim(), 10) + return Number.isFinite(count) ? count : null + } catch { + return null + } + } + async getRepoInfo(rawPath: string): Promise { const context = await this.getGitContext(rawPath) @@ -246,6 +389,81 @@ export class GitDiffService { } } +function parseWorktreeList(output: string, currentRepoRoot: string): GitWorktreeStatus[] { + const sections = output + .split(/\r?\n\r?\n/) + .map((section) => section.trim()) + .filter((section) => section.length > 0) + const normalizedCurrentRepoRoot = path.resolve(currentRepoRoot) + + return sections.flatMap((section, sectionIndex) => { + const lines = section.split(/\r?\n/) + let worktreePath: string | null = null + let branch: string | null = null + let head: string | null = null + let isBare = false + let isDetached = false + let isLocked = false + let isPrunable = false + + for (const line of lines) { + if (line.startsWith('worktree ')) { + worktreePath = line.slice('worktree '.length) + } else if (line.startsWith('HEAD ')) { + head = line.slice('HEAD '.length).trim() || null + } else if (line.startsWith('branch ')) { + branch = normalizeWorktreeBranch(line.slice('branch '.length)) + } else if (line === 'bare') { + isBare = true + } else if (line === 'detached') { + isDetached = true + } else if (line.startsWith('locked')) { + isLocked = true + } else if (line.startsWith('prunable')) { + isPrunable = true + } + } + + if (!worktreePath) { + return [] + } + + const resolvedPath = path.resolve(worktreePath) + + return [ + { + path: resolvedPath, + name: path.basename(resolvedPath) || resolvedPath, + branch, + head, + aheadOfMainCount: null, + isCurrent: resolvedPath === normalizedCurrentRepoRoot, + isDirtyBranch: false, + isMain: sectionIndex === 0, + isBare, + isDetached, + isLocked, + isPrunable, + entries: [], + }, + ] + }) +} + +function normalizeWorktreeBranch(refName: string): string | null { + const trimmed = refName.trim() + if (!trimmed) { + return null + } + + const headsPrefix = 'refs/heads/' + if (trimmed.startsWith(headsPrefix)) { + return trimmed.slice(headsPrefix.length) + } + + return trimmed +} + function parseExplorerStatuses(output: string, rootPath: string): Record { const result: Record = {} const entries = output.split('\0').filter((entry) => entry.length > 0) diff --git a/electron/main.ts b/electron/main.ts index c3b17f9..446633f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2371,6 +2371,24 @@ ipcMain.handle('fs:get-git-panel-status', async (_event, payload: { dirPath: str return gitDiffService.getPanelStatus(payload.dirPath) }) +ipcMain.handle('fs:get-worktree-panel-status', async (_event, payload: { dirPath: string }) => { + return gitDiffService.getWorktreePanelStatus(payload.dirPath) +}) + +ipcMain.handle( + 'fs:move-git-worktree', + async (_event, payload: { repoPath: string; worktreePath: string; newPath: string }) => { + await gitDiffService.moveWorktree(payload.repoPath, payload.worktreePath, payload.newPath) + }, +) + +ipcMain.handle( + 'fs:remove-git-worktree', + async (_event, payload: { force?: boolean; repoPath: string; worktreePath: string }) => { + await gitDiffService.removeWorktree(payload.repoPath, payload.worktreePath, payload.force === true) + }, +) + ipcMain.handle('fs:rename', async (_event, { oldPath, newPath }: { oldPath: string; newPath: string }) => { await rename(oldPath, newPath) }) diff --git a/electron/preload.ts b/electron/preload.ts index b83965a..a273f44 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -51,6 +51,7 @@ import type { TerminalRecordingState, TerminayTestApi, TerminalZoomMessage, + WorktreePanelStatus, } from '../src/types/terminay' type ElectronListener = (_event: Electron.IpcRendererEvent, payload: T) => void @@ -64,6 +65,12 @@ contextBridge.exposeInMainWorld('terminay', { ipcRenderer.invoke('fs:get-git-statuses', { dirPath }) as Promise, getGitPanelStatus: (dirPath: string) => ipcRenderer.invoke('fs:get-git-panel-status', { dirPath }) as Promise, + getWorktreePanelStatus: (dirPath: string) => + ipcRenderer.invoke('fs:get-worktree-panel-status', { dirPath }) as Promise, + moveGitWorktree: (payload: { repoPath: string; worktreePath: string; newPath: string }) => + ipcRenderer.invoke('fs:move-git-worktree', payload) as Promise, + removeGitWorktree: (payload: { force?: boolean; repoPath: string; worktreePath: string }) => + ipcRenderer.invoke('fs:remove-git-worktree', payload) as Promise, getFileInfo: (filePath: string) => ipcRenderer.invoke('file:get-info', { path: filePath }) as Promise, readFileBytes: (options: { path: string; start: number; length: number }) => ipcRenderer.invoke('file:read-bytes', options) as Promise, diff --git a/src/App.tsx b/src/App.tsx index 555355e..4d003e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,6 +45,7 @@ import { FilePanel, FileTab } from './components/file-viewer'; import type { FolderPanelInstanceParams } from './components/folder-viewer'; import { FolderPanel, FolderTab } from './components/folder-viewer'; import { GitPanel } from './components/git-panel/GitPanel'; +import { WorktreesPanel } from './components/git-panel/WorktreesPanel'; import { SidebarPane } from './components/sidebar/SidebarPane'; import { SidebarSplit } from './components/sidebar/SidebarSplit'; import { McpInstallModal } from './components/McpInstallModal'; @@ -86,10 +87,12 @@ import type { FileExplorerGitStatus, FileSearchResult, GitChangeEntry, + GitWorktreeStatus, GitPanelStatus, RemoteAccessStatus, TerminalRecordingStartMetadata, TerminalRecordingState, + WorktreePanelStatus, } from './types/terminay'; import type { FileViewerMode } from './types/fileViewer'; import './App.css'; @@ -201,7 +204,9 @@ type ProjectTab = { isFileExplorerOpen: boolean; isExplorerPaneCollapsed: boolean; isGitPaneCollapsed: boolean; + isWorktreesPaneCollapsed: boolean; sidebarExplorerHeight: number; + sidebarGitHeight: number; rootFolder: string; }; @@ -477,7 +482,10 @@ function createProjectTab( isFileExplorerOpen: false, isExplorerPaneCollapsed: sidebarDefaults.defaultExplorerState === 'collapsed', isGitPaneCollapsed: sidebarDefaults.defaultGitState === 'collapsed', + isWorktreesPaneCollapsed: + sidebarDefaults.defaultWorktreesState === 'collapsed', sidebarExplorerHeight: sidebarDefaults.defaultExplorerPaneHeight, + sidebarGitHeight: sidebarDefaults.defaultGitPaneHeight, rootFolder: homePath, }; } @@ -1605,6 +1613,20 @@ function joinFileExplorerPath(dirPath: string, name: string): string { return `${dirPath}/${name}`; } +function getFileExplorerPathParent(filePath: string): string { + const trimmedPath = filePath.replace(/[\\/]+$/, ''); + const lastSlash = Math.max( + trimmedPath.lastIndexOf('/'), + trimmedPath.lastIndexOf('\\'), + ); + + if (lastSlash <= 0) { + return lastSlash === 0 ? trimmedPath.slice(0, 1) : ''; + } + + return trimmedPath.slice(0, lastSlash); +} + const ProjectWorkspace = forwardRef< ProjectWorkspaceHandle, ProjectWorkspaceProps @@ -1678,6 +1700,8 @@ const ProjectWorkspace = forwardRef< const [gitPanelStatus, setGitPanelStatus] = useState( null, ); + const [worktreePanelStatus, setWorktreePanelStatus] = + useState(null); const [gitPushMenuPosition, setGitPushMenuPosition] = useState<{ x: number; y: number; @@ -2159,6 +2183,7 @@ const ProjectWorkspace = forwardRef< if (!project.rootFolder) { setGitStatuses({}); setGitPanelStatus(null); + setWorktreePanelStatus(null); return; } @@ -2168,15 +2193,18 @@ const ProjectWorkspace = forwardRef< isRefreshingGitStatusesRef.current = true; try { - const [nextStatuses, nextPanel] = await Promise.all([ + const [nextStatuses, nextPanel, nextWorktrees] = await Promise.all([ window.terminay.getFileExplorerGitStatuses(project.rootFolder), window.terminay.getGitPanelStatus(project.rootFolder), + window.terminay.getWorktreePanelStatus(project.rootFolder), ]); setGitStatuses(nextStatuses.statuses); setGitPanelStatus(nextPanel); + setWorktreePanelStatus(nextWorktrees); } catch { setGitStatuses({}); setGitPanelStatus(null); + setWorktreePanelStatus(null); } finally { isRefreshingGitStatusesRef.current = false; } @@ -2755,6 +2783,95 @@ const ProjectWorkspace = forwardRef< ], ); + const handleSwitchProjectRootToWorktree = useCallback( + (worktree: GitWorktreeStatus) => { + onUpdateProject(project.id, { rootFolder: worktree.path }); + setExpandedPaths({ [worktree.path]: true }); + setErrorText(null); + }, + [onUpdateProject, project.id], + ); + + const handleRenameWorktree = useCallback( + async (worktree: GitWorktreeStatus) => { + const nextName = await requestFileExplorerName({ + initialValue: worktree.name, + label: 'Worktree folder name', + submitLabel: 'Rename', + title: 'Rename Worktree', + }); + if (!nextName || nextName === worktree.name) { + return; + } + + const parentDir = getFileExplorerPathParent(worktree.path); + const newPath = joinFileExplorerPath(parentDir, nextName); + + try { + await window.terminay.moveGitWorktree({ + repoPath: project.rootFolder, + worktreePath: worktree.path, + newPath, + }); + if (project.rootFolder === worktree.path) { + onUpdateProject(project.id, { rootFolder: newPath }); + setExpandedPaths({ [newPath]: true }); + } + void loadDirectory(parentDir || project.rootFolder); + void refreshGitStatuses(); + } catch (error) { + setErrorText(`Failed to rename worktree: ${String(error)}`); + } + }, + [ + loadDirectory, + onUpdateProject, + project.id, + project.rootFolder, + refreshGitStatuses, + requestFileExplorerName, + ], + ); + + const handleDeleteWorktree = useCallback( + async (worktree: GitWorktreeStatus) => { + if ( + !window.confirm( + `Delete worktree "${worktree.name}"?\n\n${worktree.path}\n\nThis permanently removes this worktree folder, including uncommitted and untracked files.`, + ) + ) { + return; + } + + try { + await window.terminay.removeGitWorktree({ + force: true, + repoPath: project.rootFolder, + worktreePath: worktree.path, + }); + setErrorText(null); + const parentDir = getFileExplorerPathParent(worktree.path); + void loadDirectory(parentDir || project.rootFolder); + void refreshGitStatuses(); + } catch (error) { + setErrorText(`Failed to delete worktree: ${String(error)}`); + void refreshGitStatuses(); + } + }, + [loadDirectory, project.rootFolder, refreshGitStatuses], + ); + + const handleRevealWorktree = useCallback((worktree: GitWorktreeStatus) => { + void window.terminay.revealInOS(worktree.path); + }, []); + + const handleOpenTerminalAtWorktree = useCallback( + (worktree: GitWorktreeStatus) => { + void handleOpenTerminalAt(worktree.path); + }, + [handleOpenTerminalAt], + ); + const handleOpenGitEntry = useCallback( (entry: GitChangeEntry) => { // Untracked files have no diff to show; open them directly. Everything @@ -5900,7 +6017,10 @@ const ProjectWorkspace = forwardRef< > { @@ -5948,60 +6068,110 @@ const ProjectWorkspace = forwardRef< } bottom={ - { - const next = !project.isGitPaneCollapsed; + { onUpdateProject(project.id, { - isGitPaneCollapsed: next, + sidebarGitHeight: height, }); + }} + onTopHeightCommit={(height) => { updateSidebarSettings({ - defaultGitState: next ? 'collapsed' : 'expanded', + defaultGitPaneHeight: height, }); }} - count={gitPanelStatus?.entries.length} - accessory={ - gitPanelStatus?.branch ? ( - - {gitPanelStatus.branch} - - ) : null + top={ + { + const next = !project.isGitPaneCollapsed; + onUpdateProject(project.id, { + isGitPaneCollapsed: next, + }); + updateSidebarSettings({ + defaultGitState: next ? 'collapsed' : 'expanded', + }); + }} + count={gitPanelStatus?.entries.length} + accessory={ + gitPanelStatus?.branch ? ( + + {gitPanelStatus.branch} + + ) : null + } + actions={ + project.isGitPaneCollapsed ? undefined : ( + + ) + } + > + + } - actions={ - project.isGitPaneCollapsed ? undefined : ( - - ) + bottom={ + { + const next = !project.isWorktreesPaneCollapsed; + onUpdateProject(project.id, { + isWorktreesPaneCollapsed: next, + }); + updateSidebarSettings({ + defaultWorktreesState: next + ? 'collapsed' + : 'expanded', + }); + }} + count={worktreePanelStatus?.worktrees.length} + > + + } - > - - + /> } /> diff --git a/src/components/git-panel/WorktreesPanel.tsx b/src/components/git-panel/WorktreesPanel.tsx new file mode 100644 index 0000000..aebe95e --- /dev/null +++ b/src/components/git-panel/WorktreesPanel.tsx @@ -0,0 +1,327 @@ +import { + ChevronDown, + FileEdit, + FolderInput, + FolderOpen, + GitBranch, + Terminal, + Trash2, +} from 'lucide-react'; +import { type JSX, type MouseEvent, useEffect, useRef, useState } from 'react'; +import type { + GitChangeEntry, + GitWorktreeStatus, + WorktreePanelStatus, +} from '../../types/terminay'; +import { ContextMenu, type ContextMenuItem } from '../ContextMenu'; +import { GitPanel } from './GitPanel'; +import './gitPanel.css'; + +export type WorktreesPanelProps = { + status: WorktreePanelStatus | null; + viewMode: 'list' | 'tree'; + onDeleteWorktree: (worktree: GitWorktreeStatus) => void; + onOpenEntry: (entry: GitChangeEntry) => void; + onOpenTerminal: (worktree: GitWorktreeStatus) => void; + onRenameWorktree: (worktree: GitWorktreeStatus) => void; + onRevealWorktree: (worktree: GitWorktreeStatus) => void; + onSwitchProjectRoot: (worktree: GitWorktreeStatus) => void; +}; + +function getWorktreeTitle(worktree: GitWorktreeStatus): string { + const parts = [worktree.path]; + if (worktree.branch) { + parts.push(`Branch: ${worktree.branch}`); + } + if (worktree.head) { + parts.push(`HEAD: ${worktree.head.slice(0, 12)}`); + } + if (worktree.aheadOfMainCount !== null) { + parts.push( + worktree.aheadOfMainCount > 0 + ? `${worktree.aheadOfMainCount} commit${worktree.aheadOfMainCount === 1 ? '' : 's'} ahead of main` + : 'No commits ahead of main', + ); + } + if (worktree.entries.length > 0) { + parts.push( + `${worktree.entries.length} file change${worktree.entries.length === 1 ? '' : 's'}`, + ); + } + return parts.join('\n'); +} + +export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { + const { + status, + viewMode, + onDeleteWorktree, + onOpenEntry, + onOpenTerminal, + onRenameWorktree, + onRevealWorktree, + onSwitchProjectRoot, + } = props; + const initializedWorktreesRef = useRef>(new Set()); + const [collapsedWorktrees, setCollapsedWorktrees] = useState>( + () => new Set(), + ); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + worktree: GitWorktreeStatus; + } | null>(null); + + useEffect(() => { + if (!status?.repoRoot) { + initializedWorktreesRef.current = new Set(); + setCollapsedWorktrees(new Set()); + return; + } + + const currentPaths = new Set(status.worktrees.map((worktree) => worktree.path)); + setCollapsedWorktrees((prev) => { + const next = new Set( + Array.from(prev).filter((worktreePath) => + currentPaths.has(worktreePath), + ), + ); + + for (const worktreePath of currentPaths) { + if (!initializedWorktreesRef.current.has(worktreePath)) { + next.add(worktreePath); + } + } + + return next; + }); + initializedWorktreesRef.current = currentPaths; + }, [status]); + + const toggleWorktree = (worktreePath: string) => { + setCollapsedWorktrees((prev) => { + const next = new Set(prev); + if (next.has(worktreePath)) { + next.delete(worktreePath); + } else { + next.add(worktreePath); + } + return next; + }); + }; + + const openContextMenu = ( + event: MouseEvent, + worktree: GitWorktreeStatus, + ) => { + event.preventDefault(); + event.stopPropagation(); + setContextMenu({ + x: event.clientX, + y: event.clientY, + worktree, + }); + }; + + if (status === null) { + return ( +
+
Loading…
+
+ ); + } + + if (!status.gitAvailable) { + return ( +
+
Git is not available
+
+ ); + } + + if (!status.repoRoot) { + return ( +
+
Not a git repository
+
+ ); + } + + if (status.worktrees.length === 0) { + return ( +
+
No worktrees
+
+ ); + } + + return ( +
+ {status.worktrees.map((worktree) => { + const collapsed = collapsedWorktrees.has(worktree.path); + const branchLabel = worktree.branch ?? (worktree.isDetached ? 'HEAD' : ''); + const hasUnmergedOrUncommittedWork = + worktree.isDirtyBranch || worktree.entries.length > 0; + const worktreeStatus = { + gitAvailable: status.gitAvailable, + repoRoot: worktree.path, + branch: worktree.branch, + entries: worktree.entries, + }; + + return ( +
+ + {collapsed ? null : worktree.errorMessage ? ( +
{worktree.errorMessage}
+ ) : worktree.isBare ? ( +
Bare worktree
+ ) : worktree.isPrunable ? ( +
Prunable worktree
+ ) : ( +
+ +
+ )} +
+ ); + })} + {contextMenu ? ( + setContextMenu(null)} + items={buildWorktreeContextMenuItems({ + onDeleteWorktree, + onOpenTerminal, + onRenameWorktree, + onRevealWorktree, + onSwitchProjectRoot, + worktree: contextMenu.worktree, + })} + /> + ) : null} +
+ ); +} + +function buildWorktreeContextMenuItems(options: { + onDeleteWorktree: (worktree: GitWorktreeStatus) => void; + onOpenTerminal: (worktree: GitWorktreeStatus) => void; + onRenameWorktree: (worktree: GitWorktreeStatus) => void; + onRevealWorktree: (worktree: GitWorktreeStatus) => void; + onSwitchProjectRoot: (worktree: GitWorktreeStatus) => void; + worktree: GitWorktreeStatus; +}): ContextMenuItem[] { + const { + onDeleteWorktree, + onOpenTerminal, + onRenameWorktree, + onRevealWorktree, + onSwitchProjectRoot, + worktree, + } = options; + const unavailable = worktree.isBare || worktree.isPrunable; + const cannotMoveOrRemove = + worktree.isCurrent || worktree.isMain || worktree.isBare; + + return [ + { + label: 'Switch project root', + icon: , + disabled: worktree.isCurrent || unavailable, + onClick: () => onSwitchProjectRoot(worktree), + }, + { + label: 'Rename worktree', + icon: , + disabled: cannotMoveOrRemove || worktree.isPrunable, + onClick: () => onRenameWorktree(worktree), + }, + { + label: 'Delete worktree', + icon: , + danger: true, + disabled: cannotMoveOrRemove, + onClick: () => onDeleteWorktree(worktree), + }, + { separator: true, label: '', onClick: () => {} }, + { + label: 'Open terminal here', + icon: , + disabled: unavailable, + onClick: () => onOpenTerminal(worktree), + }, + { + label: 'Reveal in OS', + icon: , + disabled: unavailable, + onClick: () => onRevealWorktree(worktree), + }, + ]; +} diff --git a/src/components/git-panel/gitPanel.css b/src/components/git-panel/gitPanel.css index b4b6fe2..1543575 100644 --- a/src/components/git-panel/gitPanel.css +++ b/src/components/git-panel/gitPanel.css @@ -218,3 +218,174 @@ .git-panel__badge--conflicted { color: #e2a03f; } + +.worktrees-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + width: 100%; + overflow-y: auto; + background: #0d1014; + color: #ffffff; + font-size: 13px; + padding: 4px 0; +} + +.worktrees-panel::-webkit-scrollbar { + width: 10px; +} + +.worktrees-panel::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 6px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.worktrees-panel::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); + background-clip: padding-box; +} + +.worktrees-panel__worktree { + display: flex; + flex-direction: column; + min-width: 0; +} + +.worktrees-panel__worktree + .worktrees-panel__worktree { + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.worktrees-panel__worktree-header { + all: unset; + box-sizing: border-box; + min-height: 28px; + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px 2px 12px; + color: #ffffff; + cursor: pointer; + font-size: 13px; + transition: background 100ms ease; + user-select: none; +} + +.worktrees-panel__worktree-header:hover { + background: rgba(255, 255, 255, 0.05); +} + +.worktrees-panel__worktree-header:focus-visible { + background: rgba(255, 255, 255, 0.08); + outline: 1px solid rgba(87, 183, 255, 0.5); + outline-offset: -1px; +} + +.worktrees-panel__worktree-header--dirty .worktrees-panel__worktree-name { + color: #e2c08d; +} + +.worktrees-panel__worktree-header--current .worktrees-panel__worktree-name { + color: #73c991; +} + +.worktrees-panel__worktree-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: rgba(220, 226, 240, 0.3); +} + +.worktrees-panel__worktree-icon--dirty { + color: #e2c08d; +} + +.worktrees-panel__worktree-icon--current { + color: #73c991; +} + +.worktrees-panel__worktree-main { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.worktrees-panel__worktree-name { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: rgba(220, 226, 240, 0.9); + line-height: 16px; +} + +.worktrees-panel__worktree-path { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: rgba(220, 226, 240, 0.34); + font-size: 11px; + line-height: 13px; +} + +.worktrees-panel__branch, +.worktrees-panel__pill, +.worktrees-panel__count { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + max-width: 88px; + min-width: 0; + height: 18px; + padding: 0 6px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.08); + color: rgba(220, 226, 240, 0.58); + font-size: 10px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.worktrees-panel__pill { + color: #57b7ff; +} + +.worktrees-panel__count { + width: 18px; + padding: 0; + color: rgba(220, 226, 240, 0.62); +} + +.worktrees-panel__changes { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + margin-left: 28px; + padding-left: 10px; + border-left: 1px solid rgba(220, 226, 240, 0.08); +} + +.worktrees-panel__changes .git-panel { + flex: 0 0 auto; + min-height: 0; + overflow: visible; + background: transparent; + padding: 0 0 6px; +} + +.worktrees-panel__changes .git-panel__message { + padding-left: 20px; +} diff --git a/src/terminalSettings.ts b/src/terminalSettings.ts index 0fa7945..da9f7af 100644 --- a/src/terminalSettings.ts +++ b/src/terminalSettings.ts @@ -260,8 +260,10 @@ export const defaultTerminalSettings: TerminalSettings = { gitPanelViewMode: 'tree', defaultExplorerState: 'expanded', defaultGitState: 'expanded', + defaultWorktreesState: 'expanded', defaultWidth: 280, defaultExplorerPaneHeight: 320, + defaultGitPaneHeight: 180, }, theme: { foreground: '#dce2f0', @@ -925,6 +927,20 @@ export const terminalSettingsSections: SettingsSectionDefinition[] = [ ], keywords: ['sidebar', 'git', 'collapse', 'expand', 'default'], }), + makeField({ + key: 'sidebar.defaultWorktreesState', + label: 'Default Worktrees state', + description: + 'Whether the Worktrees pane starts expanded or collapsed in new projects.', + sectionId: 'sidebar', + categoryId: 'files', + input: 'select', + options: [ + { label: 'Expanded', value: 'expanded' }, + { label: 'Collapsed', value: 'collapsed' }, + ], + keywords: ['sidebar', 'worktrees', 'collapse', 'expand', 'default'], + }), makeField({ key: 'sidebar.defaultWidth', label: 'Default sidebar width', @@ -958,6 +974,19 @@ export const terminalSettingsSections: SettingsSectionDefinition[] = [ 'default', ], }), + makeField({ + key: 'sidebar.defaultGitPaneHeight', + label: 'Default Git pane height', + description: + 'Initial height in pixels of the Git pane above the Worktrees pane in new projects.', + sectionId: 'sidebar', + categoryId: 'files', + input: 'number', + min: 80, + max: 1000, + step: 10, + keywords: ['sidebar', 'git', 'worktrees', 'height', 'size', 'default'], + }), ], }, { @@ -2325,6 +2354,11 @@ export function normalizeTerminalSettings( sidebarInput.defaultGitState === 'expanded' ? sidebarInput.defaultGitState : defaultTerminalSettings.sidebar.defaultGitState, + defaultWorktreesState: + sidebarInput.defaultWorktreesState === 'collapsed' || + sidebarInput.defaultWorktreesState === 'expanded' + ? sidebarInput.defaultWorktreesState + : defaultTerminalSettings.sidebar.defaultWorktreesState, defaultWidth: clampNumber( Number(sidebarInput.defaultWidth), defaultTerminalSettings.sidebar.defaultWidth, @@ -2337,6 +2371,12 @@ export function normalizeTerminalSettings( 80, 2000, ), + defaultGitPaneHeight: clampNumber( + Number(sidebarInput.defaultGitPaneHeight), + defaultTerminalSettings.sidebar.defaultGitPaneHeight, + 80, + 2000, + ), }, theme: { foreground: diff --git a/src/types/settings.ts b/src/types/settings.ts index fcabf95..1ab2ef2 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -68,8 +68,10 @@ export type SidebarSettings = { gitPanelViewMode: GitPanelViewMode; defaultExplorerState: SidebarPaneState; defaultGitState: SidebarPaneState; + defaultWorktreesState: SidebarPaneState; defaultWidth: number; defaultExplorerPaneHeight: number; + defaultGitPaneHeight: number; }; export type FileViewerSettings = { diff --git a/src/types/terminay.ts b/src/types/terminay.ts index 1952a6f..5dc79e0 100644 --- a/src/types/terminay.ts +++ b/src/types/terminay.ts @@ -236,6 +236,30 @@ export type GitPanelStatus = { entries: GitChangeEntry[] } +export type GitWorktreeStatus = { + path: string + name: string + /** Branch name, or a short detached-HEAD label, or null when unknown. */ + branch: string | null + head: string | null + aheadOfMainCount: number | null + isDirtyBranch: boolean + isCurrent: boolean + isMain: boolean + isBare: boolean + isDetached: boolean + isLocked: boolean + isPrunable: boolean + errorMessage?: string + entries: GitChangeEntry[] +} + +export type WorktreePanelStatus = { + gitAvailable: boolean + repoRoot: string | null + worktrees: GitWorktreeStatus[] +} + export type TerminalZoomMessage = { zoomLevel: number } @@ -493,6 +517,9 @@ export interface TerminayApi { searchFiles: (options: { rootPath: string; query: string; limit?: number }) => Promise getFileExplorerGitStatuses: (dirPath: string) => Promise getGitPanelStatus: (dirPath: string) => Promise + getWorktreePanelStatus: (dirPath: string) => Promise + moveGitWorktree: (payload: { repoPath: string; worktreePath: string; newPath: string }) => Promise + removeGitWorktree: (payload: { force?: boolean; repoPath: string; worktreePath: string }) => Promise getFileInfo: (filePath: string) => Promise readFileBytes: (options: { path: string; start: number; length: number }) => Promise readFileText: (options: { From 66b24295e1d4adb7de0e9c0f09ca753a67db18f7 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 20 Jun 2026 16:09:33 +0100 Subject: [PATCH 2/7] fix(worktrees): make active row bold instead of green --- src/components/git-panel/WorktreesPanel.tsx | 4 ---- src/components/git-panel/gitPanel.css | 6 +----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/git-panel/WorktreesPanel.tsx b/src/components/git-panel/WorktreesPanel.tsx index aebe95e..caa3db8 100644 --- a/src/components/git-panel/WorktreesPanel.tsx +++ b/src/components/git-panel/WorktreesPanel.tsx @@ -203,10 +203,6 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { hasUnmergedOrUncommittedWork ? ' worktrees-panel__worktree-icon--dirty' : '' - }${ - worktree.isCurrent - ? ' worktrees-panel__worktree-icon--current' - : '' }`} aria-hidden="true" > diff --git a/src/components/git-panel/gitPanel.css b/src/components/git-panel/gitPanel.css index 1543575..095f4d5 100644 --- a/src/components/git-panel/gitPanel.css +++ b/src/components/git-panel/gitPanel.css @@ -288,7 +288,7 @@ } .worktrees-panel__worktree-header--current .worktrees-panel__worktree-name { - color: #73c991; + font-weight: 700; } .worktrees-panel__worktree-icon { @@ -305,10 +305,6 @@ color: #e2c08d; } -.worktrees-panel__worktree-icon--current { - color: #73c991; -} - .worktrees-panel__worktree-main { flex: 1 1 auto; min-width: 0; From f1f6693acc7dd850b48780b2719b8bf94a5342a7 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 20 Jun 2026 16:17:08 +0100 Subject: [PATCH 3/7] fix(worktrees): clarify row status layout --- electron/fileViewer/gitDiffService.ts | 100 +++++++++++++++++++- src/components/git-panel/WorktreesPanel.tsx | 87 ++++++++++++----- src/components/git-panel/gitPanel.css | 55 ++++++++--- src/types/terminay.ts | 3 + 4 files changed, 207 insertions(+), 38 deletions(-) diff --git a/electron/fileViewer/gitDiffService.ts b/electron/fileViewer/gitDiffService.ts index b436f0b..6187889 100644 --- a/electron/fileViewer/gitDiffService.ts +++ b/electron/fileViewer/gitDiffService.ts @@ -204,7 +204,11 @@ export class GitDiffService { branch === null ? await this.resolveDetachedBranch(worktree.path) : branch || worktree.branch - const aheadOfMainCount = await this.getAheadOfMainCount(worktree.path) + const [aheadOfMainCount, lineDelta, lastChangedAt] = await Promise.all([ + this.getAheadOfMainCount(worktree.path), + this.getWorktreeLineDelta(worktree.path), + this.getLastChangedAt(worktree.path, entries), + ]) return { ...worktree, @@ -212,6 +216,9 @@ export class GitDiffService { branch: resolvedBranch, entries, isDirtyBranch: aheadOfMainCount !== null && aheadOfMainCount > 0, + lastChangedAt, + lineAdditions: lineDelta.additions, + lineDeletions: lineDelta.deletions, } } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -281,6 +288,94 @@ export class GitDiffService { } } + private async getWorktreeLineDelta(cwd: string): Promise<{ additions: number | null; deletions: number | null }> { + const [branchDelta, workingTreeDelta] = await Promise.all([ + this.getNumstatDelta(cwd, ['diff', '--numstat', 'main...HEAD']), + this.getNumstatDelta(cwd, ['diff', '--numstat', 'HEAD']), + ]) + + if (!branchDelta && !workingTreeDelta) { + return { additions: null, deletions: null } + } + + return { + additions: (branchDelta?.additions ?? 0) + (workingTreeDelta?.additions ?? 0), + deletions: (branchDelta?.deletions ?? 0) + (workingTreeDelta?.deletions ?? 0), + } + } + + private async getNumstatDelta( + cwd: string, + args: string[], + ): Promise<{ additions: number; deletions: number } | null> { + try { + const result = await execFileAsync('git', args, { cwd }) + let additions = 0 + let deletions = 0 + + for (const line of result.stdout.split(/\r?\n/)) { + if (!line.trim()) { + continue + } + + const [rawAdditions, rawDeletions] = line.split('\t') + const nextAdditions = Number.parseInt(rawAdditions ?? '', 10) + const nextDeletions = Number.parseInt(rawDeletions ?? '', 10) + + if (Number.isFinite(nextAdditions)) { + additions += nextAdditions + } + if (Number.isFinite(nextDeletions)) { + deletions += nextDeletions + } + } + + return { additions, deletions } + } catch { + return null + } + } + + private async getLastChangedAt(cwd: string, entries: GitChangeEntry[]): Promise { + const latestEntryMtime = await this.getLatestEntryMtime(entries) + const latestCommitTime = await this.getLatestCommitTime(cwd) + + if (latestEntryMtime === null) { + return latestCommitTime + } + + if (latestCommitTime === null) { + return new Date(latestEntryMtime).toISOString() + } + + return new Date(Math.max(latestEntryMtime, Date.parse(latestCommitTime))).toISOString() + } + + private async getLatestEntryMtime(entries: GitChangeEntry[]): Promise { + const mtimes = await Promise.all( + entries.map(async (entry) => { + const info = await this.fileBufferService.getFileInfo(entry.path) + return info.exists ? info.mtimeMs : null + }), + ) + + return mtimes.reduce((latest, mtime) => { + if (mtime === null) { + return latest + } + return latest === null ? mtime : Math.max(latest, mtime) + }, null) + } + + private async getLatestCommitTime(cwd: string): Promise { + try { + const result = await execFileAsync('git', ['log', '-1', '--format=%cI', 'HEAD'], { cwd }) + return result.stdout.trim() || null + } catch { + return null + } + } + async getRepoInfo(rawPath: string): Promise { const context = await this.getGitContext(rawPath) @@ -437,6 +532,9 @@ function parseWorktreeList(output: string, currentRepoRoot: string): GitWorktree branch, head, aheadOfMainCount: null, + lineAdditions: null, + lineDeletions: null, + lastChangedAt: null, isCurrent: resolvedPath === normalizedCurrentRepoRoot, isDirtyBranch: false, isMain: sectionIndex === 0, diff --git a/src/components/git-panel/WorktreesPanel.tsx b/src/components/git-panel/WorktreesPanel.tsx index caa3db8..db4cbad 100644 --- a/src/components/git-panel/WorktreesPanel.tsx +++ b/src/components/git-panel/WorktreesPanel.tsx @@ -4,6 +4,7 @@ import { FolderInput, FolderOpen, GitBranch, + FolderGit, Terminal, Trash2, } from 'lucide-react'; @@ -48,9 +49,33 @@ function getWorktreeTitle(worktree: GitWorktreeStatus): string { `${worktree.entries.length} file change${worktree.entries.length === 1 ? '' : 's'}`, ); } + if (worktree.lineAdditions !== null || worktree.lineDeletions !== null) { + parts.push(`+${worktree.lineAdditions ?? 0} -${worktree.lineDeletions ?? 0}`); + } + if (worktree.lastChangedAt) { + parts.push(`Last changed: ${formatWorktreeDate(worktree.lastChangedAt)}`); + } return parts.join('\n'); } +function formatWorktreeDate(value: string | null): string { + if (!value) { + return 'No changes'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Unknown date'; + } + + return new Intl.DateTimeFormat(undefined, { + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + month: 'short', + }).format(date); +} + export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { const { status, @@ -162,6 +187,7 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { const branchLabel = worktree.branch ?? (worktree.isDetached ? 'HEAD' : ''); const hasUnmergedOrUncommittedWork = worktree.isDirtyBranch || worktree.entries.length > 0; + const WorktreeIcon = worktree.isMain ? FolderGit : GitBranch; const worktreeStatus = { gitAvailable: status.gitAvailable, repoRoot: worktree.path, @@ -198,36 +224,45 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { > - - - {worktree.name} + + + + {worktree.name} + + + {worktree.isCurrent ? ( + current + ) : null} + {branchLabel ? ( + {branchLabel} + ) : null} - - {worktree.path} + + + +{worktree.lineAdditions ?? 0} + + + -{worktree.lineDeletions ?? 0} + + + {formatWorktreeDate(worktree.lastChangedAt)} + + + + {worktree.entries.length} + - {worktree.isCurrent ? ( - current - ) : null} - {worktree.isMain && !worktree.isCurrent ? ( - main - ) : null} - {branchLabel ? ( - {branchLabel} - ) : null} - - {worktree.entries.length} - {collapsed ? null : worktree.errorMessage ? (
{worktree.errorMessage}
diff --git a/src/components/git-panel/gitPanel.css b/src/components/git-panel/gitPanel.css index 095f4d5..20c5a79 100644 --- a/src/components/git-panel/gitPanel.css +++ b/src/components/git-panel/gitPanel.css @@ -261,11 +261,11 @@ .worktrees-panel__worktree-header { all: unset; box-sizing: border-box; - min-height: 28px; + min-height: 48px; display: flex; align-items: center; gap: 6px; - padding: 2px 8px 2px 12px; + padding: 4px 8px 4px 12px; color: #ffffff; cursor: pointer; font-size: 13px; @@ -310,26 +310,41 @@ min-width: 0; display: flex; flex-direction: column; - gap: 1px; + gap: 2px; } -.worktrees-panel__worktree-name { +.worktrees-panel__worktree-topline, +.worktrees-panel__worktree-meta { + display: flex; + align-items: center; min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: rgba(220, 226, 240, 0.9); - line-height: 16px; + gap: 6px; } -.worktrees-panel__worktree-path { +.worktrees-panel__worktree-name, +.worktrees-panel__date { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.worktrees-panel__worktree-name { + color: rgba(220, 226, 240, 0.9); + font-size: 13px; + line-height: 18px; +} + +.worktrees-panel__worktree-meta { color: rgba(220, 226, 240, 0.34); font-size: 11px; - line-height: 13px; + line-height: 16px; + padding-left: 22px; +} + +.worktrees-panel__spacer { + flex: 1 1 auto; + min-width: 0; } .worktrees-panel__branch, @@ -358,6 +373,24 @@ color: #57b7ff; } +.worktrees-panel__delta { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.worktrees-panel__delta--additions { + color: #73c991; +} + +.worktrees-panel__delta--deletions { + color: #f14c4c; +} + +.worktrees-panel__date { + color: rgba(220, 226, 240, 0.45); +} + .worktrees-panel__count { width: 18px; padding: 0; diff --git a/src/types/terminay.ts b/src/types/terminay.ts index 5dc79e0..d07dc25 100644 --- a/src/types/terminay.ts +++ b/src/types/terminay.ts @@ -243,6 +243,9 @@ export type GitWorktreeStatus = { branch: string | null head: string | null aheadOfMainCount: number | null + lineAdditions: number | null + lineDeletions: number | null + lastChangedAt: string | null isDirtyBranch: boolean isCurrent: boolean isMain: boolean From eaea63c80d58fc70326ebd090fac5132f41ca87a Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 20 Jun 2026 16:21:12 +0100 Subject: [PATCH 4/7] fix(worktrees): simplify row metadata --- src/components/git-panel/WorktreesPanel.tsx | 45 ++++++++++++--------- src/components/git-panel/gitPanel.css | 15 ++++--- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/src/components/git-panel/WorktreesPanel.tsx b/src/components/git-panel/WorktreesPanel.tsx index db4cbad..eecdb0b 100644 --- a/src/components/git-panel/WorktreesPanel.tsx +++ b/src/components/git-panel/WorktreesPanel.tsx @@ -187,6 +187,9 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { const branchLabel = worktree.branch ?? (worktree.isDetached ? 'HEAD' : ''); const hasUnmergedOrUncommittedWork = worktree.isDirtyBranch || worktree.entries.length > 0; + const hasLineChanges = + (worktree.lineAdditions ?? 0) > 0 || + (worktree.lineDeletions ?? 0) > 0; const WorktreeIcon = worktree.isMain ? FolderGit : GitBranch; const worktreeStatus = { gitAvailable: status.gitAvailable, @@ -224,18 +227,18 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { >
+ - {worktree.name} @@ -248,19 +251,21 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { ) : null} - - +{worktree.lineAdditions ?? 0} - - - -{worktree.lineDeletions ?? 0} - + {hasLineChanges ? ( + <> + + +{worktree.lineAdditions ?? 0} + + + -{worktree.lineDeletions ?? 0} + + + ) : ( + clean + )} {formatWorktreeDate(worktree.lastChangedAt)} - - - {worktree.entries.length} - diff --git a/src/components/git-panel/gitPanel.css b/src/components/git-panel/gitPanel.css index 20c5a79..0a17231 100644 --- a/src/components/git-panel/gitPanel.css +++ b/src/components/git-panel/gitPanel.css @@ -263,7 +263,7 @@ box-sizing: border-box; min-height: 48px; display: flex; - align-items: center; + align-items: stretch; gap: 6px; padding: 4px 8px 4px 12px; color: #ffffff; @@ -297,7 +297,7 @@ align-items: center; justify-content: center; width: 16px; - height: 16px; + align-self: stretch; color: rgba(220, 226, 240, 0.3); } @@ -348,8 +348,7 @@ } .worktrees-panel__branch, -.worktrees-panel__pill, -.worktrees-panel__count { +.worktrees-panel__pill { flex: 0 0 auto; display: inline-flex; align-items: center; @@ -391,10 +390,10 @@ color: rgba(220, 226, 240, 0.45); } -.worktrees-panel__count { - width: 18px; - padding: 0; - color: rgba(220, 226, 240, 0.62); +.worktrees-panel__clean { + flex: 0 0 auto; + color: rgba(220, 226, 240, 0.5); + font-weight: 600; } .worktrees-panel__changes { From 4e1d76fad27b22898f5d536a6082f3e0588d6fd0 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 20 Jun 2026 16:23:38 +0100 Subject: [PATCH 5/7] fix(worktrees): keep icon on title row --- src/components/git-panel/WorktreesPanel.tsx | 20 ++++++++++---------- src/components/git-panel/gitPanel.css | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/git-panel/WorktreesPanel.tsx b/src/components/git-panel/WorktreesPanel.tsx index eecdb0b..5770525 100644 --- a/src/components/git-panel/WorktreesPanel.tsx +++ b/src/components/git-panel/WorktreesPanel.tsx @@ -227,18 +227,18 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { > - + {worktree.name} diff --git a/src/components/git-panel/gitPanel.css b/src/components/git-panel/gitPanel.css index 0a17231..3f9efef 100644 --- a/src/components/git-panel/gitPanel.css +++ b/src/components/git-panel/gitPanel.css @@ -263,7 +263,7 @@ box-sizing: border-box; min-height: 48px; display: flex; - align-items: stretch; + align-items: center; gap: 6px; padding: 4px 8px 4px 12px; color: #ffffff; @@ -297,7 +297,7 @@ align-items: center; justify-content: center; width: 16px; - align-self: stretch; + height: 16px; color: rgba(220, 226, 240, 0.3); } From 957ddd3cda0695e9515c188284895f45cef7eb6e Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 20 Jun 2026 16:26:34 +0100 Subject: [PATCH 6/7] fix(worktrees): brighten active clean icon --- src/components/git-panel/WorktreesPanel.tsx | 4 ++++ src/components/git-panel/gitPanel.css | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/components/git-panel/WorktreesPanel.tsx b/src/components/git-panel/WorktreesPanel.tsx index 5770525..ab96309 100644 --- a/src/components/git-panel/WorktreesPanel.tsx +++ b/src/components/git-panel/WorktreesPanel.tsx @@ -234,6 +234,10 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { hasUnmergedOrUncommittedWork ? ' worktrees-panel__worktree-icon--dirty' : '' + }${ + worktree.isCurrent + ? ' worktrees-panel__worktree-icon--current' + : '' }`} aria-hidden="true" > diff --git a/src/components/git-panel/gitPanel.css b/src/components/git-panel/gitPanel.css index 3f9efef..84476ac 100644 --- a/src/components/git-panel/gitPanel.css +++ b/src/components/git-panel/gitPanel.css @@ -305,6 +305,14 @@ color: #e2c08d; } +.worktrees-panel__worktree-icon--current { + color: rgba(220, 226, 240, 0.9); +} + +.worktrees-panel__worktree-icon--current.worktrees-panel__worktree-icon--dirty { + color: #e2c08d; +} + .worktrees-panel__worktree-main { flex: 1 1 auto; min-width: 0; From 1b2b1d06597c4a9065ee78d821300b2cbb841bbb Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 20 Jun 2026 16:29:05 +0100 Subject: [PATCH 7/7] fix(worktrees): underline active worktree title --- src/components/git-panel/WorktreesPanel.tsx | 3 --- src/components/git-panel/gitPanel.css | 2 ++ 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/git-panel/WorktreesPanel.tsx b/src/components/git-panel/WorktreesPanel.tsx index ab96309..5ba7dd6 100644 --- a/src/components/git-panel/WorktreesPanel.tsx +++ b/src/components/git-panel/WorktreesPanel.tsx @@ -247,9 +247,6 @@ export function WorktreesPanel(props: WorktreesPanelProps): JSX.Element { {worktree.name} - {worktree.isCurrent ? ( - current - ) : null} {branchLabel ? ( {branchLabel} ) : null} diff --git a/src/components/git-panel/gitPanel.css b/src/components/git-panel/gitPanel.css index 84476ac..1d3eddb 100644 --- a/src/components/git-panel/gitPanel.css +++ b/src/components/git-panel/gitPanel.css @@ -289,6 +289,8 @@ .worktrees-panel__worktree-header--current .worktrees-panel__worktree-name { font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; } .worktrees-panel__worktree-icon {