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()
})
})