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:
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
+712
-598
File diff suppressed because it is too large
Load Diff
@@ -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}>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user