Files
apes-sidebar/src/client/changes/GitLens.tsx
T
Menghuan1918 5a1e75e46b fix(lint): 清零存量 lint 告警并接入 CI / Makefile 门禁
存量清理(首跑 141 errors / 21 warnings → 0 / 0):
- 删除孤儿 disable:SideChatView / Sidebar / selection-popup 三处 exhaustive-deps 豁免与 chunk-artifact 的 no-new-func 豁免(多行理由注释把 disable-next-line 顶离了实报行;selection-popup 改为提取 popupOpen 标志真修复)
- 死代码:未用导入(Sidebar×7、TextEditor、SideCardSection、config、fs-operations 及 6 个 spec)、sidechat-core 死累加器 total、chunk-loader.spec 死助手 registry、sidechat-seed-validation 死助手 toolResultMessage
- 真实小修:agent-opens 三处 throw 补 cause;lazy-chunk any → ComponentType<P>(P extends object);service.ts claimed 初值改定值赋值;chunk-loader task / spec 内 etag / pendingLoad / snapshot 改 const;terminal-links 去掉字符类内多余转义;SubagentView catalogs 空兜底 useMemo 稳定标识;editor-host.spec 组件提出大写 FakeViewer(rules-of-hooks);pty-helpers.spec 内联 require 提升为静态导入;panel-host-css 注释里的零宽空格改为可见写法
- 依赖数组豁免(均带理由):granular scope 字段(EditorHost / PdfView / ChangesTab / GitLens×2)、job.status 粒度(SubagentView×2)、清理期读 ref、mount-only 注册等;ctx(挂载期恒定)直接入 deps(Sidebar×3)、TextEditor previewScroll 补 scope(幂等)
- 刻意控制字符正则(conversation-draft / markdown-images)与宿主槽位 any 签名(context-types)以带理由豁免保留

接入:
- Makefile:lint 目标(typecheck 旁),check 聚合补 lint 保持「对齐 CI」语义
- ci.yml ci / ci-windows 两 job 与 release.yml 在 Typecheck 后加 Lint 步骤(release 注释同步顺序)
2026-09-03 20:00:08 +08:00

766 lines
32 KiB
TypeScript

/**
* 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 { 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<unknown>
}
/** 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<GitStatusResult | null>(null)
const [worktrees, setWorktrees] = useState<GitWorktree[]>([])
const [selectedWorktree, setSelectedWorktree] = useState<string | undefined>()
const [repoRoot, setRepoRoot] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [branchNames, setBranchNames] = useState<string[]>([])
const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
const [commitMsg, setCommitMsg] = useState('')
const [busy, setBusy] = useState(false)
const [commitError, setCommitError] = useState<string | null>(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<ConfirmState | null>(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<string | undefined>(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<void> => {
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<void> => {
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 })
}
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<div
key={`${staged ? 's' : 'u'}:${entry.path}`}
className={css.gitRow}
data-selected={selected ? 'true' : undefined}
>
<button
type="button"
className={css.gitRowMain}
title={entry.path}
onClick={() => { onPreview(worktreeRefOf(entry, staged)) }}
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
>
<span className={css.gitBadge} data-letter={badgeOf(entry)}>{badgeOf(entry)}</span>
<span className={css.gitName}>{entry.path}</span>
</button>
<button
type="button"
className={css.iconButton}
aria-label={staged ? t('unstage') : t('stage')}
title={staged ? t('unstage') : t('stage')}
disabled={busy}
onClick={() => { void stageEntry(entry, staged) }}
>
{staged ? <IconTrashOutline16 /> : <IconPlusOutline16 />}
</button>
</div>
)
}
return (
<div className={css.git}>
{worktrees.length > 1 && (
<div className={css.gitWorktreeRow}>
<span className={css.gitWorktreeLabel}>{t('worktree')}</span>
<select
className={css.gitBranchSelect}
value={selectedWorktree ?? ''}
title={selectedWorktree}
disabled={busy}
onChange={(event) => { chooseWorktree(event.target.value) }}
>
{worktrees.map(entry => (
<option key={entry.path} value={entry.path}>
{entry.branch} · {baseName(entry.path)} ({entry.changes})
</option>
))}
</select>
</div>
)}
<div className={css.gitHeader}>
{(status?.repositories?.length ?? 0) > 1 && (
<select
className={css.gitBranchSelect}
value={repoRoot ?? ''}
title={repoRoot}
onChange={(event) => { chooseRepo(event.target.value) }}
disabled={busy}
>
{status!.repositories!.map(root => <option key={root} value={root}>{baseName(root)}</option>)}
</select>
)}
<select
className={css.gitBranchSelect}
value={status?.branch ?? ''}
onChange={(event) => { void checkout(event.target.value) }}
disabled={busy || (status !== null && !status.isRepo)}
>
{(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
{branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
</select>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { void refresh() }}
>
<IconRefreshOutline16 size={14} />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{error}</div>}
{!loading && status !== null && !status.isRepo && (
<div className={css.gitPlaceholder}>{t('notRepo')}</div>
)}
{status !== null && status.isRepo && (
<>
{status.truncated === true && (
<div className={css.gitEmpty}>{t('statusTruncated')}</div>
)}
<div className={css.gitSection}>
<div className={css.gitSectionHeader}>
<span>{t('staged')} ({stagedEntries.length})</span>
{stagedEntries.length > 0 && (
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
{t('unstageAll')}
</button>
)}
</div>
{stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
{stagedEntries.map(entry => renderEntry(entry, true))}
</div>
<div className={css.gitSection}>
<div className={css.gitSectionHeader}>
<span>{t('unstaged')} ({unstagedEntries.length})</span>
{unstagedEntries.length > 0 && (
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
{t('stageAll')}
</button>
)}
</div>
{unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
{unstagedEntries.map(entry => renderEntry(entry, false))}
</div>
<div className={css.gitCommit}>
<Input
className={css.gitCommitInput}
placeholder={t('commitPlaceholder')}
value={commitMsg}
disabled={busy}
onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
}}
/>
<button
type="button"
className={css.gitCommitButton}
disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
onClick={() => { void commit() }}
>
{t('commit')}
</button>
</div>
{commitError !== null && <div className={css.gitError}>{commitError}</div>}
<div className={css.gitSection}>
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
{logEntries.map(entry => (
<div
key={entry.hashFull}
role="button"
tabIndex={0}
className={css.gitLogRow}
data-selected={selectedRef?.kind === 'commit' && selectedRef.hashFull === entry.hashFull ? 'true' : undefined}
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
onClick={() => { onPreview(commitRefOf(entry)) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onPreview(commitRefOf(entry))
}
}}
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
>
<span className={css.gitLogLine1}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
</span>
<span className={css.gitLogLine2}>
{refNames(entry.refs).map(ref => (
<span key={ref} className={css.gitLogRef}>{ref}</span>
))}
<span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
</span>
</div>
))}
{!logEnded && (
<button
type="button"
className={css.gitLogMore}
disabled={logLoadingMore || busy}
onClick={() => { void loadMoreLog() }}
>
{logLoadingMore ? t('loading') : t('loadMore')}
</button>
)}
</div>
{/*
The one shared file-row context menu, positioned at the right-click
cursor (portal so the panel's overflow clip cannot crop it).
*/}
<Menu
open={fileMenu !== null}
onClose={() => { 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: <IconCodeOutline16 size={14} /> }]
: []),
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconPlusOutline16 size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
: []),
{ type: 'separator', id: 'sep1' },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
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
align="start"
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
anchor={<span />}
/>
{/* The shared history-row context menu. */}
<Menu
open={historyMenu !== null}
onClose={() => { setHistoryMenu(null) }}
items={[
{ id: 'view', label: t('viewCommitDiff') },
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
{ 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
align="start"
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
anchor={<span />}
/>
{/* Destructive actions land here first: Cancel / Confirm. */}
<Modal
open={confirm !== null}
onClose={() => { setConfirm(null) }}
title={confirm?.title ?? ''}
closeLabel={t('cancel')}
footer={(
<>
<Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
<Button
variant="primary"
disabled={busy}
onClick={() => {
const pending = confirm
if (pending === null) return
setConfirm(null)
void pending.onConfirm()
}}
>
{confirm?.confirmLabel ?? ''}
</Button>
</>
)}
>
<p className={css.gitConfirmDesc}>{confirm?.description}</p>
</Modal>
</>
)}
</div>
)
}