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')} + +
+ )}