fix: "Show in folder" reveals in the explorer instead of opening a directory in the editor

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 "<cwd>/." 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 ("." / "./" /
  "<cwd>/.") 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.
This commit is contained in:
GammaChineYov
2026-08-15 17:08:04 +08:00
parent 5d2d6e5801
commit 1ca4d4ef4d
11 changed files with 267 additions and 22 deletions
+31 -5
View File
@@ -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 `@<relative path>` 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<Record<string, LevelData>>({})
const dataRef = useRef(data)
const bodyRef = useRef<HTMLDivElement>(null)
const [refreshTick, setRefreshTick] = useState(0)
/** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(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 (
<div key={entry.path}>
<div
role="button"
tabIndex={0}
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 22 + 6 }}
style={{
paddingLeft: depth * 22 + 6,
...(isRevealed ? { background: 'var(--dsw-alias-interactive-bg-active)' } : {}),
}}
data-revealed={isRevealed || undefined}
onClick={() => { onToggle(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -169,13 +186,18 @@ export function ExplorerView(props: {
</div>
)
}
const isRevealed = revealed.includes(entry.path)
return (
<div
key={entry.path}
role="button"
tabIndex={0}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 22 + 6 }}
style={{
paddingLeft: depth * 22 + 6,
...(isRevealed ? { background: 'var(--dsw-alias-interactive-bg-active)' } : {}),
}}
data-revealed={isRevealed || undefined}
title={entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
@@ -212,14 +234,18 @@ export function ExplorerView(props: {
<IconRefreshOutline16 />
</button>
</div>
<div className={css.explorerBody}>
<div ref={bodyRef} className={css.explorerBody}>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
style={{
paddingLeft: 6,
...(revealed.includes(root) ? { background: 'var(--dsw-alias-interactive-bg-active)' } : {}),
}}
data-revealed={revealed.includes(root) || undefined}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
+4 -2
View File
@@ -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}
+2 -1
View File
@@ -79,11 +79,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
single: true,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
component: ({ ctx, store, scope, expanded, revealed, onToggleDir, onReferenceFile }) => (
<ExplorerView
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded ?? []}
revealed={revealed ?? []}
onToggle={onToggleDir ?? (() => { /* no-op */ })}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
+52 -6
View File
@@ -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 && <span className={css.producedMore}>+{hidden}</span>}
{hidden > 0 && (
<button
type="button"
className={css.producedMore}
style={{ cursor: 'pointer', textDecoration: 'underline', textUnderlineOffset: 2 }}
onClick={() => { onShowInFolder(matched) }}
>
{t('showInFolder')}
</button>
)}
</div>
)
}
@@ -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) },
})
}
+2
View File
@@ -89,6 +89,7 @@ export const zh = {
historyLoadError: '加载更多历史失败',
produced: '本次产出',
producedOpen: '在侧边栏中打开',
showInFolder: '在文件夹中显示',
disconnected: '终端连接断开,重连中…',
exited: '终端进程已退出',
noSession: '选择一个会话以使用侧边栏',
@@ -310,6 +311,7 @@ export const en: Record<keyof typeof zh, string> = {
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',
+19 -2
View File
@@ -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 `"<cwd>/."`); 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()
}
}
+33 -4
View File
@@ -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<string>()
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
}
+2
View File
@@ -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
+35
View File
@@ -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,