diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index fcb55f7..b5d6c70 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -153,7 +153,7 @@ const TabContent = memo(function TabContent(props: TabContentProps) { RenderBoundary, { className: css.tabBoundaryError }, createElement(descriptor.component, { - ctx, store, scope, tab, visible, expanded, + ctx, store, scope, tab, visible, expanded, revealed, onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump, }), ) @@ -1245,6 +1245,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { sessionId={sessionId} cwd={cwd} expanded={state.expanded} + revealed={state.revealed ?? []} onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }} onReferenceFile={referenceInChat} ctx={ctx} diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index eb4830f..05385ac 100644 --- a/src/client/intercept.tsx +++ b/src/client/intercept.tsx @@ -8,7 +8,7 @@ */ import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' -import type { SidebarStore } from './state.ts' +import { revealPaths, type SidebarStore } from './state.ts' import { t } from './locales.ts' import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts' import { wrapOpenPath } from './openpath-intercept.ts' @@ -25,12 +25,42 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st ctx.get('betterSidebar')?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` }) } +/** + * The produced files the turn-tail selector last matched for the visible + * session. The "Show in folder" gesture carries no file path of its own + * (`'.'`), so the reveal highlights exactly these rows when available. + */ +let lastProduced: readonly string[] = [] + +/** + * Reveal the produced files in the sidebar explorer: expand their parent + * directories, highlight the rows, and focus the explorer tab (expanding the + * hosting panel when it is collapsed). Unknown files fall back to revealing + * the workspace root itself. + */ +export function revealInExplorer( + ctx: Context, + store: SidebarStore, + sessionId: string, + files: readonly string[], +): void { + const summary = ctx.sessions.list.getSnapshot().byId[sessionId] + const cwd = summary?.cwd + const targets = files.length > 0 ? files : cwd === undefined ? [] : [cwd] + store.reduce(state => revealPaths(state, cwd, targets)) + // A content open (path seed) lands in sight: the single-instance editor + // home tab (the files window) is focused and its hosting panel expands. + ctx.betterSidebar?.openTab({ type: 'editor', title: t('files') }) +} + /** The intercepted produced-files row (visual twin of the deliverables chips). */ export function SidebarProducedFiles(props: { matched: readonly string[] openInSidebar: (path: string) => void + /** Reveal the produced files in the explorer ("Show in folder" twin). */ + onShowInFolder: (files: readonly string[]) => void }) { - const { matched, openInSidebar } = props + const { matched, openInSidebar, onShowInFolder } = props const shown = matched.slice(0, 6) const hidden = matched.length - shown.length return ( @@ -53,6 +83,16 @@ export function SidebarProducedFiles(props: { ) })} {hidden > 0 && +{hidden}} + {hidden > 0 && ( + + )} ) } @@ -81,12 +121,15 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore): select: (owner) => { if (store.getSuspended()) return null if (store.getPrefs().tabsEnabled['editor'] === false) return null - return selectProducedFiles(owner) + const matched = selectProducedFiles(owner) + if (matched !== null) lastProduced = matched + return matched }, priority: -1, registrant: 'dsh-better-sidebar', inject: (sessionId: string) => ({ openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) }, + onShowInFolder: (files: readonly string[]) => { revealInExplorer(ctx, store, sessionId, files) }, }), }, SidebarProducedFiles)) } @@ -95,9 +138,11 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore): * Register the chat file-open interception: wraps `ctx.workspaces.openPath` * — the single funnel every chat-side file open goes through (tool-row path * links, the produced-files row, prose mentions) — so opens land in the - * sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath` - * pref and the editor tab's enable switch; declined opens fall through to - * the original method. Returns the disposer restoring the original (HMR-safe). + * sidebar editor instead of the Host OS. The folder-reveal gesture ("Show in + * folder" passes `'.'`) is the one exception: it is routed to the explorer. + * Gated by BOTH the `interceptOpenPath` pref and the editor tab's enable + * switch; declined opens fall through to the original method. Returns the + * disposer restoring the original (HMR-safe). */ export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void { return wrapOpenPath(ctx.workspaces, { @@ -106,5 +151,6 @@ export function registerOpenPathInterception(ctx: Context, store: SidebarStore): && store.getPrefs().tabsEnabled['editor'] !== false, currentSessionId: () => ctx.sessions.list.getSnapshot().current, openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) }, + revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) }, }) } diff --git a/src/client/locales.ts b/src/client/locales.ts index 8ea32de..d17d5f7 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -153,6 +153,7 @@ export const zh = { historyLoadError: '加载更多历史失败', produced: '本次产出', producedOpen: '在侧边栏中打开', + showInFolder: '在文件夹中显示', disconnected: '终端连接断开,重连中…', exited: '终端进程已退出', noSession: '选择一个会话以使用侧边栏', @@ -491,6 +492,7 @@ export const en: Record = { historyLoadError: 'Failed to load more history', produced: 'Produced', producedOpen: 'Open in sidebar', + showInFolder: 'Show in folder', disconnected: 'Terminal disconnected, reconnecting…', exited: 'Terminal process exited', noSession: 'Select a conversation to use the sidebar', diff --git a/src/client/openpath-intercept.ts b/src/client/openpath-intercept.ts index 708e897..4bac265 100644 --- a/src/client/openpath-intercept.ts +++ b/src/client/openpath-intercept.ts @@ -30,13 +30,29 @@ export interface OpenPathInterceptDeps { currentSessionId(): string | undefined /** Route the open into the sidebar editor (the established openSidebarFile). */ openInSidebar(path: string, sessionId: string): void + /** Route a folder-reveal gesture ("Show in folder" passes '.') into the sidebar explorer. */ + revealInExplorer(path: string, sessionId: string): void +} + +/** + * Whether a path is the "Show in folder" folder-reveal gesture. The stock + * ui-deliverables row passes `'.'` (the session workspace root, resolved by + * the chat view to `"/."`); any path whose final segment is `.` is the + * same gesture. A directory has no editor content, so these opens must reach + * the explorer instead of an editor tab. + */ +export function isFolderRevealPath(path: string): boolean { + if (path === '.' || path === './') return true + const trimmed = path.replace(/[\\/]+$/, '') + return trimmed === '.' || /[\\/]\.$/.test(trimmed) } /** * Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar * editor instead of the Host OS and resolve as success (the original's * callers ignore the result); anything that declines falls through to the - * original method untouched. + * original method untouched. The one exception is the folder-reveal gesture, + * which is routed to {@link OpenPathInterceptDeps.revealInExplorer} instead. * @param workspaces - the client workspaces service to wrap. * @param deps - per-call takeover decisions. * @returns the disposer restoring the original method (HMR-safe). @@ -50,7 +66,8 @@ export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathIntercep if (deps.takeoverEnabled()) { const sessionId = deps.currentSessionId() if (sessionId !== undefined) { - deps.openInSidebar(path, sessionId) + if (isFolderRevealPath(path)) deps.revealInExplorer(path, sessionId) + else deps.openInSidebar(path, sessionId) return Promise.resolve() } } diff --git a/src/client/produced-files.ts b/src/client/produced-files.ts index 4a7a2f5..23a2f1e 100644 --- a/src/client/produced-files.ts +++ b/src/client/produced-files.ts @@ -66,14 +66,43 @@ export function producedForClosing(nodes: readonly unknown[], seq: number): read /** * Claim the turn-tail chain only when the closing turn produced files. - * @param owner - the turn-tail owner currency ({nodes, seq}). + * + * The authoritative source is the engine Turn data — the same value + * ui-deliverables reads (`owner.turn.data.get('deliverables')`): a + * `{ produced: [{ seq, path }, ...] }` record accumulated per Turn. The + * node-based replica below stays as a fallback for compositions that do not + * publish it. + * @param owner - the turn-tail owner currency ({turn, seq, openFile}). * @returns produced paths as the matched value, or null to decline. */ export function selectProducedFiles(owner: unknown): readonly string[] | null { - const record = owner as { nodes?: unknown; seq?: unknown } | null + const record = owner as { + turn?: { data?: { get?: (key: string) => unknown } } + nodes?: unknown + seq?: unknown + } | null if (record === null || typeof record !== 'object') return null - if (!Array.isArray(record.nodes) || typeof record.seq !== 'number') return null - const paths = producedForClosing(record.nodes, record.seq) + const seq = typeof record.seq === 'number' ? record.seq : Number.POSITIVE_INFINITY + const data = record.turn?.data?.get?.('deliverables') as + | { produced?: unknown } + | null + | undefined + if (data !== null && typeof data === 'object' && Array.isArray(data.produced)) { + const paths: string[] = [] + const seen = new Set() + for (const item of data.produced) { + if (item === null || typeof item !== 'object') continue + const produced = item as { path?: unknown; seq?: unknown } + if (typeof produced.path !== 'string' || produced.path === '') continue + if (typeof produced.seq === 'number' && produced.seq > seq) continue + if (seen.has(produced.path)) continue + seen.add(produced.path) + paths.push(produced.path) + } + return paths.length === 0 ? null : paths + } + if (!Array.isArray(record.nodes)) return null + const paths = producedForClosing(record.nodes, seq) return paths.length === 0 ? null : paths } diff --git a/src/client/service.ts b/src/client/service.ts index ff69670..e7a2fa4 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -148,6 +148,8 @@ export interface TabComponentProps { visible: boolean /** The explorer's expanded directory set (ExplorerView). */ expanded?: string[] + /** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */ + revealed?: string[] onToggleDir?: (path: string) => void onReferenceFile?: (path: string) => void onOpenFile?: (path: string) => void diff --git a/src/client/state.ts b/src/client/state.ts index 73b0eba..1fdd885 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -89,6 +89,12 @@ export interface SidebarState { nextBrowser: number /** Explorer expansion set (absolute directory paths). */ expanded: string[] + /** + * Explorer rows highlighted by a "Show in folder" reveal (absolute paths). + * Transient by design: sanitizeState never restores it, so a reload starts + * unhighlighted. + */ + revealed: string[] /** The right sidebar's split tree (the original workbench). */ splits: SplitNode /** Whether the bottom panel (a second, independent workbench) is open. */ @@ -213,6 +219,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: nextTerminal: 1, nextBrowser: 1, expanded: [], + revealed: [], splits: leaf, bottomOpen: false, bottomHeight: BOTTOM_DEFAULT, @@ -704,6 +711,33 @@ export function toggleExpanded(state: SidebarState, path: string): SidebarState return { ...state, expanded } } +/** + * Reveal files in the explorer: expand every ancestor directory between the + * explorer root and each file (so the lazy tree actually shows the row) and + * record the paths for highlighting. The reveal set is transient — + * sanitizeState never restores it, so a reload starts unhighlighted. + * @param state - current sidebar state. + * @param cwd - the explorer's root (session working directory). + * @param files - absolute paths to highlight (parent dirs are expanded). + * @returns the next state, or the same reference when nothing is revealed. + */ +export function revealPaths(state: SidebarState, cwd: string | undefined, files: readonly string[]): SidebarState { + const expanded = new Set(state.expanded) + const revealed: string[] = [] + const rootParts = (cwd ?? '').split(/[\\/]+/).filter(part => part !== '') + for (const file of files) { + if (typeof file !== 'string' || file === '') continue + revealed.push(file) + const parts = file.split(/[\\/]+/).filter(part => part !== '' && part !== '.') + const separator = file.includes('\\') ? '\\' : '/' + for (let i = rootParts.length; i < parts.length - 1; i++) { + expanded.add(parts.slice(0, i + 1).join(separator)) + } + } + if (revealed.length === 0) return state + return { ...state, expanded: [...expanded], revealed } +} + /** Adjust one split divider: `i` is the left/top child index, delta in fractions. */ export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode { if (node.kind === 'leaf') return node @@ -1128,6 +1162,7 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { nextTerminal: record.nextTerminal, nextBrowser, expanded: record.expanded as string[], + revealed: [], splits, bottomOpen, bottomHeight, diff --git a/tests/turn-tail-intercept.spec.ts b/tests/turn-tail-intercept.spec.ts index d709454..352100c 100644 --- a/tests/turn-tail-intercept.spec.ts +++ b/tests/turn-tail-intercept.spec.ts @@ -151,6 +151,12 @@ describe('turn-tail interception registration (issue #15)', () => { // Enabled (default): a produced turn claims the chain; an empty one declines. expect(select(producedOwner(['a.ts', 'b.ts']))).toEqual(['a.ts', 'b.ts']) expect(select(emptyOwner())).toBeNull() + // The engine Turn data path (the real owner currency: { turn, seq, + // openFile }) claims through the deliverables record too. + expect(select({ + turn: { data: { get: (key: string) => key === 'deliverables' ? { produced: [{ seq: 1, path: 'a.ts' }] } : undefined } }, + seq: 1, + })).toEqual(['a.ts']) // Editor tab disabled: even a produced turn falls back to the default // deliverables row (chips that cannot open must not be offered). @@ -160,13 +166,14 @@ describe('turn-tail interception registration (issue #15)', () => { restore() }) - it('wires the openInSidebar seat to the sidebar editor tab', () => { + it('wires the openInSidebar and onShowInFolder seats', () => { const fake = fakeSlots(true) const ctx = clientCtx(fake.slots) const store = createSidebarStore() const restore = registerTurnTailInterception(ctx, store) const inject = fake.registered[0]!.options.inject as (sessionId: string) => { openInSidebar: (path: string) => void + onShowInFolder: (files: readonly string[]) => void } // The seat hands the session-scoped opener to the chips row. @@ -180,6 +187,14 @@ describe('turn-tail interception registration (issue #15)', () => { id: 'editor:/w/src/a.ts', }) + // The show-in-folder seat reveals the produced files in the files window + // (the editor home tab) — the panel expands and the rows highlight. + expect(seat.onShowInFolder).toBeTypeOf('function') + seat.onShowInFolder(['/w/src/a.ts']) + expect(ctx.betterSidebar.openTab).toHaveBeenLastCalledWith(expect.objectContaining({ + type: 'editor', + })) + restore() }) })