Files
dsh-context/src/client/fileActivity.ts
T
bowenliang123 0cd9762bad feat(client): add a by-name sort to File Activity
The sort toggle ordered rows by counts, recency, and path; the row's own
last segment — the bold name a reader scans for — had no order of its
own, so same-named files in different directories never grouped. Name
leads on the displayed last segment (either separator style, via the
shared leafNameOf) and the full path breaks ties between rows that share
a name.

The Preferences picker, the host Config union, the shared
DefaultFileSort vocabulary, and both dictionaries carry the fourth
option; glyphOf now reuses the same leaf helper instead of its own split.
2026-10-17 12:47:42 +08:00

398 lines
17 KiB
TypeScript

/**
* File activity — what the agent DID to files. The op-log generation's host fold
* derives one record per executed file op over the whole log
* (shared/fileOps.ts), so this module only filters and aggregates; older hosts
* serve no `fileOps`, and the legacy path re-derives the ops from the served
* nodes joined with the conversation window (window-bound).
*
* `before` is the exclusive upper seq bound (the next request's seq).
*/
import type { FileOpRecord, RequestRecord, SurfaceNode } from '../shared/types'
import { opsOfCall, parseCallArgs } from '../shared/fileOps'
import { leafNameOf } from '../shared/attachment'
// The harness's browser-safe file-address layer, inlined by the client bundle:
// the produced address is the `dsh-resource://file/…` spelling the preview claims.
import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
import type { ConversationNodeLike } from './services'
export type FileOp = FileOpRecord
export type FileOpKind = FileOpRecord['kind']
export type FileForm = 'text' | 'image' | 'dir'
export interface FileEntry {
path: string
form: FileForm
reads: number
writes: number
searches: number
added: number
removed: number
errs: number
ops: FileOp[]
/** Set when `path` is a pathless search's PATTERN, not a file path — display must not relativize it. */
pattern?: true
}
export interface FileKindTotal { files: number; ops: number }
export interface FileActivity {
entries: FileEntry[]
totals: Record<FileOpKind | 'image', FileKindTotal> & { added: number; removed: number }
}
export function formOf(tool: string, path: string): FileForm {
if (tool === 'read_image' || IMAGE_EXT.test(path)) return 'image'
if (path.endsWith('/')) return 'dir'
return 'text'
}
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i
/** One row icon: the emoji, or a letter badge when `color` is set, plus the i18n key naming its bucket. */
export interface FileGlyph {
glyph: string
tip: string
/** Language-badge fill; present only on the code-file buckets. */
color?: string
text?: string
}
/** Directory buckets by last path segment; then hidden dirs, then the plain folder. */
const DIR_BUCKETS: (readonly [readonly string[], string, string])[] = [
[['test', 'tests', '__tests__', 'spec', 'specs', 'e2e'], '🧪', 'files.glyph.tests'],
[['doc', 'docs', 'documentation'], '📚', 'files.glyph.docs'],
[['node_modules', 'vendor', 'third_party', 'third-party', 'packages'], '📦', 'files.glyph.deps'],
[['dist', 'build', 'out', 'target', 'release', 'debug', 'coverage', 'artifacts'], '🏗️', 'files.glyph.build'],
[['scripts', 'tools', 'bin'], '🛠️', 'files.glyph.scripts'],
[['config', 'configs', 'settings', '.config'], '⚙️', 'files.glyph.config'],
[['assets', 'static', 'public', 'images', 'fonts', 'icons', 'media'], '🎨', 'files.glyph.assets'],
]
const LOCK_NAMES = ['package-lock.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json']
const MAKE_NAMES = ['makefile', 'justfile', 'cmakelists.txt']
const TEST_NAME = /(^|[^a-z0-9])test([^a-z0-9]|$)|\.test\./
/** Language files render as letter badges over their language's color, before the emoji buckets. */
const CODE_LANGS: (readonly [readonly string[], string, string, string])[] = [
[['tsx'], 'TSX', '#3178c6', 'files.glyph.lang.ts'],
[['ts'], 'TS', '#3178c6', 'files.glyph.lang.ts'],
[['js', 'jsx', 'mjs', 'cjs'], 'JS', '#f7df1e', 'files.glyph.lang.js'],
[['py', 'pyi', 'pyw'], 'PY', '#3572a5', 'files.glyph.python'],
[['ipynb'], 'NB', '#da5b0b', 'files.glyph.notebook'],
[['go'], 'GO', '#00add8', 'files.glyph.lang.go'],
[['rs'], 'RS', '#dea584', 'files.glyph.lang.rust'],
[['java'], 'JV', '#b07219', 'files.glyph.lang.java'],
[['kt', 'kts'], 'KT', '#a97bff', 'files.glyph.lang.kotlin'],
[['rb'], 'RB', '#701516', 'files.glyph.lang.ruby'],
[['php'], 'PHP', '#4f5d95', 'files.glyph.lang.php'],
[['c', 'h'], 'C', '#555555', 'files.glyph.lang.c'],
[['cpp', 'cc', 'cxx', 'hpp'], 'C++', '#f34b7d', 'files.glyph.lang.cpp'],
[['cs'], 'C#', '#178600', 'files.glyph.lang.csharp'],
[['scala'], 'SC', '#c22d40', 'files.glyph.lang.scala'],
[['lua'], 'LUA', '#000080', 'files.glyph.lang.lua'],
[['dart'], 'DA', '#00b4ab', 'files.glyph.lang.dart'],
[['swift'], 'SW', '#f05138', 'files.glyph.lang.swift'],
[['vue'], 'VUE', '#41b883', 'files.glyph.lang.vue'],
[['svelte'], 'SV', '#ff3e00', 'files.glyph.lang.svelte'],
[['sh', 'bash', 'zsh', 'fish', 'ps1'], 'SH', '#89e051', 'files.glyph.shell'],
[['html', 'htm', 'xhtml'], 'HT', '#e34c26', 'files.glyph.lang.html'],
[['css', 'scss', 'sass', 'less', 'styl'], 'CSS', '#563d7c', 'files.glyph.style'],
[['sql'], 'SQL', '#e38c00', 'files.glyph.database'],
]
/** Badge text by fill luminance: white on dark shades, near-black (never pure black) on light ones. */
function badgeTextColor(hex: string): string {
const n = parseInt(hex.slice(1), 16)
const luminance = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255
return luminance < 0.6 ? '#ffffff' : '#1f2328'
}
const EXT_BUCKETS: (readonly [readonly string[], string, string])[] = [
[['yaml', 'yml', 'toml', 'ini', 'conf', 'cfg', 'properties', 'env'], '⚙️', 'files.glyph.config'],
[['json', 'jsonc', 'json5', 'jsonl', 'ndjson', 'xml'], '🧾', 'files.glyph.data'],
[['md', 'mdx', 'markdown', 'rst', 'adoc', 'org'], '📝', 'files.glyph.markdown'],
[['log'], '📜', 'files.glyph.log'],
[['csv', 'tsv', 'xls', 'xlsx', 'ods'], '📊', 'files.glyph.sheet'],
[['pdf', 'doc', 'docx', 'odt', 'rtf'], '📕', 'files.glyph.document'],
[['zip', 'gz', 'tgz', 'tar', 'bz2', 'xz', '7z', 'rar', 'jar'], '🗜️', 'files.glyph.archive'],
[['ttf', 'otf', 'woff', 'woff2', 'eot'], '🔤', 'files.glyph.font'],
[['mp4', 'mov', 'mkv', 'webm', 'mp3', 'wav', 'flac', 'ogg'], '🎬', 'files.glyph.media'],
]
function dirGlyph(base: string): FileGlyph {
if (base === '' || base === '.') return { glyph: '🏠', tip: 'files.glyph.root' }
const name = base.toLowerCase()
for (const [names, glyph, tip] of DIR_BUCKETS) {
if (names.includes(name)) return { glyph, tip }
}
if (name.startsWith('.')) return { glyph: '🗄️', tip: 'files.glyph.hidden' }
return { glyph: '📁', tip: 'files.form.dir' }
}
function fileGlyph(base: string): FileGlyph {
const name = base.toLowerCase()
const dot = name.lastIndexOf('.')
// A dot at position 0 is a dotfile, not an extension — `.env` below reads by name.
const ext = dot > 0 ? name.slice(dot + 1) : ''
if (name.endsWith('.lock') || LOCK_NAMES.includes(name)) return { glyph: '🔒', tip: 'files.glyph.lock' }
if (TEST_NAME.test(name)) return { glyph: '🧪', tip: 'files.glyph.tests' }
if (name === 'dockerfile' || name.startsWith('dockerfile.')) return { glyph: '🐳', tip: 'files.glyph.docker' }
if (MAKE_NAMES.includes(name)) return { glyph: '🛠️', tip: 'files.glyph.scripts' }
if (name === '.gitignore' || name === '.gitattributes') return { glyph: '🚫', tip: 'files.glyph.ignore' }
if (name === 'license' || name.startsWith('license.') || name === 'copying') return { glyph: '⚖️', tip: 'files.glyph.license' }
if (name === '.env' || name.startsWith('.env.')) return { glyph: '⚙️', tip: 'files.glyph.config' }
if (ext !== '') {
for (const [exts, label, color, tip] of CODE_LANGS) {
if (exts.includes(ext)) return { glyph: label, tip, color, text: badgeTextColor(color) }
}
for (const [exts, glyph, tip] of EXT_BUCKETS) {
if (exts.includes(ext)) return { glyph, tip }
}
}
return { glyph: '📄', tip: 'files.form.text' }
}
/** The last segment of a path, either separator style, a trailing slash trimmed first. */
export function baseNameOf(path: string): string {
return leafNameOf(path.endsWith('/') ? path.slice(0, -1) : path)
}
export function glyphOf(path: string, form: FileForm): FileGlyph {
if (form === 'image') return { glyph: '🖼', tip: 'files.form.image' }
const base = baseNameOf(path)
return form === 'dir' ? dirGlyph(base) : fileGlyph(base)
}
const DRIVE_PATH = /^[a-zA-Z]:[\\/]/
/** The absolute form of a real file path: verbatim when already absolute,
* resolved against the workspace root when relative, undefined when a relative path has no root to resolve against. */
export function absPathOf(path: string, workspace: string | undefined): string | undefined {
if (path.startsWith('/') || DRIVE_PATH.test(path)) return path
if (workspace === undefined || workspace === '' || path.startsWith('.')) return undefined
return workspace.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '')
}
/** The session-scoped `dsh-resource://file/…` preview address, built by the
* harness's own `fileAddressFor`, so every route to one file settles on one
* tab. Undefined for a search pattern, a directory, a missing session, or a path the encoder rejects. */
export function previewAddressOf(
path: string,
form: FileForm,
pattern: boolean | undefined,
sessionId: string | undefined,
workspace: string | undefined,
): string | undefined {
if (pattern === true || form === 'dir' || sessionId === undefined || sessionId === '') return undefined
try {
return fileAddressFor(sessionId, workspace, path)
} catch {
// A lone surrogate in an untrusted log path fails URI encoding: not previewable, never a thrown render.
return undefined
}
}
/** The display form of a relative remainder: its repeating leading `./` drops;
* a raw all-`./` degenerate leaves nothing to show, so it displays as itself. */
function bareOf(remainder: string): string {
const bare = remainder.replace(/^(?:\.\/)+/, '')
return bare !== '' ? bare : remainder
}
/** The row's display path: inside the workspace it shortens to the bare workspace-relative
* form, and other forms stay verbatim. */
export function displayPathOf(path: string, workspace: string | undefined): string {
const root = workspace !== undefined && workspace.length > 1 ? workspace.replace(/\/+$/, '') : undefined
if (root !== undefined) {
if (path === root || path === root + '/') return './'
if (path.startsWith(root + '/')) return bareOf(path.slice(root.length + 1))
}
if (path.startsWith('/') || DRIVE_PATH.test(path)) return path
return bareOf(path)
}
/** Fold op records into per-file activity: scope-filtered, aggregated per path, ops newest first. */
function aggregateOps(ops: readonly FileOp[], before: number | null): FileActivity {
const totals: FileActivity['totals'] = {
read: { files: 0, ops: 0 },
write: { files: 0, ops: 0 },
search: { files: 0, ops: 0 },
image: { files: 0, ops: 0 },
added: 0,
removed: 0,
}
const byPath = new Map<string, FileEntry>()
for (const op of ops) {
// A nested op's scope key is its parent result's seq.
const key = op.parent ?? op.seq
if (before !== null && key >= before) continue
totals[op.kind].ops++
let entry = byPath.get(op.path)
if (entry === undefined) {
entry = {
path: op.path,
form: formOf(op.tool, op.path),
reads: 0,
writes: 0,
searches: 0,
added: 0,
removed: 0,
errs: 0,
ops: [],
...(op.pattern === true ? { pattern: true as const } : {}),
}
byPath.set(op.path, entry)
}
if (op.kind === 'read') entry.reads++
else if (op.kind === 'write') entry.writes++
else entry.searches++
entry.added += op.added
entry.removed += op.removed
if (op.err) entry.errs++
entry.ops.push(op)
}
const entries = [...byPath.values()]
for (const e of entries) {
e.ops.sort((a, b) => b.seq - a.seq)
if (e.reads > 0) totals.read.files++
if (e.writes > 0) totals.write.files++
if (e.searches > 0) totals.search.files++
if (e.form === 'image') {
totals.image.files++
totals.image.ops += e.ops.length
}
totals.added += e.added
totals.removed += e.removed
}
entries.sort((a, b) => b.ops[0].seq - a.ops[0].seq)
return { entries, totals }
}
/** The op-log generation's read of the card: fold-derived records, with `gone` joined from the detail's archive at render time.
*/
export function activityOfOps(ops: readonly FileOpRecord[], archive: readonly SurfaceNode[], before: number | null): FileActivity {
const goneBySeq = new Map<number, number>()
for (const n of archive) if (n.gone !== undefined) goneBySeq.set(n.seq, n.gone)
const joined = ops.map((op) => {
const gone = goneBySeq.get(op.parent ?? op.seq)
return gone !== undefined && op.gone === undefined ? { ...op, gone } : op
})
return aggregateOps(joined, before)
}
interface SubCall {
name: string
argsRaw: string
seq: number
time?: number
err: boolean
subCalls?: readonly unknown[]
}
/** Narrow one block of a node's `subCalls` tree to a settled nested call; a running or malformed block is dropped. */
function subCallOf(block: unknown): SubCall | null {
const b = block as Record<string, unknown>
if (b.kind !== 'tool-result') return null
if (b.call === null || typeof b.call !== 'object') return null
const call = b.call as { name?: unknown; argsRaw?: unknown }
if (typeof call.name !== 'string' || typeof call.argsRaw !== 'string') return null
if (typeof b.seq !== 'number' || !Number.isFinite(b.seq)) return null
return {
name: call.name,
argsRaw: call.argsRaw,
seq: b.seq,
...(typeof b.time === 'number' ? { time: b.time } : {}),
...(b.isError === true ? { err: true } : { err: false }),
...(Array.isArray(b.subCalls) ? { subCalls: b.subCalls } : {}),
}
}
/** The run_code call's model-authored description — the nested ops' "why". */
function programOf(conv: ConversationNodeLike | undefined): string | undefined {
const description = parseCallArgs(conv?.call?.argsRaw)?.description
return typeof description === 'string' && description !== '' ? description : undefined
}
/** Depth guard for nested Code-Mode trees: the SDK bindings exclude `run_code` itself, so a real tree is one level deep. */
const SUBCALL_MAX_DEPTH = 8
/** The legacy nested walk: every settled sub-dispatch whose arguments resolve to
* a file target books one op, located on the parent run_code result. `seen`
* holds visited blocks, so a malformed cyclic join cannot loop. */
function foldSubCalls(
blocks: readonly unknown[],
parent: SurfaceNode,
program: string | undefined,
ops: FileOp[],
seen: Set<object>,
depth: number,
): void {
if (depth > SUBCALL_MAX_DEPTH) return
for (const block of blocks) {
if (block === null || typeof block !== 'object' || seen.has(block)) continue
seen.add(block)
const sub = subCallOf(block)
if (sub !== null) {
ops.push(...opsOfCall({
seq: sub.seq,
tool: sub.name,
argsRaw: sub.argsRaw,
err: sub.err,
...(sub.time !== undefined ? { time: sub.time } : {}),
...(parent.gone !== undefined ? { gone: parent.gone } : {}),
parent: parent.seq,
...(program !== undefined ? { program } : {}),
}))
if (sub.subCalls !== undefined) foldSubCalls(sub.subCalls, parent, program, ops, seen, depth + 1)
}
}
}
/** The INLINE generation's derivation from the served nodes joined with the
* conversation window (window-bound). Anything that throws while folding a node drops that node and the walk carries on. */
export function activityOf(
nodes: SurfaceNode[],
convOf: (seq: number) => ConversationNodeLike | undefined,
before: number | null,
): FileActivity {
const ops: FileOp[] = []
for (const n of nodes) {
if (n.cat !== 'tool') continue
if (before !== null && n.seq >= before) continue
const tool = n.tool
if (tool === undefined) continue
try {
const conv = convOf(n.seq)
ops.push(...opsOfCall({
seq: n.seq,
tool,
argsRaw: conv?.call?.argsRaw,
meta: conv?.meta,
err: n.err === true || conv?.isError === true,
...(n.time !== undefined ? { time: n.time } : {}),
...(n.gone !== undefined ? { gone: n.gone } : {}),
}))
// Nested Code-Mode calls (PTC): one settled sub-dispatch is one op.
const subCalls = conv?.subCalls
if (subCalls !== undefined && subCalls.length > 0) {
foldSubCalls(subCalls, n, programOf(conv), ops, new Set<object>(), 1)
}
} catch {
// The guard exists so malformed join data can never matter.
}
}
return aggregateOps(ops, null)
}
/** The browser step whose surface SHOWS an op's result node: the first request dispatched after it while it was still
* alive. Sorted here because the wire's append order is not guaranteed (restored rows can re-order) — the same rule
* the browser's own entry stamps read. */
export function locateStepOf(requests: RequestRecord[], seq: number, gone: number | undefined): number | 'live' | null {
const sorted = requests.slice().sort((a, b) => a.seq - b.seq)
for (const r of sorted) {
if (r.seq > seq && (gone === undefined || gone > r.seq)) return r.seq
}
return gone === undefined ? 'live' : null
}