/** * The source-control panel: status list (staged vs unstaged), per-file diff * through the shared DiffBlock (old = HEAD/index content, new = worktree or * index content), stage/unstage, commit with a message box, branch list with * switch, and recent history. Refresh is manual + on mount/focus (no file * watcher — KISS). */ import { useCallback, useEffect, useState, type ReactNode } from 'react' import clsx from 'clsx' import { DiffBlock, IconBranchOutline16, IconRefreshOutline16, IconTrashOutline16, Input, } from '@deepseek-ai/dsh-client-ui-primitives' import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts' import { api } from './api.ts' import { t } from './locales.ts' import css from './sidebar.module.css' /** The XY status letters a row badge shows (X = index, Y = worktree). */ function badgeOf(entry: GitStatusEntry): string { const index = entry.xy[0] const worktree = entry.xy[1] if (index !== undefined && index !== ' ' && index !== '?') return index if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree return '?' } export function GitView(props: { scope: SessionScope }) { const { scope } = props const [status, setStatus] = useState(null) 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) // Selected diff: path + side (staged) + the two text sides for DiffBlock. const [diffPath, setDiffPath] = useState(null) const [diffStaged, setDiffStaged] = useState(false) const [diffOld, setDiffOld] = useState(null) const [diffNew, setDiffNew] = useState('') const refresh = useCallback(async (): Promise => { setLoading(true) setError(null) try { const [statusResult, branchResult, logResult] = await Promise.all([ api.gitStatus(scope), api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })), api.gitLog(scope).catch(() => [] as GitLogEntry[]), ]) setStatus(statusResult) setBranchNames(branchResult.names) setLogEntries(logResult) } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } finally { setLoading(false) } }, [scope.sessionId, scope.cwd]) useEffect(() => { void refresh() }, [refresh]) /** Load the diff sides for one entry: HEAD content vs worktree/index content. */ const selectDiff = async (entry: GitStatusEntry, staged: boolean): Promise => { setDiffPath(entry.path) setDiffStaged(staged) setDiffOld(null) setDiffNew('') const oldPromise = api.gitShow(scope, 'HEAD', entry.path) .then(result => result.content) .catch(() => null) const newPromise = staged ? api.gitShow(scope, ':', entry.path).then(result => result.content ?? '').catch(() => '') : api.fsRead(scope, entry.path) .then(result => (result.kind === 'text' ? result.content : '')) .catch(() => '') const [oldText, newText] = await Promise.all([oldPromise, newPromise]) setDiffOld(oldText) setDiffNew(newText) } 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) setDiffPath(null) await refresh() } finally { setBusy(false) } } const stageAll = async (staged: boolean): Promise => { setBusy(true) try { if (staged) await api.gitUnstage(scope) else await api.gitStage(scope) setDiffPath(null) 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(scope, message) setCommitMsg('') setDiffPath(null) await refresh() } catch (reason) { setCommitError(reason instanceof Error ? reason.message : String(reason)) } finally { setBusy(false) } } const checkout = async (branch: string): Promise => { if (branch === status?.branch || busy) return setBusy(true) setCommitError(null) try { await api.gitCheckout(scope, branch) setDiffPath(null) await refresh() } catch (reason) { setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`) } finally { setBusy(false) } } const stagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) !== '?') const unstagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) === '?') const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => { const selected = diffPath === entry.path && diffStaged === staged return (
) } return (
{loading &&
{t('loading')}
} {!loading && error !== null &&
{error}
} {!loading && status !== null && !status.isRepo && (
{t('notRepo')}
)} {status !== null && status.isRepo && ( <>
{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))}
{diffPath !== null && (
)}
{ 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 => (
{entry.hash} {entry.subject}
))}
)}
) }