From 1ca4d4ef4d0b06a7838be9e27d20daa2e99a982f Mon Sep 17 00:00:00 2001 From: GammaChineYov Date: Sat, 15 Aug 2026 17:08:04 +0800 Subject: [PATCH] fix: "Show in folder" reveals in the explorer instead of opening a directory in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The open-path interception reroutes EVERY chat-side file open into the sidebar editor, including the stock "Show in folder" gesture: the ui-deliverables row passes "." (resolved to "/." by the chat view), so clicking it opened a bogus editor tab for the workspace directory (host error: '"..." is a directory'). - openpath-intercept: detect the folder-reveal gesture ("." / "./" / "/.") and route it to a new revealInExplorer dep instead of the editor. - intercept: revealInExplorer switches to the explorer tab (expanding the panel), expands the produced files' parent directories, and highlights / scrolls to them; the intercepted produced-files row now also renders its own "Show in folder" button when files are hidden. - produced-files: selectProducedFiles reads the engine Turn data (owner.turn.data.get("deliverables")) — the same source ui-deliverables uses — with the node replica as fallback. The old code read owner.nodes, which the real TurnTailOwnerProps ({turn, seq, openFile}) does not carry, so the takeover never claimed the chain. - state: transient `revealed` set (never persisted) plus a revealPaths reducer that expands ancestor directories. - ExplorerView: highlight + scroll revealed rows; Sidebar/tabs/service thread `revealed` through TabComponentProps. - tests: folder-gesture routing, engine-data selector, wiring seat, zh/en copy. --- src/client/ExplorerView.tsx | 36 +++++++++++++--- src/client/Sidebar.tsx | 6 ++- src/client/builtins/tabs.tsx | 3 +- src/client/intercept.tsx | 58 ++++++++++++++++++++++--- src/client/locales.ts | 2 + src/client/openpath-intercept.ts | 21 ++++++++- src/client/produced-files.ts | 37 ++++++++++++++-- src/client/service.ts | 2 + src/client/state.ts | 35 +++++++++++++++ tests/turn-tail-intercept.spec.ts | 17 +++++++- tests/unit.spec.ts | 72 ++++++++++++++++++++++++++++++- 11 files changed, 267 insertions(+), 22 deletions(-) diff --git a/src/client/ExplorerView.tsx b/src/client/ExplorerView.tsx index d0eb593..e14ac73 100644 --- a/src/client/ExplorerView.tsx +++ b/src/client/ExplorerView.tsx @@ -40,14 +40,17 @@ export function ExplorerView(props: { sessionId: string cwd: string | undefined expanded: string[] + /** Absolute paths to highlight ("Show in folder" reveal target rows). */ + revealed: string[] onToggle: (path: string) => void onOpenFile: (path: string) => void /** Insert `@` into the composer draft. */ onReferenceFile: (path: string) => void }) { - const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props + const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onReferenceFile } = props const [data, setData] = useState>({}) const dataRef = useRef(data) + const bodyRef = useRef(null) const [refreshTick, setRefreshTick] = useState(0) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) @@ -78,6 +81,15 @@ export function ExplorerView(props: { for (const dir of expanded) loadDir(dir) }, [cwd, expanded, refreshTick, loadDir]) + const revealedKey = revealed.join('\u0000') + useEffect(() => { + if (revealedKey === '') return + const el = bodyRef.current?.querySelector('[data-revealed="true"]') + // block:"nearest" keeps an already-visible row from jumping; a level that + // loads AFTER the reveal re-runs this and scrolls then. + el?.scrollIntoView({ block: 'nearest' }) + }, [revealedKey, data]) + /** Copy `text`; on success flip the row's copied label for a moment. */ const copyPath = useCallback((text: string, path: string): void => { void writeClipboard(text).then((ok) => { @@ -145,13 +157,18 @@ export function ExplorerView(props: { return entries.map(entry => { if (entry.isDir) { const isOpen = expanded.includes(entry.path) + const isRevealed = revealed.includes(entry.path) return (
{ onToggle(entry.path) }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { @@ -169,13 +186,18 @@ export function ExplorerView(props: {
) } + const isRevealed = revealed.includes(entry.path) return (
{ onOpenFile(entry.path) }} onKeyDown={(event) => { @@ -212,14 +234,18 @@ export function ExplorerView(props: {
-
+
{root === undefined ? (
{t('noSession')}
) : ( <>
{ openRowMenu(event, root, true) }} > diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index c450a36..c5b0c2a 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -61,6 +61,7 @@ function TabContent(props: { sessionId: string cwd: string | undefined expanded: string[] + revealed: string[] onToggleDir: (path: string) => void onReferenceFile: (path: string) => void ctx: Context @@ -72,7 +73,7 @@ function TabContent(props: { /** Open a diff tab from the git panel (placement handled by the store). */ onOpenDiff: (tab: SidebarTab) => void }) { - const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props + const { tab, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props const scope = { sessionId, cwd } const descriptor = ctx.betterSidebar?.getTab(tab.type) if (descriptor === undefined) { @@ -90,7 +91,7 @@ function TabContent(props: { RenderBoundary, { className: css.tabBoundaryError }, createElement(descriptor.component, { - ctx, store, scope, tab, visible, expanded, + ctx, store, scope, tab, visible, expanded, revealed, onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump, }), ) @@ -714,6 +715,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/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 049d3e7..3fa98b9 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -79,11 +79,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { icon: (size: number) => , order: 10, single: true, - component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => ( + component: ({ ctx, store, scope, expanded, revealed, onToggleDir, onReferenceFile }) => ( { /* no-op */ })} onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }} onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index e8f3b70..43efb10 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.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 explorer + // tab is focused and its hosting panel expands. + ctx.betterSidebar?.openTab({ type: 'explorer', title: t('explorer'), path: cwd ?? '' }) +} + /** 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 && ( + + )}
) } @@ -79,12 +119,15 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore): // deliverables behavior instead of offering chips that cannot open. select: (owner) => { 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)) } @@ -93,9 +136,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, { @@ -103,5 +148,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 1ec4604..f579339 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -89,6 +89,7 @@ export const zh = { historyLoadError: '加载更多历史失败', produced: '本次产出', producedOpen: '在侧边栏中打开', + showInFolder: '在文件夹中显示', disconnected: '终端连接断开,重连中…', exited: '终端进程已退出', noSession: '选择一个会话以使用侧边栏', @@ -310,6 +311,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 a0c2a7b..cefe28d 100644 --- a/src/client/produced-files.ts +++ b/src/client/produced-files.ts @@ -65,14 +65,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 ee333bd..0f85b9f 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -125,6 +125,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 8656ddc..2339810 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -72,6 +72,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. */ @@ -162,6 +168,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedEx nextTerminal: 1, nextBrowser: 1, expanded: [], + revealed: [], splits: leaf, bottomOpen: false, bottomHeight: BOTTOM_DEFAULT, @@ -635,6 +642,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 @@ -836,6 +870,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 8504fa1..bcc333c 100644 --- a/tests/turn-tail-intercept.spec.ts +++ b/tests/turn-tail-intercept.spec.ts @@ -147,6 +147,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). @@ -156,13 +162,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. @@ -176,6 +183,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 explorer tab. + expect(seat.onShowInFolder).toBeTypeOf('function') + seat.onShowInFolder(['/w/src/a.ts']) + expect(ctx.betterSidebar.openTab).toHaveBeenLastCalledWith(expect.objectContaining({ + type: 'explorer', + path: '/w', + })) + restore() }) }) diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 1cccf71..c0efdc0 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -951,6 +951,29 @@ describe('produced-files derivation', () => { expect(selectProducedFiles(null)).toBeNull() }) + it('selector reads the engine Turn data first (the same source ui-deliverables uses)', () => { + // The real owner currency is { turn, seq, openFile } — `turn.data` carries + // the deliverables `{ produced: [{ seq, path }, ...] }` record. The node + // replica remains a fallback for compositions that do not publish it. + const engineOwner = (produced: Array<{ seq: number; path: string }>, seq: number): unknown => ({ + turn: { data: { get: (key: string) => key === 'deliverables' ? { produced } : undefined } }, + seq, + }) + expect(selectProducedFiles(engineOwner([{ seq: 1, path: 'a.ts' }, { seq: 2, path: 'b.ts' }], 2))) + .toEqual(['a.ts', 'b.ts']) + // A settlement AFTER the closing seq is excluded. + expect(selectProducedFiles(engineOwner([{ seq: 1, path: 'a.ts' }, { seq: 5, path: 'late.ts' }], 2))) + .toEqual(['a.ts']) + // No produced files → decline, even though the engine record exists. + expect(selectProducedFiles(engineOwner([], 2))).toBeNull() + // An engine record without a produced list falls back to the node replica. + expect(selectProducedFiles({ + turn: { data: { get: () => ({}) } }, + nodes: [diffResult('a.ts'), { kind: 'assistant', seq: 1, turn: 1 }], + seq: 1, + })).toEqual(['a.ts']) + }) + it('resolves relative paths against the session cwd', () => { expect(resolveSidebarPath('/work/proj', 'src/a.ts')).toBe('/work/proj/src/a.ts') expect(resolveSidebarPath('/work/proj', '/abs/x.ts')).toBe('/abs/x.ts') @@ -1577,13 +1600,17 @@ describe('open-path interception', () => { const deps = (overrides: Partial = {}): OpenPathInterceptDeps & { sidebar: string[] + revealed: string[] } => { const sidebar: string[] = [] + const revealed: string[] = [] return { sidebar, + revealed, takeoverEnabled: () => true, currentSessionId: () => 's1', openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) }, + revealInExplorer: (path, sessionId) => { revealed.push(`${sessionId}:${path}`) }, ...overrides, } } @@ -1630,6 +1657,23 @@ describe('open-path interception', () => { restore() }) + it('routes the "Show in folder" gesture (a directory) to the explorer, not the editor', async () => { + const ws = service() + const d = deps() + const restore = wrapOpenPath(ws, d) + // The stock produced-files row passes '.' — the chat view resolves it to + // "/." — a directory has no editor content, so it must reveal in the + // explorer instead of opening a bogus editor tab. + for (const gesture of ['/w/.', '/w/./', '.', './']) { + await ws.openPath(gesture) + } + // A real file path still routes to the sidebar editor. + await ws.openPath('/w/src/a.ts') + expect(d.sidebar).toEqual(['s1:/w/src/a.ts']) + expect(d.revealed).toEqual(['s1:/w/.', 's1:/w/./', 's1:.', 's1:./']) + restore() + }) + it('restores the original method on dispose (HMR-safe)', async () => { const ws = service() const d = deps() @@ -1654,6 +1698,20 @@ describe('open-path interception', () => { }) describe('open-path interception wiring', () => { + // The folder-reveal arm writes the reveal set through store.reduce, whose + // per-session persist scheduling touches window timers (see the v0.12.0 + // store additions describe for the same stub pattern). + beforeEach(() => { + const g = globalThis as Record + g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 } + g.localStorage = { getItem: () => null, setItem: () => {} } + }) + afterEach(() => { + const g = globalThis as Record + delete g.window + delete g.localStorage + }) + it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => { // A realistic client-context fake: the sessions list feed (current + cwd), // the workspaces funnel, and the sidebar service the editor goes through. @@ -1667,6 +1725,7 @@ describe('open-path interception wiring', () => { betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record) } }, } as unknown as Context const store = createSidebarStore() + store.setSession('s1') const original = ctx.workspaces.openPath const restore = registerOpenPathInterception(ctx, store) @@ -1680,13 +1739,24 @@ describe('open-path interception wiring', () => { id: 'editor:/w/src/a.ts', }]) + // The "Show in folder" gesture ('.' — the workspace root, resolved to + // "/." by the chat view) reveals in the explorer tab instead of + // opening a bogus editor tab for a directory. With no cached produced + // files the reveal falls back to the workspace root itself. + await ctx.workspaces.openPath('/w/.') + const explorerSeed = opened.find(seed => seed.type === 'explorer') as Record | undefined + expect(explorerSeed).toBeDefined() + expect(explorerSeed?.path).toBe('/w') + expect(store.getSnapshot().state?.revealed).toEqual(['/w']) + // The interceptOpenPath pref off → the original funnel runs untouched. store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false }) const calls: string[] = [] ctx.workspaces.openPath = async (path: string) => { calls.push(path) } await ctx.workspaces.openPath('/w/src/b.ts') expect(calls).toEqual(['/w/src/b.ts']) - expect(opened).toHaveLength(1) + // The editor seed plus the folder-reveal explorer seed from above. + expect(opened).toHaveLength(2) // The editor tab disabled → falls through too (an editor that cannot // open must not swallow opens).