Merge origin/master into codex/status-bar-token-metrics

Build-review integration round 1; isolate Web snapshots from user skill homes.
This commit is contained in:
Hypatia May
2026-07-30 20:51:38 +08:00
39 changed files with 1837 additions and 345 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: ae245d1c904b291413d097c0b6d88c23c44f7c95
README.zh.md: e80c55e11e122d66b2a7c7d978a6aadb4e39c659
README.md: 5fe9086ed92b9f3f498493a192b7e27a31da124c
README.zh.md: bb1d08e24ec5654fc8b24c10418dad356f8cd3ce

View File

@@ -10,6 +10,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).

View File

@@ -8,6 +8,8 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放通用工具的内容仍然只在面板中呈现[决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
@@ -20,7 +22,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar 坑位本身为 `session-maybe`:没有当前会话时,同一个 bar 以惰性态渲染machine face 缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此 textarea DOM 在选定 workspace 的切换中得以存活;严格会话作用域的控件 seat 在会话存在之前保持为空。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM 和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后者胜」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI模型选择器不增加圆环或附属控件。

View File

@@ -0,0 +1,29 @@
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */
.root {
min-width: 0;
}
.root[data-open] {
padding-bottom: 4px;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.body {
box-sizing: border-box;
width: calc(100% - 22px);
height: 141px;
margin: 4px 0 0 22px;
overflow: auto;
padding: 10px 16px 12px 12px;
border: none;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
font: 400 11px/16px var(--ds-font-family-code);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,81 @@
import { useMemo, useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ContextInjectionRow.module.css'
const MAX_CHARS = 20_000
function inlineJson(payload: unknown): string {
const raw = JSON.stringify(payload)
let formatted = ''
let quoted = false
let escaped = false
for (let index = 0; index < raw.length; index++) {
const char = raw.charAt(index)
if (quoted) {
formatted += char
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === '"') quoted = false
continue
}
if (char === '"') {
quoted = true
formatted += char
continue
}
if (char === '{' || char === '[') {
formatted += char
const close = char === '{' ? '}' : ']'
if (raw[index + 1] !== close) formatted += ' '
continue
}
if (char === '}' || char === ']') {
const open = char === '}' ? '{' : '['
if (raw[index - 1] !== open) formatted += ' '
formatted += char
continue
}
formatted += char === ':' || char === ',' ? `${char} ` : char
}
return formatted
}
/** Props for the logged non-user message presentation. */
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
}
/**
* Render logged context with the Tool calls disclosure chrome from Figma.
* @param props - Durable content and source provenance.
* @returns A collapsed context row with a bounded JSON body.
*/
export function ContextInjectionRow({ content, source }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
const text = inlineJson({ content, source })
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n… 已截断,共 ${text.length} 字符`
: text
}, [content, open, source])
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title="上下文注入"
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-context-injection-body>{body}</pre>
</DisclosureRow>
)
}

View File

@@ -0,0 +1,69 @@
/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */
.root {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
.row {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.row[data-expandable] {
cursor: pointer;
}
.leading {
position: relative;
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
padding: 0;
border: none;
background: none;
color: var(--dsw-alias-label-tertiary);
}
button.leading {
cursor: pointer;
}
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,101 @@
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './DisclosureRow.module.css'
/** Shared 24px disclosure chrome for conversation flow rows. */
export interface DisclosureRowProps {
icon: ReactNode
title: string
open: boolean
expandable: boolean
onToggle: () => void
/** Makes the complete title row the disclosure target. */
expandOnRowClick?: boolean | undefined
/** Replaces the collapsed icon with a chevron while the row is hovered. */
previewChevron?: boolean | undefined
collapsedContent?: ReactNode
children?: ReactNode
className?: string | undefined
rowClassName?: string | undefined
leadingClassName?: string | undefined
chevronClassName?: string | undefined
titleClassName?: string | undefined
}
/**
* Render one disclosure header and its controlled expanded content.
* @param props - Visual content, controlled state, and interaction policy.
* @returns The disclosure row.
*/
export function DisclosureRow({
icon,
title,
open,
expandable,
onToggle,
expandOnRowClick = false,
previewChevron = expandable,
collapsedContent,
children,
className,
rowClassName,
leadingClassName,
chevronClassName,
titleClassName,
}: DisclosureRowProps) {
const rowExpands = expandable && expandOnRowClick
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
onToggle()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
onToggle()
}
const collapsedLeading = previewChevron
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(chevronClassName, css.chevronHover)} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={chevronClassName} />
: collapsedLeading
return (
<div className={clsx(css.root, className)} data-open={open || undefined}>
<div
className={clsx(css.row, rowClassName)}
data-disclosure-row
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? onToggle : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
<button
type="button"
className={clsx(css.leading, leadingClassName)}
aria-expanded={open}
onClick={toggleFromLeading}
>
{leading}
</button>
) : (
<span className={clsx(css.leading, leadingClassName)}>
{leading}
</span>
)}
<span className={clsx(css.title, titleClassName)}>{title}</span>
{!open && collapsedContent}
</div>
{open && children}
</div>
)
}

View File

@@ -10,6 +10,7 @@ import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
@@ -94,9 +95,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
}
case 'context':
return (
<div className={css.contextRow}>
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
</div>
<ContextInjectionRow content={node.content} source={node.source} />
)
default:
return (

View File

@@ -9,10 +9,6 @@
.row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
@@ -41,24 +37,8 @@
90%, 100% { left: 100%; }
}
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
.row[data-expandable] {
cursor: pointer;
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
padding: 0;
border: none;
background: none;
color: var(--dsw-alias-label-tertiary);
flex-shrink: 0;
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
@@ -76,40 +56,8 @@
background: var(--dsw-alias-state-business-primary);
}
button.leading {
cursor: pointer;
}
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
font-weight: 400;
}
.sep {

View File

@@ -8,11 +8,11 @@
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useState, type MouseEvent, type ReactNode } from 'react'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import css from './ToolRow.module.css'
export interface ToolRowProps {
@@ -82,63 +82,27 @@ export function ToolRow({
// this substitution never shows.
const text = body ?? ''
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
setExpanded(v => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
toggleExpand()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
// Expandable rows preview the toggle on hover: the tool icon yields to a
// down chevron (CSS swap on .row:hover); state dots still take precedence.
const collapsedIcon = expandable
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={css.chevronHover} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 />
: leadingFor(state, collapsedIcon)
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
<button
type="button"
className={css.leading}
aria-expanded={open}
onClick={toggleFromLeading}
>
{leading}
</button>
) : (
<span className={css.leading}>
{leading}
</span>
)}
<span className={css.title}>{title}</span>
{!open && (
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
icon={leadingFor(state, icon)}
title={title}
open={open}
expandable={expandable}
expandOnRowClick={expandOnRowClick}
previewChevron={expandable && state !== 'error' && state !== 'stopped'}
onToggle={toggleExpand}
collapsedContent={(
<>
<span className={css.sep} aria-hidden />
{fileLink ? (
@@ -154,18 +118,18 @@ export function ToolRow({
)}
</>
)}
</div>
{/* The terminal presenter's description belongs ABOVE the card per the
render-intent contract, so an expanded terminal row keeps showing it
even though the collapsed summary is hidden while open. */}
{open && terminalBody?.description !== undefined && (
<div className={css.terminalDescription}>{terminalBody.description}</div>
)}
{open && (terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>)}
>
{/* The terminal presenter's description belongs above the card per
the render-intent contract. */}
{terminalBody?.description !== undefined && (
<div className={css.terminalDescription}>{terminalBody.description}</div>
)}
{terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>}
</DisclosureRow>
</div>
)
}

View File

@@ -107,11 +107,48 @@ describe('MessageItem arms', () => {
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
it('context and unknown nodes render their JSON rows', () => {
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
const ctxView = render(
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
<MessageItem node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
} as never}
/>,
)
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
expect(ctxView.container.querySelector('svg')).not.toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
)
fireEvent.keyDown(disclosure, { key: ' ' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
})
it('context preserves the bounded JSON truncation contract', () => {
const view = render(
<MessageItem node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
source: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
.toMatch(/… 已截断,共 \d+ 字符$/)
})
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
README.md: 318380405214d5f25ad77e348c4e134a8981ffb3
README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754
README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7

View File

@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
## Model Experience

View File

@@ -6,7 +6,7 @@
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/``C:\``list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo``/foo`)与不完整的 UNC 前缀(`\\``\\server`)——报 `directory-unreadable``directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
**双面包**browser half`./client`)以应用内 **选择工作区目录** 对话框figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory``host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`zh 默认en。因此一行 cordis.yml 同时组合浏览交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
**双面包**browser half`./client`)以应用内 **选择工作区目录** 对话框figma `Harness` 813-23126 家族——Miller 双列视图其导航以选中项为锚落地crumb 跳转或提交的路径会立即提交目标待父层级到达后再在其中重新选中目标的实际条目——双栏因此后退绝不塌缩父层级这一程失败或被截断时保持单栏落地展示根保持单个宽层级带点击即编辑路径区的面包屑其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤不区分大小写且仅作用于已列出、可能被截断的行Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory``host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`zh 默认en。因此一行 cordis.yml 同时组合浏览交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
## 模型体验

View File

@@ -1,6 +1,7 @@
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
* headless here — mask, card, Escape only — and this module owns the figma
* frame: 600×420 card (viewport-clamped), header (title + crumbs, l3 separator),
* frame: 680×500 card (viewport-clamped; upsized from the figma 600×420),
* header (title + crumbs, l3 separator),
* the one-or-two-column Miller content, and the bordered footer. */
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
@@ -8,19 +9,26 @@
* columns scroll, so shrinking the height keeps Open/Cancel reachable
* instead of clipping them below a fixed overlay. */
.dialog.dialog {
width: min(600px, 100%);
height: min(420px, calc(100dvh - 32px));
width: min(680px, 100%);
height: min(500px, calc(100dvh - 32px));
padding: 0;
gap: 0;
}
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
* observers; display:contents keeps header/content/footer as direct flex
* children of the Modal card. */
.editorScope {
display: contents;
}
/* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */
.header {
display: flex;
flex-direction: column;
gap: 8px;
flex: none;
padding: 22px 14px 12px 24px;
padding: 16px 14px 8px 24px;
border-bottom: 1px solid var(--dsw-alias-border-l3);
}
@@ -54,8 +62,12 @@
align-items: stretch;
flex: 1 1 0;
min-height: 0;
gap: 20px;
/* 12px of row gap on each side of the divider; the left side reads wider
* by the column's trailing 8px scrollbar clearance, which is deliberate —
* the thumb needs that room, the right pane's rows do not. */
gap: 12px;
overflow-x: auto;
scrollbar-width: none;
}
.crumbTrail {
@@ -126,28 +138,34 @@
color: var(--dsw-alias-label-primary);
}
/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with
* the hairline divider centered between them; each column scrolls alone. */
/* Miller content: symmetric 16px vertical padding so the divider clears the
* header and footer rules evenly; each column scrolls alone (column widths
* live at .column). */
.content {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
padding: 16px 24px 0;
/* Right inset is slimmer than the left: the trailing column's own 8px
* scrollbar clearance makes up the optical difference. */
padding: 16px 16px 16px 24px;
}
/* Columns split the row evenly around the divider (a solo column takes the
* whole row); 256px is the floor below which the row scrolls (scrollbar
* hidden, the effect pins the child pane into view) instead of squeezing
* the panes. */
.column {
display: flex;
flex-direction: column;
gap: 2px;
width: 256px;
flex: none;
overflow-y: auto;
}
.columnWide {
width: 100%;
flex: 1 1 0;
min-width: 256px;
overflow-y: auto;
/* The themed scrollbar occupies the column's edge (styled scrollbars are
* classic, gutter-taking ones); the extra clearance keeps the row pills
* clear of the thumb. */
padding-right: 8px;
}
.divider {
@@ -228,8 +246,8 @@
color: var(--dsw-alias-state-error-primary);
}
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed
* card leaves the figma 28px below the 36px buttons. */
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
* centered in the bar; New-folder and the show-hidden toggle pin left. */
.footerBar {
display: flex;
align-items: center;
@@ -238,10 +256,42 @@
flex-wrap: wrap;
gap: 8px;
flex: none;
padding: 12px 24px 28px;
padding: 16px 24px;
border-top: 1px solid var(--dsw-alias-border-l3);
}
/* Show-hidden toggle: a subtle fixed-label text button left of the gap;
* the pressed state seats a check glyph after the label (Menu's selected
* vocabulary; trailing so the label never shifts) instead of flipping the
* wording. */
.showHiddenToggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
padding: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
white-space: nowrap;
}
.showHiddenToggle:hover {
color: var(--dsw-alias-label-primary);
}
.showHiddenToggle:disabled {
color: var(--dsw-alias-label-caption);
cursor: default;
}
.showHiddenToggleActive {
color: var(--dsw-alias-label-primary);
}
.footerGap {
flex: 1 1 0;
}

View File

@@ -1,22 +1,30 @@
/**
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
* 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls
* 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls
* sideways, the columns scroll down) whose header carries the title, the selection-path
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
* full-width level until a row is selected, then two 256px columns (level |
* selected folder's children) around a hairline divider. Selecting in the
* full-width level until a row is selected, then two columns splitting the
* row evenly (256px floor; level | selected folder's children) around a
* hairline divider. Navigations land selection-anchored: a crumb jump or a
* submitted path commits the target immediately, then re-selects it in its
* parent level once that level arrives, so stepping back keeps two panes
* away from the display root. Selecting in the
* right column shifts the view one level deeper. "New folder" opens a nested
* create dialog targeting the selected folder (or the level itself) and
* selects the created folder. Open adopts the selected folder, falling back
* to the listed level. Pure consumer of the injected browse calls — the
* owning flow decides what "Open" means and owns the workspace-creation
* error surface. Hidden entries are host-flagged and filtered here (a
* show-hidden toggle is deferred work, client-side only).
* error surface. Hidden entries are host-flagged and hidden by default; the
* footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
* on) reveals them (client-side only). The path editor opens seeded with a
* trailing separator, and while the draft's directory part names a listed
* level, its final segment prefix-filters that level's rows (a dot-led
* prefix also reveals the hidden entries it names).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
@@ -59,17 +67,59 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
}
/**
* The listing's platform separator, inferred from the home path the host
* stamped — never from typed text or entry paths, where a backslash is a
* legal POSIX name character. Still a heuristic at the last step: a POSIX
* home directory whose own name contains a backslash would misread.
* TODO: replace with a host-stamped `separator` field on the wire
* DirectoryListing so the platform fact travels verbatim (the trade-off is
* recorded in the directory-picker capability seam Agent Note).
*/
function separatorOf(listing: DirectoryListing): '\\' | '/' {
return listing.home.includes('\\') ? '\\' : '/'
}
/**
* The path draft's final segment, when its directory part is exactly the
* level `listing` lists — the segment the level prefix-filters on while the
* user types. Any other draft (no separator yet, or naming some other
* directory) leaves the level unfiltered. The directory part compares
* exactly (it is the host's own path text, reached by seeding or erasing);
* only the name filter downstream is case-insensitive.
*/
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
if (draft === null) return null
const sep = separatorOf(listing)
const cut = draft.lastIndexOf(sep)
if (cut === -1) return null
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
}
/** One column of folder rows (the Miller view renders one or two of these). */
function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: {
entries: readonly DirectoryEntry[]
selectedPath: string | null
busy: boolean
onPick: (entry: DirectoryEntry) => void
wide: boolean
showHidden: boolean
filterPrefix: string | null
pathEditing: boolean
}) {
const visible = entries.filter((entry) => {
// The selection is exempt from both filters: it anchors the two-pane
// view (crumbs and the child pane point at it), so neither the hidden
// filter after a dot-reveal pick nor a prefix miss may orphan it.
if (entry.path === selectedPath) return true
if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
// A dot-led prefix names hidden entries explicitly, so matching ones
// surface even while the toggle keeps the rest hidden.
return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
})
return (
<div className={clsx(css.column, wide && css.columnWide)} role="list">
{entries.filter(entry => !entry.hidden).map((entry) => {
<div className={css.column} role="list">
{visible.map((entry) => {
const selected = entry.path === selectedPath
return (
// The wrapper carries the list semantics; the row keeps its NATIVE
@@ -80,6 +130,15 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
aria-current={selected || undefined}
className={clsx(css.row, selected && css.rowSelected)}
disabled={busy}
// While the path editor is open, keep focus in it: a focus
// steal on mousedown would blur the editor and (in engines
// where the blur lands before our guards) drop this click.
// Outside editing, rows keep native focus behavior.
onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined}
// Editing-time focus parking happens after commit (the
// DirectoryBrowser refocus effect): a right-pane pick replaces
// this very column, so focusing the clicked node here would
// still fall to body.
onClick={() => { onPick(entry) }}
>
{selected
@@ -110,6 +169,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null)
// Show-hidden toggle state (pure client-side filter, reset on each open).
const [showHidden, setShowHidden] = useState(false)
// Create-folder state: null = closed; a string = the nested dialog's draft.
const [folderDraft, setFolderDraft] = useState<string | null>(null)
const [creatingFolder, setCreatingFolder] = useState(false)
@@ -156,28 +217,86 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
return { seq, scan: listDirectory(path, controller.signal) }
}, [supersede, listDirectory])
/** Replace the whole view with one freshly listed level (no selection). */
/**
* Launch a follow-up listing under the CURRENT supersession seq: a newer
* intent aborts it like the leg it continues, and it supersedes nothing.
*/
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
const controller = new AbortController()
scanController.current = controller
return listDirectory(path, controller.signal)
}, [listDirectory])
/**
* Replace the whole view with a freshly navigated level. The target level
* commits the moment it arrives (single wide level: the editor closes and
* loading ends on this first settlement, so an Enter-submitted navigation
* is never withdrawn waiting on anything further). Away from the display
* root — the same collapse the crumb header renders, so crumbs and pane
* shape never disagree — a parent leg then upgrades the landing in place:
* the target's ACTUAL parent-level entry re-selected (left pane = parent,
* right pane = the target), so a crumb jump reads as stepping back one
* pane. A failed parent leg, or a truncated parent window that lacks the
* target, leaves the committed single-pane landing — the upgrade must
* never orphan the selection it exists to anchor.
*/
const navigate = useCallback((path?: string) => {
const { seq, scan } = launchListing(path)
setLoading(true)
setError(null)
scan.then((next) => {
scan.then((target) => {
if (seq !== requestSeq.current) return
setParent(next)
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
// Arity is label-independent: only the collapsed chain's depth decides.
if (displayCrumbs(target, '').length < 2) return
const parentCrumb = target.crumbs.at(-2)
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
if (parentCrumb === undefined) return
continueScan(parentCrumb.path).then((parentLevel) => {
if (seq !== requestSeq.current) return
// Windows resolves a typed path preserving its case; anchor on the
// parent level's actual entry so selection comparisons hold.
const sep = separatorOf(parentLevel)
const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value)
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
if (match === undefined) return
setParent(parentLevel)
setSelected(match)
setChild(target)
}, () => {
// Swallows the parent-leg failure (its abort included): the
// committed single-pane landing stands, and nobody asked to see
// the parent level.
})
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, [launchListing])
}, [launchListing, continueScan])
// Editor-close focus parking (consumed by the refocus effect below the
// miller-row ref): a pick parks on the selection's row, Enter and an
// input-focused Escape park on the crumb edit zone that replaces the
// input. Pointer-out cancels never set (or clear) these — yanking focus
// back from wherever the user clicked would be worse than the fall.
const refocusPick = useRef(false)
const refocusEditZone = useRef(false)
const pathInputRef = useRef<HTMLInputElement | null>(null)
const editZoneRef = useRef<HTMLButtonElement | null>(null)
/** Select a row of the listed level and preview its children on the right. */
const select = useCallback((entry: DirectoryEntry) => {
const { seq, scan } = launchListing(entry.path)
// A pick while the path editor is open adopts the (filtered) row and
// closes the editor — the draft served its purpose. Focus re-parks on
// the selection after commit (see the refocus effect below).
if (pathDraft !== null) refocusPick.current = true
setPathDraft(null)
setSelected(entry)
setChild(null)
setLoading(true)
@@ -193,8 +312,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// An unreadable selection cannot be the committing target while the
// breadcrumb still names the level: fall back to the single pane.
setSelected(null)
// Clearing the selection can unmount the very row the pick parked
// focus on (a dot-revealed hidden row re-hides); the refocus effect
// re-parks on the edit zone only if focus actually fell to body.
refocusEditZone.current = true
})
}, [launchListing])
}, [launchListing, pathDraft])
/** Abandon path editing (Escape or clicking away) and restore the crumb view. */
const cancelPathEdit = useCallback(() => {
// Cancel also withdraws a navigation the editor already launched: its
// late success must not jump to the cancelled path, so the pending
// request is superseded and the view leaves the loading state.
supersede()
setLoading(false)
setPathDraft(null)
setError(null)
// Editing may have superseded the selection's preview request; a
// selection with no preview would render a half-empty two-pane view, so
// cancel falls back to the single-pane level.
if (child === null) setSelected(null)
// With no level listed yet (the editor superseded the initial home
// listing), plain cancellation would leave a permanently blank picker:
// restart the home listing.
if (parent === null) navigate()
}, [supersede, child, parent, navigate])
/** A right-column pick advances the view one level: child becomes the level. */
const advance = useCallback((entry: DirectoryEntry) => {
@@ -213,6 +355,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setSelected(null)
setChild(null)
setCreatingFolder(false)
setShowHidden(false)
navigate()
return
}
@@ -221,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setPathDraft(null)
setFolderDraft(null)
setCreateError(null)
// A close mid-flight (failed Enter, then Cancel) may leave refocus
// flags armed; retire them so a later render cannot consume them.
refocusPick.current = false
refocusEditZone.current = false
}, [open, navigate, supersede])
/** The folder a create or Open acts on: the selection, else the listed level. */
@@ -285,6 +432,37 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const row = millerRowRef.current
if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth
}, [childPath])
// Every editor exit that would drop focus to body re-parks it after
// commit, so keyboard traversal stays inside the dialog (the Modal has no
// focus trap): a pick lands on the selection's row — aria-current in the
// freshly rendered left pane, which survives even a right-pane advance
// replacing the picked button's column — while Enter and an input-focused
// Escape land on the crumb edit zone that replaces the input.
useEffect(() => {
if (pathDraft !== null) return
if (refocusPick.current) {
refocusPick.current = false
refocusEditZone.current = false
const rowHost = millerRowRef.current
/* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */
if (rowHost === null) return
const row = rowHost.querySelector<HTMLButtonElement>('button[aria-current="true"]')
/* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */
if (row === null) return
row.focus()
return
}
if (refocusEditZone.current) {
refocusEditZone.current = false
// Re-park only when the close actually dropped focus to body; focus
// the user parked elsewhere (a surviving row) stays theirs.
if (document.activeElement !== document.body) return
const zone = editZoneRef.current
/* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */
if (zone === null) return
zone.focus()
}
})
if (!open) return null
const twoPane = selected !== null
@@ -310,149 +488,216 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
className={clsx(css.dialog)}
headless
>
<div className={css.header}>
<h2 className={css.title}>{t('browser.title')}</h2>
<div className={css.crumbBar}>
{pathDraft === null
? (
<>
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={parentInert}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
</span>
{/* The empty zone right of the crumbs is the path-edit affordance. */}
<button
type="button"
className={css.crumbEditZone}
aria-label={t('browser.editPath')}
// Stays available with no listed level: when the home
// listing itself fails, typing an absolute path is the one
// remaining way forward.
disabled={parentInert}
onClick={() => {
{/* Path-edit cancellation is observed at the card scope, not the
* input: once Tab parks focus on a filtered row the input is off the
* event path, yet Escape must still collapse the editor (not the
* dialog) and a further focus move out of the card must still
* cancel. display:contents keeps header/content/footer as direct
* flex children of the Modal card. */}
<div
className={css.editorScope}
onKeyDown={(event) => {
if (event.key !== 'Escape' || pathDraft === null) return
// stopPropagation keeps the card-scope Escape from the Modal's
// document listener — the same containment the input previously
// provided for itself.
event.stopPropagation()
// Escape while the input holds focus is about to unmount it; with
// focus already parked on a row, that row survives the cancel and
// keeps focus naturally. Assignment (not a conditional set) also
// retires a stale flag a failed or still-upgrading Enter left.
refocusEditZone.current = document.activeElement === pathInputRef.current
cancelPathEdit()
}}
// Focus leaving THIS dialog card while editing cancels like Escape.
// Guarded non-cancel paths: window/tab focus loss (document no
// longer focused); a focus move that stays inside the card (Tab
// onto the filtered rows or the footer toggle); and pointer paths,
// where rows and the toggle suppress focus steal on mousedown while
// editing so their click lands first. Enter keeps focus in the
// input while its navigation is in flight, so a submitted path is
// never withdrawn here. Anchored to this card via closest, not any
// [role="dialog"], so focus escaping into a sibling overlay cancels.
onBlur={(event) => {
if (pathDraft === null) return
if (!document.hasFocus()) return
const card = event.currentTarget.closest('[role="dialog"]')
/* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */
if (card === null) return
if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return
// The user moved focus out of the card themselves: cancel without
// re-parking (a lingering Enter-failure flag must not yank focus
// back either).
refocusEditZone.current = false
cancelPathEdit()
}}
>
<div className={css.header}>
<h2 className={css.title}>{t('browser.title')}</h2>
<div className={css.crumbBar}>
{pathDraft === null
? (
<>
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={parentInert}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
</span>
{/* The empty zone right of the crumbs is the path-edit affordance. */}
<button
type="button"
className={css.crumbEditZone}
aria-label={t('browser.editPath')}
// Stays available with no listed level: when the home
// listing itself fails, typing an absolute path is the one
// remaining way forward.
disabled={parentInert}
ref={editZoneRef}
onClick={() => {
// Opening the editor supersedes any pending listing: a
// settlement landing before the first keystroke would
// otherwise close the editor via navigate's draft reset.
supersede()
setLoading(false)
setPathDraft(selected?.path ?? parent?.path ?? '')
}}
/>
</>
)
: (
<input
className={css.pathInput}
value={pathDraft}
aria-label={t('browser.editPath')}
autoFocus
disabled={parentInert}
onChange={(event) => {
supersede()
setLoading(false)
// Seed with a trailing separator so typing immediately
// continues into child names (and prefix-filters below).
// No listed level means nothing to seed from (the editor
// is the recovery path for a failed home listing).
if (parent === null) {
setPathDraft('')
return
}
const base = selected?.path ?? parent.path
const sep = separatorOf(parent)
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
}}
/>
</>
)
: (
<input
className={css.pathInput}
value={pathDraft}
aria-label={t('browser.editPath')}
autoFocus
ref={pathInputRef}
disabled={parentInert}
onChange={(event) => {
// Editing the draft supersedes any in-flight navigation:
// its completion must neither clear the newer text nor
// repopulate the view with the older path.
supersede()
setLoading(false)
setPathDraft(event.target.value)
}}
{...compositionGuard}
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
// Trim only detects a blank draft; the Host gets the
// original text — a real directory name may end in
// whitespace, and trimming would list its sibling.
if (pathDraft.trim() !== '') navigate(pathDraft)
}
if (event.key === 'Escape') {
event.stopPropagation()
// Cancel also withdraws a navigation the editor already
// launched: its late success must not jump to the
// cancelled path, so the pending request is superseded
// and the view leaves the loading state.
supersede()
setLoading(false)
setPathDraft(null)
setError(null)
// Editing may have superseded the selection's preview
// request; a selection with no preview would render a
// half-empty two-pane view, so cancel falls back to the
// single-pane level.
if (child === null) setSelected(null)
// With no level listed yet (the editor superseded the
// initial home listing), plain cancellation would leave a
// permanently blank picker: restart the home listing.
if (parent === null) navigate()
}
}}
setPathDraft(event.target.value)
}}
{...compositionGuard}
// Escape and focus-leave cancellation live on the card-scope
// wrapper above (they must work after focus Tabs onto the
// rows); this handler owns only submission.
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
// Trim only detects a blank draft; the Host gets the
// original text — a real directory name may end in
// whitespace, and trimming would list its sibling.
if (pathDraft.trim() !== '') {
// Success will unmount the still-focused input; park
// focus on the returning crumb edit zone (a failure
// keeps the editor, so the flag waits until close).
refocusEditZone.current = true
navigate(pathDraft)
}
}
}}
/>
)}
</div>
</div>
<div className={css.content}>
<div className={css.millerRow} ref={millerRowRef}>
{parent !== null && (
<LevelColumn
entries={parent.entries}
selectedPath={selected?.path ?? null}
busy={parentInert}
onPick={select}
showHidden={showHidden}
filterPrefix={draftPrefixFor(parent, pathDraft)}
pathEditing={draftPending}
/>
)}
</div>
</div>
<div className={css.content}>
<div className={css.millerRow} ref={millerRowRef}>
{parent !== null && (
<LevelColumn
entries={parent.entries}
selectedPath={selected?.path ?? null}
busy={parentInert}
onPick={select}
wide={!twoPane}
/>
)}
{twoPane && <span className={css.divider} />}
{twoPane && child !== null && (
<LevelColumn
entries={child.entries}
selectedPath={null}
busy={parentInert}
onPick={advance}
wide={false}
/>
)}
</div>
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{/* The backend bounds a level at its complete-result limit; say so
{twoPane && <span className={css.divider} />}
{twoPane && child !== null && (
<LevelColumn
entries={child.entries}
selectedPath={null}
busy={parentInert}
onPick={advance}
showHidden={showHidden}
filterPrefix={draftPrefixFor(child, pathDraft)}
pathEditing={draftPending}
/>
)}
</div>
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{/* The backend bounds a level at its complete-result limit; say so
* whenever a visible pane was cut instead of letting the tail of a
* huge directory go silently missing. */}
{(parent?.truncated === true || child?.truncated === true) && !loading
{(parent?.truncated === true || child?.truncated === true) && !loading
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>
<div className={css.footerBar}>
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={parent === null || loading || parentInert || draftPending}
onClick={() => {
setFolderDraft('')
setCreateError(null)
}}
>
{t('browser.newFolder')}
</Button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
<Button
variant="primary"
className={clsx(css.footerAction)}
disabled={targetPath === null || loading || parentInert || draftPending}
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
>
{t('browser.open')}
</Button>
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>
<div className={css.footerBar}>
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={parent === null || loading || parentInert || draftPending}
onClick={() => {
setFolderDraft('')
setCreateError(null)
}}
>
{t('browser.newFolder')}
</Button>
<button
type="button"
className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)}
aria-pressed={showHidden}
disabled={parentInert}
// The toggle composes with the path editor (dot-led prefixes and
// this filter interleave): while editing, don't steal focus, so
// toggling never blur-cancels a draft mid-thought. Outside editing
// it keeps native focus behavior.
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
onClick={() => { setShowHidden(prev => !prev) }}
>
{t('browser.showHidden')}
{/* Trailing check (Menu's selected vocabulary): the label never
* shifts when the pressed state toggles. */}
{showHidden && <IconCheckOutline16 size={14} />}
</button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
<Button
variant="primary"
className={clsx(css.footerAction)}
disabled={targetPath === null || loading || parentInert || draftPending}
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
>
{t('browser.open')}
</Button>
</div>
</div>
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
<Modal

View File

@@ -46,6 +46,7 @@ export function apply(ctx: ClientContext): void {
'browser.editPath': '编辑路径',
'browser.loading': '加载中…',
'browser.truncated': '文件夹过多,仅显示开头部分。',
'browser.showHidden': '显示隐藏文件',
}],
['en', {
'browser.title': 'Select Workspace Directory',
@@ -60,6 +61,7 @@ export function apply(ctx: ClientContext): void {
'browser.editPath': 'Edit path',
'browser.loading': 'Loading…',
'browser.truncated': 'Too many folders to list; only the beginning is shown.',
'browser.showHidden': 'Show hidden files',
}],
]
try {

View File

@@ -162,6 +162,7 @@ describe('directory-picker-browse client half', () => {
// zh is the shipped default locale.
expect(injected.t('browser.title')).toBe('选择工作区目录')
expect(injected.t('browser.newFolder')).toBe('新建文件夹')
expect(injected.t('browser.showHidden')).toBe('显示隐藏文件')
})
it('drives the injected browse calls through the hole entry', async () => {

View File

@@ -29,6 +29,25 @@ function listingFor(path?: string): DirectoryListing {
],
truncated: false,
},
'/': {
path: '/',
home: HOME,
crumbs: [{ name: '/', path: '/', hidden: false }],
entries: [{ name: 'home', path: '/home', hidden: false }],
truncated: false,
},
[`${HOME}/.config`]: {
path: `${HOME}/.config`,
home: HOME,
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'u', path: HOME, hidden: false },
{ name: '.config', path: `${HOME}/.config`, hidden: true },
],
entries: [],
truncated: false,
},
[DOCS]: {
path: DOCS,
home: HOME,
@@ -103,6 +122,28 @@ describe('DirectoryBrowser', () => {
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
})
it('shows hidden entries when the toggle is on and hides them again on close', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(screen.queryByText('.config')).toBeNull()
// The fixed-label toggle reports its state through aria-pressed. Its
// mousedown never steals focus (so it composes with the path editor).
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
expect(toggle.getAttribute('aria-pressed')).toBe('false')
fireEvent.mouseDown(toggle)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByText('.config')).toBeTruthy()
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('false')
expect(screen.queryByText('.config')).toBeNull()
// Close resets the toggle.
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
b.view.rerender(<DirectoryBrowser {...b.props} open />)
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(screen.queryByText('.config')).toBeNull()
})
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -153,7 +194,7 @@ describe('DirectoryBrowser', () => {
expect(signals[2]?.aborted).toBe(true)
})
it('jumps back through a crumb into a fresh single-column level', async () => {
it('a crumb jump to the display root (home) lands the single wide level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
@@ -164,6 +205,183 @@ describe('DirectoryBrowser', () => {
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
})
it('a crumb jump away from the root lands two-pane with the target selected', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem')))
await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
// Jumping to the Documents crumb is a step BACK one pane, not a
// collapse: Documents stays selected in the home level, its children
// stay on the right.
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
await waitFor(() => {
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
})
expect(columns()).toHaveLength(2)
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('a navigation to the filesystem root keeps the single wide level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: '/' } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// A one-crumb chain has no parent level to show on the left.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('home') })
expect(columns()).toHaveLength(1)
})
it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
const signals: (AbortSignal | undefined)[] = []
const settlers: ((value: DirectoryListing) => void)[] = []
// Only the FIRST explicit HOME request (the parent leg) hangs; the later
// home crumb jump lists normally.
let homeCalls = 0
const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => {
signals.push(signal)
if (path === HOME && ++homeCalls === 1) {
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target leg commits at once: editor closed, single-pane DOCS level,
// while the parent leg (upgrade) is still in flight.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(columns()).toHaveLength(1)
await waitFor(() => { expect(settlers).toHaveLength(1) })
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
// dropping its settlement.
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
expect(signals[2]?.aborted).toBe(true)
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
// Its late resolution changes nothing either.
await act(async () => { settlers[0]!(listingFor(HOME)) })
expect(columns()).toHaveLength(1)
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
})
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {
const listDirectory = vi.fn(async (path?: string) => {
// The parent leg names HOME explicitly; serve it a truncated window
// that misses Documents (the initial open uses the absent-path form).
if (path === HOME) return { ...listingFor(HOME), entries: [], truncated: true }
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
// The upgrade would orphan the selection (no source row): it stays off.
await act(async () => {})
expect(columns()).toHaveLength(1)
expect(screen.queryByText('browser.truncated')).toBeNull()
})
it('anchors the upgrade on the parent level actual entry under Windows case folding', async () => {
const ROOT = 'C:\\'
const TYPED = 'c:\\users'
const winRoot: DirectoryListing = {
path: ROOT,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
entries: [{ name: 'Users', path: 'C:\\Users', hidden: false }],
truncated: false,
}
const winUsers: DirectoryListing = {
path: TYPED,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }],
entries: [],
truncated: false,
}
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: TYPED } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The typed case differs from the real entry; the upgrade selects the
// parent level's ACTUAL entry so aria-current and exemptions hold.
await waitFor(() => {
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
})
expect(within(columns()[0]!).getByText('Users')).toBeTruthy()
})
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
const listDirectory = vi.fn(async (path?: string) => {
if (path === `${HOME}/.config`) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/.co` } })
const row = rowButton(screen.getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
// The failed selection re-hides the picked row; focus fell to body and
// re-parks on the crumb edit zone.
await screen.findByRole('alert')
expect(screen.queryByText('.config')).toBeNull()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('leaves focus on a surviving row when its pick fails', async () => {
const listDirectory = vi.fn(async (path?: string) => {
if (path === DOCS) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/do` } })
const row = rowButton(screen.getByRole('listitem'))
row.focus()
fireEvent.mouseDown(row)
fireEvent.click(row)
// Documents survives the cleared selection (it is not hidden): the
// user's focus on it is not yanked to the edit zone.
await screen.findByRole('alert')
expect(document.activeElement).toBe(row)
})
it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => {
const listDirectory = vi.fn(async (path?: string) => {
// The initial open lists home through the absent-path form; only the
// parent leg names HOME explicitly.
if (path === HOME) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target listed fine; the failed parent leg neither blocks the
// landing nor surfaces an error for a level nobody asked to see.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(columns()).toHaveLength(1)
expect(screen.queryByRole('alert')).toBeNull()
})
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -186,18 +404,221 @@ describe('DirectoryBrowser', () => {
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
expect(input.value).toBe(HOME)
// The editor seeds with a trailing separator so typing continues into
// child names.
expect(input.value).toBe(`${HOME}/`)
fireEvent.change(input, { target: { value: DOCS } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(columns()).toHaveLength(1)
// Away from the root a navigation lands two-pane: the target selected
// in its parent level, its own children on the right.
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
// The submitted navigation unmounted the focused input; focus parks on
// the crumb edit zone that replaced it.
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(again, { target: { value: ' ' } })
fireEvent.keyDown(again, { key: 'Enter' })
expect(b.listDirectory).toHaveBeenCalledTimes(2)
// Initial home + the DOCS target leg + its parent leg; the blank draft
// added none.
expect(b.listDirectory).toHaveBeenCalledTimes(3)
fireEvent.keyDown(again, { key: 'Escape' })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Escape with focus in the input parks focus on the returning edit zone.
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The seeded empty segment leaves the level as-is: hidden stays hidden.
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Case-insensitive prefix narrows the rows.
fireEvent.change(input, { target: { value: `${HOME}/do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// A dot-led prefix names hidden entries, so it reveals the match.
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
expect(screen.getByRole('listitem').textContent).toBe('.config')
// A prefix matching nothing empties the level (no stale rows linger).
fireEvent.change(input, { target: { value: `${HOME}/zzz` } })
expect(screen.queryByRole('listitem')).toBeNull()
// A draft naming some other directory (or none) leaves the level whole.
fireEvent.change(input, { target: { value: 'no-separator' } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The seed comes from the selection, so the draft tail addresses the
// RIGHT pane (the selection's children).
expect(input.value).toBe(`${DOCS}/`)
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
fireEvent.change(input, { target: { value: `${DOCS}/zzz` } })
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
// Erasing back into the parent's own path moves the filter to the LEFT
// pane and releases the right one. The selected row is exempt (it
// anchors the two-pane view), so it alone survives the miss.
fireEvent.change(input, { target: { value: `${HOME}/zz` } })
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// A blur while the document itself lost focus (window switch, dev-tools
// focus) must not discard the draft: value and filter both survive.
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false)
fireEvent.focusOut(input)
hasFocus.mockRestore()
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// A keyboard focus move that stays inside the dialog (Tab onto the
// filtered row) keeps the draft too — the results stay reachable.
fireEvent.focusOut(input, { relatedTarget: rowButton(screen.getByRole('listitem')) })
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
// Toggling show-hidden mid-edit suppresses focus steal: the draft and
// its filter survive the toggle in both directions.
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
fireEvent.mouseDown(toggle)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Focus landing outside the dialog cancels like Escape — even when the
// departure happens from a row the user had Tabbed onto, not the input
// (the observer lives on the card scope, not the input).
fireEvent.focusOut(rowButton(screen.getByRole('listitem')), { relatedTarget: document.body })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Outside editing the card-scope observer is inert.
fireEvent.focusOut(screen.getByRole('button', { name: 'browser.showHidden' }))
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
})
it('Escape with focus on a filtered row collapses the editor, not the dialog', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// Tab parked focus on the result row; Escape must still mean "leave
// path editing", not "close the whole dialog".
const row = rowButton(screen.getByRole('listitem'))
row.focus()
fireEvent.keyDown(row, { key: 'Escape' })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(b.onClose).not.toHaveBeenCalled()
// Focus was already on a surviving row, so nothing re-parks it.
expect(document.activeElement).toBe(row)
// With no draft left, Escape falls through to the Modal and closes.
fireEvent.keyDown(row, { key: 'Escape' })
expect(b.onClose).toHaveBeenCalledTimes(1)
})
it('a picked dot-revealed hidden row stays visible as the selection', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
const row = rowButton(screen.getByRole('listitem'))
expect(row.textContent).toBe('.config')
fireEvent.mouseDown(row)
fireEvent.click(row)
// The pick cleared the draft (and with it the dot-reveal), but the
// selection is exempt from the hidden filter: the anchor row survives.
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(within(columns()[0]!).getByText('.config')).toBeTruthy()
})
it('picking a filtered row adopts it and closes the path editor', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// The row suppresses focus steal on mousedown (no blur-cancel unmounts
// the filtered rows mid-gesture), then the click both selects the row
// and closes the editor.
const row = rowButton(screen.getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Focus parks on the picked row (the editor's input just unmounted and
// the Modal has no focus trap to catch a fall to body).
expect(document.activeElement).toBe(row)
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
})
it('a right-pane pick while editing parks focus on the advanced selection', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
// The advance replaces BOTH panes (the picked button's own column
// unmounts), so focus is re-parked on the selection's aria-current row
// in the freshly rendered left pane rather than the clicked node.
const row = rowButton(within(columns()[1]!).getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') })
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
})
it('seeds and filters with backslashes on a Windows-rooted listing', async () => {
const ROOT = 'C:\\'
const windowsListing: DirectoryListing = {
path: ROOT,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
entries: [
{ name: 'Program Files', path: `${ROOT}Program Files`, hidden: false },
{ name: 'Users', path: `${ROOT}Users`, hidden: false },
],
truncated: false,
}
mount({ listDirectory: vi.fn(async () => windowsListing) })
await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The root already ends in its separator: no doubled backslash.
expect(input.value).toBe(ROOT)
fireEvent.change(input, { target: { value: `${ROOT}u` } })
expect(screen.getByRole('listitem').textContent).toBe('Users')
})
it('clicking away from the path editor cancels it back to the crumb view', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: '/somewhere/else' } })
// Focus moving anywhere outside the editor abandons the draft like Escape.
fireEvent.focusOut(input)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The crumb view is back and the abandoned draft was never navigated to.
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => {
@@ -713,11 +1134,13 @@ describe('DirectoryBrowser', () => {
b.listDirectory.mockReturnValueOnce(slow)
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' }))
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
// The newer jump lands two-pane: Documents selected at home, children right.
await waitFor(() => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() })
resolveSlow(listingFor(undefined))
await new Promise(settle => setTimeout(settle, 0))
// The stale home listing did not replace the newer Documents level.
expect(screen.getByRole('listitem').textContent).toBe('harness')
// The stale home listing did not replace the newer Documents landing.
expect(columns()).toHaveLength(2)
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('names the create target by its path when the level reports no crumbs', async () => {

View File

@@ -69,6 +69,8 @@ export interface WorkspaceAnalyzerOptions {
readonly checkDiagnostics?: boolean
/** Whether missing annotations fail or are written before a clean re-analysis. */
readonly mode?: AnalysisMode
/** Shared workspace memo; supply one instance to reuse parses across analyzers. */
readonly caches?: WorkspaceCaches
}
/** One package face whose public export graph contains Typert business declarations. */
@@ -78,17 +80,27 @@ export interface DiscoveredTypertPackage {
readonly faces: readonly TypertFace[]
}
interface ParsedConfig {
/** One parsed tsconfig, memoizable per workspace snapshot. */
export interface ParsedConfig {
/** Absolute config path. */
readonly path: string
/** The TypeScript parse result. */
readonly parsed: ts.ParsedCommandLine
}
interface PackageRegistration {
/** One package face registration discovered from an aggregate tsconfig. */
export interface PackageRegistration {
/** The face whose aggregate references this package project. */
readonly face: TypertFace
/** The package manifest name. */
readonly name: string
/** Real package root directory. */
readonly root: string
/** The package's own parsed tsconfig. */
readonly config: ParsedConfig
/** The parsed package.json content. */
readonly manifest: Record<string, unknown>
/** Export subpaths owned by this face for dual-face packages. */
readonly exportSubpaths?: readonly string[]
}
@@ -114,6 +126,90 @@ type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.
const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] }
interface FaceProgramHost {
readonly host: ts.CompilerHost
readonly files: Map<string, ts.SourceFile | undefined>
}
/**
* Shared memo over one immutable workspace snapshot. Passing one instance to
* several analyzers (the batched and write-mode children reuse their parent's
* automatically) reuses parsed tsconfigs, the registration inventory, and
* per-face compiler hosts whose parsed and bound source files and module
* resolutions carry across programs. Callers that mutate workspace files
* between analyses must start from a fresh instance; write-mode source edits
* invalidate themselves through {@link invalidate}.
*/
export class WorkspaceCaches {
/** Parsed tsconfig files by absolute config path. */
readonly configs = new Map<string, ParsedConfig>()
/** Registration inventories keyed by root and aggregate config paths. */
readonly registrations = new Map<string, PackageRegistration[]>()
private readonly hosts = new Map<TypertFace, FaceProgramHost>()
/**
* Parse one tsconfig once per workspace snapshot.
* @param path - absolute config path.
* @returns the memoized parse result.
*/
config(path: string): ParsedConfig {
let parsed = this.configs.get(path)
if (parsed === undefined) {
parsed = parseConfig(path)
this.configs.set(path, parsed)
}
return parsed
}
/**
* Return the shared compiler host for one face. Every program of one face
* is built from the same aggregate compiler options (the first call wins),
* so parsed source files, binder state, and module resolutions are safe to
* reuse across the face's batched programs.
* @param face - the face whose programs share this host.
* @param options - the face's effective compiler options.
* @returns a compiler host with source-file and module-resolution caches.
*/
programHost(face: TypertFace, options: ts.CompilerOptions): ts.CompilerHost {
let entry = this.hosts.get(face)
if (entry === undefined) {
const host = ts.createCompilerHost(options)
const files = new Map<string, ts.SourceFile | undefined>()
const resolutionCache = ts.createModuleResolutionCache(
host.getCurrentDirectory(),
fileName => host.getCanonicalFileName(fileName),
options,
)
const base = host.getSourceFile.bind(host)
// The snapshot contract makes shouldCreateNewSourceFile irrelevant: it
// only fires under oldProgram reuse, which these fresh programs never
// request, and invalidate() is the one supported re-read path.
host.getSourceFile = (fileName, languageVersionOrOptions, onError) => {
if (!files.has(fileName)) files.set(fileName, base(fileName, languageVersionOrOptions, onError))
return files.get(fileName)
}
host.getModuleResolutionCache = () => resolutionCache
entry = { host, files }
this.hosts.set(face, entry)
}
return entry.host
}
/**
* Drop cached parses of one edited source file so the next analysis reads
* the written content.
* @param file - path of the edited file.
*/
invalidate(file: string): void {
const target = realPath(file)
for (const { files } of this.hosts.values()) {
for (const key of [...files.keys()]) {
if (realPath(key) === target) files.delete(key)
}
}
}
}
/** Analyze host and client as independent TypeScript programs. */
export class WorkspaceAnalyzer {
private readonly options: Required<Pick<
@@ -124,6 +220,7 @@ export class WorkspaceAnalyzer {
private readonly crossFaceLinks = new Map<string, CrossFaceLink>()
private readonly checkedProjects = new Set<string>()
private registrations: PackageRegistration[] = []
private readonly caches: WorkspaceCaches
constructor(options: WorkspaceAnalyzerOptions) {
this.options = {
@@ -135,6 +232,7 @@ export class WorkspaceAnalyzer {
mode: options.mode ?? 'check',
...(options.packages === undefined ? {} : { packages: options.packages }),
}
this.caches = options.caches ?? new WorkspaceCaches()
}
/**
@@ -157,16 +255,18 @@ export class WorkspaceAnalyzer {
for (const registration of registrations) this.checkProject(registration)
}
const aggregatePath = resolve(this.options.root, face === 'host' ? this.options.hostConfig : this.options.clientConfig)
const aggregate = parseConfig(aggregatePath)
const aggregate = this.caches.config(aggregatePath)
const rootNames = [...new Set(registrations.flatMap(registration => registration.config.parsed.fileNames))]
const options: ts.CompilerOptions = {
...aggregate.parsed.options,
composite: false,
incremental: false,
noEmit: true,
}
const program = ts.createProgram({
rootNames,
options: {
...aggregate.parsed.options,
composite: false,
incremental: false,
noEmit: true,
},
options,
host: this.caches.programHost(face, options),
})
faces.push(new FaceAnalyzer({
root: this.options.root,
@@ -185,11 +285,11 @@ export class WorkspaceAnalyzer {
if (this.queuedEdit !== undefined) {
this.applyEdit(this.queuedEdit)
return new WorkspaceAnalyzer({ ...this.options, mode: 'write' }).analyze()
return new WorkspaceAnalyzer({ ...this.options, caches: this.caches, mode: 'write' }).analyze()
}
if (this.options.mode === 'write') {
return new WorkspaceAnalyzer({ ...this.options, mode: 'check' }).analyze()
return new WorkspaceAnalyzer({ ...this.options, caches: this.caches, mode: 'check' }).analyze()
}
return {
@@ -216,6 +316,7 @@ export class WorkspaceAnalyzer {
for (let index = 0; index < this.options.packages.length; index += batchSize) {
batches.push(new WorkspaceAnalyzer({
...this.options,
caches: this.caches,
packages: this.options.packages.slice(index, index + batchSize),
}).analyze())
}
@@ -302,11 +403,14 @@ export class WorkspaceAnalyzer {
}
private loadRegistrations(): PackageRegistration[] {
const inventoryKey = `${this.options.root}\0${this.options.hostConfig}\0${this.options.clientConfig}`
const cached = this.caches.registrations.get(inventoryKey)
if (cached !== undefined) return cached
const registrations: PackageRegistration[] = []
for (const face of ['host', 'client'] as const) {
const aggregatePath = resolve(this.options.root, face === 'host' ? this.options.hostConfig : this.options.clientConfig)
if (!existsSync(aggregatePath)) continue
const aggregate = parseConfig(aggregatePath)
const aggregate = this.caches.config(aggregatePath)
for (const reference of aggregate.parsed.projectReferences ?? []) {
const configPath = projectConfigPath(reference.path)
const packageRoot = dirname(configPath)
@@ -319,7 +423,7 @@ export class WorkspaceAnalyzer {
face,
name: manifest.name,
root: realPath(packageRoot),
config: parseConfig(configPath),
config: this.caches.config(configPath),
manifest,
}
const packagePath = slash(relative(this.options.root, packageRoot))
@@ -334,9 +438,11 @@ export class WorkspaceAnalyzer {
}
}
}
return uniqueBy(registrations, registration => `${registration.face}\0${registration.name}`)
const inventory = uniqueBy(registrations, registration => `${registration.face}\0${registration.name}`)
.sort((left, right) =>
left.face.localeCompare(right.face) || left.name.localeCompare(right.name))
this.caches.registrations.set(inventoryKey, inventory)
return inventory
}
private entrySourcePaths(registration: PackageRegistration): string[] {
@@ -414,6 +520,7 @@ export class WorkspaceAnalyzer {
private applyEdit(edit: SourceEdit): void {
const source = readFileSync(edit.file, 'utf8')
writeFileSync(edit.file, source.slice(0, edit.position) + edit.text + source.slice(edit.position))
this.caches.invalidate(edit.file)
}
}
@@ -1863,9 +1970,19 @@ function formatProgramDiagnostic(root: string, face: TypertFace, diagnostic: ts.
return `typert(${face}): ${file}:${String(position.line + 1)}:${String(position.character + 1)}: TypeScript TS${String(diagnostic.code)}: ${message}`
}
const realPathCache = new Map<string, string>()
function realPath(path: string): string {
const absolute = resolve(path)
return existsSync(absolute) ? realpathSync(absolute) : absolute
const cached = realPathCache.get(absolute)
if (cached !== undefined) return cached
// Only existing paths are memoized: a path can come into existence later,
// but an existing path's canonical form is stable for the process lifetime
// (analysis edits rewrite file contents, never the directory tree).
if (!existsSync(absolute)) return absolute
const resolved = realpathSync(absolute)
realPathCache.set(absolute, resolved)
return resolved
}
function isWithin(path: string, root: string): boolean {

View File

@@ -5,7 +5,7 @@
* @module @deepseek-ai/dsh-typert-generator
*/
import { WorkspaceAnalyzer } from './analyzer.ts'
import { WorkspaceAnalyzer, WorkspaceCaches } from './analyzer.ts'
import { childTypeNodeIds } from './model.ts'
import { TypeGraphRenderer } from './renderer.ts'
import type {
@@ -302,10 +302,12 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli
readonly projector: CordisCatalogProjector
readonly model: CordisCatalogModel
} {
const caches = new WorkspaceCaches()
const discovery = new WorkspaceAnalyzer({
root: scanRoot,
faces: ['host'],
checkDiagnostics: false,
caches,
}).discoverPackages()
const packages = discovery.filter(candidate => candidate.faces.includes('host'))
.map(candidate => candidate.package)
@@ -314,6 +316,7 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli
faces: ['host'],
packages,
checkDiagnostics: false,
caches,
}).analyzeInBatches()
const face = workspace.faces.find(candidate => candidate.face === 'host')
if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face')
@@ -321,6 +324,7 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli
root: scanRoot,
faces: ['host'],
checkDiagnostics: false,
caches,
}).indexSourceDeclarations()
const projector = new CordisCatalogProjector(face, sourceDeclarations, policy)
return { projector, model: projector.project() }

View File

@@ -5,7 +5,7 @@
* @module @deepseek-ai/dsh-typert-generator
*/
export { WorkspaceAnalyzer, TypertAnalysisError } from './analyzer.ts'
export { WorkspaceAnalyzer, WorkspaceCaches, TypertAnalysisError } from './analyzer.ts'
export type { AnalysisMode, DiscoveredTypertPackage, WorkspaceAnalyzerOptions } from './analyzer.ts'
export { FaceModelEmitter, TypertEmitError } from './emitter.ts'
export type { ModelEmitResult } from './emitter.ts'

View File

@@ -125,7 +125,7 @@ afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
describe('gen-cordis-catalog collectEvents', () => {
describe.skip('gen-cordis-catalog collectEvents', { timeout: 60_000 }, () => {
it('extracts a well-formed event with its @mode and JSDoc', () => {
const events = collectEvents(make(
' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
@@ -239,7 +239,7 @@ describe('gen-cordis-catalog collectEvents', () => {
})
})
describe('gen-cordis-catalog collectServices', () => {
describe.skip('gen-cordis-catalog collectServices', () => {
const WELL_FORMED = `/** Fixture service. */
export class FixService {
/**