/** * The Git lens of the changes tab: repository truth — status list (staged vs * unstaged), stage/unstage, commit with a message box, branch switch, and a * VSCode-like history with branch decorations, author and relative time. * Clicking a changed file or a history row previews it in the tab's shared * bottom pane (see {@link DiffPane}); rows carry right-click context menus * with advanced operations (open in editor, discard, revert, cherry-pick, * copy paths/hashes). Refresh is manual + on mount/focus. While visible it * polls lightweight porcelain state so model-authored file changes appear * without a manual refresh. Everything here is the former standalone git * panel, re-homed as a lens. */ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import { Button, IconCodeOutline16, IconCopyOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16, Input, Menu, Modal, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts' import { api } from '../api.ts' import { usePolling } from '../use-polling.ts' import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts' import { resolveSidebarPath } from '../produced-files.ts' import { relativeTime, t } from '../locales.ts' import type { SidebarDiffRef, SidebarStore } from '../state.ts' import css from './changes.module.css' /** The XY status letters a row badge shows (X = index, Y = worktree). */ function badgeOf(entry: GitStatusEntry): string { const index = entry.xy[0] if (index !== undefined && index !== ' ' && index !== '?') return index const worktree = entry.xy[1] if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree return '?' } /** Whether the entry carries STAGED (index) changes — the X letter is set. */ function isStagedEntry(entry: GitStatusEntry): boolean { const index = entry.xy[0] return index !== undefined && index !== ' ' && index !== '?' } /** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set * (untracked `??` counts as unstaged: it is a worktree-only change). A file * with both letters set ('MM') lands in BOTH sections. */ function isUnstagedEntry(entry: GitStatusEntry): boolean { if (entry.xy === '??') return true const worktree = entry.xy[1] return worktree !== undefined && worktree !== ' ' && worktree !== '?' } /** Whether the entry is untracked (`??`): git diff never includes it. */ function isUntracked(entry: GitStatusEntry): boolean { return badgeOf(entry) === '?' } /** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */ function refNames(refs: string): string[] { return [...new Set( refs .split(',') .map(ref => ref.trim()) .filter(ref => ref !== '') .map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref)) .map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)), )] } /** One thrown value as display text (every error banner/row here normalizes * through this so non-Error rejections never render as '[object Object]'). */ function errorMessage(reason: unknown): string { return reason instanceof Error ? reason.message : String(reason) } /** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */ interface ConfirmState { title: string description: string confirmLabel: string onConfirm: () => Promise } /** History batch size: the log loads lazily in pages so a long history never * floods the panel at once (the end of the log is reached by paging). */ const LOG_BATCH = 20 /** Every Nth silent poll re-lists worktrees (and re-runs auto-selection): the * 2s tick only needs the selected checkout's STATUS, and re-listing spawned * a second git process per tick for a list that almost never changes — a * linked checkout the agent creates mid-session is picked up within ~30s * instead of 2s. */ const WORKTREE_RECHECK_TICKS = 15 export interface GitLensProps { scope: SessionScope /** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */ store: SidebarStore onOpenFile: (path: string) => void /** Preview one change in the shared bottom pane (worktree or commit ref). */ onPreview: (ref: SidebarDiffRef) => void /** The ref currently previewed (row highlight); null when the pane is closed. */ selectedRef: SidebarDiffRef | null /** Poll only while the tab is actually visible. */ visible: boolean } export function GitLens(props: GitLensProps) { const { scope, store, onOpenFile, onPreview, selectedRef, visible } = props const [status, setStatus] = useState(null) const [worktrees, setWorktrees] = useState([]) const [selectedWorktree, setSelectedWorktree] = useState() const [repoRoot, setRepoRoot] = useState(undefined) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [branchNames, setBranchNames] = useState([]) const [logEntries, setLogEntries] = useState([]) const [commitMsg, setCommitMsg] = useState('') const [busy, setBusy] = useState(false) const [commitError, setCommitError] = useState(null) /** Whether the history was fully paged (a batch shorter than LOG_BATCH). */ const [logEnded, setLogEnded] = useState(false) const [logLoadingMore, setLogLoadingMore] = useState(false) /** The open file-row context menu (cursor position for the portaled Menu). */ const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null) /** The open history-row context menu. */ 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) /** Monotonic request id: a manual worktree switch invalidates any older poll * before it can publish state from the previous checkout. */ const refreshGeneration = useRef(0) 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 chosenPathRef = useRef(undefined) useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree]) /** Silent polls since the last worktree re-list (see WORKTREE_RECHECK_TICKS). */ const silentTickCount = useRef(0) const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot } /** Publish a complete checkout-derived view. Status, branch choices and * history are one consistency unit: never mix rows from two worktrees. */ const refreshTarget = useCallback(async ( target: string | undefined, options: { loading: boolean; generation: number }, ): Promise => { if (options.loading) setLoading(true) setError(null) try { const [statusResult, branchResult, logResult] = await Promise.all([ api.gitStatus(gitScope, target), api.gitBranch(gitScope, target).catch(() => ({ current: '', names: [] as string[] })), api.gitLog(gitScope, LOG_BATCH, 0, target).catch(() => [] as GitLogEntry[]), ]) if (options.generation !== refreshGeneration.current) return setStatus(statusResult) if (statusResult.root !== undefined && statusResult.root !== repoRoot) setRepoRoot(statusResult.root) setBranchNames(branchResult.names) setLogEntries(logResult) setLogEnded(logResult.length < LOG_BATCH) } catch (reason) { if (options.generation === refreshGeneration.current) { setError(errorMessage(reason)) } } finally { if (options.loading && options.generation === refreshGeneration.current) setLoading(false) } // Granular scope fields: the scope object's identity churns, only its // sessionId / cwd fields gate the git target. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope.sessionId, scope.cwd, repoRoot]) const refresh = useCallback(async (silent = false): Promise => { if (refreshInFlight.current) return refreshInFlight.current = true let generation = refreshGeneration.current try { // Fast path for the ordinary silent tick: the selected checkout's // STATUS is all that changes between worktree re-lists (see // WORKTREE_RECHECK_TICKS) — one git process instead of two. if (silent && chosenPathRef.current !== undefined && (silentTickCount.current += 1) % WORKTREE_RECHECK_TICKS !== 0) { const statusResult = await api.gitStatus(gitScope, chosenPathRef.current) if (generation === refreshGeneration.current) setStatus(statusResult) return } silentTickCount.current = 0 const listed = await api.gitWorktrees(scope) if (generation !== refreshGeneration.current) return setWorktrees(listed) const selectedStillExists = listed.some(entry => entry.path === chosenPathRef.current) let target = selectedStillExists ? chosenPathRef.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 } const targetChanged = target !== chosenPathRef.current if (targetChanged) { // Changing the automatically selected checkout invalidates any direct // target refresh that may still be resolving for the previous one. generation = refreshGeneration.current += 1 chosenPathRef.current = target setSelectedWorktree(target) // Remove rows owned by the previous checkout immediately: keeping them // interactive while the target changes could apply a destructive action // to the new checkout with stale history from the old one. setStatus(null) setBranchNames([]) setLogEntries([]) setLogEnded(false) setLogLoadingMore(false) } // A poll may update status alone only while staying on the same checkout. // Any automatic selection change refreshes the complete derived view. if (silent && !targetChanged) { const statusResult = await api.gitStatus(gitScope, target) if (generation === refreshGeneration.current) setStatus(statusResult) return } await refreshTarget(target, { loading: !silent, generation }) } catch (reason) { if (generation === refreshGeneration.current) { setError(errorMessage(reason)) if (!silent) setLoading(false) } } finally { refreshInFlight.current = false } // Granular scope fields: the scope object's identity churns, only its // sessionId / cwd fields gate the refresh target. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope.sessionId, scope.cwd, refreshTarget]) useEffect(() => { refreshGeneration.current += 1 refreshInFlight.current = false worktreeChosenByUser.current = false chosenPathRef.current = undefined silentTickCount.current = 0 setSelectedWorktree(undefined) }, [scope.sessionId, scope.cwd]) useEffect(() => { void refresh() }, [refresh]) /** A user choice invalidates any older poll and atomically refreshes every * checkout-derived surface before destructive history actions can run. */ const chooseWorktree = (target: string): void => { worktreeChosenByUser.current = true chosenPathRef.current = target setSelectedWorktree(target) setStatus(null) setBranchNames([]) setLogEntries([]) setLogEnded(false) setLogLoadingMore(false) const generation = refreshGeneration.current += 1 void refreshTarget(target, { loading: true, generation }) } /** Switching the selected child repository must invalidate every * target-derived surface (status/history/log) before the asynchronous * refresh resolves; otherwise stale rows remain actionable while their * handlers already address the new repository. Mirrors chooseWorktree. */ const chooseRepo = (target: string): void => { setRepoRoot(target) setStatus(null) setBranchNames([]) setLogEntries([]) setLogEnded(false) setLogLoadingMore(false) // Re-list worktrees for the selected child (a workspace container's // own worktree list is empty); keep the current linked-checkout choice // unless it does not belong to the new repository. const generation = refreshGeneration.current += 1 void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation }) } /** The silent poll tick (the status-only fast path between worktree * re-lists, see refresh) — fixed 2s cadence while visible, no initial * burst (mount and scope changes already refresh above). */ const pollTick = useCallback((): Promise => refresh(true), [refresh]) usePolling(visible, pollTick, { intervalMs: 2_000 }) /** Append the next history page (lazy: only when the user asks for more). */ const loadMoreLog = async (): Promise => { if (logLoadingMore || logEnded) return const generation = refreshGeneration.current const target = chosenPathRef.current setLogLoadingMore(true) try { const next = await api.gitLog(gitScope, LOG_BATCH, logEntries.length, target) // A worktree switch clears the old history and increments generation. // Never append a late page from that checkout into the new one. if (generation !== refreshGeneration.current || target !== chosenPathRef.current) return setLogEntries(entries => [...entries, ...next]) if (next.length < LOG_BATCH) setLogEnded(true) } catch (reason) { if (generation === refreshGeneration.current && target === chosenPathRef.current) { setCommitError(`${t('historyLoadError')}: ${errorMessage(reason)}`) } } finally { if (generation === refreshGeneration.current && target === chosenPathRef.current) setLogLoadingMore(false) } } /** The preview ref for one changed file (one ref per path+side). */ const worktreeRefOf = (entry: GitStatusEntry, staged: boolean): SidebarDiffRef => ({ kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree, repoRoot, }) /** The preview ref for one commit. */ const commitRefOf = (entry: GitLogEntry): SidebarDiffRef => ({ kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject, worktree: selectedWorktree, repoRoot, }) /** Whether a worktree row is the one currently previewed. */ const isPreviewedWorktree = (entry: GitStatusEntry, staged: boolean): boolean => { if (selectedRef === null || selectedRef.kind !== 'worktree') return false return selectedRef.path === entry.path && selectedRef.staged === staged && (selectedRef.worktree ?? '') === (selectedWorktree ?? '') } const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise => { setBusy(true) try { if (staged) await api.gitUnstage(gitScope, entry.path, selectedWorktree) else await api.gitStage(gitScope, entry.path, selectedWorktree) await refresh() } finally { setBusy(false) } } const stageAll = async (staged: boolean): Promise => { setBusy(true) try { if (staged) await api.gitUnstage(gitScope, undefined, selectedWorktree) else await api.gitStage(gitScope, undefined, selectedWorktree) await refresh() } finally { setBusy(false) } } const commit = async (): Promise => { const message = commitMsg.trim() if (message === '' || busy) return setBusy(true) setCommitError(null) try { await api.gitCommit(gitScope, message, selectedWorktree) setCommitMsg('') await refresh() } catch (reason) { setCommitError(errorMessage(reason)) } finally { setBusy(false) } } const checkout = async (branch: string): Promise => { if (branch === status?.branch || busy) return setBusy(true) setCommitError(null) try { await api.gitCheckout(gitScope, branch, selectedWorktree) await refresh() } catch (reason) { setCommitError(`${t('checkoutError')}: ${errorMessage(reason)}`) } finally { setBusy(false) } } /** Run one destructive operation after the confirm modal, then refresh. */ const runConfirmed = (confirmState: ConfirmState): void => { setConfirm({ ...confirmState, onConfirm: async () => { setBusy(true) setCommitError(null) try { await confirmState.onConfirm() await refresh() } catch (reason) { setCommitError(errorMessage(reason)) } finally { setBusy(false) } } }) } /** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */ const copy = (text: string): void => { void writeClipboard(text) } const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => { event.preventDefault() event.stopPropagation() setFileMenu({ entry, staged, x: event.clientX, y: event.clientY }) } const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => { event.preventDefault() event.stopPropagation() setHistoryMenu({ entry, x: event.clientX, y: event.clientY }) } const stagedEntries = (status?.entries ?? []).filter(isStagedEntry) const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry) const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => { const selected = isPreviewedWorktree(entry, staged) return (
) } return (
{worktrees.length > 1 && (
{t('worktree')}
)}
{(status?.repositories?.length ?? 0) > 1 && ( )}
{loading &&
{t('loading')}
} {!loading && error !== null &&
{error}
} {!loading && status !== null && !status.isRepo && (
{t('notRepo')}
)} {status !== null && status.isRepo && ( <> {status.truncated === true && (
{t('statusTruncated')}
)}
{t('staged')} ({stagedEntries.length}) {stagedEntries.length > 0 && ( )}
{stagedEntries.length === 0 &&
{t('noChanges')}
} {stagedEntries.map(entry => renderEntry(entry, true))}
{t('unstaged')} ({unstagedEntries.length}) {unstagedEntries.length > 0 && ( )}
{unstagedEntries.length === 0 &&
{t('noChanges')}
} {unstagedEntries.map(entry => renderEntry(entry, false))}
{ setCommitMsg(event.target.value); setCommitError(null) }} onKeyDown={(event) => { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit() }} />
{commitError !== null &&
{commitError}
}
{t('history')}
{logEntries.map(entry => (
{ onPreview(commitRefOf(entry)) }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onPreview(commitRefOf(entry)) } }} onContextMenu={(event) => { openHistoryMenu(event, entry) }} > {entry.hash} {entry.subject} {refNames(entry.refs).map(ref => ( {ref} ))} {entry.author} · {relativeTime(entry.date)}
))} {!logEnded && ( )}
{/* The one shared file-row context menu, positioned at the right-click cursor (portal so the panel's overflow clip cannot crop it). */} { setFileMenu(null) }} items={[ // A linked worktree outside the session workspace cannot be // opened in the editor while the host's workspace fence is // armed: it rejects every path under that checkout. Hide the // action for that checkout so the menu does not offer a no-op // that confuses the user; with the fence disarmed (the // `workspaceFence` pref) the open is allowed through. ...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path))) ? [{ id: 'open', label: t('openEditor'), icon: }] : []), fileMenu?.staged === true ? { id: 'stage', label: t('unstage'), icon: } : { id: 'stage', label: t('stage'), icon: }, ...(fileMenu !== null && !isUntracked(fileMenu.entry) ? [{ id: 'discard', label: t('discard'), icon: , danger: true }] : []), { type: 'separator', id: 'sep1' }, { id: 'relative', label: t('copyRelative'), icon: }, { id: 'absolute', label: t('copyAbsolute'), icon: }, ]} onSelect={(id) => { const target = fileMenu if (target === null) return setFileMenu(null) if (id === 'open') { const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path) // Defense-in-depth: the menu hides this action when the // resolved path escapes the session workspace, but a // racing repo switch could still reach here with a path // the host would reject. No-op in that case — unless the // workspace fence is disarmed by pref. if (store.getPrefs().workspaceFence !== false && !isWithinWorkspace(scope.cwd ?? '', resolved)) return onOpenFile(resolved) return } if (id === 'stage') { void stageEntry(target.entry, target.staged) return } if (id === 'discard') { runConfirmed({ title: t('discardTitle'), description: t('discardDesc', { path: target.entry.path }), confirmLabel: t('discard'), onConfirm: () => api.gitDiscard(gitScope, target.entry.path, selectedWorktree), }) return } if (id === 'relative') { copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.entry.path)) return } if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path)) }} portal compact align="start" getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))} anchor={} /> {/* The shared history-row context menu. */} { setHistoryMenu(null) }} items={[ { id: 'view', label: t('viewCommitDiff') }, { id: 'copyShort', label: t('copyShortHash'), icon: }, { id: 'copyFull', label: t('copyFullHash'), icon: }, { id: 'copySubject', label: t('copySubject'), icon: }, { type: 'separator', id: 'sep2' }, { id: 'revert', label: t('revertCommit'), danger: true }, { id: 'cherryPick', label: t('cherryPickCommit'), danger: true }, ]} onSelect={(id) => { const target = historyMenu if (target === null) return setHistoryMenu(null) if (id === 'view') { onPreview(commitRefOf(target.entry)) return } if (id === 'copyShort') { copy(target.entry.hash) return } if (id === 'copyFull') { copy(target.entry.hashFull) return } if (id === 'copySubject') { copy(target.entry.subject) return } if (id === 'revert') { runConfirmed({ title: t('revertTitle'), description: t('revertDesc', { subject: target.entry.subject }), confirmLabel: t('revertCommit'), onConfirm: () => api.gitRevert(gitScope, target.entry.hashFull, selectedWorktree), }) return } if (id === 'cherryPick') { runConfirmed({ title: t('cherryPickTitle'), description: t('cherryPickDesc', { subject: target.entry.subject }), confirmLabel: t('cherryPickCommit'), onConfirm: () => api.gitCherryPick(gitScope, target.entry.hashFull, selectedWorktree), }) } }} portal compact align="start" getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))} anchor={} /> {/* Destructive actions land here first: Cancel / Confirm. */} { setConfirm(null) }} title={confirm?.title ?? ''} closeLabel={t('cancel')} footer={( <> )} >

{confirm?.description}

)}
) }