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:
@@ -40,14 +40,17 @@ export function ExplorerView(props: {
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
cwd: string | undefined
|
cwd: string | undefined
|
||||||
expanded: string[]
|
expanded: string[]
|
||||||
|
/** Absolute paths to highlight ("Show in folder" reveal target rows). */
|
||||||
|
revealed: string[]
|
||||||
onToggle: (path: string) => void
|
onToggle: (path: string) => void
|
||||||
onOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
/** Insert `@<relative path>` into the composer draft. */
|
/** Insert `@<relative path>` into the composer draft. */
|
||||||
onReferenceFile: (path: string) => void
|
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 [data, setData] = useState<Record<string, LevelData>>({})
|
||||||
const dataRef = useRef(data)
|
const dataRef = useRef(data)
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null)
|
||||||
const [refreshTick, setRefreshTick] = useState(0)
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||||
@@ -78,6 +81,15 @@ export function ExplorerView(props: {
|
|||||||
for (const dir of expanded) loadDir(dir)
|
for (const dir of expanded) loadDir(dir)
|
||||||
}, [cwd, expanded, refreshTick, loadDir])
|
}, [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. */
|
/** Copy `text`; on success flip the row's copied label for a moment. */
|
||||||
const copyPath = useCallback((text: string, path: string): void => {
|
const copyPath = useCallback((text: string, path: string): void => {
|
||||||
void writeClipboard(text).then((ok) => {
|
void writeClipboard(text).then((ok) => {
|
||||||
@@ -145,13 +157,18 @@ export function ExplorerView(props: {
|
|||||||
return entries.map(entry => {
|
return entries.map(entry => {
|
||||||
if (entry.isDir) {
|
if (entry.isDir) {
|
||||||
const isOpen = expanded.includes(entry.path)
|
const isOpen = expanded.includes(entry.path)
|
||||||
|
const isRevealed = revealed.includes(entry.path)
|
||||||
return (
|
return (
|
||||||
<div key={entry.path}>
|
<div key={entry.path}>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
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) }}
|
onClick={() => { onToggle(entry.path) }}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -169,13 +186,18 @@ export function ExplorerView(props: {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const isRevealed = revealed.includes(entry.path)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
|
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}
|
title={entry.path}
|
||||||
onClick={() => { onOpenFile(entry.path) }}
|
onClick={() => { onOpenFile(entry.path) }}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -212,14 +234,18 @@ export function ExplorerView(props: {
|
|||||||
<IconRefreshOutline16 />
|
<IconRefreshOutline16 />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.explorerBody}>
|
<div ref={bodyRef} className={css.explorerBody}>
|
||||||
{root === undefined ? (
|
{root === undefined ? (
|
||||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={css.explorerRow}
|
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) }}
|
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||||
>
|
>
|
||||||
<IconFolderOpen16 size={14} />
|
<IconFolderOpen16 size={14} />
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function TabContent(props: {
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
cwd: string | undefined
|
cwd: string | undefined
|
||||||
expanded: string[]
|
expanded: string[]
|
||||||
|
revealed: string[]
|
||||||
onToggleDir: (path: string) => void
|
onToggleDir: (path: string) => void
|
||||||
onReferenceFile: (path: string) => void
|
onReferenceFile: (path: string) => void
|
||||||
ctx: Context
|
ctx: Context
|
||||||
@@ -72,7 +73,7 @@ function TabContent(props: {
|
|||||||
/** Open a diff tab from the git panel (placement handled by the store). */
|
/** Open a diff tab from the git panel (placement handled by the store). */
|
||||||
onOpenDiff: (tab: SidebarTab) => void
|
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 scope = { sessionId, cwd }
|
||||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||||
if (descriptor === undefined) {
|
if (descriptor === undefined) {
|
||||||
@@ -90,7 +91,7 @@ function TabContent(props: {
|
|||||||
RenderBoundary,
|
RenderBoundary,
|
||||||
{ className: css.tabBoundaryError },
|
{ className: css.tabBoundaryError },
|
||||||
createElement(descriptor.component, {
|
createElement(descriptor.component, {
|
||||||
ctx, store, scope, tab, visible, expanded,
|
ctx, store, scope, tab, visible, expanded, revealed,
|
||||||
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -714,6 +715,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
expanded={state.expanded}
|
expanded={state.expanded}
|
||||||
|
revealed={state.revealed ?? []}
|
||||||
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
||||||
onReferenceFile={referenceInChat}
|
onReferenceFile={referenceInChat}
|
||||||
ctx={ctx}
|
ctx={ctx}
|
||||||
|
|||||||
@@ -79,11 +79,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
|||||||
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
icon: (size: number) => <IconFolderOpen16 size={size} />,
|
||||||
order: 10,
|
order: 10,
|
||||||
single: true,
|
single: true,
|
||||||
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
|
component: ({ ctx, store, scope, expanded, revealed, onToggleDir, onReferenceFile }) => (
|
||||||
<ExplorerView
|
<ExplorerView
|
||||||
sessionId={scope.sessionId}
|
sessionId={scope.sessionId}
|
||||||
cwd={scope.cwd}
|
cwd={scope.cwd}
|
||||||
expanded={expanded ?? []}
|
expanded={expanded ?? []}
|
||||||
|
revealed={revealed ?? []}
|
||||||
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
onToggle={onToggleDir ?? (() => { /* no-op */ })}
|
||||||
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
|
||||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { Context } from '../context-types.ts'
|
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 { t } from './locales.ts'
|
||||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||||
import { wrapOpenPath } from './openpath-intercept.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}` })
|
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). */
|
/** The intercepted produced-files row (visual twin of the deliverables chips). */
|
||||||
export function SidebarProducedFiles(props: {
|
export function SidebarProducedFiles(props: {
|
||||||
matched: readonly string[]
|
matched: readonly string[]
|
||||||
openInSidebar: (path: string) => void
|
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 shown = matched.slice(0, 6)
|
||||||
const hidden = matched.length - shown.length
|
const hidden = matched.length - shown.length
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +83,16 @@ export function SidebarProducedFiles(props: {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{hidden > 0 && <span className={css.producedMore}>+{hidden}</span>}
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -79,12 +119,15 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
|||||||
// deliverables behavior instead of offering chips that cannot open.
|
// deliverables behavior instead of offering chips that cannot open.
|
||||||
select: (owner) => {
|
select: (owner) => {
|
||||||
if (store.getPrefs().tabsEnabled['editor'] === false) 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,
|
priority: -1,
|
||||||
registrant: 'dsh-better-sidebar',
|
registrant: 'dsh-better-sidebar',
|
||||||
inject: (sessionId: string) => ({
|
inject: (sessionId: string) => ({
|
||||||
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||||
|
onShowInFolder: (files: readonly string[]) => { revealInExplorer(ctx, store, sessionId, files) },
|
||||||
}),
|
}),
|
||||||
}, SidebarProducedFiles))
|
}, SidebarProducedFiles))
|
||||||
}
|
}
|
||||||
@@ -93,9 +136,11 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
|||||||
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
|
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
|
||||||
* — the single funnel every chat-side file open goes through (tool-row path
|
* — 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
|
* links, the produced-files row, prose mentions) — so opens land in the
|
||||||
* sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath`
|
* sidebar editor instead of the Host OS. The folder-reveal gesture ("Show in
|
||||||
* pref and the editor tab's enable switch; declined opens fall through to
|
* folder" passes `'.'`) is the one exception: it is routed to the explorer.
|
||||||
* the original method. Returns the disposer restoring the original (HMR-safe).
|
* 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 {
|
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
|
||||||
return wrapOpenPath(ctx.workspaces, {
|
return wrapOpenPath(ctx.workspaces, {
|
||||||
@@ -103,5 +148,6 @@ export function registerOpenPathInterception(ctx: Context, store: SidebarStore):
|
|||||||
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
&& store.getPrefs().tabsEnabled['editor'] !== false,
|
||||||
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
|
||||||
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||||
|
revealInExplorer: (_path, sessionId) => { revealInExplorer(ctx, store, sessionId, lastProduced) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export const zh = {
|
|||||||
historyLoadError: '加载更多历史失败',
|
historyLoadError: '加载更多历史失败',
|
||||||
produced: '本次产出',
|
produced: '本次产出',
|
||||||
producedOpen: '在侧边栏中打开',
|
producedOpen: '在侧边栏中打开',
|
||||||
|
showInFolder: '在文件夹中显示',
|
||||||
disconnected: '终端连接断开,重连中…',
|
disconnected: '终端连接断开,重连中…',
|
||||||
exited: '终端进程已退出',
|
exited: '终端进程已退出',
|
||||||
noSession: '选择一个会话以使用侧边栏',
|
noSession: '选择一个会话以使用侧边栏',
|
||||||
@@ -310,6 +311,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|||||||
historyLoadError: 'Failed to load more history',
|
historyLoadError: 'Failed to load more history',
|
||||||
produced: 'Produced',
|
produced: 'Produced',
|
||||||
producedOpen: 'Open in sidebar',
|
producedOpen: 'Open in sidebar',
|
||||||
|
showInFolder: 'Show in folder',
|
||||||
disconnected: 'Terminal disconnected, reconnecting…',
|
disconnected: 'Terminal disconnected, reconnecting…',
|
||||||
exited: 'Terminal process exited',
|
exited: 'Terminal process exited',
|
||||||
noSession: 'Select a conversation to use the sidebar',
|
noSession: 'Select a conversation to use the sidebar',
|
||||||
|
|||||||
@@ -30,13 +30,29 @@ export interface OpenPathInterceptDeps {
|
|||||||
currentSessionId(): string | undefined
|
currentSessionId(): string | undefined
|
||||||
/** Route the open into the sidebar editor (the established openSidebarFile). */
|
/** Route the open into the sidebar editor (the established openSidebarFile). */
|
||||||
openInSidebar(path: string, sessionId: string): void
|
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
|
* Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar
|
||||||
* editor instead of the Host OS and resolve as success (the original's
|
* editor instead of the Host OS and resolve as success (the original's
|
||||||
* callers ignore the result); anything that declines falls through to the
|
* 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 workspaces - the client workspaces service to wrap.
|
||||||
* @param deps - per-call takeover decisions.
|
* @param deps - per-call takeover decisions.
|
||||||
* @returns the disposer restoring the original method (HMR-safe).
|
* @returns the disposer restoring the original method (HMR-safe).
|
||||||
@@ -50,7 +66,8 @@ export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathIntercep
|
|||||||
if (deps.takeoverEnabled()) {
|
if (deps.takeoverEnabled()) {
|
||||||
const sessionId = deps.currentSessionId()
|
const sessionId = deps.currentSessionId()
|
||||||
if (sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
deps.openInSidebar(path, sessionId)
|
if (isFolderRevealPath(path)) deps.revealInExplorer(path, sessionId)
|
||||||
|
else deps.openInSidebar(path, sessionId)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* 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.
|
* @returns produced paths as the matched value, or null to decline.
|
||||||
*/
|
*/
|
||||||
export function selectProducedFiles(owner: unknown): readonly string[] | null {
|
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 (record === null || typeof record !== 'object') return null
|
||||||
if (!Array.isArray(record.nodes) || typeof record.seq !== 'number') return null
|
const seq = typeof record.seq === 'number' ? record.seq : Number.POSITIVE_INFINITY
|
||||||
const paths = producedForClosing(record.nodes, record.seq)
|
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
|
return paths.length === 0 ? null : paths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ export interface TabComponentProps {
|
|||||||
visible: boolean
|
visible: boolean
|
||||||
/** The explorer's expanded directory set (ExplorerView). */
|
/** The explorer's expanded directory set (ExplorerView). */
|
||||||
expanded?: string[]
|
expanded?: string[]
|
||||||
|
/** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */
|
||||||
|
revealed?: string[]
|
||||||
onToggleDir?: (path: string) => void
|
onToggleDir?: (path: string) => void
|
||||||
onReferenceFile?: (path: string) => void
|
onReferenceFile?: (path: string) => void
|
||||||
onOpenFile?: (path: string) => void
|
onOpenFile?: (path: string) => void
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ export interface SidebarState {
|
|||||||
nextBrowser: number
|
nextBrowser: number
|
||||||
/** Explorer expansion set (absolute directory paths). */
|
/** Explorer expansion set (absolute directory paths). */
|
||||||
expanded: string[]
|
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). */
|
/** The right sidebar's split tree (the original workbench). */
|
||||||
splits: SplitNode
|
splits: SplitNode
|
||||||
/** Whether the bottom panel (a second, independent workbench) is open. */
|
/** 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,
|
nextTerminal: 1,
|
||||||
nextBrowser: 1,
|
nextBrowser: 1,
|
||||||
expanded: [],
|
expanded: [],
|
||||||
|
revealed: [],
|
||||||
splits: leaf,
|
splits: leaf,
|
||||||
bottomOpen: false,
|
bottomOpen: false,
|
||||||
bottomHeight: BOTTOM_DEFAULT,
|
bottomHeight: BOTTOM_DEFAULT,
|
||||||
@@ -635,6 +642,33 @@ export function toggleExpanded(state: SidebarState, path: string): SidebarState
|
|||||||
return { ...state, expanded }
|
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. */
|
/** 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 {
|
export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode {
|
||||||
if (node.kind === 'leaf') return node
|
if (node.kind === 'leaf') return node
|
||||||
@@ -836,6 +870,7 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
|||||||
nextTerminal: record.nextTerminal,
|
nextTerminal: record.nextTerminal,
|
||||||
nextBrowser,
|
nextBrowser,
|
||||||
expanded: record.expanded as string[],
|
expanded: record.expanded as string[],
|
||||||
|
revealed: [],
|
||||||
splits,
|
splits,
|
||||||
bottomOpen,
|
bottomOpen,
|
||||||
bottomHeight,
|
bottomHeight,
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ describe('turn-tail interception registration (issue #15)', () => {
|
|||||||
// Enabled (default): a produced turn claims the chain; an empty one declines.
|
// 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(producedOwner(['a.ts', 'b.ts']))).toEqual(['a.ts', 'b.ts'])
|
||||||
expect(select(emptyOwner())).toBeNull()
|
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
|
// Editor tab disabled: even a produced turn falls back to the default
|
||||||
// deliverables row (chips that cannot open must not be offered).
|
// deliverables row (chips that cannot open must not be offered).
|
||||||
@@ -156,13 +162,14 @@ describe('turn-tail interception registration (issue #15)', () => {
|
|||||||
restore()
|
restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('wires the openInSidebar seat to the sidebar editor tab', () => {
|
it('wires the openInSidebar and onShowInFolder seats', () => {
|
||||||
const fake = fakeSlots(true)
|
const fake = fakeSlots(true)
|
||||||
const ctx = clientCtx(fake.slots)
|
const ctx = clientCtx(fake.slots)
|
||||||
const store = createSidebarStore()
|
const store = createSidebarStore()
|
||||||
const restore = registerTurnTailInterception(ctx, store)
|
const restore = registerTurnTailInterception(ctx, store)
|
||||||
const inject = fake.registered[0]!.options.inject as (sessionId: string) => {
|
const inject = fake.registered[0]!.options.inject as (sessionId: string) => {
|
||||||
openInSidebar: (path: string) => void
|
openInSidebar: (path: string) => void
|
||||||
|
onShowInFolder: (files: readonly string[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// The seat hands the session-scoped opener to the chips row.
|
// 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',
|
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()
|
restore()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+71
-1
@@ -951,6 +951,29 @@ describe('produced-files derivation', () => {
|
|||||||
expect(selectProducedFiles(null)).toBeNull()
|
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', () => {
|
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', 'src/a.ts')).toBe('/work/proj/src/a.ts')
|
||||||
expect(resolveSidebarPath('/work/proj', '/abs/x.ts')).toBe('/abs/x.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> = {}): OpenPathInterceptDeps & {
|
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): OpenPathInterceptDeps & {
|
||||||
sidebar: string[]
|
sidebar: string[]
|
||||||
|
revealed: string[]
|
||||||
} => {
|
} => {
|
||||||
const sidebar: string[] = []
|
const sidebar: string[] = []
|
||||||
|
const revealed: string[] = []
|
||||||
return {
|
return {
|
||||||
sidebar,
|
sidebar,
|
||||||
|
revealed,
|
||||||
takeoverEnabled: () => true,
|
takeoverEnabled: () => true,
|
||||||
currentSessionId: () => 's1',
|
currentSessionId: () => 's1',
|
||||||
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
|
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
|
||||||
|
revealInExplorer: (path, sessionId) => { revealed.push(`${sessionId}:${path}`) },
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1630,6 +1657,23 @@ describe('open-path interception', () => {
|
|||||||
restore()
|
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
|
||||||
|
// "<cwd>/." — 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 () => {
|
it('restores the original method on dispose (HMR-safe)', async () => {
|
||||||
const ws = service()
|
const ws = service()
|
||||||
const d = deps()
|
const d = deps()
|
||||||
@@ -1654,6 +1698,20 @@ describe('open-path interception', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('open-path interception wiring', () => {
|
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<string, unknown>
|
||||||
|
g.window = { clearTimeout: () => {}, setTimeout: () => 0, innerWidth: 1024, innerHeight: 800 }
|
||||||
|
g.localStorage = { getItem: () => null, setItem: () => {} }
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
const g = globalThis as Record<string, unknown>
|
||||||
|
delete g.window
|
||||||
|
delete g.localStorage
|
||||||
|
})
|
||||||
|
|
||||||
it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => {
|
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),
|
// A realistic client-context fake: the sessions list feed (current + cwd),
|
||||||
// the workspaces funnel, and the sidebar service the editor goes through.
|
// 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<string, unknown>) } },
|
betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } },
|
||||||
} as unknown as Context
|
} as unknown as Context
|
||||||
const store = createSidebarStore()
|
const store = createSidebarStore()
|
||||||
|
store.setSession('s1')
|
||||||
const original = ctx.workspaces.openPath
|
const original = ctx.workspaces.openPath
|
||||||
const restore = registerOpenPathInterception(ctx, store)
|
const restore = registerOpenPathInterception(ctx, store)
|
||||||
|
|
||||||
@@ -1680,13 +1739,24 @@ describe('open-path interception wiring', () => {
|
|||||||
id: 'editor:/w/src/a.ts',
|
id: 'editor:/w/src/a.ts',
|
||||||
}])
|
}])
|
||||||
|
|
||||||
|
// The "Show in folder" gesture ('.' — the workspace root, resolved to
|
||||||
|
// "<cwd>/." 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<string, unknown> | 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.
|
// The interceptOpenPath pref off → the original funnel runs untouched.
|
||||||
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
|
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
|
ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
|
||||||
await ctx.workspaces.openPath('/w/src/b.ts')
|
await ctx.workspaces.openPath('/w/src/b.ts')
|
||||||
expect(calls).toEqual(['/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
|
// The editor tab disabled → falls through too (an editor that cannot
|
||||||
// open must not swallow opens).
|
// open must not swallow opens).
|
||||||
|
|||||||
Reference in New Issue
Block a user