From 6d6d5829968e6405b4e751d88b70346ef247102a Mon Sep 17 00:00:00 2001 From: dsh-better-sidebar-fix Date: Wed, 19 Aug 2026 22:51:56 +0800 Subject: [PATCH 1/6] fix(git): show changes across linked worktrees The Git panel only ran git status against the session's own worktree, so when DSH (or any coding agent) created a linked worktree via 'git worktree add' and modified files there, the sidebar's Git panel stayed empty - no changed files, no diff, nothing to commit. Enumerate all linked checkouts of the session repository and let the user select which one the panel targets. Auto-select the dirty linked worktree when the primary checkout is clean and exactly one linked worktree has changes (the common DSH agent pattern). Poll status every 2s while the panel is visible so model-authored edits appear without a manual refresh. Security: resolveWorktree validates the client-supplied worktree path against 'git worktree list' before using it as a git -C cwd. Uses -z porcelain framing, which disables core.quotePath C-style quoting so non-ASCII checkout paths round-trip losslessly. --- src/client/DiffTab.tsx | 9 +-- src/client/GitView.tsx | 128 +++++++++++++++++++++++++++------- src/client/api.ts | 64 ++++++++++------- src/client/builtins/tabs.tsx | 3 +- src/client/locales.ts | 2 + src/client/sidebar.module.css | 14 ++++ src/client/state.ts | 4 +- src/git.ts | 80 +++++++++++++++++++++ src/index.ts | 38 ++++++---- tests/git-worktree.spec.ts | 68 ++++++++++++++++++ 10 files changed, 339 insertions(+), 71 deletions(-) create mode 100644 tests/git-worktree.spec.ts diff --git a/src/client/DiffTab.tsx b/src/client/DiffTab.tsx index 715edb8..ec66d4e 100644 --- a/src/client/DiffTab.tsx +++ b/src/client/DiffTab.tsx @@ -14,6 +14,7 @@ import { api } from './api.ts' import type { SidebarDiffRef } from './state.ts' import { DiffView } from './DiffView.tsx' import { t } from './locales.ts' +import { resolveSidebarPath } from './produced-files.ts' import css from './sidebar.module.css' /** The loaded diff surface (untracked content rendered as a full addition). */ @@ -40,17 +41,17 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif const load = async (): Promise => { try { if (diff.kind === 'commit') { - const result = await api.gitCommitDiff(scope, diff.hashFull) + const result = await api.gitCommitDiff(scope, diff.hashFull, diff.worktree) if (!cancelled) setData({ diff: result.diff }) return } - let result = await api.gitDiff(scope, diff.path, diff.staged) + let result = await api.gitDiff(scope, diff.path, diff.staged, diff.worktree) if (result.diff === '') { // The requested side is empty — try the OTHER side once: the ref // may predate the staged-flag fix, or the change moved sides (a // file staged after its tab opened). Both sides empty means the // file genuinely has no text changes. - const other = await api.gitDiff(scope, diff.path, !diff.staged) + const other = await api.gitDiff(scope, diff.path, !diff.staged, diff.worktree) if (other.diff !== '') result = other } if (result.diff !== '') { @@ -60,7 +61,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif // Empty diff: an untracked file (git diff never lists it) falls back // to a full-file addition; anything else is a genuine no-text-change. if (diff.untracked === true && !diff.staged) { - const text = await api.fsRead(scope, diff.path) + const text = await api.fsRead(scope, resolveSidebarPath(diff.worktree ?? cwd, diff.path)) if (!cancelled) { setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' }) } diff --git a/src/client/GitView.tsx b/src/client/GitView.tsx index 4075079..acc42e6 100644 --- a/src/client/GitView.tsx +++ b/src/client/GitView.tsx @@ -6,16 +6,18 @@ * placed below the git pane on first use. File rows and history rows open a * right-click context menu with advanced operations (open in editor, discard, * revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/ - * focus (no file watcher — KISS). + * focus. While visible it polls lightweight porcelain state so model-authored + * file changes appear without a manual refresh. */ -import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import { Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16, IconTrashOutline16, Input, Menu, Modal, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts' +import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from './api.ts' import { api } from './api.ts' import { relativeTo } from './paths.ts' +import { resolveSidebarPath } from './produced-files.ts' import { relativeTime, t } from './locales.ts' import type { SidebarTab } from './state.ts' import css from './sidebar.module.css' @@ -84,9 +86,13 @@ export function GitView(props: { onOpenFile: (path: string) => void /** Open a diff tab (the shell places it below the git pane on first use). */ onOpenDiff: (tab: SidebarTab) => void + /** Poll only while the tab is actually visible. */ + visible: boolean }) { - const { scope, onOpenFile, onOpenDiff } = props + const { scope, onOpenFile, onOpenDiff, visible } = props const [status, setStatus] = useState(null) + const [worktrees, setWorktrees] = useState([]) + const [selectedWorktree, setSelectedWorktree] = useState() const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [branchNames, setBranchNames] = useState([]) @@ -104,16 +110,45 @@ export function GitView(props: { const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null) /** The pending destructive action awaiting confirmation. */ const [confirm, setConfirm] = useState(null) + const refreshInFlight = useRef(false) + const worktreeChosenByUser = useRef(false) + /** selectedWorktree read inside refresh without re-creating the callback: + * avoids a spurious full refresh on every auto-select (the very state + * change refresh writes back via setSelectedWorktree would recreate the + * callback and re-trigger the mount effect — an N→N+1 fetch loop). */ + const selectedRef = useRef(undefined) + useEffect(() => { selectedRef.current = selectedWorktree }, [selectedWorktree]) - const refresh = useCallback(async (): Promise => { - setLoading(true) + const refresh = useCallback(async (silent = false): Promise => { + if (refreshInFlight.current) return + refreshInFlight.current = true + if (!silent) setLoading(true) setError(null) try { + const listed = await api.gitWorktrees(scope) + setWorktrees(listed) + const selectedStillExists = listed.some(entry => entry.path === selectedRef.current) + let target = selectedStillExists ? selectedRef.current : listed.find(entry => entry.current)?.path + // DSH and other coding agents commonly create one linked checkout while + // the session remains rooted at the clean primary checkout. Select that + // checkout automatically only when the choice is unambiguous. + const current = listed.find(entry => entry.current) + const dirtyLinked = listed.filter(entry => !entry.current && entry.changes > 0) + if (!worktreeChosenByUser.current) { + target = (current?.changes ?? 0) === 0 && dirtyLinked.length === 1 + ? dirtyLinked[0]!.path + : current?.path + } + if (target !== selectedRef.current) setSelectedWorktree(target) + if (silent) { + setStatus(await api.gitStatus(scope, target)) + return + } const [statusResult, branchResult, logResult] = await Promise.all([ - api.gitStatus(scope), - api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })), + api.gitStatus(scope, target), + api.gitBranch(scope, target).catch(() => ({ current: '', names: [] as string[] })), // The first history page only; the rest arrives via "load more". - api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]), + api.gitLog(scope, LOG_BATCH, 0, target).catch(() => [] as GitLogEntry[]), ]) setStatus(statusResult) setBranchNames(branchResult.names) @@ -122,18 +157,36 @@ export function GitView(props: { } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } finally { - setLoading(false) + refreshInFlight.current = false + if (!silent) setLoading(false) } }, [scope.sessionId, scope.cwd]) + useEffect(() => { + worktreeChosenByUser.current = false + setSelectedWorktree(undefined) + }, [scope.sessionId, scope.cwd]) useEffect(() => { void refresh() }, [refresh]) + // When the user picks a worktree from the dropdown, fetch its status + // immediately instead of waiting for the next 2s poll tick. Gated on + // worktreeChosenByUser so the auto-select inside refresh() doesn't + // trigger a redundant fetch (that was the N→N+1 loop the ref fix avoids). + useEffect(() => { + if (!worktreeChosenByUser.current) return + void refresh(true) + }, [selectedWorktree, refresh]) + useEffect(() => { + if (!visible) return + const timer = window.setInterval(() => { void refresh(true) }, 2_000) + return () => { window.clearInterval(timer) } + }, [visible, refresh]) /** Append the next history page (lazy: only when the user asks for more). */ const loadMoreLog = async (): Promise => { if (logLoadingMore || logEnded) return setLogLoadingMore(true) try { - const next = await api.gitLog(scope, LOG_BATCH, logEntries.length) + const next = await api.gitLog(scope, LOG_BATCH, logEntries.length, selectedWorktree) setLogEntries(entries => [...entries, ...next]) if (next.length < LOG_BATCH) setLogEnded(true) } catch (reason) { @@ -146,28 +199,28 @@ export function GitView(props: { /** The diff tab for one changed file (one tab per path+side; same id = focused). */ const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => { onOpenDiff({ - id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`, + id: `diff:w:${encodeURIComponent(selectedWorktree ?? '')}:${staged ? 's' : 'u'}:${entry.path}`, type: 'diff', title: baseName(entry.path), - diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) }, + diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree }, }) } /** The diff tab for one commit (one tab per commit). */ const openCommitDiff = (entry: GitLogEntry): void => { onOpenDiff({ - id: `diff:c:${entry.hashFull}`, + id: `diff:c:${encodeURIComponent(selectedWorktree ?? '')}:${entry.hashFull}`, type: 'diff', title: `${entry.hash} ${entry.subject}`, - diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject }, + diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject, worktree: selectedWorktree }, }) } const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise => { setBusy(true) try { - if (staged) await api.gitUnstage(scope, entry.path) - else await api.gitStage(scope, entry.path) + if (staged) await api.gitUnstage(scope, entry.path, selectedWorktree) + else await api.gitStage(scope, entry.path, selectedWorktree) await refresh() } finally { setBusy(false) @@ -177,8 +230,8 @@ export function GitView(props: { const stageAll = async (staged: boolean): Promise => { setBusy(true) try { - if (staged) await api.gitUnstage(scope) - else await api.gitStage(scope) + if (staged) await api.gitUnstage(scope, undefined, selectedWorktree) + else await api.gitStage(scope, undefined, selectedWorktree) await refresh() } finally { setBusy(false) @@ -191,7 +244,7 @@ export function GitView(props: { setBusy(true) setCommitError(null) try { - await api.gitCommit(scope, message) + await api.gitCommit(scope, message, selectedWorktree) setCommitMsg('') await refresh() } catch (reason) { @@ -206,7 +259,7 @@ export function GitView(props: { setBusy(true) setCommitError(null) try { - await api.gitCheckout(scope, branch) + await api.gitCheckout(scope, branch, selectedWorktree) await refresh() } catch (reason) { setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`) @@ -280,6 +333,27 @@ export function GitView(props: { return (
+ {worktrees.length > 1 && ( +
+ {t('worktree')} + +
+ )}
{ setRepoRoot(event.target.value) }} + disabled={busy} + > + {status!.repositories!.map(root => )} + + )}