feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取

文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
This commit is contained in:
Menghuan1918
2026-09-28 00:27:31 +08:00
parent 606339f81e
commit 36fbc5ac7e
76 changed files with 6961 additions and 3944 deletions
+122 -62
View File
@@ -2,27 +2,33 @@
* The unified changes tab: one tab, two lenses on "what changed?" — Git
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets expand into the dedicated diff tab docked
* in the workbench's diff pane. The active lens and the pane height persist
* in the tab's meta, so the tab
* reopens exactly where it was left.
* A 36px header carries the host {@link SegmentedControl} and the current
* lens's refresh action; both lenses preview their selections in a shared
* resizable bottom pane ({@link DiffPane}), and git targets expand into the
* dedicated diff tab docked in the workbench's diff pane. The active lens and
* the pane height persist in the tab's meta, so the tab reopens exactly where
* it was left.
*
* The session events ride the host's `changes.ops` route (the client
* runtime exposes no event-log face): the tab pulls the delta past its
* cursor while visible, folds it into ops, and publishes the op count to a
* module-level cache the tab-strip badge reads.
* The session events ride the host's `changes.ops` route (the client runtime
* exposes no event-log face): the tab pulls the delta past its cursor and
* folds it into ops, and publishes the op count to a module-level cache the
* tab-strip badge reads. The pull is one catch-up whenever the tab becomes
* visible and then a cadence ONLY while the session lens is on screen — the
* git lens has no use for the session event log (and the shared git-status
* store owns its own poller).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IconRefreshOutlineRegular, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { api } from '../api.ts'
import { usePolling } from '../use-polling.ts'
import type { SidebarDiffRef } from '../state.ts'
import { IconButton, Notice } from '../ui/index.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { DiffPane, clampPaneHeight, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts'
import css from './changes.module.css'
@@ -51,18 +57,28 @@ interface ChangesMeta {
previewH?: number
}
/** What the user selected to preview: a git ref, or the IDENTITY of a session
* op. Holding the callId (not the op object) is what makes the pane follow
* the op as it settles: every render re-derives the op from the latest fold,
* so a running call turns into its result without the user re-clicking. */
type PreviewSelection =
| { kind: 'git'; ref: SidebarDiffRef }
| { kind: 'op'; path: string; callId: string }
export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }: TabComponentProps) {
const meta = (tab.meta ?? {}) as ChangesMeta
const [lens, setLens] = useState<Lens>(meta.lens === 'session' ? 'session' : 'git')
const [preview, setPreview] = useState<ChangesPreview | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(
typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT,
)
const [selection, setSelection] = useState<PreviewSelection | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(() => clampPaneHeight(
typeof meta.previewH === 'number' ? meta.previewH : PANE_HEIGHT_DEFAULT,
))
/** The header refresh action for the Git lens (the lens owns its own queue). */
const [gitRefresh, setGitRefresh] = useState(0)
// ── Session-event accumulation: one pull on mount, then a 2.5s delta
// poll while visible (paused otherwise; the next visible tick catches
// up). The cursor is the last delivered seq, so each poll ships only
// what the accumulator lacks. ────────────────────────────────────────
// ── Session-event accumulation: one catch-up pull when the tab becomes
// visible, then a 2.5s delta poll while the SESSION lens is on screen.
// The cursor is the last delivered seq, so each poll ships only what the
// accumulator lacks. ───────────────────────────────────────────────────
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
// The fold of eventsRef as of the last poll. extractFileOps parses every
// accumulated tool/call (up to EVENTS_CAP events); running it once per
@@ -72,13 +88,30 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
const opsRef = useRef<readonly FileOp[]>([])
const seqRef = useRef(0)
const pollGen = useRef(0)
/** One delta request in flight at a time (the poller is fixed-interval, so a
* slow host would otherwise run two pulls over the same cursor and append
* the same delta twice). A pull that arrives while one is running is a
* no-op: the in-flight answer already carries everything up to now. */
const pullingRef = useRef(false)
const [opsError, setOpsError] = useState(false)
const [tick, setTick] = useState(0)
const pull = useCallback(async (): Promise<void> => {
const pull = useCallback(async (signal?: AbortSignal): Promise<void> => {
const generation = pollGen.current
if (pullingRef.current) return
pullingRef.current = true
try {
const { events, lastSeq } = await api.changesOps(scope, seqRef.current)
if (generation !== pollGen.current) return
const { events, lastSeq } = await api.changesOps(scope, seqRef.current, signal)
// An aborted poll belongs to a torn-down run: its answer must never
// publish state (the transport may deliver it anyway).
if (generation !== pollGen.current || signal?.aborted === true) return
// Nothing new: KEEP the previous fold's identity. Re-folding an
// unchanged window produced a fresh array every 2.5s, which defeated
// every downstream memo — the session lens re-encoded each op body into
// a Blob to size it, on every idle tick.
if (events.length === 0 && lastSeq <= seqRef.current && opsRef.current.length > 0) {
setOpsError(false)
return
}
if (events.length > 0) {
const merged = [...eventsRef.current, ...events]
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
@@ -92,7 +125,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
} catch {
// Offline / route unavailable: keep the last fold; surface it inline
// only while nothing has ever loaded.
if (generation === pollGen.current) setOpsError(true)
if (generation === pollGen.current && signal?.aborted !== true) setOpsError(true)
} finally {
pullingRef.current = false
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the poll target.
@@ -105,15 +140,24 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
// One pull on every input change — mount, scope change, and visibility
// flip all re-pull (a hidden tab still catches up on the flip); only the
// poll CADENCE below is gated by visibility.
useEffect(() => { void pull() }, [visible, pull])
usePolling(visible, pull, { intervalMs: 2_500 })
// One catch-up whenever the tab becomes visible (the badge count follows the
// tab being opened); the CADENCE below runs only while this lens is showing.
useEffect(() => { if (visible) void pull() }, [visible, pull])
usePolling(visible && lens === 'session', pull, { intervalMs: 2_500 })
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = opsRef.current
/** The preview target of the current selection, re-derived every render so
* an op that settled (or fell out of the window) is reflected immediately. */
const previewTarget = useMemo<ChangesPreview | null>(() => {
if (selection === null) return null
if (selection.kind === 'git') return selection
const op = ops.find(candidate => candidate.callId === selection.callId)
if (op === undefined) return null
return { kind: 'op', path: selection.path, op, prior: knownContentBefore(ops, selection.path, op) }
}, [selection, ops])
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
ctx.get('betterSidebar')?.updateTab(tab.id, {
@@ -127,21 +171,29 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
patchMeta({ lens: next })
}
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setPreview({ kind: 'git', ref })
/** The header's refresh action: the Git lens reloads through its own queue
* (a click while a refresh is in flight is queued, not dropped), the
* session lens re-pulls its event delta. */
const refreshCurrent = (): void => {
if (lens === 'git') setGitRefresh(value => value + 1)
else void pull()
}
/** Preview one session op (with its best-effort prior content snapshot). */
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setSelection({ kind: 'git', ref })
}
/** Preview one session op by callId (the tab re-derives it each render). */
const previewOp = (path: string, op: FileOp): void => {
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
setSelection({ kind: 'op', path, callId: op.callId })
}
/** Expand the current git preview into the dedicated diff tab, docked
* into the workbench's diff pane. */
const expandPreview = (): void => {
if (preview?.kind !== 'git') return
onOpenDiff?.(diffTabOf(preview.ref))
if (previewTarget?.kind !== 'git') return
onOpenDiff?.(diffTabOf(previewTarget.ref))
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
@@ -152,27 +204,23 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
return (
<div className={css.root}>
<div className={css.lensBar}>
<div className={css.lensSwitch} role="group" aria-label={t('changes')}>
<button
type="button"
className={css.lensButton}
data-active={lens === 'git' ? 'true' : undefined}
aria-pressed={lens === 'git'}
onClick={() => { chooseLens('git') }}
>
{t('changesGitLens')}
</button>
<button
type="button"
className={css.lensButton}
data-active={lens === 'session' ? 'true' : undefined}
aria-pressed={lens === 'session'}
onClick={() => { chooseLens('session') }}
>
{t('changesSessionLens')}
</button>
</div>
<div className={css.head}>
<SegmentedControl
id={`changes-lens-${tab.id}`}
value={lens}
options={[
{ value: 'git', label: t('changesGitLens') },
{ value: 'session', label: t('changesSessionLens') },
]}
onChange={chooseLens}
label={t('changes')}
/>
<IconButton
className={css.headAction}
icon={<IconRefreshOutlineRegular />}
label={t('refresh')}
onClick={refreshCurrent}
/>
</div>
{lens === 'git'
? (
@@ -180,9 +228,10 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
scope={scope}
store={store}
visible={visible}
refreshTick={gitRefresh}
onOpenFile={onOpenFile ?? (() => { /* no-op */ })}
onPreview={previewGit}
selectedRef={preview !== null && preview.kind === 'git' ? preview.ref : null}
selectedRef={previewTarget !== null && previewTarget.kind === 'git' ? previewTarget.ref : null}
/>
)
: (
@@ -190,20 +239,31 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
ops={ops}
loadError={opsError && ops.length === 0}
onPreview={previewOp}
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
selectedCallId={selection !== null && selection.kind === 'op' ? selection.callId : null}
/>
)}
{preview !== null && (
{previewTarget !== null && (
<DiffPane
key={previewKey(preview)}
target={preview}
key={previewKey(previewTarget)}
target={previewTarget}
scope={scope}
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
onClose={() => { setPreview(null) }}
onClose={() => { setSelection(null) }}
onExpand={expandPreview}
/>
)}
{selection !== null && selection.kind === 'op' && previewTarget === null && (
<div className={css.paneGone}>
<Notice kind="warn">{t('changesOpGone')}</Notice>
<IconButton
size="sm"
icon={<IconRefreshOutlineRegular size={14} />}
label={t('changesClosePreview')}
onClick={() => { setSelection(null) }}
/>
</div>
)}
</div>
)
}
+61 -199
View File
@@ -2,16 +2,16 @@
* The changes tab's shared bottom preview pane: one selected target — a git
* worktree change, a commit patch, or a session file op — rendered through
* the unified diff stack (the same one the diff tab uses). Git targets load
* on demand (refreshable, with the untracked full-addition fallback); op
* targets are pure snapshots (diff / read view / error text). The pane is
* resizable by drag (clamped; the height commits to the tab's persisted
* meta on release) and by keyboard; git targets can expand into a dedicated
* diff tab via the shell.
* through the shared {@link useGitDiffTarget} hook (refreshable, with the
* untracked full-addition fallback); op targets are pure snapshots (diff /
* read view / error text). The pane is resizable by drag (clamped; the
* height commits to the tab's persisted meta on release) and by keyboard;
* git targets can expand into a dedicated diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconRightUpOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api, htmlUrl } from '../api.ts'
import { htmlUrl } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../paths.ts'
@@ -21,7 +21,8 @@ import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { PdfView } from '../PdfView.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, displayPath, foldRowsFromContents, parseUnifiedDiff, unifiedSegments, type DiffFile, type DiffRow, type FoldSegment } from '../diff/rows.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { useGitDiffTarget } from '../diff/use-git-diff.ts'
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
import { redactText } from '../redact.ts'
import { rewriteLocalImageUrls } from '../markdown-images.ts'
@@ -29,6 +30,7 @@ import { markdownTextProps } from '../markdown-labels.tsx'
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
import { LazyMermaidMarkdown } from '../mermaid-lazy.tsx'
import { createFrameBatcher } from '../frame-batcher.ts'
import { Chip, IconButton, Notice, StatusBadge } from '../ui/index.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
@@ -36,6 +38,16 @@ import diffCss from '../diff/diff.module.css'
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/**
* Clamp one pane height to the pane's own bounds: never below the content
* minimum, never above 70% of the window. Both the drag handler and the
* RESTORE path (a height persisted under a taller window) run through this,
* so a saved value can never open the pane taller than the viewport allows.
*/
export function clampPaneHeight(value: number): number {
return Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
}
/** The redaction preference, persisted under the repo's sidebar storage
* prefix (see state.ts's `dsh-sidebar:v1`). */
const REDACTION_KEY = 'dsh-sidebar:v1:redaction'
@@ -84,21 +96,10 @@ export function HtmlRenderPreview(props: { src: string; title: string }) {
)
}
/** One header pill toggle — the redaction / reading / render toggles share
* the shape (on-state styling + aria-pressed). */
/** One header pill toggle — the redaction / reading / render toggles share the
* shape: the global {@link Chip} (its active state is the "on" picture). */
function PaneToggle(props: { on: boolean; label: string; title?: string; onClick: () => void }) {
return (
<button
type="button"
className={css.mdToggle}
data-on={props.on ? 'true' : undefined}
aria-pressed={props.on}
title={props.title}
onClick={props.onClick}
>
{props.label}
</button>
)
return <Chip active={props.on} title={props.title} onClick={props.onClick}>{props.label}</Chip>
}
/** The reading-mode body of one markdown op target: the shared MarkdownText
@@ -157,146 +158,10 @@ export interface DiffPaneProps {
}
export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExpand }: DiffPaneProps) {
// ── Git target loading (mirrors the diff tab: staged-side fallback, the
// untracked full-addition fallback, refresh by tick). ─────────────────
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(target.kind === 'git')
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
// The staged flag of the side ACTUALLY rendered: when the requested side's
// diff came back empty the load falls back to the other side, and the fold
// expansion must read that side's revisions (else the sliced line numbers
// land on the wrong contents).
const [effectiveStaged, setEffectiveStaged] = useState<boolean | null>(null)
// ── Git target loading (the shared loader: staged-side fallback, the
// untracked full-addition fallback, refresh by tick, fold cache). ─────
const gitRef = target.kind === 'git' ? target.ref : null
// The scope every git call of this target shares (repoRoot folded in when
// the ref carries one, exactly like the load effect's paneScope).
const gitScope = useMemo<SessionScope>(() => ({
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef?.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}), [scope.sessionId, scope.cwd, gitRef?.repoRoot])
useEffect(() => {
if (gitRef === null) return
let cancelled = false
const paneScope: SessionScope = {
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
setEffectiveStaged(null)
const load = async (): Promise<void> => {
try {
if (gitRef.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, gitRef.hashFull, gitRef.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, gitRef.path, gitRef.staged, gitRef.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
if (other.diff !== '') {
result = other
if (!cancelled) setEffectiveStaged(!gitRef.staged)
}
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content.
if (gitRef.untracked === true && !gitRef.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, gitRef.path))
if (!cancelled && text.kind === 'text') {
setDiffText('')
setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, tick])
// ── On-demand git fold expansion: a fold's hidden rows come from both
// sides' full contents (git.show / fsRead), fetched ONCE per file so
// sibling folds share the request, then sliced by each fold's line
// ranges. The cache dies with the target or a refresh tick. ───────────
const foldContents = useRef(new Map<string, Promise<{ old: string; new: string }>>())
useEffect(() => { foldContents.current = new Map() }, [gitRef, tick])
const foldLoader = useMemo(() => {
if (gitRef === null) return undefined
const sidesOf = (file: DiffFile): Promise<{ old: string; new: string }> => {
// Both sides empty cannot cover a non-empty fold — treat it as a failed
// fetch so the fold degrades to the unavailable marker instead of
// silently expanding to nothing (the symptom of a bad rev or path
// reading null on both sides).
const ofSides = (oldContent: string | null, newContent: string | null): { old: string; new: string } => {
if ((oldContent ?? '') === '' && (newContent ?? '') === '') throw new Error('no content on either side')
return { old: oldContent ?? '', new: newContent ?? '' }
}
const fetchSides = async (): Promise<{ old: string; new: string }> => {
if (gitRef.kind === 'commit') {
// The patch's -m --first-parent shape: old side from the parent,
// new side from the commit (a root commit's parent read fails → '').
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, `${gitRef.hashFull}^`, displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, gitRef.hashFull, displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
// Worktree change: staged is HEAD vs index, unstaged is index vs
// worktree (the worktree side reads the live file).
const staged = effectiveStaged ?? gitRef.staged
if (staged) {
const [oldSide, newSide] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, 'HEAD', displayPath(file.oldPath), gitRef.worktree),
file.newPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.newPath), gitRef.worktree),
])
return ofSides(oldSide.content, newSide.content)
}
const [oldSide, worktree] = await Promise.all([
file.oldPath === '/dev/null'
? Promise.resolve({ content: null })
: api.gitShow(gitScope, ':0', displayPath(file.oldPath), gitRef.worktree),
api.fsRead(gitScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, displayPath(file.newPath))).catch(() => null),
])
return ofSides(oldSide.content, worktree !== null && worktree.kind === 'text' ? worktree.content : null)
}
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
let promise = foldContents.current.get(path)
if (promise === undefined) {
promise = fetchSides()
foldContents.current.set(path, promise)
}
return promise
}
return (file: DiffFile, segment: FoldSegment): Promise<readonly DiffRow[]> =>
sidesOf(file).then(sides => foldRowsFromContents(segment, sides.old, sides.new))
}, [gitRef, gitScope, effectiveStaged, scope])
const { loading, error, diffText, untracked, refresh, resolveFold } = useGitDiffTarget(gitRef, scope)
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
@@ -422,7 +287,7 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
// shell). Arrow keys resize by a step for keyboard users. ────────────
const [dragHeight, setDragHeight] = useState<number | null>(null)
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const clamp = clampPaneHeight
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
// Pointer streams fire several times per frame; one setState per event
// re-rendered the whole pane at event cadence (see frame-batcher).
@@ -471,15 +336,15 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
/>
<div className={css.diffHead}>
{target.kind === 'op' && (
<span className={css.diffKind} data-kind={target.op.kind}>
<StatusBadge tone={target.op.kind}>
{t(target.op.kind === 'read' ? 'changesRead' : target.op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
</StatusBadge>
)}
{target.kind === 'git' && target.ref.kind === 'worktree' && (
<span className={css.diffKind} data-kind="git">{target.ref.staged ? t('staged') : t('unstaged')}</span>
<StatusBadge tone="neutral">{target.ref.staged ? t('staged') : t('unstaged')}</StatusBadge>
)}
{target.kind === 'git' && target.ref.kind === 'commit' && (
<span className={css.diffKind} data-kind="git">{target.ref.hash}</span>
<StatusBadge tone="neutral">{target.ref.hash}</StatusBadge>
)}
<span className={css.diffPath} title={title}>{title}</span>
{stats !== null && (stats.added > 0 || stats.deleted > 0) && (
@@ -490,25 +355,19 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
)}
{target.kind === 'git' && (
<>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
<IconButton
size="sm"
icon={<IconRefreshOutlineRegular size={14} />}
label={t('refresh')}
disabled={loading}
onClick={() => { setTick(value => value + 1) }}
>
<IconRefreshOutlineRegular size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('changesOpenDiffTab')}
title={t('changesOpenDiffTab')}
onClick={refresh}
/>
<IconButton
size="sm"
icon={<IconRightUpOutlineRegular size={14} />}
label={t('changesOpenDiffTab')}
onClick={onExpand}
>
<IconRightUpOutlineRegular size={14} />
</button>
/>
</>
)}
{redactionHit && (
@@ -543,15 +402,12 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
onClick={() => { setRenderingPdf(value => !value) }}
/>
)}
<button
type="button"
className={css.iconButton}
aria-label={t('changesClosePreview')}
title={t('changesClosePreview')}
<IconButton
size="sm"
icon={<IconCloseOutlineRegular size={14} />}
label={t('changesClosePreview')}
onClick={onClose}
>
<IconCloseOutlineRegular size={14} />
</button>
/>
</div>
{target.kind === 'op' && htmlOp && rendering && htmlRenderSrc !== ''
? <HtmlRenderPreview src={htmlRenderSrc} title={target.path} />
@@ -566,9 +422,9 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
: target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<div className={css.readError} role="alert">
<Notice kind="error" className={css.readError}>
{op.errorText ?? t('changesError')}
</div>
</Notice>
</div>
)
: target.kind === 'op' && op !== null && op.kind === 'read'
@@ -582,26 +438,32 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
<div className={css.paneBody}>
{op !== null && op.kind === 'write'
&& prior === undefined
&& <div className={css.priorUnknown}>{t('changesPriorUnknown')}</div>}
&& <Notice kind="hint" tone="inline">{t('changesPriorUnknown')}</Notice>}
<DiffRows key={target.op.callId} segments={opSegments} lang={opLang} />
</div>
)
: loading
? <div className={css.paneBody}><div className={css.gitPlaceholder}>{t('loading')}</div></div>
? <div className={css.paneBody}><Notice kind="loading" tone="page">{t('loading')}</Notice></div>
: error !== null
? <div className={css.paneBody}><div className={css.gitError}>{t('diffLoadError')}: {error}</div></div>
? (
<div className={css.paneBody}>
<Notice kind="error">{t('diffLoadError')}: {error}</Notice>
</div>
)
: (
<div className={css.paneBody}>
{diffText !== null && diffText !== '' && (
{/* The untracked fallback has NO diff text (git diff never
lists the file): its content is the whole render. */}
{((diffText !== null && diffText !== '') || untracked !== undefined) && (
<DiffFiles
diff={diffText}
resolveFold={foldLoader}
diff={diffText ?? ''}
resolveFold={resolveFold}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
)}
{diffText === '' && untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
<Notice kind="empty" tone="page">{t('diffEmpty')}</Notice>
)}
</div>
)}
File diff suppressed because it is too large Load Diff
+21 -24
View File
@@ -7,15 +7,18 @@
* arrive pre-folded from the tab (which owns the event poll); this
* component is purely presentational.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { relativeTime, t } from '../locales.ts'
import { groupByFile, type FileOp, type FileOpKind } from './ops.ts'
import { formatBytes } from '../diff/rows.ts'
import { Chip, Notice, StatusBadge } from '../ui/index.ts'
import css from './changes.module.css'
/** The op-kind filter chips: 'all' or one concrete kind. */
type OpFilter = 'all' | FileOpKind
const KIND_LABEL = { read: 'changesRead', write: 'changesWrite', edit: 'changesEdit' } as const
export interface SessionLensProps {
/** The folded file operations, newest first (the tab's poll owns them). */
ops: readonly FileOp[]
@@ -41,19 +44,6 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
return map
}, [ops])
const chip = (value: OpFilter, label: string, count: number): ReactNode => (
<button
type="button"
key={value}
className={css.filterChip}
data-active={filter === value ? 'true' : undefined}
onClick={() => { setFilter(value) }}
aria-pressed={filter === value}
>
{label}{value !== 'all' ? ` ${String(count)}` : ''}
</button>
)
// Sizing a row's content constructs a Blob (a UTF-8 encode of the whole
// edit) — that was per row PER RENDER, re-encoding every field on every
// poll tick. ops keeps its identity between unchanged polls (see the
@@ -71,15 +61,24 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
return (
<div className={css.session}>
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
{chip('all', t('changesFilterAll'), ops.length)}
{chip('write', t('changesWrite'), counts.get('write') ?? 0)}
{chip('edit', t('changesEdit'), counts.get('edit') ?? 0)}
{chip('read', t('changesRead'), counts.get('read') ?? 0)}
<Chip active={filter === 'all'} count={ops.length} onClick={() => { setFilter('all') }}>
{t('changesFilterAll')}
</Chip>
{(['write', 'edit', 'read'] as const).map(kind => (
<Chip
key={kind}
active={filter === kind}
count={counts.get(kind) ?? 0}
onClick={() => { setFilter(kind) }}
>
{t(KIND_LABEL[kind])}
</Chip>
))}
</div>
<div className={css.sessionList}>
{loadError && <div className={css.loadError}>{t('changesLoadError')}</div>}
{ops.length === 0 && !loadError && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
{ops.length > 0 && filteredOps.length === 0 && <div className={css.empty}>{t('changesFilterEmpty')}</div>}
{loadError && <Notice kind="error" role="alert">{t('changesLoadError')}</Notice>}
{ops.length === 0 && !loadError && <Notice kind="empty" tone="page">{t('changesSessionEmpty')}</Notice>}
{ops.length > 0 && filteredOps.length === 0 && <Notice kind="empty" tone="page">{t('changesFilterEmpty')}</Notice>}
{[...groups.entries()].map(([path, fileOps]) => (
<div key={path} className={css.fileGroup}>
<div className={css.filePath} title={path}>{path}</div>
@@ -93,9 +92,7 @@ export function SessionLens({ ops, loadError, onPreview, selectedCallId }: Sessi
data-selected={selectedCallId === op.callId ? 'true' : undefined}
onClick={() => { onPreview(path, op) }}
>
<span className={css.opKind} data-kind={op.kind}>
{t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
<StatusBadge tone={op.kind}>{t(KIND_LABEL[op.kind])}</StatusBadge>
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
<span className={css.opMeta}>
+85 -282
View File
@@ -1,18 +1,20 @@
/* The unified changes tab: lens switcher + Git lens + session lens + the
shared preview pane. Semantic --dsw-alias tokens only (skin contract).
The git lens styles moved here from sidebar.module.css when the git panel
became a lens of this tab; the generic placeholder/empty/error trio stays
duplicated in both modules (the diff tab still owns its copy). */
/* The unified changes tab: the 36px lens header + Git lens + session lens +
the shared preview pane. Token-only by contract (`tests/theme.spec.ts` scans
every module sheet) and cut to the plugin's native scale — 12px body, 11px
meta, 28px rows/controls, 2/3/4px radii (see
docs/plans/2026-09-27-files-changes-uiux.md §1).
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 12px; }
The generic chrome — icon buttons, chips, section bands, badges, empty /
loading / error lines and the confirm dialog — now lives in
`src/client/ui/kit.module.css`; this sheet keeps only layout and the
surfaces unique to this tab. */
/* ── Lens switcher ─────────────────────────────────────────────────────── */
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font: var(--dsw-font-xxs-12); }
.lensBar { flex: none; display: flex; align-items: center; padding: 8px 8px 4px 12px; }
.lensSwitch { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; }
.lensButton { border: 0; border-radius: 6px; padding: 2px 10px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); cursor: pointer; }
.lensButton:hover { color: var(--dsw-alias-label-primary); }
.lensButton[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
/* ── Lens header ───────────────────────────────────────────────────────── */
.head { flex: none; display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 8px 0 12px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.headAction { margin-left: auto; }
/* ── Git lens (the former git panel) ───────────────────────────────────── */
@@ -28,311 +30,134 @@
overflow-x: hidden;
}
.gitHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
/* The selector band: branch / repository / worktree pickers (only rendered
for real choices) and nothing else — the counts live in the section bands. */
.selectors { flex: none; display: flex; align-items: center; gap: 6px; padding: 6px 12px 0; }
.gitWorktreeRow {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 0 12px;
}
.gitWorktreeLabel {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
}
.gitBranchSelect {
.select {
flex: 1;
min-width: 0;
height: 26px;
height: 28px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
border-radius: 3px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.gitSection {
border-top: 1px solid var(--dsw-alias-border-l1);
}
/* A single branch has nothing to choose: it reads as the row's label. */
.branchLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); }
.gitSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 4px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
}
/* The section band's text action ("stage all" / "unstage all"). */
.link { border: none; background: transparent; padding: 0; font: var(--dsw-font-xxxs-strong-11); color: var(--dsw-alias-brand-primary); cursor: pointer; }
.link:hover:not(:disabled) { text-decoration: underline; }
.link:disabled { opacity: 0.4; cursor: default; }
.gitLink {
border: none;
background: transparent;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
padding: 0;
}
.gitLink:hover:not(:disabled) {
text-decoration: underline;
}
.gitLink:disabled {
opacity: 0.4;
cursor: default;
}
.gitRow {
/* One changed file: badge + name + dimmed directory + the trailing action. */
.row {
display: flex;
align-items: center;
gap: 6px;
min-height: 34px;
min-height: 28px;
margin: 0 6px;
padding: 0 8px;
border-radius: 8px;
padding: 0 4px 0 8px;
border-radius: 4px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.row:hover { background: var(--dsw-alias-interactive-bg-hover); }
.row[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
.gitRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitRowMain {
.rowMain {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
align-items: baseline;
gap: 6px;
border: none;
background: transparent;
padding: 3px 0;
padding: 0;
cursor: pointer;
text-align: left;
}
.gitBadge {
.rowName { flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }
/* The directory is context, not identity: it shrinks first and stays quiet. */
.rowDir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font: var(--dsw-font-xxxs-11); direction: rtl; text-align: left; }
/* ── Commit bar (docked to the bottom edge of the scroll area) ─────────── */
.commitBar {
position: sticky;
bottom: 0;
z-index: 1;
flex: none;
width: 20px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts;
untracked '?' reads as a not-yet-added file, so it shares the added tint. */
.gitBadge[data-letter='A'], .gitBadge[data-letter='?'] { color: var(--dsw-alias-state-success-primary); }
.gitBadge[data-letter='D'] { color: var(--dsw-alias-state-error-primary); }
.gitBadge[data-letter='M'], .gitBadge[data-letter='R'] { color: var(--dsw-alias-state-business-primary); }
.gitBadge[data-letter='C'], .gitBadge[data-letter='U'] { color: var(--dsw-alias-state-warn-primary); }
.gitName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitEmpty {
padding: 4px 12px 8px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.gitPlaceholder {
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.gitError {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
white-space: pre-wrap;
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
display: flex;
gap: 6px;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
align-items: center;
background: var(--dsw-alias-bg-base);
}
.commitRow { display: flex; align-items: center; gap: 6px; }
.commitInput { flex: 1; min-width: 0; }
.gitCommitInput {
flex: 1;
min-width: 0;
}
.gitCommitButton {
flex: none;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 6px;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-inverted);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.gitCommitButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.gitCommitButton:disabled {
opacity: 0.45;
cursor: default;
}
/* ── History ───────────────────────────────────────────────────────────── */
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
.gitLogRow {
.logRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
padding: 4px 12px;
margin: 0 6px;
border-radius: 4px;
cursor: pointer;
border-radius: 8px;
}
.logRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.logRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.logLine1 { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.logHash { flex: none; font: var(--dsw-font-markdown-code-block-small); color: var(--dsw-alias-label-tertiary); }
.logLine2 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; }
.logRef { flex: none; padding: 0 5px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 2px; font: var(--dsw-font-xxxs-strong-11); color: var(--dsw-alias-brand-primary); white-space: nowrap; }
.logSubject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }
.logMeta { font: var(--dsw-font-xxxs-11); color: var(--dsw-alias-label-tertiary); }
.gitLogRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.gitLogHash {
flex: none;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.gitLogRef {
flex: none;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-brand-primary);
white-space: nowrap;
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.gitLogMore {
.logMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
height: 28px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
border-radius: 3px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
.logMore:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.logMore:disabled { opacity: 0.5; cursor: default; }
/* ── Session lens (this round's files) ─────────────────────────────────── */
.session { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 2px 8px 6px 12px; flex-wrap: wrap; }
.filterChip { border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 1px 8px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); cursor: pointer; }
.filterChip:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
.filterChip[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); border-color: transparent; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 6px 12px; flex-wrap: wrap; }
.sessionList { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 10px; }
.empty { color: var(--dsw-alias-label-tertiary); padding: 24px 8px; text-align: center; }
.loadError { margin: 4px 8px; padding: 6px 10px; border-left: 3px solid var(--dsw-alias-state-warn-primary); border-radius: 0 6px 6px 0; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.fileGroup { margin-bottom: 6px; }
/* The group label sits between op blocks: extra head padding separates it from
the previous file's rows, the first group starts flush. */
.filePath { display: block; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 6px 4px 2px; word-break: break-all; }
.fileGroup:first-child .filePath { padding-top: 2px; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 5px; cursor: pointer; text-align: left; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; min-height: 24px; border: 0; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 0 5px; cursor: pointer; text-align: left; }
.opRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.opRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
/* A failed op carries a quiet error inset bar (the flag chip names it). */
.opRow[data-op-error='true'] { box-shadow: inset 2px 0 0 var(--dsw-alias-state-error-primary); }
.opKind { border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); flex: none; }
.opKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.opKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* Size + relative time ride as one right-aligned cluster so the kind chips
/* Size + relative time ride as one right-aligned cluster so the kind badges
form a scannable left rail and the meta column stays flush right. */
.opMeta { margin-left: auto; display: inline-flex; align-items: baseline; gap: 8px; flex: none; min-width: 0; }
.opTime { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
@@ -342,10 +167,7 @@
/* ── Shared preview pane ───────────────────────────────────────────────── */
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 7px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdToggle { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; cursor: pointer; }
.mdToggle:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.mdToggle[data-on='true'] { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 2px; color: var(--dsw-alias-state-warn-primary); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdBody { padding: 12px 16px 20px; font: var(--dsw-font-xs-regular-13); line-height: 1.6; min-height: 0; overflow-y: auto; }
.htmlPane { flex: 1; min-height: 0; display: flex; }
.htmlFrame { flex: 1; width: 100%; border: none; background: var(--dsw-alias-bg-base); }
@@ -355,36 +177,18 @@
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
.dragHandle:hover::after, .dragHandle:focus-visible::after { background: var(--dsw-alias-state-business-primary); }
.diffHead { display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.diffKind { flex: none; border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
.diffKind[data-kind='git'] { color: var(--dsw-alias-brand-primary); }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
/* The path keeps its tail (the file name is the identity; directories are
context): an RTL paragraph renders an ASCII path in reading order while
clipping its HEAD under the ellipsis. */
.diffPath { flex: 1; direction: rtl; font-family: var(--ds-font-family-code, monospace); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-primary); min-width: 0; }
.diffStats { flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.iconButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.iconButton:disabled { opacity: 0.4; cursor: default; }
.paneBody { flex: 1; min-height: 0; overflow-y: auto; }
.readError { color: var(--dsw-alias-state-error-primary); border-left: 3px solid var(--dsw-alias-state-error-primary); border-radius: 0 6px 6px 0; margin: 6px 8px; padding: 6px 10px; white-space: pre-wrap; word-break: break-word; }
.priorUnknown { color: var(--dsw-alias-label-tertiary); font-size: 10px; padding: 3px 10px; }
/* An op's error body: the kit's error ink with the row's inset bar. */
.readError { border-left: 3px solid var(--dsw-alias-state-error-primary); margin: 6px 8px; padding: 6px 10px; word-break: break-word; }
/* The op preview whose callId left the loaded event window. */
.paneGone { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--dsw-alias-border-l1); }
/* ── Shared bits ───────────────────────────────────────────────────────── */
@@ -393,15 +197,14 @@
from { opacity: 0; }
}
/* Keyboard focus rings match the shared sidebar convention. */
.gitLink:focus-visible, .gitRowMain:focus-visible, .gitLogRow:focus-visible,
.gitCommitButton:focus-visible, .gitLogMore:focus-visible, .gitBranchSelect:focus-visible,
.lensButton:focus-visible, .filterChip:focus-visible, .iconButton:focus-visible,
.dragHandle:focus-visible {
/* Keyboard focus rings match the shared sidebar convention (the kit atoms
carry their own). */
.link:focus-visible, .rowMain:focus-visible, .logRow:focus-visible,
.logMore:focus-visible, .select:focus-visible, .dragHandle:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
.gitRow { animation: none; }
.row { animation: none; }
}