存量清理(首跑 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 注释同步顺序)
766 lines
32 KiB
TypeScript
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>
|
|
)
|
|
}
|