文件页(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)
114 lines
4.8 KiB
TypeScript
114 lines
4.8 KiB
TypeScript
/**
|
|
* The session lens of the changes tab: agent truth — every file the model
|
|
* read, wrote, or edited in this session, grouped by file, newest first,
|
|
* with kind filters. Clicking an op previews it in the tab's shared bottom
|
|
* pane (see {@link DiffPane}): writes and edits as line diffs, reads as a
|
|
* line-numbered content view, failures as their real error text. The ops
|
|
* arrive pre-folded from the tab (which owns the event poll); this
|
|
* component is purely presentational.
|
|
*/
|
|
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[]
|
|
/** Every poll failed and nothing loaded: an inline notice, not an empty
|
|
* state that would read as "no operations". */
|
|
loadError: boolean
|
|
/** Preview one op in the shared bottom pane. */
|
|
onPreview: (path: string, op: FileOp) => void
|
|
/** The op currently previewed (row highlight); null when the pane is closed. */
|
|
selectedCallId: string | null
|
|
}
|
|
|
|
export function SessionLens({ ops, loadError, onPreview, selectedCallId }: SessionLensProps) {
|
|
const [filter, setFilter] = useState<OpFilter>('all')
|
|
const filteredOps = useMemo(
|
|
() => (filter === 'all' ? ops : ops.filter(op => op.kind === filter)),
|
|
[ops, filter],
|
|
)
|
|
const groups = useMemo(() => groupByFile(filteredOps), [filteredOps])
|
|
const counts = useMemo(() => {
|
|
const map = new Map<FileOpKind, number>([['read', 0], ['write', 0], ['edit', 0]])
|
|
for (const op of ops) map.set(op.kind, (map.get(op.kind) ?? 0) + 1)
|
|
return map
|
|
}, [ops])
|
|
|
|
// 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
|
|
// tab's opsRef), so one pass per changed fold covers every render.
|
|
const opSizes = useMemo(() => {
|
|
const sizes = new Map<string, string>()
|
|
for (const op of ops) {
|
|
if (op.kind !== 'read' && !op.isError) {
|
|
sizes.set(op.callId, formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size))
|
|
}
|
|
}
|
|
return sizes
|
|
}, [ops])
|
|
|
|
return (
|
|
<div className={css.session}>
|
|
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
|
|
<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 && <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>
|
|
{fileOps.map(op => (
|
|
<button
|
|
type="button"
|
|
key={op.callId}
|
|
className={css.opRow}
|
|
data-op-kind={op.kind}
|
|
data-op-error={op.isError ? 'true' : undefined}
|
|
data-selected={selectedCallId === op.callId ? 'true' : undefined}
|
|
onClick={() => { onPreview(path, op) }}
|
|
>
|
|
<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}>
|
|
{opSizes.get(op.callId) !== undefined && (
|
|
<span className={css.opSize}>
|
|
{opSizes.get(op.callId)}
|
|
</span>
|
|
)}
|
|
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|