mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
Merge origin/master into fix/mcp-pagination-cycles
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md
|
||||
2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba
|
||||
2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Keep the room reading independent of hidden split controls
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in.
|
||||
|
||||
## Decision
|
||||
|
||||
When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split.
|
||||
|
||||
**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize.
|
||||
|
||||
**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading.
|
||||
|
||||
## Consequences
|
||||
|
||||
The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Keep the room reading independent of hidden split controls
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。
|
||||
|
||||
## Decision
|
||||
|
||||
当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。
|
||||
|
||||
**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。
|
||||
|
||||
**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。
|
||||
|
||||
## Consequences
|
||||
|
||||
空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。
|
||||
+2
-2
@@ -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 .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
|
||||
2026-09-04-right-sidebar-docking-infrastructure.md: 3600a17fdd0659c4f235922fa1bc905f03f5841e
|
||||
2026-09-04-right-sidebar-docking-infrastructure.zh.md: 2a9ddfb7ec1fa34cefe434692829939767a6b710
|
||||
2026-09-04-right-sidebar-docking-infrastructure.md: 3f6bcff565598c4d8a24e374d308c388c1f6bb0a
|
||||
2026-09-04-right-sidebar-docking-infrastructure.zh.md: aea7aacc4cb50b8974ada4a7e7eec27ed41623b9
|
||||
|
||||
+4
-4
@@ -37,9 +37,9 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes;
|
||||
|
||||
### State
|
||||
|
||||
[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes explicit last-tab closing and default-guide reseeding here; moving tabs still settles emptied panes.
|
||||
[Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes.
|
||||
|
||||
`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the guide tab is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log.
|
||||
`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the current default page is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log.
|
||||
|
||||
### Beyond the surface
|
||||
|
||||
@@ -69,7 +69,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind
|
||||
|
||||
**Undo and redo buttons on the panel header.** Shipped first, then removed: the sequence is an architectural fact, and stepping it is not a product action yet. The API stays reachable as `@internal` methods for tests and the future navigation controller.
|
||||
|
||||
**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent now settles the surface so an emptied pane is merged away and an emptied root pane reseeds the guide.
|
||||
**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent settles the surface so an emptied pane is merged away and an emptied root pane reseeds the current default page.
|
||||
|
||||
**Inline the kit through `packages/util` and the `INLINE_SAFE` list.** A build probe showed it works, but the util build chain has no CSS pipeline and the kit ships a stylesheet; the static-linked client package (the `ui-primitives` precedent) was chosen knowing that changing the kit means rebuilding the shell and reloading.
|
||||
|
||||
@@ -77,7 +77,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind
|
||||
|
||||
- The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split.
|
||||
- Layout is undoable and per session, and it is memory-only; a reload starts every session collapsed. Undo is reachable only through `@internal` service methods; the product shows no history controls.
|
||||
- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane brings the guide back.
|
||||
- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane restores the current default page.
|
||||
- A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide.
|
||||
- A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement.
|
||||
- The Sidebar panel never moves when the presentation switches, and its slide is the same in both presentations; the conversation is the only thing that animates on a switch. A hidden panel keeps its tabs mounted, so a preview survives a collapse.
|
||||
|
||||
+4
-4
@@ -37,9 +37,9 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
|
||||
|
||||
### 状态
|
||||
|
||||
[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代此处的显式关闭最后一个 tab 和默认补入引导页;移动 tab 仍会处理被清空的格。
|
||||
[默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。
|
||||
|
||||
`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上引导 tab——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。
|
||||
`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上当前默认页——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。
|
||||
|
||||
### 面之外
|
||||
|
||||
@@ -69,7 +69,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
|
||||
|
||||
**面板头部的 undo 与 redo 按钮。** 先上后撤:序列是架构事实,步进它现在还不是产品动作。API 以 `@internal` 方法保留给测试与将来的导航控制器。
|
||||
|
||||
**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;现在每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上引导。
|
||||
**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上当前默认页。
|
||||
|
||||
**经 `packages/util` 与 `INLINE_SAFE` 清单内联库。** 构建探针证明可行,但 util 构建链没有 CSS 管线而库带样式表;在知晓改库须重建壳并刷新页面的前提下,选择静态链接的 client 包(`ui-primitives` 先例)。
|
||||
|
||||
@@ -77,7 +77,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
|
||||
|
||||
- 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。
|
||||
- 布局可撤销且按会话隔离,同时仅在内存;刷新使所有会话回到折叠态。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。
|
||||
- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会让引导回来。
|
||||
- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会恢复当前默认页。
|
||||
- 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。
|
||||
- pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。
|
||||
- 切换呈现模式时 Sidebar 面板一动不动,两种模式的平移一模一样;切换时只有会话区在动。隐藏的面板保持 tab 挂载,预览在折叠后仍在。
|
||||
|
||||
@@ -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 .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md
|
||||
2026-09-08-sidebar-default-pages.md: c78d6a3af2c88c61ee5ba8fe4319d9b3f59ae7b4
|
||||
2026-09-08-sidebar-default-pages.zh.md: 8e0a081cb25862bffc20fdcac2b97c57b0080c36
|
||||
2026-09-08-sidebar-default-pages.md: 13374ab294cab74b54ada8550a6dff5af2cd9cd1
|
||||
2026-09-08-sidebar-default-pages.zh.md: 6730a87fbe9c59ef6d448fe0f2d4c3c2cb75e6bb
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Sidebar default pages and close protection
|
||||
# Agent Note: Sidebar default pages
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,13 +6,13 @@ English | [中文](2026-09-08-sidebar-default-pages.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A guide with one registered entry adds a click without offering a choice. Hiding only the last tab's close button would let an added guide make the default file browser closable again.
|
||||
A guide with one registered entry adds a click without offering a choice.
|
||||
|
||||
## Decision
|
||||
|
||||
The Sidebar selects each default page from the registered guide-entry list. Exactly one entry opens that entry's page; zero or multiple entries open the guide. Resource viewers without guide entries do not affect this count. Explicitly adding a guide always opens a guide, and each pane holds at most one.
|
||||
|
||||
A single-entry default is protected from explicit close for its record lifetime. Every pane's final tab is also protected; other tabs can close. The Sidebar stores protected record IDs and shares one close predicate between its store actions and docking controls. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only.
|
||||
The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) owns close protection: the sole docked guide remains open, while any other sole tab closes together with the column. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only.
|
||||
|
||||
This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-text-preview-and-file-tree.md) and explicit last-tab closing in [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, content-state, engine and layout ownership decisions remain active.
|
||||
|
||||
@@ -20,8 +20,6 @@ This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-
|
||||
|
||||
**Count all registered tab types or currently open tabs.** Neither counts choices available on the guide; resource viewers need not contribute an entry.
|
||||
|
||||
**Protect only the final tab.** Adding a guide would expose a close control on the single-entry default, violating its retained-entry behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab, while ordinary close cannot empty a pane. Store and component tests cover registration counts and close protection; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing the guide.
|
||||
One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab. Store and component tests cover registration counts; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing a lone tab.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Sidebar 默认页与关闭保护
|
||||
# Agent Note: Sidebar 默认页
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
只有一个注册入口的引导页增加一次点击,却不提供选择。若只隐藏最后一个 tab 的关闭按钮,新增引导页后,默认文件浏览页又会变得可关闭。
|
||||
只有一个注册入口的引导页增加一次点击,却不提供选择。
|
||||
|
||||
## 决策
|
||||
|
||||
Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入口时打开对应页面;没有入口或有多个入口时打开引导页。没有引导入口的资源查看器不影响计数。显式添加引导页始终打开引导,每个格最多持有一个。
|
||||
|
||||
单入口默认页在记录生命周期内受到显式关闭保护。每个格的最后一个 tab 也受保护;其他 tab 可以关闭。Sidebar 保存受保护的记录 ID,store 动作与停靠控件共享一个关闭判定。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。
|
||||
[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责关闭保护:作为唯一停靠 tab 的引导页保持打开,其他任何唯一 tab 都会连同整列一起关闭。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。
|
||||
|
||||
本决策取代[随包类型](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的默认引导选择,以及[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的显式关闭最后一个 tab。它们的注册、内容状态、引擎与布局所有权决策继续有效。
|
||||
|
||||
@@ -20,8 +20,6 @@ Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入
|
||||
|
||||
**统计所有已注册 tab 类型或已打开的 tab。** 两者都不代表引导页提供的选择;资源查看器不一定贡献入口。
|
||||
|
||||
**只保护最后一个 tab。** 新增引导页后,单入口默认页会出现关闭控件,违反保留该入口的行为要求。
|
||||
|
||||
## 后果
|
||||
|
||||
单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab,普通关闭则不能清空一个格。store 与组件测试覆盖注册数量和关闭保护;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭引导后返回 Files。
|
||||
单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab。store 与组件测试覆盖注册数量;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭唯一 tab 后返回 Files。
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md
|
||||
2026-09-08-comment-only-review-routing.md: 050905285b2291b34da9873d19c2f122c088a9e5
|
||||
2026-09-08-comment-only-review-routing.zh.md: b98f5d70b4d5c0fd27df1c393238b0802e420e09
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md
|
||||
2026-09-08-sidebar-last-tab-close-rules.md: 4fb0e54af1075dd595d18db4d74779a7cfd2656b
|
||||
2026-09-08-sidebar-last-tab-close-rules.zh.md: 87cadf0f15eecb0cadc2fb2dacc5695771fd66f7
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Last-tab close rules on the Sidebar's docked surface
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-08-sidebar-last-tab-close-rules.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The settle planner guarantees the docked surface is never empty: closing the last tab reseeds the current default page. That guarantee made the last tab's close control a dead end in both directions. Closing the guide standing alone put the same guide straight back — a control that does nothing. Closing any other lone tab left the user with a column showing only the default page — after "close the last thing", an expanded panel with nothing in it is not what the gesture meant. The guide's chip also drew a hover capsule and a context menu whose only item was that no-op close.
|
||||
|
||||
## Decision
|
||||
|
||||
The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and lets the settle planner prepare the current default page for the next expansion. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active.
|
||||
|
||||
Two kit-side presentation rules complete it in [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) and [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx): a pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it; and a menu that would hold no item at all produces no visible popup, so a secondary press on such a chip shows nothing rather than an empty box.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the guide closable and let settle reseed it.** The visible result is a close control that does nothing; the control lies about what a press will do.
|
||||
|
||||
**Hide the close in the Sidebar's renderer instead of a kit prop.** The kit draws the chip's close and the menu's close item, so the embedder cannot withhold them without a seam; a CSS override would leave the menu item live and split one decision across two owners.
|
||||
|
||||
**Collapse the column from the kit when the last tab closes.** The kit has no concept of the column or its expansion; the collapse is the embedder's intent, recorded by the store alongside the close in the same entry.
|
||||
|
||||
## Consequences
|
||||
|
||||
`canCloseTab` is a third control-policy prop every embedder may set; leaving it out keeps every tab closable. The quiet-chip and empty-menu rules are unconditional kit behavior keyed on the same policy, so any embedder withholding a lone tab's close gets the same presentation. Reopening the column after a lone-tab close shows the default page selected from the current guide entries. Kit specs cover the withheld control, the quiet chip, and the self-dismissing menu; Sidebar unit specs cover `closeTab`'s refusal and the close-with-column entry; a [browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) walks the whole rule on the rendered panel.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note:Sidebar 停靠面最后一个 tab 的关闭规则
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-08-sidebar-last-tab-close-rules.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
settle planner 保证停靠面永不为空:关掉最后一个 tab 会重新播种当前默认页。这条保证让最后一个 tab 的关闭控件在两个方向上都走进死胡同。独自留下的引导页被关闭后,同一个引导页立刻回来——一个什么也不做的控件。任何其它 tab 独自留下时被关闭,用户面前只剩一列只显示默认页的面板——在「关掉最后一个东西」之后,一块展开着却空无内容的面板不是这个手势的本意。引导页的 chip 还画着悬停胶囊,右键菜单里唯一的条目就是那个无效的关闭。
|
||||
|
||||
## 决定
|
||||
|
||||
停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,于是 settle planner 为下次展开准备当前默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。
|
||||
|
||||
两条套件侧的呈现规则在 [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) 与 [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx) 里补全它:某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事;一个连一项都没有的菜单不会产生可见弹层,于是对这样的 chip 次键按下什么都不显示,而不是画一个空框。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**让引导页保持可关闭,由 settle 重新播种。** 可见的结果是一个什么也不做的关闭控件;这个控件在按下会发生什么这件事上撒谎。
|
||||
|
||||
**在 Sidebar 的渲染器里藏掉关闭,而不加套件 prop。** chip 的关闭控件与菜单的关闭项都由套件绘制,没有接缝嵌入方就无法收起它们;CSS 覆盖会留下仍然生效的菜单项,把一个决定拆给两个所有者。
|
||||
|
||||
**由套件在最后一个 tab 关闭时收起整列。** 套件没有「列」或「展开」的概念;收起是嵌入方的意图,由 store 在同一条历史里与关闭一并记录。
|
||||
|
||||
## 后果
|
||||
|
||||
`canCloseTab` 成为每个嵌入方都可设置的第三个控制策略 prop;不设置时每个 tab 都可关闭。安静 chip 与空菜单两条规则是套件的无条件行为,键在同一策略上,任何收起了独 tab 关闭的嵌入方都得到同样的呈现。独 tab 关闭后重新展开的列显示根据当前引导入口选出的默认页。套件 spec 覆盖收起的控件、安静 chip 与自行消失的菜单;Sidebar 单元 spec 覆盖 `closeTab` 的拒绝与「关闭连带整列」的历史条目;一个[浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts)在渲染出的面板上走完整条规则。
|
||||
@@ -1,41 +0,0 @@
|
||||
# Agent Note: Exclude documentation and comment-only changes from review routing
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-08-comment-only-review-routing.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Directory ownership alone treats documentation and comment edits like executable changes. These edits do not require the automatic code-owner request that protects behavior changes.
|
||||
|
||||
GitHub may omit or truncate a file patch. A scanner that assumes every patch is complete can miss executable changes that occur outside the supplied hunks.
|
||||
|
||||
## Decision
|
||||
|
||||
Review routing classifies every old and new path in this order: test, documentation, comment-only, then reviewable code. Test classification wins when a test path also has a documentation extension. Every filename ending in `.md` or `.yaml`, matched without case sensitivity, is documentation. A `.yml` file is not documentation under this rule.
|
||||
|
||||
Comment-only classification applies only to files with `status: modified` and a declared source-comment syntax. The scanner reconstructs the before and after text for each patch hunk, removes comments outside quoted strings, removes empty lines left by comments, and requires the remaining text to be identical.
|
||||
|
||||
The scanner counts added and deleted patch lines and compares them with GitHub's file record before accepting a comment-only result. A missing patch, a count mismatch, a rename, an unsupported extension, or a comment form that remains visible to the lexer keeps the file reviewable. This fail-safe result can request an unnecessary review but cannot suppress a known code change.
|
||||
|
||||
The supported lexical rules cover C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for an explicit extension set in the scanner. Comment directives such as JSDoc tags, lint controls, compiler controls, and coverage controls are comments for routing purposes.
|
||||
|
||||
## Verification
|
||||
|
||||
[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover documentation extensions, supported comment forms, quoted comment markers, executable token changes, incomplete patches, renames, unsupported extensions, exclusion precedence, and the no-request result when every file is excluded.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep every non-test file reviewable.** This requests code owners for documentation and comment maintenance even though the routing policy is intended to identify executable changes.
|
||||
|
||||
**Infer arbitrary semantic equivalence.** Proving behavior equivalence across the repository's languages requires language toolchains and still cannot assign one stable meaning to generated files, configuration, or build directives. The scanner performs only lexical comment removal.
|
||||
|
||||
**Trust every patch returned by GitHub.** GitHub can omit or truncate patches. Matching the patch's added and deleted line counts to the file record prevents a partial patch from producing a comment-only verdict.
|
||||
|
||||
**Fetch and parse every complete file revision.** Per-file content requests multiply API traffic for large pull requests and still require the same language-specific parsing. The changed-file response already carries enough evidence for complete ordinary patches.
|
||||
|
||||
## Consequences
|
||||
|
||||
Documentation and proven comment-only changes request nobody. The workflow logs them separately from tests so maintainers can audit why owner matching ignored a file.
|
||||
|
||||
Unsupported or incomplete inputs remain reviewable. Comment directives do not request owners even when another tool interprets them, because this policy classifies their lexical form rather than downstream tool behavior.
|
||||
@@ -1,41 +0,0 @@
|
||||
# Agent Note: 从评审路由中排除文档和纯注释变更
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-08-comment-only-review-routing.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
只按目录分配 owner 会把文档和注释编辑视为可执行变更。这些编辑不需要用于保护行为变更的自动代码 owner 请求。
|
||||
|
||||
GitHub 可能省略或截断文件 patch。如果扫描器假定每个 patch 都完整,就可能漏掉位于已提供 hunk 之外的可执行变更。
|
||||
|
||||
## 决策
|
||||
|
||||
评审路由按测试、文档、纯注释、可评审代码的顺序对每个新旧路径分类。当测试路径同时具有文档扩展名时,测试分类优先。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,扩展名匹配不区分大小写;此规则不把 `.yml` 文件视为文档。
|
||||
|
||||
纯注释分类只适用于 `status: modified` 且已声明源码注释语法的文件。扫描器重建每个 patch hunk 的变更前后文本,移除引号字符串外的注释和注释留下的空行,并要求其余文本完全相同。
|
||||
|
||||
扫描器会统计 patch 的新增行和删除行,并在接受纯注释结果前与 GitHub 文件记录比较。缺失 patch、计数不符、重命名、不受支持的扩展名,或词法分析器仍能看到的注释形式都会使文件保持可评审状态。该保守结果可能产生不必要的评审请求,但不会隐藏已知代码变更。
|
||||
|
||||
受支持的词法规则按扫描器中显式的扩展名集合覆盖 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释及 HTML 注释。JSDoc 标签、lint 控制、编译器控制和覆盖率控制等注释指令在评审路由中仍属于注释。
|
||||
|
||||
## 验证
|
||||
|
||||
[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖文档扩展名、受支持的注释形式、引号内的注释标记、可执行 token 变更、不完整 patch、重命名、不受支持的扩展名、排除优先级,以及所有文件均被排除时不发出请求的结果。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**让每个非测试文件都保持可评审。** 这会为文档和注释维护请求代码 owner,但该路由策略的目标是识别可执行变更。
|
||||
|
||||
**推断任意语义等价。** 证明仓库中多种语言的行为等价需要各语言工具链,而且仍然无法为生成文件、配置或构建指令提供一种稳定含义。扫描器只执行词法注释移除。
|
||||
|
||||
**信任 GitHub 返回的每个 patch。** GitHub 可能省略或截断 patch。将 patch 的新增和删除行数与文件记录匹配,可以防止不完整 patch 产生纯注释结论。
|
||||
|
||||
**获取并解析每个文件的完整修订版本。** 对于大型 PR,逐文件内容请求会增加多倍 API 流量,而且仍需相同的语言专用解析。普通完整 patch 所需的证据已包含在变更文件响应中。
|
||||
|
||||
## 后果
|
||||
|
||||
文档和确认的纯注释变更不会请求任何人。Workflow 会将它们与测试分开记录,以便维护者检查 owner 匹配忽略文件的原因。
|
||||
|
||||
不受支持或不完整的输入仍需评审。即使其他工具会解释注释指令,这些指令也不会请求 owner,因为该策略按词法形式分类,而不是按下游工具行为分类。
|
||||
@@ -1,55 +0,0 @@
|
||||
# Agent Note: Route reviews from trusted changed-file policy
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision.
|
||||
|
||||
Review routing needs an observable changed-file input, explicit owner rules, complete test exclusions, and a write-capable workflow that remains safe for pull requests from forks.
|
||||
|
||||
## Decision
|
||||
|
||||
The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS), outside GitHub's native CODEOWNERS locations. The map accepts only explicit absolute directory patterns with one or two individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, more than two owners, duplicate patterns, and duplicate owners. Later matching patterns replace earlier matches.
|
||||
|
||||
The policy test counts non-test tracked lines in directories that match an ownership rule. It rejects a map in which `@turtle1999` owns more than one third of that eligible owned codebase.
|
||||
|
||||
The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on `pull_request_target` events for opened, synchronized, reopened, ready-for-review, and converted-to-draft pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets.
|
||||
|
||||
The scanner fetches every changed-file record before deciding. It fails if the pull request reports more than GitHub's 3,000-file API limit or if pagination returns an incomplete list. It normalizes repository paths, evaluates old and new paths of a rename independently, and escapes filenames before logging them.
|
||||
|
||||
The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.<ext>`, `.corpus.<ext>`, `.e2e.<ext>`, `.perf.<ext>`, `.snapshot.<ext>`, `.spec.<ext>`, `.stress.<ext>`, or `.test.<ext>`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions.
|
||||
|
||||
The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, approved owners omitted from new requests, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it fetches the complete chronological review list and reduces each owner's undismissed `APPROVED` and `CHANGES_REQUESTED` reviews to the latest decisive state; `COMMENTED` and `PENDING` reviews leave that state unchanged. It removes the pull-request author, owners with an active approval, and users who remain requested from the matched individual owners. An active approval remains sufficient after later synchronize events, while a later changes-requested review makes the owner eligible again. The review-list operation fails before mutation at 3,000 entries or on an invalid record.
|
||||
|
||||
The workflow keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores.
|
||||
|
||||
When current review requests exist, the workflow reads the complete review-request timeline before mutation. A current reviewer is workflow-authored only when its latest matching `review_requested` event identifies `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. A non-draft run cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit; current relevance order selects which matching workflow reviewer remains. Planned cancellations release capacity before the workflow selects a new reviewer. A draft run cancels every current workflow-authored request. Requests made by people remain unchanged. An attributable event with invalid provenance and timelines above 3,000 events fail before mutation.
|
||||
|
||||
## Verification
|
||||
|
||||
[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, file and review limits, approval-state reduction, approved-owner suppression and next-owner selection, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use native CODEOWNERS.** Native routing cannot ignore test-only changes and offers no repository-owned decision log before requesting reviewers.
|
||||
|
||||
**Run under `pull_request` and check out the pull-request head.** A fork workflow does not receive a write-capable token, while granting a write token to code from an untrusted head is unsafe.
|
||||
|
||||
**Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners.
|
||||
|
||||
**Select capped candidates by login order.** Login order is stable but ignores how much reviewable code changed under each owner's directories. Changed LOC makes the limited requests follow the pull request's strongest ownership relevance while retaining login order for ties.
|
||||
|
||||
**Cancel every reviewer that no longer matches.** A person may request a reviewer for reasons outside the ownership map. Only requests attributed to the workflow identity are safe for automated reconciliation.
|
||||
|
||||
**Treat an empty current request as an owner who still needs review.** GitHub removes the pending request when the reviewer submits a review. Requesting an owner with an active approval again adds no ownership coverage and creates repeated notifications after later synchronize events.
|
||||
|
||||
**Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch.
|
||||
|
||||
## Consequences
|
||||
|
||||
Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, rule and changed-file updates remove obsolete workflow-authored requests on the next run, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
|
||||
|
||||
The workflow requests at most one reviewer per run, does not repeat a request while that owner has an active approval, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
|
||||
|
||||
Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file, review, or timeline API limit fail without applying a partial reviewer mutation.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Custom static-scanner input. Its nested path keeps GitHub from loading it as
|
||||
# the repository's native CODEOWNERS file.
|
||||
/apps/cli/ @turtle1999
|
||||
/apps/web/ @imccyu
|
||||
/docs/ @turtle1999
|
||||
/native/ @mektpoy
|
||||
/patches/ @mektpoy
|
||||
/python/ @LegGasai
|
||||
/vendor/ @turtle1999
|
||||
/website/ @LegGasai
|
||||
/packages/acp/ @mektpoy
|
||||
/packages/api/ @imccyu
|
||||
/packages/attachment/ @CreatixChu
|
||||
/packages/boot/ @turtle1999
|
||||
/packages/bundle/ @turtle1999
|
||||
/packages/client/ @imccyu
|
||||
/packages/code-runtime/ @Chinesezjc
|
||||
/packages/compaction/ @imccyu
|
||||
/packages/context/ @turtle1999
|
||||
/packages/core/ @turtle1999 @mektpoy
|
||||
/packages/credentials/ @mektpoy
|
||||
/packages/e2b/ @mektpoy
|
||||
/packages/experimental/ @mektpoy
|
||||
/packages/extensions/ @mektpoy
|
||||
/packages/feedback/ @mektpoy
|
||||
/packages/fs/ @mektpoy
|
||||
/packages/goal/ @mektpoy
|
||||
/packages/guard/ @turtle1999
|
||||
/packages/hooks/ @mektpoy
|
||||
/packages/host/ @turtle1999
|
||||
/packages/identity/ @imccyu
|
||||
/packages/interaction/ @imccyu
|
||||
/packages/jobs/ @imccyu
|
||||
/packages/llm/ @LegGasai
|
||||
/packages/lsp/ @mektpoy
|
||||
/packages/mcp/ @mektpoy
|
||||
/packages/plan/ @mektpoy
|
||||
/packages/preset/ @LegGasai @turtle1999
|
||||
/packages/runtime-diagnostics/ @mektpoy
|
||||
/packages/sandbox/ @mektpoy
|
||||
/packages/schedule/ @imccyu
|
||||
/packages/sdk/ @mektpoy
|
||||
/packages/session/ @turtle1999 @mektpoy
|
||||
/packages/session-query/ @mektpoy
|
||||
/packages/settings/ @mektpoy
|
||||
/packages/shell/ @mektpoy
|
||||
/packages/skill/ @mektpoy
|
||||
/packages/spill/ @mektpoy
|
||||
/packages/storage/ @imccyu
|
||||
/packages/subagent/ @Dudu-0223
|
||||
/packages/subprocess/ @mektpoy
|
||||
/packages/terminal/ @imccyu
|
||||
/packages/todo/ @mektpoy
|
||||
/packages/typert/ @imccyu
|
||||
/packages/util/ @mektpoy
|
||||
/packages/web/ @imccyu
|
||||
/packages/webhook/ @mektpoy
|
||||
/packages/workflow/ @mektpoy
|
||||
/packages/workspace/ @imccyu
|
||||
@@ -1,34 +1,16 @@
|
||||
# Automated pull-request reviews
|
||||
# Pull-request approval policy
|
||||
|
||||
## Summary
|
||||
|
||||
The [`request-review` workflow](../workflows/request-review.yml) requests owners for reviewable code. The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Both write-capable workflows execute policy from the trusted default branch.
|
||||
The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Reviewer selection and review requests remain manual.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Routing](#routing)
|
||||
- [Approval scoring](#approval-scoring)
|
||||
- [Review exclusions](#review-exclusions)
|
||||
- [Security](#security)
|
||||
- [Verification](#verification)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
<a id="routing"></a>
|
||||
|
||||
## Routing
|
||||
|
||||
Pull requests run the workflow when opened, synchronized, reopened, marked ready for review, or converted to a draft. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API.
|
||||
|
||||
For a non-draft pull request, the workflow keeps at most one current individual review request other than `@turtle1999`; an existing request for `@turtle1999` does not consume that slot. Each run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when made by people outside the ownership map. When more candidates remain than the available counted slot can cover, the workflow ranks them by the total GitHub-reported additions plus deletions in reviewable changed-file records that match each owner. A rename contributes its changed LOC once to an owner even when both paths match that owner. Higher changed LOC ranks first, and login order resolves ties.
|
||||
|
||||
Before selecting a new reviewer, a non-draft run fetches the pull request's complete chronological review list. An owner's latest undismissed decisive review is `APPROVED` or `CHANGES_REQUESTED`; comments and pending reviews do not replace that decision. An approved owner remains omitted after later synchronize events, while a later changes-requested review makes the owner eligible again. The workflow fails before mutation when the list reaches the supported 3,000-review limit or contains an invalid record.
|
||||
|
||||
On every run with current review requests, the workflow reads the pull-request timeline. A current reviewer is workflow-authored only when the latest matching `review_requested` event names `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. On a non-draft pull request, the workflow cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit. Current relevance order decides which matching workflow reviewer remains when the limit shrinks. It then fills any slot left by the planned cancellations. On a draft, it cancels every current workflow-authored request. Requests made by people remain unchanged in both states. An attributable event with invalid provenance fails before mutation, and the workflow also fails without cancellation when the timeline exceeds 3,000 events.
|
||||
|
||||
The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; approved owners omitted from new requests; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author, approved owners, and users who remain requested are omitted from new requests.
|
||||
|
||||
The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
|
||||
|
||||
<a id="approval-scoring"></a>
|
||||
|
||||
## Approval scoring
|
||||
@@ -41,34 +23,22 @@ Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` dec
|
||||
|
||||
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
|
||||
|
||||
<a id="review-exclusions"></a>
|
||||
|
||||
## Review exclusions
|
||||
|
||||
Review routing excludes the repository's unit, end-to-end, expected-output, snapshot, benchmark, performance, stress, corpus, native, and Python test conventions. This includes `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, and `stress-tests` directories; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; recognized test filename suffixes; and Python `test_*.py` or `*_test.py` files.
|
||||
|
||||
Test infrastructure that can alter how evidence is produced remains reviewable, including `vitest*.config.ts` and gate implementations under `scripts`. A production file named `test.ts`, `spec.ts`, or `snapshot.ts` is not excluded solely by that name.
|
||||
|
||||
Files ending in `.md` or `.yaml`, with case-insensitive extension matching, are documentation and never contribute owners. A `.yml` file remains reviewable unless another exclusion applies.
|
||||
|
||||
For a modified file with a supported source extension, the scanner compares the pre-change and post-change text after removing parsed comments. It excludes the file only when GitHub supplies a patch whose counted additions and deletions prove that the patch is complete and the remaining code is identical. The parser recognizes C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for their declared extensions. Renames, unsupported languages, missing or partial patches, and uncertain comment forms remain reviewable.
|
||||
|
||||
<a id="security"></a>
|
||||
|
||||
## Security
|
||||
|
||||
The write-capable jobs check out only the repository default branch. They do not check out or execute pull-request code and do not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request filenames and reviews are treated as API data and escaped in logs.
|
||||
The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
|
||||
|
||||
Ownership and approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing either program or policy for its own run.
|
||||
Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
|
||||
|
||||
<a id="verification"></a>
|
||||
|
||||
## Verification
|
||||
|
||||
Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs both policy checks and the workflow tests in CI.
|
||||
Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
## Dev Note
|
||||
|
||||
The [review-routing decision](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) records the security model, test exclusions, and alternatives.
|
||||
None.
|
||||
|
||||
@@ -1,660 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const API_VERSION = '2026-03-10'
|
||||
const MAX_OWNERS_PER_RULE = 2
|
||||
const MAX_PULL_REQUEST_FILES = 3_000
|
||||
const MAX_PULL_REQUEST_REVIEWS = 3_000
|
||||
const MAX_COUNTED_REQUESTED_REVIEWERS = 1
|
||||
const MAX_TIMELINE_EVENTS = 3_000
|
||||
const PAGE_SIZE = 100
|
||||
const PULL_REQUEST_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
|
||||
const UNCOUNTED_REVIEWER = 'turtle1999'
|
||||
const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]'
|
||||
const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests'])
|
||||
const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u
|
||||
const PYTHON_TEST_FILE = /^(?:test_.+|.+_tests?)\.py$/u
|
||||
const DOCUMENTATION_FILE = /\.(?:md|yaml)$/iu
|
||||
const C_STYLE_EXTENSIONS = new Set([
|
||||
'c', 'cc', 'cjs', 'cpp', 'cts', 'cxx', 'go', 'h', 'hpp', 'java', 'js', 'jsx',
|
||||
'kt', 'kts', 'less', 'mjs', 'mts', 'rs', 'scss', 'swift', 'ts', 'tsx',
|
||||
])
|
||||
const BLOCK_COMMENT_EXTENSIONS = new Set(['css'])
|
||||
const HASH_COMMENT_EXTENSIONS = new Set(['bash', 'ps1', 'py', 'pyi', 'r', 'rb', 'sh', 'toml', 'yml', 'zsh'])
|
||||
const HTML_COMMENT_EXTENSIONS = new Set(['htm', 'html'])
|
||||
|
||||
/**
|
||||
* Parse the explicit directory subset accepted from the review ownership file.
|
||||
* @param {string} source CODEOWNERS-compatible source text.
|
||||
* @returns {Array<{pattern: string, prefix: string, owners: string[]}>} Ordered ownership rules.
|
||||
*/
|
||||
export function parseOwnership(source) {
|
||||
const rules = []
|
||||
const patterns = new Set()
|
||||
for (const [index, rawLine] of source.split('\n').entries()) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const [pattern, ...owners] = line.split(/\s+/u)
|
||||
const location = `ownership line ${index + 1}`
|
||||
if (!/^\/[^*?[\]#!\\]+\/$/u.test(pattern)) {
|
||||
throw new Error(`${location}: expected one explicit absolute directory pattern`)
|
||||
}
|
||||
if (pattern.startsWith('/.')) throw new Error(`${location}: hidden-directory patterns are not allowed`)
|
||||
if (patterns.has(pattern)) throw new Error(`${location}: duplicate pattern ${JSON.stringify(pattern)}`)
|
||||
if (owners.length === 0) throw new Error(`${location}: expected at least one owner`)
|
||||
if (owners.length > MAX_OWNERS_PER_RULE) {
|
||||
throw new Error(`${location}: expected at most ${MAX_OWNERS_PER_RULE} owners`)
|
||||
}
|
||||
const normalizedOwners = []
|
||||
const seenOwners = new Set()
|
||||
for (const owner of owners) {
|
||||
if (!/^@[A-Za-z0-9-]+$/u.test(owner)) {
|
||||
throw new Error(`${location}: only individual GitHub users are supported`)
|
||||
}
|
||||
const key = owner.toLowerCase()
|
||||
if (seenOwners.has(key)) throw new Error(`${location}: duplicate owner ${owner}`)
|
||||
seenOwners.add(key)
|
||||
normalizedOwners.push(owner)
|
||||
}
|
||||
patterns.add(pattern)
|
||||
rules.push({ pattern, prefix: pattern.slice(1), owners: normalizedOwners })
|
||||
}
|
||||
if (rules.length === 0) throw new Error('ownership file contains no rules')
|
||||
return rules
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a repository-relative path received from GitHub.
|
||||
* @param {unknown} value GitHub file path.
|
||||
* @returns {string} Slash-normalized repository path.
|
||||
*/
|
||||
export function normalizeRepositoryPath(value) {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new Error('changed file has no path')
|
||||
const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, '')
|
||||
if (
|
||||
normalized.startsWith('/')
|
||||
|| normalized.includes('\0')
|
||||
|| normalized.split('/').some(segment => !segment || segment === '.' || segment === '..')
|
||||
) {
|
||||
throw new Error(`invalid repository path ${JSON.stringify(value)}`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a repository path belongs only to test evidence or test support.
|
||||
* @param {string} value Repository-relative path.
|
||||
* @returns {boolean} Whether reviewer routing must ignore the path.
|
||||
*/
|
||||
export function isTestPath(value) {
|
||||
const file = normalizeRepositoryPath(value)
|
||||
const segments = file.split('/')
|
||||
if (segments[0] === 'benchmarks' || segments[0] === 'snapshots') return true
|
||||
if (segments[0] === 'packages' && segments[1] === 'test-support') return true
|
||||
if (segments[0] === 'scripts' && (segments[1] === 'fixtures' || segments[1] === 'snapshots')) return true
|
||||
if (segments.some(segment => TEST_DIRECTORY_NAMES.has(segment))) return true
|
||||
const basename = segments.at(-1) ?? ''
|
||||
return TEST_FILE_MARKER.test(basename) || PYTHON_TEST_FILE.test(basename)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a repository path is documentation excluded from review routing.
|
||||
* @param {string} value Repository-relative path.
|
||||
* @returns {boolean} Whether the path has an excluded documentation extension.
|
||||
*/
|
||||
export function isDocumentationPath(value) {
|
||||
return DOCUMENTATION_FILE.test(normalizeRepositoryPath(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a complete modified-file patch changes comments only.
|
||||
* @param {unknown} value GitHub changed-file record.
|
||||
* @returns {boolean} Whether supported comment parsing removes every changed token.
|
||||
*/
|
||||
export function isCommentOnlyChange(value) {
|
||||
if (!isRecord(value) || value.status !== 'modified' || typeof value.filename !== 'string'
|
||||
|| typeof value.patch !== 'string' || !Number.isSafeInteger(value.additions)
|
||||
|| value.additions < 0 || !Number.isSafeInteger(value.deletions) || value.deletions < 0) return false
|
||||
const syntax = commentSyntax(value.filename)
|
||||
if (syntax === undefined) return false
|
||||
if (value.filename.toLowerCase().endsWith('.rs') && /\b(?:br|r)#{0,255}"/u.test(value.patch)) return false
|
||||
const hunks = parsePatchHunks(value.patch)
|
||||
if (hunks === undefined || hunks.additions !== value.additions || hunks.deletions !== value.deletions) {
|
||||
return false
|
||||
}
|
||||
return hunks.values.every(({ before, after }) =>
|
||||
normalizedCode(before, syntax) === normalizedCode(after, syntax))
|
||||
}
|
||||
|
||||
function commentSyntax(filename) {
|
||||
const normalized = normalizeRepositoryPath(filename)
|
||||
const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase()
|
||||
const extension = basename.includes('.') ? basename.slice(basename.lastIndexOf('.') + 1) : ''
|
||||
const line = []
|
||||
const block = []
|
||||
if (C_STYLE_EXTENSIONS.has(extension)) {
|
||||
line.push('//')
|
||||
block.push(['/*', '*/'])
|
||||
}
|
||||
if (BLOCK_COMMENT_EXTENSIONS.has(extension)) block.push(['/*', '*/'])
|
||||
if (HASH_COMMENT_EXTENSIONS.has(extension) || basename === 'dockerfile' || basename.startsWith('dockerfile.')
|
||||
|| basename === 'makefile' || basename.startsWith('makefile.')) line.push('#')
|
||||
if (extension === 'sql') {
|
||||
line.push('--')
|
||||
block.push(['/*', '*/'])
|
||||
}
|
||||
if (HTML_COMMENT_EXTENSIONS.has(extension)) block.push(['<!--', '-->'])
|
||||
return line.length === 0 && block.length === 0 ? undefined : { line, block }
|
||||
}
|
||||
|
||||
function parsePatchHunks(patch) {
|
||||
const values = []
|
||||
let current
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
for (const line of patch.split('\n')) {
|
||||
if (line.startsWith('@@')) {
|
||||
current = { before: [], after: [] }
|
||||
values.push(current)
|
||||
continue
|
||||
}
|
||||
if (current === undefined || line.startsWith('\\ No newline at end of file')) continue
|
||||
const prefix = line[0]
|
||||
const content = line.slice(1)
|
||||
if (prefix === ' ') {
|
||||
current.before.push(content)
|
||||
current.after.push(content)
|
||||
} else if (prefix === '-') {
|
||||
current.before.push(content)
|
||||
deletions++
|
||||
} else if (prefix === '+') {
|
||||
current.after.push(content)
|
||||
additions++
|
||||
}
|
||||
}
|
||||
return values.length === 0 ? undefined : { values, additions, deletions }
|
||||
}
|
||||
|
||||
function normalizedCode(lines, syntax) {
|
||||
return stripComments(lines.join('\n'), syntax)
|
||||
.split('\n')
|
||||
.map(line => line.trimEnd())
|
||||
.filter(line => line.trim().length > 0)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function stripComments(source, syntax) {
|
||||
let result = ''
|
||||
let quote
|
||||
let blockEnd
|
||||
for (let index = 0; index < source.length;) {
|
||||
if (blockEnd !== undefined) {
|
||||
if (source.startsWith(blockEnd, index)) {
|
||||
index += blockEnd.length
|
||||
blockEnd = undefined
|
||||
} else {
|
||||
index++
|
||||
}
|
||||
continue
|
||||
}
|
||||
const character = source[index]
|
||||
if (quote !== undefined) {
|
||||
result += character
|
||||
index++
|
||||
if (character === '\\' && index < source.length) {
|
||||
result += source[index]
|
||||
index++
|
||||
} else if (character === quote) {
|
||||
quote = undefined
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (character === '\'' || character === '"' || character === '`') {
|
||||
quote = character
|
||||
result += character
|
||||
index++
|
||||
continue
|
||||
}
|
||||
const block = syntax.block.find(([start]) => source.startsWith(start, index))
|
||||
if (block !== undefined) {
|
||||
index += block[0].length
|
||||
blockEnd = block[1]
|
||||
continue
|
||||
}
|
||||
const line = syntax.line.find(marker => source.startsWith(marker, index))
|
||||
const lineStart = index === 0 || source[index - 1] === '\n'
|
||||
const hashStartsComment = line !== '#' || lineStart || /\s/u.test(source[index - 1] ?? '')
|
||||
if (line !== undefined && hashStartsComment && !(line === '#' && lineStart && source[index + 1] === '!')) {
|
||||
const newline = source.indexOf('\n', index + line.length)
|
||||
if (newline === -1) break
|
||||
result += '\n'
|
||||
index = newline + 1
|
||||
continue
|
||||
}
|
||||
result += character
|
||||
index++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand changed-file records into reviewable, test, documentation, and comment-only paths.
|
||||
* @param {unknown[]} files Pull-request file records from GitHub.
|
||||
* @returns {{changedCodeFiles: string[], reviewableChanges: Array<{paths: string[], changedLines: number}>, excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths and their GitHub-reported changed-line counts.
|
||||
*/
|
||||
export function classifyChangedFiles(files) {
|
||||
const changedCodeFiles = new Set()
|
||||
const reviewableChanges = []
|
||||
const excludedTestFiles = new Set()
|
||||
const excludedDocumentationFiles = new Set()
|
||||
const excludedCommentOnlyFiles = new Set()
|
||||
for (const entry of files) {
|
||||
if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry')
|
||||
const changedLines = changedLineCount(entry)
|
||||
const paths = [normalizeRepositoryPath(entry.filename)]
|
||||
const commentOnly = isCommentOnlyChange(entry)
|
||||
if (entry.previous_filename !== undefined) {
|
||||
paths.unshift(normalizeRepositoryPath(entry.previous_filename))
|
||||
}
|
||||
const reviewablePaths = []
|
||||
for (const file of new Set(paths)) {
|
||||
if (isTestPath(file)) excludedTestFiles.add(file)
|
||||
else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file)
|
||||
else if (commentOnly) excludedCommentOnlyFiles.add(file)
|
||||
else {
|
||||
changedCodeFiles.add(file)
|
||||
reviewablePaths.push(file)
|
||||
}
|
||||
}
|
||||
if (reviewablePaths.length > 0) {
|
||||
reviewableChanges.push({ paths: reviewablePaths.sort(), changedLines })
|
||||
}
|
||||
}
|
||||
return {
|
||||
changedCodeFiles: [...changedCodeFiles].sort(),
|
||||
reviewableChanges,
|
||||
excludedTestFiles: [...excludedTestFiles].sort(),
|
||||
excludedDocumentationFiles: [...excludedDocumentationFiles].sort(),
|
||||
excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(),
|
||||
}
|
||||
}
|
||||
|
||||
function changedLineCount(entry) {
|
||||
for (const field of ['additions', 'deletions']) {
|
||||
if (!Number.isSafeInteger(entry[field]) || entry[field] < 0) {
|
||||
throw new Error(`changed-file ${field} must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
const changedLines = entry.additions + entry.deletions
|
||||
if (!Number.isSafeInteger(changedLines)) throw new Error('changed-file LOC exceeds the safe integer range')
|
||||
return changedLines
|
||||
}
|
||||
|
||||
/**
|
||||
* Match changed paths and rank owners by their reviewable changed LOC.
|
||||
* @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules.
|
||||
* @param {Array<{paths: string[], changedLines: number}>} reviewableChanges Reviewable GitHub file records.
|
||||
* @returns {{matches: Array<{file: string, changedLines: number, owners: string[]}>, reviewers: Array<{login: string, changedLines: number}>}} Routing plan.
|
||||
*/
|
||||
export function planReviewers(rules, reviewableChanges) {
|
||||
const matches = []
|
||||
const reviewers = new Map()
|
||||
for (const change of reviewableChanges) {
|
||||
const changeOwners = new Map()
|
||||
for (const file of change.paths) {
|
||||
let owners = []
|
||||
for (const rule of rules) {
|
||||
if (file.startsWith(rule.prefix)) owners = rule.owners
|
||||
}
|
||||
matches.push({ file, changedLines: change.changedLines, owners })
|
||||
for (const owner of owners) changeOwners.set(owner.toLowerCase(), owner.slice(1))
|
||||
}
|
||||
for (const [key, login] of changeOwners) {
|
||||
const changedLines = (reviewers.get(key)?.changedLines ?? 0) + change.changedLines
|
||||
if (!Number.isSafeInteger(changedLines)) throw new Error(`changed LOC for @${login} exceeds the safe integer range`)
|
||||
reviewers.set(key, { login, changedLines })
|
||||
}
|
||||
}
|
||||
return {
|
||||
matches: matches.sort((left, right) => left.file.localeCompare(right.file, 'en')),
|
||||
reviewers: [...reviewers.values()].sort((left, right) => {
|
||||
if (left.changedLines !== right.changedLines) return left.changedLines < right.changedLines ? 1 : -1
|
||||
return left.login.localeCompare(right.login, 'en')
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a repository-scoped GitHub JSON API caller.
|
||||
* @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies.
|
||||
* @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} API caller.
|
||||
*/
|
||||
export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) {
|
||||
if (!token) throw new Error('GITHUB_TOKEN is not set')
|
||||
if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable')
|
||||
const root = apiUrl.replace(/\/+$/u, '')
|
||||
return async (path, { method = 'GET', body } = {}) => {
|
||||
const response = await fetchImpl(`${root}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'deepseek-harness-request-review',
|
||||
'X-GitHub-Api-Version': API_VERSION,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.text()
|
||||
throw new Error(`GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`)
|
||||
}
|
||||
if (response.status === 204) return undefined
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the complete pull-request file list or fail before routing a partial list.
|
||||
* @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
|
||||
* @param {string} repository Owner/name repository identifier.
|
||||
* @param {number} pullNumber Pull-request number.
|
||||
* @param {number} expectedCount Pull-request changed-file count.
|
||||
* @returns {Promise<unknown[]>} Complete changed-file records.
|
||||
*/
|
||||
export async function listPullRequestFiles(api, repository, pullNumber, expectedCount) {
|
||||
if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) {
|
||||
throw new Error('pull request changed_files must be a non-negative integer')
|
||||
}
|
||||
if (expectedCount > MAX_PULL_REQUEST_FILES) {
|
||||
throw new Error(`pull request has ${expectedCount} files; GitHub exposes at most ${MAX_PULL_REQUEST_FILES}`)
|
||||
}
|
||||
const files = []
|
||||
for (let page = 1; files.length < expectedCount; page++) {
|
||||
const response = await api(`/repos/${repository}/pulls/${pullNumber}/files?per_page=${PAGE_SIZE}&page=${page}`)
|
||||
if (!Array.isArray(response) || response.length === 0) {
|
||||
throw new Error(`GitHub returned ${files.length} of ${expectedCount} changed files`)
|
||||
}
|
||||
files.push(...response)
|
||||
if (files.length > expectedCount) {
|
||||
throw new Error(`GitHub returned ${files.length} files but the pull request reports ${expectedCount}`)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the complete chronological pull-request review list.
|
||||
* @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
|
||||
* @param {string} repository Owner/name repository identifier.
|
||||
* @param {number} pullNumber Pull-request number.
|
||||
* @returns {Promise<unknown[]>} Complete review list within the supported limit.
|
||||
*/
|
||||
export async function listPullRequestReviews(api, repository, pullNumber) {
|
||||
const reviews = []
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`)
|
||||
if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array')
|
||||
reviews.push(...response)
|
||||
if (response.length < PAGE_SIZE) return reviews
|
||||
if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) {
|
||||
throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} entries`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return users whose latest undismissed decisive review approves the pull request.
|
||||
* @param {unknown[]} reviews Chronological GitHub pull-request review records.
|
||||
* @returns {string[]} Approved reviewer logins in stable order.
|
||||
*/
|
||||
export function approvedReviewerLogins(reviews) {
|
||||
const approved = new Map()
|
||||
for (const review of reviews) {
|
||||
if (!isRecord(review) || !isRecord(review.user) || typeof review.user.login !== 'string') {
|
||||
throw new Error('pull-request reviews response contains an invalid reviewer')
|
||||
}
|
||||
if (typeof review.state !== 'string' || !PULL_REQUEST_REVIEW_STATES.has(review.state)) {
|
||||
throw new Error('pull-request reviews response contains an invalid state')
|
||||
}
|
||||
const key = review.user.login.toLowerCase()
|
||||
if (review.state === 'APPROVED') approved.set(key, review.user.login)
|
||||
else if (review.state === 'CHANGES_REQUESTED') approved.delete(key)
|
||||
}
|
||||
return [...approved.values()].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the pull request timeline used to identify workflow-authored review requests.
|
||||
* @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
|
||||
* @param {string} repository Owner/name repository identifier.
|
||||
* @param {number} pullNumber Pull-request number.
|
||||
* @returns {Promise<unknown[]>} Complete timeline event list within the supported limit.
|
||||
*/
|
||||
export async function listPullRequestTimeline(api, repository, pullNumber) {
|
||||
const events = []
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await api(`/repos/${repository}/issues/${pullNumber}/timeline?per_page=${PAGE_SIZE}&page=${page}`)
|
||||
if (!Array.isArray(response)) throw new Error('pull-request timeline response is not an array')
|
||||
events.push(...response)
|
||||
if (response.length < PAGE_SIZE) return events
|
||||
if (events.length >= MAX_TIMELINE_EVENTS) {
|
||||
throw new Error(`pull-request timeline exceeds ${MAX_TIMELINE_EVENTS} events`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return current requested reviewers whose latest request came from this workflow identity. */
|
||||
function workflowRequestedReviewers(events, requestedReviewers) {
|
||||
const requested = new Map(requestedReviewers.map(login => [login.toLowerCase(), login]))
|
||||
const latestRequester = new Map()
|
||||
for (const event of events) {
|
||||
if (!isRecord(event) || event.event !== 'review_requested') continue
|
||||
if (!isRecord(event.requested_reviewer) || typeof event.requested_reviewer.login !== 'string') continue
|
||||
const key = event.requested_reviewer.login.toLowerCase()
|
||||
if (!requested.has(key)) continue
|
||||
if (!isRecord(event.review_requester) || typeof event.review_requester.login !== 'string') {
|
||||
throw new Error('review-request timeline event has no requester login')
|
||||
}
|
||||
latestRequester.set(key, event.review_requester.login.toLowerCase())
|
||||
}
|
||||
return [...requested]
|
||||
.filter(([key]) => latestRequester.get(key) === WORKFLOW_REVIEW_REQUESTER)
|
||||
.map(([, login]) => login)
|
||||
}
|
||||
|
||||
/** Extract and validate individual logins from GitHub's requested-reviewer response. */
|
||||
function requestedReviewerLogins(response) {
|
||||
if (!isRecord(response) || !Array.isArray(response.users)) {
|
||||
throw new Error('requested-reviewers response has no users array')
|
||||
}
|
||||
return response.users.map((user) => {
|
||||
if (!isRecord(user) || typeof user.login !== 'string') {
|
||||
throw new Error('requested-reviewers response contains an invalid user')
|
||||
}
|
||||
return user.login
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Print changed paths, reconcile workflow-authored requests with current
|
||||
* ownership, and cancel workflow-authored requests on drafts.
|
||||
* @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, write?: (line: string) => void}} options Runtime inputs.
|
||||
* @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result.
|
||||
*/
|
||||
export async function requestReviews({ event, ownershipSource, api, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
const pull = pullRequestFromEvent(event)
|
||||
write('This is by automated Angry Turtle Cyborg, not a human')
|
||||
const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount)
|
||||
const { reviewableChanges, ...classified } = classifyChangedFiles(files)
|
||||
const plan = planReviewers(parseOwnership(ownershipSource), reviewableChanges)
|
||||
writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file)))
|
||||
writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file)))
|
||||
writeList(
|
||||
write,
|
||||
'Excluded documentation files',
|
||||
classified.excludedDocumentationFiles.map(file => JSON.stringify(file)),
|
||||
)
|
||||
writeList(
|
||||
write,
|
||||
'Excluded comment-only files',
|
||||
classified.excludedCommentOnlyFiles.map(file => JSON.stringify(file)),
|
||||
)
|
||||
writeList(
|
||||
write,
|
||||
'Owners by changed file',
|
||||
plan.matches.map(({ file, changedLines, owners }) =>
|
||||
`${JSON.stringify(file)} (${changedLines} LOC): ${owners.length ? owners.join(' ') : '(none)'}`),
|
||||
)
|
||||
writeList(
|
||||
write,
|
||||
'Owner relevance by changed LOC',
|
||||
plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`),
|
||||
)
|
||||
|
||||
const ownerCandidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
|
||||
if (pull.draft) {
|
||||
const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
|
||||
const requestedReviewers = requestedReviewerLogins(existing)
|
||||
const reviewers = requestedReviewers.length === 0
|
||||
? []
|
||||
: workflowRequestedReviewers(
|
||||
await listPullRequestTimeline(api, pull.repository, pull.number),
|
||||
requestedReviewers,
|
||||
)
|
||||
writeList(write, 'Review requests to cancel', reviewers.map(login => `@${login}`))
|
||||
if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
|
||||
|
||||
await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
|
||||
method: 'DELETE',
|
||||
body: { reviewers },
|
||||
})
|
||||
const requestLabel = reviewers.length === 1 ? 'request' : 'requests'
|
||||
write(`Cancelled review ${requestLabel} for ${reviewers.map(login => `@${login}`).join(' ')}.`)
|
||||
return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers }
|
||||
}
|
||||
|
||||
const approvedReviewerKeys = new Set(
|
||||
(ownerCandidates.length === 0
|
||||
? []
|
||||
: approvedReviewerLogins(await listPullRequestReviews(api, pull.repository, pull.number)))
|
||||
.map(login => login.toLowerCase()),
|
||||
)
|
||||
const approvedOwners = ownerCandidates.filter(({ login }) => approvedReviewerKeys.has(login.toLowerCase()))
|
||||
const candidates = ownerCandidates.filter(({ login }) => !approvedReviewerKeys.has(login.toLowerCase()))
|
||||
writeList(write, 'Approved owners omitted from review requests', approvedOwners.map(({ login }) => `@${login}`))
|
||||
|
||||
const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
|
||||
const currentReviewers = requestedReviewerLogins(existing).sort((left, right) => left.localeCompare(right, 'en'))
|
||||
const workflowReviewers = currentReviewers.length === 0
|
||||
? []
|
||||
: workflowRequestedReviewers(
|
||||
await listPullRequestTimeline(api, pull.repository, pull.number),
|
||||
currentReviewers,
|
||||
)
|
||||
const workflowReviewerKeys = new Set(workflowReviewers.map(login => login.toLowerCase()))
|
||||
const manualReviewers = currentReviewers.filter(login => !workflowReviewerKeys.has(login.toLowerCase()))
|
||||
let retainedCountedSlots = Math.max(
|
||||
0,
|
||||
MAX_COUNTED_REQUESTED_REVIEWERS
|
||||
- manualReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length,
|
||||
)
|
||||
const retainedWorkflowReviewerKeys = new Set()
|
||||
for (const { login } of candidates) {
|
||||
const key = login.toLowerCase()
|
||||
if (!workflowReviewerKeys.has(key)) continue
|
||||
if (key === UNCOUNTED_REVIEWER) retainedWorkflowReviewerKeys.add(key)
|
||||
else if (retainedCountedSlots > 0) {
|
||||
retainedWorkflowReviewerKeys.add(key)
|
||||
retainedCountedSlots--
|
||||
}
|
||||
}
|
||||
const reviewersToCancel = workflowReviewers.filter(
|
||||
login => !retainedWorkflowReviewerKeys.has(login.toLowerCase()),
|
||||
)
|
||||
const cancelledReviewerKeys = new Set(reviewersToCancel.map(login => login.toLowerCase()))
|
||||
const remainingReviewers = currentReviewers.filter(login => !cancelledReviewerKeys.has(login.toLowerCase()))
|
||||
const alreadyRequested = new Set(remainingReviewers.map(login => login.toLowerCase()))
|
||||
const availableSlots = Math.max(
|
||||
0,
|
||||
MAX_COUNTED_REQUESTED_REVIEWERS
|
||||
- remainingReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length,
|
||||
)
|
||||
writeList(write, 'Current individual review requests', currentReviewers.map(login => `@${login}`))
|
||||
write(`Available counted review request slots: ${availableSlots}.`)
|
||||
const reviewers = candidates
|
||||
.filter(({ login }) => !alreadyRequested.has(login.toLowerCase()))
|
||||
.slice(0, availableSlots)
|
||||
.map(({ login }) => login)
|
||||
writeList(write, 'Review requests to cancel', reviewersToCancel.map(login => `@${login}`))
|
||||
writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`))
|
||||
if (reviewersToCancel.length > 0) {
|
||||
await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
|
||||
method: 'DELETE',
|
||||
body: { reviewers: reviewersToCancel },
|
||||
})
|
||||
const requestLabel = reviewersToCancel.length === 1 ? 'request' : 'requests'
|
||||
write(`Cancelled review ${requestLabel} for ${reviewersToCancel.map(login => `@${login}`).join(' ')}.`)
|
||||
}
|
||||
|
||||
if (reviewers.length > 0) {
|
||||
await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
|
||||
method: 'POST',
|
||||
body: { reviewers },
|
||||
})
|
||||
write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`)
|
||||
}
|
||||
return { ...classified, requestedReviewers: reviewers, cancelledReviewers: reviewersToCancel }
|
||||
}
|
||||
|
||||
function pullRequestFromEvent(event) {
|
||||
if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string') {
|
||||
throw new Error('event has no repository.full_name')
|
||||
}
|
||||
if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)) {
|
||||
throw new Error('event has no pull_request')
|
||||
}
|
||||
const { pull_request: pull } = event
|
||||
if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number')
|
||||
if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag')
|
||||
if (typeof pull.user.login !== 'string' || !pull.user.login) throw new Error('pull request has no author login')
|
||||
return {
|
||||
repository: event.repository.full_name,
|
||||
number: pull.number,
|
||||
draft: pull.draft,
|
||||
author: pull.user.login,
|
||||
changedFileCount: pull.changed_files,
|
||||
}
|
||||
}
|
||||
|
||||
function writeList(write, title, entries) {
|
||||
write(`${title}:`)
|
||||
if (entries.length === 0) write('- (none)')
|
||||
else for (const entry of entries) write(`- ${entry}`)
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const eventPath = process.env.GITHUB_EVENT_PATH
|
||||
if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set')
|
||||
const event = JSON.parse(readFileSync(eventPath, 'utf8'))
|
||||
const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8')
|
||||
const api = createGitHubApi({
|
||||
token: process.env.GITHUB_TOKEN ?? '',
|
||||
apiUrl: process.env.GITHUB_API_URL,
|
||||
})
|
||||
await requestReviews({ event, ownershipSource, api })
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`request-review failed: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -1,869 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
approvedReviewerLogins,
|
||||
classifyChangedFiles,
|
||||
createGitHubApi,
|
||||
isCommentOnlyChange,
|
||||
isDocumentationPath,
|
||||
isTestPath,
|
||||
listPullRequestFiles,
|
||||
listPullRequestReviews,
|
||||
listPullRequestTimeline,
|
||||
normalizeRepositoryPath,
|
||||
parseOwnership,
|
||||
planReviewers,
|
||||
requestReviews,
|
||||
} from './request-review.mjs'
|
||||
|
||||
const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8')
|
||||
|
||||
const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false } = {}) => ({
|
||||
repository: { full_name: 'deepseek-harness/deepseek-harness' },
|
||||
pull_request: {
|
||||
number: 42,
|
||||
draft,
|
||||
changed_files: changedFiles,
|
||||
user: { login: author },
|
||||
},
|
||||
})
|
||||
|
||||
test('loads the repository ownership policy without test-only directory rules', () => {
|
||||
const rules = parseOwnership(ownershipSource)
|
||||
const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners]))
|
||||
assert.equal(rules.length, 57)
|
||||
assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false)
|
||||
assert.equal(rules.some(rule => rule.pattern === '/scripts/'), false)
|
||||
assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false)
|
||||
assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false)
|
||||
assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999'])
|
||||
assert.deepEqual(ownersByPattern.get('/docs/'), ['@turtle1999'])
|
||||
assert.deepEqual(ownersByPattern.get('/packages/core/'), ['@turtle1999', '@mektpoy'])
|
||||
assert.deepEqual(ownersByPattern.get('/packages/llm/'), ['@LegGasai'])
|
||||
assert.deepEqual(ownersByPattern.get('/packages/preset/'), ['@LegGasai', '@turtle1999'])
|
||||
assert.deepEqual(ownersByPattern.get('/packages/session/'), ['@turtle1999', '@mektpoy'])
|
||||
assert.deepEqual(ownersByPattern.get('/packages/subagent/'), ['@Dudu-0223'])
|
||||
assert.deepEqual(ownersByPattern.get('/packages/web/'), ['@imccyu'])
|
||||
assert.deepEqual(ownersByPattern.get('/python/'), ['@LegGasai'])
|
||||
assert.deepEqual(ownersByPattern.get('/website/'), ['@LegGasai'])
|
||||
assert.equal(rules.every(rule => rule.owners.length <= 2), true)
|
||||
for (const excludedOwner of ['@tianyicui', '@kermeanx', '@pkh-xht']) {
|
||||
assert.equal(rules.some(rule => rule.owners.some(owner => owner.toLowerCase() === excludedOwner)), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps turtle below one third of the eligible owned codebase', () => {
|
||||
const rules = parseOwnership(ownershipSource)
|
||||
const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' })
|
||||
.split('\0')
|
||||
.filter(file => file && existsSync(file))
|
||||
let ownedLines = 0
|
||||
let turtleLines = 0
|
||||
for (const file of trackedFiles) {
|
||||
if (isTestPath(file) || isDocumentationPath(file)) continue
|
||||
const owners = planReviewers(rules, [{ paths: [file], changedLines: 0 }]).matches[0]?.owners ?? []
|
||||
if (owners.length === 0) continue
|
||||
const content = readFileSync(file)
|
||||
const lines = content.length === 0
|
||||
? 0
|
||||
: content.reduce((count, byte) => count + (byte === 10 ? 1 : 0), 0) + (content.at(-1) === 10 ? 0 : 1)
|
||||
ownedLines += lines
|
||||
if (owners.includes('@turtle1999')) turtleLines += lines
|
||||
}
|
||||
assert.ok(
|
||||
turtleLines * 3 <= ownedLines,
|
||||
`@turtle1999 owns ${turtleLines} of ${ownedLines} eligible owned lines`,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects ownership forms the requester cannot apply safely', () => {
|
||||
for (const [source, message] of [
|
||||
['', /contains no rules/u],
|
||||
['* @owner\n', /explicit absolute directory/u],
|
||||
['/.github/ @owner\n', /hidden-directory/u],
|
||||
['/packages/*/ @owner\n', /explicit absolute directory/u],
|
||||
['/packages/core/\n', /at least one owner/u],
|
||||
['/packages/core/ @org/team\n', /individual GitHub users/u],
|
||||
['/packages/core/ @one @two @three\n', /at most 2 owners/u],
|
||||
['/packages/core/ @owner @OWNER\n', /duplicate owner/u],
|
||||
['/packages/core/ @owner\n/packages/core/ @other\n', /duplicate pattern/u],
|
||||
]) {
|
||||
assert.throws(() => parseOwnership(source), message)
|
||||
}
|
||||
})
|
||||
|
||||
test('recognizes every repository test location and filename convention', () => {
|
||||
for (const file of [
|
||||
'apps/cli/tests/args.spec.ts',
|
||||
'apps/cli/tests/harness.ts',
|
||||
'apps/web/stress-tests/reasoning-chunks.stress.ts',
|
||||
'benchmarks/session-open/workload.ts',
|
||||
'native/landlock-run/test/entry.test.js',
|
||||
'packages/core/agent/__tests__/agent.ts',
|
||||
'packages/core/agent/benches/agent.rs',
|
||||
'packages/core/agent/src/agent.compat.spec.ts',
|
||||
'packages/core/agent/src/__snapshots__/agent.ts.snap',
|
||||
'packages/session-query/session-query/tests/test-service.ts',
|
||||
'packages/test-support/session-snapshot/src/index.ts',
|
||||
'python/sdk/src/test_client.py',
|
||||
'python/sdk/src/client_test.py',
|
||||
'scripts/fixtures/translation-prompt/response.txt',
|
||||
'scripts/session-snapshot-corpus.corpus.ts',
|
||||
'scripts/snapshots/translation-prompt-v4/request-response.expected.json',
|
||||
'snapshots/session/headless.snapshot.ts',
|
||||
]) {
|
||||
assert.equal(isTestPath(file), true, file)
|
||||
}
|
||||
})
|
||||
|
||||
test('does not confuse production names with tests', () => {
|
||||
for (const file of [
|
||||
'apps/cli/src/testing.ts',
|
||||
'packages/core/agent/src/contest.ts',
|
||||
'packages/session/session-format/src/snapshot.ts',
|
||||
'packages/session/session-format/src/spec.ts',
|
||||
'packages/session/session-format/src/test.ts',
|
||||
'scripts/run-gates.ts',
|
||||
'vitest.config.ts',
|
||||
'vitest.bench.config.ts',
|
||||
'vitest.e2e.config.ts',
|
||||
'vitest.snapshot.config.ts',
|
||||
'vitest.web.perf.config.ts',
|
||||
'website/docs.ts',
|
||||
]) {
|
||||
assert.equal(isTestPath(file), false, file)
|
||||
}
|
||||
})
|
||||
|
||||
test('excludes Markdown and YAML documentation extensions', () => {
|
||||
for (const file of [
|
||||
'README.md',
|
||||
'docs/architecture.MD',
|
||||
'packages/subagent/subagent/guide.yaml',
|
||||
'profiles/example.YAML',
|
||||
]) {
|
||||
assert.equal(isDocumentationPath(file), true, file)
|
||||
}
|
||||
for (const file of [
|
||||
'.github/workflows/request-review.yml',
|
||||
'packages/subagent/subagent/src/index.ts',
|
||||
'website/docs.ts',
|
||||
]) {
|
||||
assert.equal(isDocumentationPath(file), false, file)
|
||||
}
|
||||
})
|
||||
|
||||
test('detects comment-only changes only from complete supported patches', () => {
|
||||
for (const file of [
|
||||
{
|
||||
filename: 'packages/core/agent/src/index.ts',
|
||||
status: 'modified', additions: 1, deletions: 1,
|
||||
patch: '@@ -1,2 +1,2 @@\n-// old note\n+// new note\n const value = "https://example.com"',
|
||||
},
|
||||
{
|
||||
filename: 'python/sdk/src/client.py',
|
||||
status: 'modified', additions: 1, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-value = 1 # old note\n+value = 1 # new note',
|
||||
},
|
||||
{
|
||||
filename: 'native/landlock-run/src/main.rs',
|
||||
status: 'modified', additions: 1, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-let value = 1; /* old note */\n+let value = 1; /* new note */',
|
||||
},
|
||||
]) {
|
||||
assert.equal(isCommentOnlyChange(file), true, file.filename)
|
||||
}
|
||||
|
||||
for (const file of [
|
||||
{
|
||||
filename: 'packages/core/agent/src/index.ts',
|
||||
status: 'modified', additions: 1, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-const value = 1 // note\n+const value = 2 // note',
|
||||
},
|
||||
{
|
||||
filename: 'packages/core/agent/src/index.ts',
|
||||
status: 'modified', additions: 2, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-// old note\n+// new note',
|
||||
},
|
||||
{
|
||||
filename: 'packages/core/agent/src/data.json',
|
||||
status: 'modified', additions: 1, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-{"value":1}\n+{"value":2}',
|
||||
},
|
||||
{
|
||||
filename: 'native/landlock-run/src/main.rs',
|
||||
status: 'modified', additions: 1, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-let value = r#"https://old.example"#;\n+let value = r#"https://new.example"#;',
|
||||
},
|
||||
{
|
||||
filename: 'packages/core/agent/src/index.ts',
|
||||
status: 'renamed', additions: 1, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-// old note\n+// new note',
|
||||
},
|
||||
]) {
|
||||
assert.equal(isCommentOnlyChange(file), false, file.filename)
|
||||
}
|
||||
})
|
||||
|
||||
test('normalizes separators and rejects paths that are not repository-relative', () => {
|
||||
assert.equal(normalizeRepositoryPath('./packages\\core\\agent\\src\\index.ts'), 'packages/core/agent/src/index.ts')
|
||||
for (const file of ['', '/absolute.ts', '../escape.ts', 'packages//empty.ts', 'packages/./same.ts']) {
|
||||
assert.throws(() => normalizeRepositoryPath(file), /path/u, file)
|
||||
}
|
||||
})
|
||||
|
||||
test('classifies both sides of a rename independently', () => {
|
||||
assert.deepEqual(
|
||||
classifyChangedFiles([
|
||||
{
|
||||
filename: 'packages/core/agent/tests/moved.spec.ts',
|
||||
previous_filename: 'packages/core/agent/src/moved.ts',
|
||||
additions: 3,
|
||||
deletions: 2,
|
||||
},
|
||||
{
|
||||
filename: 'packages/client/store/src/restored.ts',
|
||||
previous_filename: 'packages/client/store/tests/restored.spec.ts',
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
},
|
||||
{ filename: 'packages/core/agent/README.md', additions: 1, deletions: 0 },
|
||||
{
|
||||
filename: 'packages/core/agent/src/commented.ts',
|
||||
status: 'modified', additions: 1, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-// old note\n+// new note',
|
||||
},
|
||||
]),
|
||||
{
|
||||
changedCodeFiles: [
|
||||
'packages/client/store/src/restored.ts',
|
||||
'packages/core/agent/src/moved.ts',
|
||||
],
|
||||
reviewableChanges: [
|
||||
{ paths: ['packages/core/agent/src/moved.ts'], changedLines: 5 },
|
||||
{ paths: ['packages/client/store/src/restored.ts'], changedLines: 3 },
|
||||
],
|
||||
excludedTestFiles: [
|
||||
'packages/client/store/tests/restored.spec.ts',
|
||||
'packages/core/agent/tests/moved.spec.ts',
|
||||
],
|
||||
excludedDocumentationFiles: ['packages/core/agent/README.md'],
|
||||
excludedCommentOnlyFiles: ['packages/core/agent/src/commented.ts'],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('uses the last matching ownership rule and ranks owners by changed LOC', () => {
|
||||
const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n')
|
||||
assert.deepEqual(
|
||||
planReviewers(rules, [
|
||||
{ paths: ['AGENTS.md'], changedLines: 1 },
|
||||
{ paths: ['packages/core/agent/src/index.ts'], changedLines: 8 },
|
||||
{ paths: ['packages/fs/fs/src/index.ts'], changedLines: 3 },
|
||||
]),
|
||||
{
|
||||
matches: [
|
||||
{ file: 'AGENTS.md', changedLines: 1, owners: [] },
|
||||
{ file: 'packages/core/agent/src/index.ts', changedLines: 8, owners: ['@core', '@second'] },
|
||||
{ file: 'packages/fs/fs/src/index.ts', changedLines: 3, owners: ['@broad'] },
|
||||
],
|
||||
reviewers: [
|
||||
{ login: 'core', changedLines: 8 },
|
||||
{ login: 'second', changedLines: 8 },
|
||||
{ login: 'broad', changedLines: 3 },
|
||||
],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('counts each changed-file record once per owner across rename paths', () => {
|
||||
const rules = parseOwnership('/packages/a/ @same @a\n/packages/b/ @same @b\n/packages/c/ @c\n')
|
||||
const plan = planReviewers(rules, [
|
||||
{ paths: ['packages/a/old.ts', 'packages/b/new.ts'], changedLines: 10 },
|
||||
{ paths: ['packages/a/other.ts'], changedLines: 5 },
|
||||
{ paths: ['packages/c/tiny.ts'], changedLines: 1 },
|
||||
])
|
||||
assert.deepEqual(plan.reviewers, [
|
||||
{ login: 'a', changedLines: 15 },
|
||||
{ login: 'same', changedLines: 15 },
|
||||
{ login: 'b', changedLines: 10 },
|
||||
{ login: 'c', changedLines: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test('rejects invalid changed-file LOC', () => {
|
||||
for (const file of [
|
||||
{ filename: 'packages/core/index.ts', deletions: 0 },
|
||||
{ filename: 'packages/core/index.ts', additions: -1, deletions: 0 },
|
||||
{ filename: 'packages/core/index.ts', additions: Number.MAX_SAFE_INTEGER, deletions: 1 },
|
||||
]) {
|
||||
assert.throws(() => classifyChangedFiles([file]), /changed-file|LOC/u)
|
||||
}
|
||||
})
|
||||
|
||||
test('fetches every declared changed file across pages', async () => {
|
||||
const calls = []
|
||||
const pageOne = Array.from({ length: 100 }, (_, index) => ({ filename: `packages/core/file-${index}.ts` }))
|
||||
const pageTwo = [{ filename: 'packages/core/file-100.ts' }]
|
||||
const api = async (path) => {
|
||||
calls.push(path)
|
||||
return calls.length === 1 ? pageOne : pageTwo
|
||||
}
|
||||
const files = await listPullRequestFiles(api, 'owner/repo', 42, 101)
|
||||
assert.equal(files.length, 101)
|
||||
assert.deepEqual(calls, [
|
||||
'/repos/owner/repo/pulls/42/files?per_page=100&page=1',
|
||||
'/repos/owner/repo/pulls/42/files?per_page=100&page=2',
|
||||
])
|
||||
})
|
||||
|
||||
test('fails closed when GitHub cannot provide the complete file list', async () => {
|
||||
let calls = 0
|
||||
await assert.rejects(
|
||||
listPullRequestFiles(async () => {
|
||||
calls++
|
||||
return calls === 1 ? [{ filename: 'one.ts' }] : []
|
||||
}, 'owner/repo', 42, 2),
|
||||
/returned 1 of 2/u,
|
||||
)
|
||||
await assert.rejects(
|
||||
listPullRequestFiles(async () => [], 'owner/repo', 42, 3_001),
|
||||
/at most 3000/u,
|
||||
)
|
||||
})
|
||||
|
||||
test('fetches pull-request reviews across pages', async () => {
|
||||
const calls = []
|
||||
const pageOne = Array.from({ length: 100 }, (_, index) => ({
|
||||
user: { login: `reviewer-${index}` },
|
||||
state: 'COMMENTED',
|
||||
}))
|
||||
const pageTwo = [{ user: { login: 'approver' }, state: 'APPROVED' }]
|
||||
const reviews = await listPullRequestReviews(async (path) => {
|
||||
calls.push(path)
|
||||
return calls.length === 1 ? pageOne : pageTwo
|
||||
}, 'owner/repo', 42)
|
||||
|
||||
assert.equal(reviews.length, 101)
|
||||
assert.deepEqual(calls, [
|
||||
'/repos/owner/repo/pulls/42/reviews?per_page=100&page=1',
|
||||
'/repos/owner/repo/pulls/42/reviews?per_page=100&page=2',
|
||||
])
|
||||
})
|
||||
|
||||
test('tracks each reviewer\'s latest undismissed approval decision', () => {
|
||||
assert.deepEqual(approvedReviewerLogins([
|
||||
{ user: { login: 'commented-after' }, state: 'APPROVED' },
|
||||
{ user: { login: 'commented-after' }, state: 'COMMENTED' },
|
||||
{ user: { login: 'changes-after' }, state: 'APPROVED' },
|
||||
{ user: { login: 'changes-after' }, state: 'CHANGES_REQUESTED' },
|
||||
{ user: { login: 'dismissed' }, state: 'DISMISSED' },
|
||||
{ user: { login: 'approved-after' }, state: 'CHANGES_REQUESTED' },
|
||||
{ user: { login: 'approved-after' }, state: 'APPROVED' },
|
||||
{ user: { login: 'pending-after' }, state: 'APPROVED' },
|
||||
{ user: { login: 'pending-after' }, state: 'PENDING' },
|
||||
]), ['approved-after', 'commented-after', 'pending-after'])
|
||||
|
||||
assert.throws(
|
||||
() => approvedReviewerLogins([{ user: { login: 'reviewer' }, state: 'UNKNOWN' }]),
|
||||
/invalid state/u,
|
||||
)
|
||||
assert.throws(() => approvedReviewerLogins([{ state: 'APPROVED' }]), /invalid reviewer/u)
|
||||
})
|
||||
|
||||
test('fails closed when the pull-request review list exceeds its limit', async () => {
|
||||
let calls = 0
|
||||
await assert.rejects(
|
||||
listPullRequestReviews(async () => {
|
||||
calls++
|
||||
return Array.from({ length: 100 }, () => ({ user: { login: 'reviewer' }, state: 'COMMENTED' }))
|
||||
}, 'owner/repo', 42),
|
||||
/exceed 3000 entries/u,
|
||||
)
|
||||
assert.equal(calls, 30)
|
||||
})
|
||||
|
||||
test('fails closed when the review-request timeline exceeds its limit', async () => {
|
||||
let calls = 0
|
||||
await assert.rejects(
|
||||
listPullRequestTimeline(async () => {
|
||||
calls++
|
||||
return Array.from({ length: 100 }, () => ({ event: 'commented' }))
|
||||
}, 'owner/repo', 42),
|
||||
/exceeds 3000 events/u,
|
||||
)
|
||||
assert.equal(calls, 30)
|
||||
})
|
||||
|
||||
test('prints changed code files and requests the highest-ranked counted owner', async () => {
|
||||
const trace = []
|
||||
const files = [
|
||||
{ filename: 'packages/core/agent/src/index.ts', additions: 70, deletions: 10 },
|
||||
{ filename: 'packages/preset/agent-presets/src/index.ts', additions: 5, deletions: 5 },
|
||||
{ filename: 'packages/client/store/src/index.ts', additions: 2, deletions: 0 },
|
||||
{ filename: 'packages/subagent/subagent/src/index.ts', additions: 40, deletions: 0 },
|
||||
{ filename: 'packages/core/agent/tests/index.spec.ts', additions: 100, deletions: 0 },
|
||||
{ filename: 'AGENTS.md', additions: 200, deletions: 0 },
|
||||
]
|
||||
const api = async (path, options = {}) => {
|
||||
trace.push({ type: 'api', path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) return files
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) return []
|
||||
if (path.endsWith('/requested_reviewers') && options.method !== 'POST') {
|
||||
return { users: [], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
}
|
||||
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent({ author: 'turtle1999', changedFiles: files.length }),
|
||||
ownershipSource,
|
||||
api,
|
||||
write: line => trace.push({ type: 'log', line }),
|
||||
})
|
||||
|
||||
assert.deepEqual(result, {
|
||||
changedCodeFiles: [
|
||||
'packages/client/store/src/index.ts',
|
||||
'packages/core/agent/src/index.ts',
|
||||
'packages/preset/agent-presets/src/index.ts',
|
||||
'packages/subagent/subagent/src/index.ts',
|
||||
],
|
||||
excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'],
|
||||
excludedDocumentationFiles: ['AGENTS.md'],
|
||||
excludedCommentOnlyFiles: [],
|
||||
requestedReviewers: ['mektpoy'],
|
||||
cancelledReviewers: [],
|
||||
})
|
||||
assert.equal(trace[0].type, 'log')
|
||||
assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human')
|
||||
const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:')
|
||||
const relevanceHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Owner relevance by changed LOC:')
|
||||
const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST')
|
||||
assert.ok(changedHeading >= 0 && changedHeading < relevanceHeading && relevanceHeading < post)
|
||||
assert.deepEqual(trace.slice(relevanceHeading, relevanceHeading + 6).map(item => item.line), [
|
||||
'Owner relevance by changed LOC:',
|
||||
'- @turtle1999: 90',
|
||||
'- @mektpoy: 80',
|
||||
'- @Dudu-0223: 40',
|
||||
'- @LegGasai: 10',
|
||||
'- @imccyu: 2',
|
||||
])
|
||||
assert.deepEqual(trace[post], {
|
||||
type: 'api',
|
||||
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
|
||||
options: {
|
||||
method: 'POST',
|
||||
body: { reviewers: ['mektpoy'] },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('does not request an owner again after that owner approves', async () => {
|
||||
const calls = []
|
||||
const output = []
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent(),
|
||||
ownershipSource: '/packages/typert/ @imccyu\n',
|
||||
api: async (path, options = {}) => {
|
||||
calls.push({ path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) {
|
||||
return [{ filename: 'packages/typert/generator/src/analyzer.ts', additions: 150, deletions: 47 }]
|
||||
}
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) {
|
||||
return [
|
||||
{ user: { login: 'imccyu' }, state: 'APPROVED' },
|
||||
{ user: { login: 'imccyu' }, state: 'COMMENTED' },
|
||||
]
|
||||
}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [], teams: [] }
|
||||
}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: line => output.push(line),
|
||||
})
|
||||
|
||||
assert.deepEqual(result.requestedReviewers, [])
|
||||
assert.equal(calls.some(call => call.options.method === 'POST'), false)
|
||||
const approvedHeading = output.indexOf('Approved owners omitted from review requests:')
|
||||
assert.ok(approvedHeading >= 0)
|
||||
assert.equal(output[approvedHeading + 1], '- @imccyu')
|
||||
})
|
||||
|
||||
test('fills the counted slot with the next owner after omitting an approved owner', async () => {
|
||||
const calls = []
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent(),
|
||||
ownershipSource: '/packages/core/ @imccyu @mektpoy\n',
|
||||
api: async (path, options = {}) => {
|
||||
calls.push({ path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) {
|
||||
return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
|
||||
}
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) {
|
||||
return [{ user: { login: 'imccyu' }, state: 'APPROVED' }]
|
||||
}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: () => {},
|
||||
})
|
||||
|
||||
assert.deepEqual(result.requestedReviewers, ['mektpoy'])
|
||||
assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
|
||||
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
|
||||
options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
|
||||
})
|
||||
})
|
||||
|
||||
test('does not add another counted owner when one is already requested', async () => {
|
||||
const calls = []
|
||||
const output = []
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent(),
|
||||
ownershipSource: '/packages/core/ @mektpoy\n',
|
||||
api: async (path, options = {}) => {
|
||||
calls.push({ path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) {
|
||||
return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
|
||||
}
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) return []
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [{ login: 'first' }], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/timeline?per_page=100&page=1')) return []
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: line => output.push(line),
|
||||
})
|
||||
|
||||
assert.deepEqual(result.requestedReviewers, [])
|
||||
assert.equal(calls.some(call => call.options.method === 'POST'), false)
|
||||
assert.deepEqual(output.slice(-7), [
|
||||
'Current individual review requests:',
|
||||
'- @first',
|
||||
'Available counted review request slots: 0.',
|
||||
'Review requests to cancel:',
|
||||
'- (none)',
|
||||
'Reviewers to request:',
|
||||
'- (none)',
|
||||
])
|
||||
})
|
||||
|
||||
test('requests at most one owner per run when turtle ranks first', async () => {
|
||||
const calls = []
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }),
|
||||
ownershipSource: '/packages/core/ @turtle1999\n/packages/client/ @mektpoy\n',
|
||||
api: async (path, options = {}) => {
|
||||
calls.push({ path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) {
|
||||
return [
|
||||
{ filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 },
|
||||
{ filename: 'packages/client/store/src/index.ts', additions: 8, deletions: 2 },
|
||||
]
|
||||
}
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) return []
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: () => {},
|
||||
})
|
||||
|
||||
assert.deepEqual(result.requestedReviewers, ['turtle1999'])
|
||||
assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
|
||||
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
|
||||
options: { method: 'POST', body: { reviewers: ['turtle1999'] } },
|
||||
})
|
||||
})
|
||||
|
||||
test('does not add turtle when one counted reviewer is already requested', async () => {
|
||||
const calls = []
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent({ author: 'contributor' }),
|
||||
ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
|
||||
api: async (path, options = {}) => {
|
||||
calls.push({ path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) {
|
||||
return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
|
||||
}
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) return []
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [{ login: 'first' }], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/timeline?per_page=100&page=1')) return []
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: () => {},
|
||||
})
|
||||
|
||||
assert.deepEqual(result.requestedReviewers, [])
|
||||
assert.equal(calls.some(call => call.options.method === 'POST'), false)
|
||||
})
|
||||
|
||||
test('keeps the counted slot available when turtle is already requested', async () => {
|
||||
const calls = []
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent({ author: 'contributor' }),
|
||||
ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
|
||||
api: async (path, options = {}) => {
|
||||
calls.push({ path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) {
|
||||
return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
|
||||
}
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) return []
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [{ login: 'turtle1999' }], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/timeline?per_page=100&page=1')) return []
|
||||
if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: () => {},
|
||||
})
|
||||
|
||||
assert.deepEqual(result.requestedReviewers, ['mektpoy'])
|
||||
assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
|
||||
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
|
||||
options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
|
||||
})
|
||||
})
|
||||
|
||||
test('replaces a workflow reviewer that no longer matches current ownership', async () => {
|
||||
const trace = []
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent({ author: 'contributor' }),
|
||||
ownershipSource: '/packages/core/ @mektpoy\n',
|
||||
api: async (path, options = {}) => {
|
||||
trace.push({ type: 'api', path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) {
|
||||
return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
|
||||
}
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) return []
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [{ login: 'Dudu-0223' }], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/timeline?per_page=100&page=1')) {
|
||||
return [{
|
||||
event: 'review_requested',
|
||||
requested_reviewer: { login: 'Dudu-0223' },
|
||||
review_requester: { login: 'github-actions[bot]' },
|
||||
}]
|
||||
}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: line => trace.push({ type: 'log', line }),
|
||||
})
|
||||
|
||||
assert.deepEqual(result.requestedReviewers, ['mektpoy'])
|
||||
assert.deepEqual(result.cancelledReviewers, ['Dudu-0223'])
|
||||
const cancelLog = trace.findIndex(item => item.type === 'log' && item.line === 'Review requests to cancel:')
|
||||
const requestLog = trace.findIndex(item => item.type === 'log' && item.line === 'Reviewers to request:')
|
||||
const firstMutation = trace.findIndex(item => item.type === 'api' && item.options.method !== undefined)
|
||||
assert.ok(cancelLog >= 0 && requestLog >= 0 && cancelLog < firstMutation && requestLog < firstMutation)
|
||||
assert.equal(trace[cancelLog + 1].line, '- @Dudu-0223')
|
||||
assert.equal(trace[requestLog + 1].line, '- @mektpoy')
|
||||
assert.deepEqual(trace.filter(item => item.type === 'api' && item.options.method !== undefined), [
|
||||
{
|
||||
type: 'api',
|
||||
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
|
||||
options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
|
||||
},
|
||||
{
|
||||
type: 'api',
|
||||
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
|
||||
options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('removes excess workflow reviewers using current relevance order', async () => {
|
||||
const calls = []
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }),
|
||||
ownershipSource: '/packages/core/ @mektpoy\n/packages/subagent/ @Dudu-0223\n',
|
||||
api: async (path, options = {}) => {
|
||||
calls.push({ path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) {
|
||||
return [
|
||||
{ filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 },
|
||||
{ filename: 'packages/subagent/subagent/src/index.ts', additions: 8, deletions: 2 },
|
||||
]
|
||||
}
|
||||
if (path.endsWith('/reviews?per_page=100&page=1')) return []
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [{ login: 'Dudu-0223' }, { login: 'mektpoy' }], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/timeline?per_page=100&page=1')) {
|
||||
return ['Dudu-0223', 'mektpoy'].map(login => ({
|
||||
event: 'review_requested',
|
||||
requested_reviewer: { login },
|
||||
review_requester: { login: 'github-actions[bot]' },
|
||||
}))
|
||||
}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: () => {},
|
||||
})
|
||||
|
||||
assert.deepEqual(result, {
|
||||
changedCodeFiles: [
|
||||
'packages/core/agent/src/index.ts',
|
||||
'packages/subagent/subagent/src/index.ts',
|
||||
],
|
||||
excludedTestFiles: [],
|
||||
excludedDocumentationFiles: [],
|
||||
excludedCommentOnlyFiles: [],
|
||||
requestedReviewers: [],
|
||||
cancelledReviewers: ['Dudu-0223'],
|
||||
})
|
||||
assert.deepEqual(calls.find(call => call.options.method === 'DELETE'), {
|
||||
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
|
||||
options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
|
||||
})
|
||||
})
|
||||
|
||||
test('does not request reviewers for test, documentation, or comment-only changes', async () => {
|
||||
const calls = []
|
||||
const output = []
|
||||
const files = [
|
||||
{ filename: 'apps/web/tests/chat.e2e.ts', additions: 10, deletions: 0 },
|
||||
{ filename: 'packages/core/agent/tests/agent.spec.ts', additions: 10, deletions: 0 },
|
||||
{ filename: 'packages/core/agent/README.md', additions: 10, deletions: 0 },
|
||||
{ filename: 'packages/core/agent/examples.yaml', additions: 10, deletions: 0 },
|
||||
{
|
||||
filename: 'packages/core/agent/src/index.ts',
|
||||
status: 'modified', additions: 1, deletions: 1,
|
||||
patch: '@@ -1 +1 @@\n-// old note\n+// new note',
|
||||
},
|
||||
]
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent({ changedFiles: files.length }),
|
||||
ownershipSource,
|
||||
api: async (path) => {
|
||||
calls.push(path)
|
||||
if (path.endsWith('/files?per_page=100&page=1')) return files
|
||||
if (path.endsWith('/requested_reviewers')) return { users: [], teams: [] }
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: line => output.push(line),
|
||||
})
|
||||
assert.deepEqual(result, {
|
||||
changedCodeFiles: [],
|
||||
excludedTestFiles: files.slice(0, 2).map(file => file.filename),
|
||||
excludedDocumentationFiles: files.slice(2, 4).map(file => file.filename),
|
||||
excludedCommentOnlyFiles: ['packages/core/agent/src/index.ts'],
|
||||
requestedReviewers: [],
|
||||
cancelledReviewers: [],
|
||||
})
|
||||
assert.equal(calls.length, 2)
|
||||
assert.deepEqual(output.slice(0, 4), [
|
||||
'This is by automated Angry Turtle Cyborg, not a human',
|
||||
'Changed code files:',
|
||||
'- (none)',
|
||||
'Excluded test files:',
|
||||
])
|
||||
})
|
||||
|
||||
test('cancels workflow-authored review requests on draft pull requests', async () => {
|
||||
const trace = []
|
||||
const files = [
|
||||
{ filename: 'packages/subagent/subagent/src/index.ts', additions: 10, deletions: 2 },
|
||||
{ filename: 'packages/subagent/subagent/tests/index.spec.ts', additions: 10, deletions: 0 },
|
||||
{ filename: 'packages/subagent/subagent/README.md', additions: 10, deletions: 0 },
|
||||
]
|
||||
const result = await requestReviews({
|
||||
event: pullRequestEvent({ draft: true, changedFiles: files.length }),
|
||||
ownershipSource,
|
||||
api: async (path, options = {}) => {
|
||||
trace.push({ type: 'api', path, options })
|
||||
if (path.endsWith('/files?per_page=100&page=1')) return files
|
||||
if (path.endsWith('/requested_reviewers') && options.method === undefined) {
|
||||
return { users: [{ login: 'Dudu-0223' }, { login: 'manual-reviewer' }], teams: [] }
|
||||
}
|
||||
if (path.endsWith('/timeline?per_page=100&page=1')) {
|
||||
return [
|
||||
{
|
||||
event: 'review_requested',
|
||||
requested_reviewer: { login: 'Dudu-0223' },
|
||||
review_requester: { login: 'maintainer' },
|
||||
},
|
||||
{
|
||||
event: 'review_requested',
|
||||
requested_reviewer: { login: 'Dudu-0223' },
|
||||
review_requester: { login: 'github-actions[bot]' },
|
||||
},
|
||||
{
|
||||
event: 'review_requested',
|
||||
requested_reviewer: { login: 'manual-reviewer' },
|
||||
review_requester: { login: 'github-actions[bot]' },
|
||||
},
|
||||
{
|
||||
event: 'review_requested',
|
||||
requested_reviewer: { login: 'manual-reviewer' },
|
||||
review_requester: { login: 'maintainer' },
|
||||
},
|
||||
]
|
||||
}
|
||||
if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
|
||||
throw new Error(`unexpected API path ${path}`)
|
||||
},
|
||||
write: line => trace.push({ type: 'log', line }),
|
||||
})
|
||||
assert.deepEqual(result, {
|
||||
changedCodeFiles: ['packages/subagent/subagent/src/index.ts'],
|
||||
excludedTestFiles: ['packages/subagent/subagent/tests/index.spec.ts'],
|
||||
excludedDocumentationFiles: ['packages/subagent/subagent/README.md'],
|
||||
excludedCommentOnlyFiles: [],
|
||||
requestedReviewers: [],
|
||||
cancelledReviewers: ['Dudu-0223'],
|
||||
})
|
||||
const remove = trace.find(item => item.type === 'api' && item.options.method === 'DELETE')
|
||||
assert.deepEqual(remove, {
|
||||
type: 'api',
|
||||
path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
|
||||
options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
|
||||
})
|
||||
assert.equal(trace.some(item => item.type === 'log' && item.line === '- @manual-reviewer'), false)
|
||||
assert.equal(trace.at(-1).line, 'Cancelled review request for @Dudu-0223.')
|
||||
})
|
||||
|
||||
test('sends authenticated JSON and escapes an API error body', async () => {
|
||||
const requests = []
|
||||
const api = createGitHubApi({
|
||||
token: 'secret',
|
||||
apiUrl: 'https://github.example/api/v3/',
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
},
|
||||
})
|
||||
assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true })
|
||||
assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo')
|
||||
assert.equal(requests[0].options.headers.Authorization, 'Bearer secret')
|
||||
assert.equal(requests[0].options.headers['X-GitHub-Api-Version'], '2026-03-10')
|
||||
assert.equal(requests[0].options.body, '{"value":1}')
|
||||
|
||||
const failing = createGitHubApi({
|
||||
token: 'secret',
|
||||
fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }),
|
||||
})
|
||||
await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
name: request-review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: request-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
request-review:
|
||||
name: request-review
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# SECURITY: the write-capable job executes policy from the trusted default
|
||||
# branch and reads pull-request filenames only as API data.
|
||||
- name: Check out trusted review policy
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- name: Request reviewers
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node .github/review-ownership/request-review.mjs
|
||||
@@ -353,7 +353,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
expect(snapshot).toContain('Minimal mode')
|
||||
expect(snapshot).toContain('button "1 subagent"')
|
||||
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode'))
|
||||
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "Session log"'))
|
||||
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "More actions"'))
|
||||
// Static chrome, not a control: the header can only report a composition
|
||||
// the host would refuse to change.
|
||||
expect(snapshot).not.toContain('button "Minimal mode"')
|
||||
|
||||
@@ -130,9 +130,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
expect(await column.locator('[data-dockkit-tab]').count()).toBe(1)
|
||||
const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText()
|
||||
const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
|
||||
expect(initialFilesClose).toBe(0)
|
||||
expect(initialFilesClose).toBe(1)
|
||||
await filesTab.click({ button: 'right' })
|
||||
expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(0)
|
||||
expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1)
|
||||
await page.keyboard.press('Escape')
|
||||
await addTab.waitFor({ state: 'visible' })
|
||||
const initialAdd = await addTab.count()
|
||||
expect(initialAdd).toBe(1)
|
||||
@@ -143,9 +144,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count()
|
||||
const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count()
|
||||
expect(guideClose).toBe(1)
|
||||
expect(filesCloseWithGuide).toBe(0)
|
||||
expect(filesCloseWithGuide).toBe(1)
|
||||
await expect.poll(() => addTab.count()).toBe(0)
|
||||
const addWithGuide = await addTab.count()
|
||||
await guideTab.hover()
|
||||
await guideTab.locator('[data-dockkit-tab-close]').click()
|
||||
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
|
||||
await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0)
|
||||
@@ -153,7 +155,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
await addTab.waitFor({ state: 'visible' })
|
||||
const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
|
||||
const restoredAdd = await addTab.count()
|
||||
expect(restoredFilesClose).toBe(0)
|
||||
expect(restoredFilesClose).toBe(1)
|
||||
expect(restoredAdd).toBe(1)
|
||||
const preview = column.locator('[data-document-preview]')
|
||||
const openFile = async (name: string): Promise<void> => {
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
- img
|
||||
- img
|
||||
- text: Minimal mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Clickable links gallery" [disabled]
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
- button "Review deepseek-harness/deepseek-harness#314" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -14,10 +14,9 @@
|
||||
- button "Review deepseek-harness/deepseek-harness#314" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- button "workspace" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "CJK strong emphasis" [disabled]
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Markdown image policy" [disabled]
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Inline code links" [disabled]
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Math rendering" [disabled]
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reference order target" [disabled]
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- button "/user-invoke-demo and confirm the fixtur" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- button "/user-invoke-demo and confirm the fixtur" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "{{workspace}}" [disabled]
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -274,12 +274,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
|
||||
await ensureSeedOpen(page)
|
||||
const exportButton = page.getByRole('button', { name: 'Session log' })
|
||||
const exportButton = page.getByRole('button', { name: 'More actions' })
|
||||
expect(await exportButton.isDisabled()).toBe(false)
|
||||
const header = exportButton.locator('xpath=ancestor::header[1]')
|
||||
// The right Sidebar's expand button holds the header's corner; the export
|
||||
// control sits immediately to its left.
|
||||
const sidebarButton = page.getByRole('button', { name: 'Open the sidebar' })
|
||||
const sidebarButton = page.getByRole('button', { name: 'Open right sidebar' })
|
||||
const [buttonBox, sidebarBox, headerBox] = await Promise.all([
|
||||
exportButton.boundingBox(), sidebarButton.boundingBox(), header.boundingBox(),
|
||||
])
|
||||
@@ -293,6 +293,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
&& new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 })
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 })
|
||||
await exportButton.click()
|
||||
await page.getByRole('menuitem', { name: 'Download session log' }).click()
|
||||
const response = await responsePromise
|
||||
expect(response.status()).toBe(200)
|
||||
const download = await downloadPromise
|
||||
|
||||
@@ -433,7 +433,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
// Put the column back so the later goldens see the default frame.
|
||||
await column.locator('[data-sidebar-right-toggle]').click()
|
||||
await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
await page.getByRole('button', { name: 'Open the sidebar', exact: true }).waitFor({ state: 'visible' })
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' })
|
||||
await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
|
||||
})
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import type { Browser, ConsoleMessage, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
@@ -360,14 +360,13 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(panelWidth).toBeGreaterThan(0)
|
||||
expect(await width(conversation)).toBe(centerBefore - panelWidth)
|
||||
await expect.poll(async () => await expand.count()).toBe(0)
|
||||
// The corner keeps its footprint, so the utilities' right edge stays where
|
||||
// it was relative to the conversation's own right edge.
|
||||
expect(await page.locator('[data-sidebar-right-expand-placeholder]').count()).toBe(1)
|
||||
// The corner seat collapses with its button, so the utilities' right edge
|
||||
// moves out toward the conversation's own.
|
||||
const utilitiesAfter = await utilities.boundingBox()
|
||||
const conversationAfter = await conversation.boundingBox()
|
||||
if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered')
|
||||
const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width)
|
||||
expect(Math.round(gapAfter)).toBe(Math.round(gapBefore))
|
||||
expect(gapAfter).toBeLessThan(gapBefore)
|
||||
|
||||
// The panel is in the column, not over it, and carries the seeded tab —
|
||||
// whose body arrives through the Files type's keyed registration, not from
|
||||
@@ -391,23 +390,24 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(await centreY(selector), selector).toBe(textLine)
|
||||
}
|
||||
|
||||
// Files is permanent. A manual guide is closable and suppresses another
|
||||
// add control in its pane until it is closed.
|
||||
// A manual guide is closable beside Files and suppresses another add
|
||||
// control in its pane until it is closed.
|
||||
const addTab = column.locator('[data-dockkit-add-tab]')
|
||||
const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
|
||||
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
|
||||
expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
||||
expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
await expect.poll(async () => await addTab.count()).toBe(1)
|
||||
expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine)
|
||||
await addTab.click()
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
|
||||
await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
|
||||
await expect.poll(async () => await addTab.count()).toBe(0)
|
||||
expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
||||
expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
||||
expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
// Back to the seeded shape the cases below start from.
|
||||
await guideTab.hover()
|
||||
await guideTab.locator('[data-dockkit-tab-close]').click()
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
|
||||
await expect.poll(async () => await addTab.count()).toBe(1)
|
||||
@@ -592,6 +592,73 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('survives grip drags past both clamps on a squeezed viewport', async () => {
|
||||
// Regression: an overshoot drag swept the panel through widths where a
|
||||
// width-blocked split control hid itself, which changed the very strip
|
||||
// measurement that had blocked it — a layout-effect feedback loop that
|
||||
// crashed the pane (React error #185) and unmounted the rightbar slot
|
||||
// entry while the column still believed it was expanded, so neither the
|
||||
// panel nor the header's expand button remained. The crash surfaces only
|
||||
// as a console error, which the scaffold tripwire does not watch, so this
|
||||
// case collects console errors itself.
|
||||
const viewport = page.viewportSize()
|
||||
if (viewport === null) throw new Error('expected a fixed viewport')
|
||||
const column = await resetSidebar(page)
|
||||
const frame = page.locator('[class*="frame"]').first()
|
||||
const panel = column.locator('[data-sidebar-right-panel]')
|
||||
const consoleErrors: string[] = []
|
||||
const collect = (message: ConsoleMessage): void => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text().slice(0, 600))
|
||||
}
|
||||
page.on('console', collect)
|
||||
try {
|
||||
await page.setViewportSize({ width: 1000, height: viewport.height })
|
||||
await ensureExpanded(page, column)
|
||||
await width(column)
|
||||
const grip = frame.locator('[data-side="rightbar"]')
|
||||
// The frame reads the new viewport through a throttled ResizeObserver,
|
||||
// a couple of frames after the resize; until then the grip sits at the
|
||||
// old frame's coordinates. Press only a grip aligned with the panel's
|
||||
// left edge (the handle is 8px wide, centred on the seam).
|
||||
await expect.poll(async () => {
|
||||
const gripBox = await grip.boundingBox()
|
||||
const panelBox = await panel.boundingBox()
|
||||
if (gripBox === null || panelBox === null) return Number.NaN
|
||||
return Math.abs(gripBox.x + 4 - panelBox.x)
|
||||
}).toBeLessThanOrEqual(1)
|
||||
// Narrow with overshoot: drag the grip far right past the clamp floor.
|
||||
const from = await centre(grip)
|
||||
await page.mouse.move(from.x, from.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(980, from.y, { steps: 30 })
|
||||
await page.mouse.up()
|
||||
// The panel holds its floor, still open, with its grip still rendered.
|
||||
await expect.poll(async () => await width(panel)).toBeLessThanOrEqual(302)
|
||||
expect(await width(panel)).toBeGreaterThanOrEqual(300)
|
||||
expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
|
||||
expect(await grip.count()).toBe(1)
|
||||
// Widen with overshoot to the far left: clamped by the frame's range.
|
||||
const back = await centre(grip)
|
||||
await page.mouse.move(back.x, back.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(20, back.y, { steps: 30 })
|
||||
await page.mouse.up()
|
||||
const widened = await width(panel)
|
||||
expect(widened).toBeGreaterThan(302)
|
||||
expect(widened).toBeLessThan(1000)
|
||||
expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
|
||||
expect(await grip.count()).toBe(1)
|
||||
expect(consoleErrors).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
page.off('console', collect)
|
||||
await page.setViewportSize(viewport)
|
||||
await ensureExpanded(page, column)
|
||||
await setPanelWidth(page, Math.round(viewport.width * 0.45))
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => {
|
||||
const files = (scaffold.ctx as unknown as {
|
||||
get(name: string): {
|
||||
@@ -686,7 +753,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
await expect.poll(async () => await panes.count()).toBe(2)
|
||||
|
||||
const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
||||
expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
|
||||
await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
|
||||
|
||||
@@ -850,33 +917,58 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('keeps the last tab unclosable and drops a pane emptied by a drag', async () => {
|
||||
it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
|
||||
const column = await resetSidebar(page)
|
||||
const panes = column.locator('[data-dockkit-pane]')
|
||||
expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
||||
expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
|
||||
await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME])
|
||||
await panes.first().locator('[data-dockkit-split-button]').click()
|
||||
await expect.poll(async () => await panes.count()).toBe(2)
|
||||
await dragTo(page, panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }),
|
||||
await pointIn(panes.nth(1), 0.5, 0.5))
|
||||
await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual(['Files', SAMPLE_NAME])
|
||||
const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
||||
await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
|
||||
await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
|
||||
|
||||
// The remaining ordinary document is also unclosable while alone.
|
||||
expect(await panes.nth(1).locator('[data-dockkit-tab-close]').count()).toBe(0)
|
||||
await dragTo(page, panes.nth(1).locator('[data-dockkit-tab]'), await pointIn(panes.first(), 0.5, 0.5))
|
||||
// Closing a pane's last tab drops the pane: there is no separate
|
||||
// "close pane" gesture, and none is needed.
|
||||
await panes.nth(1).locator('[data-dockkit-tab]').first().hover()
|
||||
await panes.nth(1).locator('[data-dockkit-tab-close]').first().click()
|
||||
await expect.poll(async () => await panes.count()).toBe(1)
|
||||
const documentTab = panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
|
||||
await expect.poll(async () => await documentTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
await documentTab.locator('[data-dockkit-tab-close]').click()
|
||||
await expect.poll(async () => await documentTab.count()).toBe(0)
|
||||
expect(await panes.count()).toBe(1)
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
|
||||
|
||||
// Leave a guide as the sole docked tab.
|
||||
const files = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
await files.hover()
|
||||
await files.locator('[data-dockkit-tab-close]').click()
|
||||
await column.locator('[data-dockkit-add-tab]').click()
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
|
||||
const sample = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
|
||||
await sample.hover()
|
||||
await sample.locator('[data-dockkit-tab-close]').click()
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
|
||||
|
||||
// The guide standing as the docked surface's only tab draws no close
|
||||
// control, sits quiet (no capsule, no hover fill), and a secondary press
|
||||
// opens no menu: an empty menu never shows.
|
||||
expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
|
||||
expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1)
|
||||
await column.locator('[data-dockkit-tab]').first().click({ button: 'right' })
|
||||
expect(await page.locator('[data-dockkit-tab-menu]').isVisible()).toBe(false)
|
||||
expect(await page.getByRole('menu').count()).toBe(0)
|
||||
|
||||
// Any other tab standing alone closes together with the column. Open the
|
||||
// sample file, close the guide (an ordinary close with two tabs), then
|
||||
// close the file: the column collapses in the same gesture, and the
|
||||
// settle rule reseeds the current default, so reopening shows Files.
|
||||
await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
|
||||
await column.locator('[data-dockkit-tab]').first().hover()
|
||||
await column.locator('[data-dockkit-tab-close]').first().click()
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
|
||||
await column.locator('[data-dockkit-tab]').first().hover()
|
||||
await column.locator('[data-dockkit-tab-close]').first().click()
|
||||
await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
await expandOf(page).click()
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
|
||||
expect(await column.locator('[data-files-state="tree"]').count()).toBe(1)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
@@ -959,8 +1051,8 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
// as a layout defect that is not there.
|
||||
expect(await width(column)).toBeGreaterThan(300)
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始'])
|
||||
await expect.poll(async () => await guide.locator('p').first().innerText())
|
||||
.toBe('侧栏用来放你想一直看着的东西。')
|
||||
await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText())
|
||||
.toBe('工作区文件')
|
||||
await shot(zhPage, '05-guide-copy-zh')
|
||||
|
||||
expect(zhTripwire.pageErrors).toEqual([])
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
- button "Stream one TypeScript fence for" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
- text: Session log
|
||||
- button "More actions":
|
||||
- img
|
||||
- button "Open the sidebar":
|
||||
- button "Open right sidebar":
|
||||
- img
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
|
||||
@@ -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 docs/i18n/README.md
|
||||
README.md: 55ae07c18e09fde141ecf5344f715dfa25658325
|
||||
README.zh.md: 674edeb9da4bf0083c607216a3c992a98f61897a
|
||||
README.md: 2ff8fc62f21d58a4d31b8aadd80c7a0c14556e6d
|
||||
README.zh.md: 73da4445bc35e779a990d4cd4aef605cc9078a06
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ Generated English references and graphs participate in pairing when a reviewed C
|
||||
- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.
|
||||
- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.
|
||||
- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.
|
||||
- [review-ownership/README.md](../../.github/review-ownership/README.md) and its [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) — repository-internal automation policy maintained in English only.
|
||||
- [review-ownership/README.md](../../.github/review-ownership/README.md) — repository-internal approval policy maintained in English only.
|
||||
- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.
|
||||
|
||||
**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。
|
||||
- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。
|
||||
- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
|
||||
- [review-ownership/README.md](../../.github/review-ownership/README.md) 及其 [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md):仓库内部自动化政策,只以英文维护。
|
||||
- [review-ownership/README.md](../../.github/review-ownership/README.md):仓库内部审批策略,只以英文维护。
|
||||
- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。
|
||||
|
||||
**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
|
||||
"test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs",
|
||||
"test:issue-management": "node .github/issue-management/policy.test.mjs",
|
||||
"test:request-review": "node --test .github/review-ownership/request-review.test.mjs",
|
||||
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
|
||||
"test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
|
||||
"test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
|
||||
|
||||
@@ -143,10 +143,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
/**
|
||||
* The header's far-right corner, past the utilities' edge and into the
|
||||
* header's own padding, for one control that must keep its place whether or
|
||||
* not it currently shows anything. The corner reserves its width while an
|
||||
* occupant is registered, so the utilities beside it never move; an
|
||||
* occupant with nothing to show renders a same-size placeholder.
|
||||
* header's own padding, for one control. The corner is laid out only while
|
||||
* its occupant renders something; an occupant with nothing to show renders
|
||||
* nothing, and the utilities take the header's edge.
|
||||
*/
|
||||
'conversation.session.header.corner': {
|
||||
kind: 'single'
|
||||
|
||||
@@ -34,23 +34,17 @@
|
||||
--dsh-composer-dock-inset: 8px;
|
||||
}
|
||||
|
||||
/* 76px with its rule: the height of the Sidebar's tab strip and header row
|
||||
(ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules
|
||||
meet at the column edge. The rows add up to it — 10px top inset, the 30px
|
||||
title row, the tab strip's 10px margin, a 16px tab line, and its 9px bottom
|
||||
inset — so no row below may grow past its figure. */
|
||||
.header {
|
||||
position: relative;
|
||||
flex: none;
|
||||
padding: 12px 28px 0 20px;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 1px;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
height: 0.5px;
|
||||
background: var(--dsw-alias-border-l3);
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
min-height: 76px;
|
||||
padding: 10px 28px 0 20px;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* Blank hero/settling: keep the strict Session header mounted without taking
|
||||
@@ -63,7 +57,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 32px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.titleCluster {
|
||||
@@ -147,14 +141,14 @@
|
||||
}
|
||||
|
||||
/* The far-right corner seat reaches 16px into the header's 28px right padding,
|
||||
so its control sits past the utilities' edge; it is laid out only while an
|
||||
occupant is registered, and the occupant keeps its width while hidden, so the
|
||||
utilities never move because of it. */
|
||||
so its control sits past the utilities' edge; it is laid out only while its
|
||||
occupant renders something. Its left margin equals the utilities' 8px gap so
|
||||
the header's trailing controls space evenly. */
|
||||
.headerCorner {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
margin-left: 8px;
|
||||
margin-right: -16px;
|
||||
}
|
||||
|
||||
@@ -162,20 +156,23 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned.
|
||||
Positioned above the header's own paint, so the active bar covers the rule. */
|
||||
.tabs {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 36px;
|
||||
margin-top: 4px;
|
||||
margin-top: 10px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 2px bar. */
|
||||
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500). The 2px bar
|
||||
reaches 1px past the tab's box to end flush with the header's bottom edge,
|
||||
sitting over the rule. */
|
||||
.tab {
|
||||
position: relative;
|
||||
padding: 0 0 11px;
|
||||
padding: 0 0 9px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
@@ -189,7 +186,7 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 1px;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
@@ -346,6 +343,10 @@
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
/* The product-wide 2px scrollbar offset (ui-workspace WorkspaceBrowser
|
||||
`.list`, ui-sidebar-files FilesBody `.body`): the bar sits 2px clear of
|
||||
the column's edge instead of flush against it. */
|
||||
margin-right: 2px;
|
||||
overflow-y: auto;
|
||||
/* Reserved unconditionally: the composer seat rides this box's content box in
|
||||
Chat and its padding box under a view's composer overlay, so an `auto`
|
||||
@@ -354,6 +355,12 @@
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* The bar also stops 2px short of the scroller's ends, matching its 2px edge
|
||||
offset. WebKit-only: the Firefox path has no track to inset. */
|
||||
.scrollBody::-webkit-scrollbar-track {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .viewArea {
|
||||
flex: 1 0 auto;
|
||||
min-height: auto;
|
||||
|
||||
@@ -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-dockkit/README.md
|
||||
README.md: f4f76072bd16cd75bff74715f8f9437348ee036a
|
||||
README.zh.md: 39e20b42bfabb49630b1c9e108b43010d6e7b199
|
||||
README.md: 396253d771196380d04da13cfc0843584b37fbc2
|
||||
README.zh.md: ec2d5077ef847b3fa50d7e78113b978ec4fa7507
|
||||
|
||||
@@ -37,7 +37,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
|
||||
- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it.
|
||||
- `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does).
|
||||
|
||||
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. The optional `canCloseTab(tabId)` hides the chip, menu, and floating close controls; the embedder enforces closing in its intent handler. A chip is a capsule carrying its close control when allowed; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
|
||||
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
## Embedding it
|
||||
@@ -47,12 +47,12 @@ Everything host-specific arrives through props:
|
||||
| Contract | Carries |
|
||||
|---|---|
|
||||
| `DockLabels` | every rendered string, already localized, accessible names included |
|
||||
| `TabRenderer` | one tab's body (`renderTab`), and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` |
|
||||
| `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` |
|
||||
| `DockIntents` | the settled results of every gesture |
|
||||
|
||||
`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Two props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`) and `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane). Hiding the add control moves nothing else in the strip. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule.
|
||||
`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule.
|
||||
|
||||
`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitAtCapacity` hides the split control at the pane budget; its default is false, and a width-blocked control remains disabled.
|
||||
`dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false.
|
||||
|
||||
A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end.
|
||||
|
||||
@@ -64,18 +64,16 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp
|
||||
These are not stylistic; each one fixes a defect found in a real browser.
|
||||
|
||||
- **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works.
|
||||
- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow: hidden`); the add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen.
|
||||
- **The chip box is not a scroll container.** A horizontal scroller claims press-and-move for itself; tabs shrink, ellipsize, and then clip instead.
|
||||
- **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 44px` on a content-box, so its footprint is 44px plus 10px + 5px of padding, 59px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (4px). A column split, which only an edge drop makes, needs each half to hold the strip (36px) plus a 48px body: one 13px secondary line at 1.6 line-height inside the body's 12px padding. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow`, and offers no edge drop zone for that axis (the release is then not a move). A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split.
|
||||
- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen.
|
||||
- **The chip box scrolls, but never claims a gesture.** A horizontal scroller would take press-and-move for itself and cancel the pointer; the box, the chips, and the strip set `touch-action: none` and the gesture captures the pointer, so a press-and-move on a chip is a drag and only the wheel scrolls the box.
|
||||
- **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 80px` on a content-box, so its footprint is 80px plus 10px + 10px of padding, 100px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (0 — its hairline paints over the seam without taking layout room, so a body's own rules run unbroken past it). A column split, which only an edge drop makes, needs each half to hold the strip (34px) plus a 48px body: one 13px secondary line at 1.6 line-height inside 12px of the body's own insets — the pane body itself is unpadded, so a tab's body reaches the strip's bottom edge and the pane's edges and draws its own. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow` (hidden instead under `hideSplitWhenBlocked`), and offers no edge drop zone for that axis (the release is then not a move). Under `hideSplitWhenBlocked` the split control's own footprint — its box plus the strip's gap — is left out of the fixed part: hiding the control sheds exactly that footprint from the strip, so a reading that counted it would flip with the control's visibility and re-render forever; leaving it out is also what the half being asked about would carry, since a half too narrow to split hides its own control. A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split.
|
||||
- **Focus lands on click, not on press.** A state change between `pointerdown` and the first `pointermove` rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing.
|
||||
- **A control nested inside a draggable chip stops its own press.** Otherwise the press starts a drag, captures the pointer, and the nested control's click never lands.
|
||||
- **Emphasis takes the platform's accent, never `--dsw-alias-brand-primary`.** This platform binds `brand-primary` to its near-black (light) or near-white (dark) foreground, so a hovered divider, the drop caret, and the drop-zone hint use `--dsw-alias-brand-primary-new-colorprimary-new-color`, as the trajectory views do. A floating panel's border is the same `--dsw-alias-border-l2` hairline whether it is active or not: the active panel is already on top and casts the shadow; a darker frame around it read as a defect.
|
||||
- **Emphasis takes the platform's accent, never `--dsw-alias-brand-primary`.** This platform binds `brand-primary` to its near-black (light) or near-white (dark) foreground, so the drop caret and the drop-zone hint use `--dsw-alias-brand-primary-new-colorprimary-new-color`, as the trajectory views do; a hovered divider takes the caption label ink instead, reading as a handle rather than a highlight. A floating panel draws no border — the menu's shadow (`--dsw-elevation-prominent`) outlines it — and the active panel gets no heavier frame: it is already on top and casts the same shadow; a darker frame around it read as a defect.
|
||||
|
||||
<a id="build-shape"></a>
|
||||
## Build shape
|
||||
|
||||
Its static ESM retains third-party imports for the Web shell's Vite build; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)).
|
||||
|
||||
The package is statically linked: tsdown's `staticLinked` preset emits one browser ESM bundle at `lib/index.js` (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under `lib/` at its `src`-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps **one** stylesheet, `dockkit.module.css`, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-reference"
|
||||
- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。
|
||||
- `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。
|
||||
|
||||
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。可选的 `canCloseTab(tabId)` 隐藏 chip、菜单和浮窗的关闭控件;嵌入方在意图处理器中执行关闭限制。chip 是一个胶囊,在允许关闭时携带关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
|
||||
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
## 如何嵌入
|
||||
@@ -47,12 +47,12 @@ kind: "package-reference"
|
||||
| 契约 | 承载内容 |
|
||||
|---|---|
|
||||
| `DockLabels` | 每一个渲染出来的字符串,已本地化,含无障碍名称 |
|
||||
| `TabRenderer` | 一个 tab 的正文(`renderTab`),以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
|
||||
| `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
|
||||
| `DockIntents` | 每次手势落定的结果 |
|
||||
|
||||
`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有两个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)与 `canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)。隐藏添加控件不会移动 tab 条里的其它任何东西。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
|
||||
`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
|
||||
|
||||
`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitAtCapacity` 在达到窗格预算时隐藏分栏控件,默认值为 false;宽度不足的控件仍以禁用状态显示。
|
||||
`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。
|
||||
|
||||
tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。
|
||||
|
||||
@@ -64,18 +64,16 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt
|
||||
这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
|
||||
|
||||
- **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。
|
||||
- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow: hidden`);添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。
|
||||
- **chip 盒不是滚动容器。** 横向滚动容器会把按下并移动据为己有;tab 转而收缩、省略、然后被裁切。
|
||||
- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 44px`,所以它的足印是 44px 加 10px + 5px 内边距,即 59px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(4px)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(36px)加 48px 正文:正文 12px 内边距内一行 13px、行高 1.6 的次级文字。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用,并且在该轴上不提供边缘落区(松手就不是移动)。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。
|
||||
- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。
|
||||
- **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子。
|
||||
- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。
|
||||
- **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。
|
||||
- **嵌套在可拖动 chip 里的控件要拦住自己的按下。** 否则按下会开始拖动、捕获指针,嵌套控件的 click 就永远落不下。
|
||||
- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此悬停的分隔条、落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致。浮窗的边框无论是否活动都是同一条 `--dsw-alias-border-l2` 细线:活动浮窗本就在最上层并投下阴影;围它一圈更深的边框读起来像缺陷。
|
||||
- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致;悬停的分隔条改用 caption 文字色,读起来是把手而不是高亮。浮窗不画边框——菜单同款阴影(`--dsw-elevation-prominent`)已勾出它的轮廓——活动浮窗也不加重边框:它本就在最上层、投同样的阴影;围它一圈更深的边框读起来像缺陷。
|
||||
|
||||
<a id="build-shape"></a>
|
||||
## 构建形态
|
||||
|
||||
静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
|
||||
|
||||
本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
|
||||
@@ -33,8 +33,8 @@ export interface DockSurfaceProps {
|
||||
* split control disabled with `labels.splitPaneNarrow` (see README).
|
||||
*/
|
||||
readonly canSplit: boolean
|
||||
/** Hide the split control when the pane budget is spent; defaults to false. Width-blocked controls remain disabled. */
|
||||
readonly hideSplitAtCapacity?: boolean
|
||||
/** Hide a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; defaults to false. */
|
||||
readonly hideSplitWhenBlocked?: boolean
|
||||
/** Body drop geometry: all edge bands, or left/right halves with whole-pane moves once splitting is unavailable. */
|
||||
readonly dropZones?: 'edges' | 'horizontal'
|
||||
/** Smallest share a divider may leave a pane; defaults to the kit's fraction. */
|
||||
@@ -45,7 +45,13 @@ export interface DockSurfaceProps {
|
||||
* end controls where they are and the chips as the only shrinking part.
|
||||
*/
|
||||
readonly canAddTab?: (paneId: PaneId) => boolean
|
||||
/** Whether a tab offers close controls; defaults to true. Called per tab on every render. */
|
||||
/**
|
||||
* Whether a tab draws its close control and its menu's close item. Called
|
||||
* per rendered chip on every render; omit to keep every tab closable.
|
||||
* `false` removes both routes without moving the chip: the close control
|
||||
* paints over the title's end rather than beside it, so the chip is the same
|
||||
* width either way. The menu still opens and carries the embedder's items.
|
||||
*/
|
||||
readonly canCloseTab?: (tabId: TabId) => boolean
|
||||
readonly intents: DockIntents
|
||||
readonly labels: DockLabels
|
||||
@@ -97,7 +103,7 @@ const NO_PREVIEW: Preview = { draggingTabId: undefined, dropTarget: undefined, s
|
||||
/** Nothing measured yet: every pane fits until a reading says otherwise. */
|
||||
const NO_FITS: ReadonlyMap<PaneId, HalvesFit> = new Map()
|
||||
|
||||
/** The default add-control policy: every pane offers one. */
|
||||
/** The default policy for the omitted callbacks: every pane offers the add control, every tab its close. */
|
||||
const ALWAYS = (): boolean => true
|
||||
|
||||
/**
|
||||
@@ -157,7 +163,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean {
|
||||
/** The split tree and the gestures over it. */
|
||||
export function DockSurface({
|
||||
state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom,
|
||||
dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false,
|
||||
dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false,
|
||||
}: DockSurfaceProps): ReactNode {
|
||||
const surface = useRef<HTMLDivElement | null>(null)
|
||||
const [preview, setPreview] = useState<Preview>(NO_PREVIEW)
|
||||
@@ -178,10 +184,10 @@ export function DockSurface({
|
||||
// wider or narrower). A reading that changed nothing renders nothing.
|
||||
const remeasure = useCallback((): void => {
|
||||
withSurface((root) => {
|
||||
const next = measurePaneFits(root)
|
||||
const next = measurePaneFits(root, hideSplitWhenBlocked)
|
||||
setFits(current => sameFits(current, next) ? current : next)
|
||||
})
|
||||
}, [withSurface])
|
||||
}, [withSurface, hideSplitWhenBlocked])
|
||||
useLayoutEffect(() => { remeasure() })
|
||||
useEffect(() => { onRoom?.(fits) }, [fits, onRoom])
|
||||
useEffect(() => {
|
||||
@@ -267,7 +273,7 @@ export function DockSurface({
|
||||
})
|
||||
},
|
||||
splitBlock,
|
||||
hideSplitAtCapacity,
|
||||
hideSplitWhenBlocked,
|
||||
canAddTab: canAddTab ?? ALWAYS,
|
||||
canCloseTab: canCloseTab ?? ALWAYS,
|
||||
dropTarget: preview.dropTarget,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* The floating layer: one overlay panel per floating pane, bottom-to-top in the
|
||||
* model's z order. A floating pane hosts exactly one tab and renders no tab
|
||||
* strip — the panel *is* the tab. Pressing a panel's body raises it. Its grip
|
||||
* model's z order. A floating pane hosts exactly one tab; its header is the
|
||||
* strip's row holding that tab's chip, never selectable or closable from the
|
||||
* chip, and the send-back and close controls. Pressing a panel's body raises it. Its grip
|
||||
* and corner report through their gesture instead: a press released in place is
|
||||
* a click and raises the panel; a drag records the move or resize, and that
|
||||
* operation raises the panel itself, so one gesture is one intent. Raising a
|
||||
@@ -14,12 +15,15 @@
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts'
|
||||
import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts'
|
||||
import { FLOAT_MIN_SIZE } from '../engine/constraints.ts'
|
||||
import { movedRect, resizedRect } from '../engine/geometry.ts'
|
||||
import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts'
|
||||
import { useGesture } from './pointer.ts'
|
||||
import { TabTitle } from './TabTitle.tsx'
|
||||
import css from './dockkit.module.css'
|
||||
|
||||
/** The layout whose `floats` this layer draws. */
|
||||
@@ -114,32 +118,39 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
|
||||
onPointerDown={() => { raise(paneId) }}
|
||||
>
|
||||
<header
|
||||
className={css.floatHeader}
|
||||
className={clsx(css.tabStrip, css.floatHeader)}
|
||||
data-dockkit-float-grip={paneId}
|
||||
onPointerDown={(event) => { drag('move', paneId, event) }}
|
||||
>
|
||||
<span className={css.floatTitle} data-dockkit-float-title>{renderTabTitle?.(tab) ?? tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={labels.dockFloat}
|
||||
data-dockkit-float-dock={paneId}
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={() => { intents.unfloatPane(paneId) }}
|
||||
>
|
||||
⇤
|
||||
</button>
|
||||
{(canCloseTab?.(tab.id) ?? true) && (
|
||||
<div className={clsx(css.tab, css.floatTitle)} data-dockkit-float-title>
|
||||
<TabTitle>{renderTabTitle?.(tab) ?? tab.title}</TabTitle>
|
||||
</div>
|
||||
<div className={css.stripFill} />
|
||||
<Tooltip label={labels.dockFloat} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={labels.closeFloat}
|
||||
data-dockkit-float-close={paneId}
|
||||
aria-label={labels.dockFloat}
|
||||
data-dockkit-float-dock={paneId}
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={() => { intents.closeTab(tab.id) }}
|
||||
onClick={() => { intents.unfloatPane(paneId) }}
|
||||
>
|
||||
✕
|
||||
<IconPanelLeftOutline16 className={css.dockGlyph} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(canCloseTab?.(tab.id) ?? true) && (
|
||||
<Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={labels.closeFloat}
|
||||
data-dockkit-float-close={paneId}
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={() => { intents.closeTab(tab.id) }}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</header>
|
||||
<div className={css.floatBody}>{renderTab(tab)}</div>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* The per-tab context menu, opened by a secondary press on the chip. It carries
|
||||
* the close gesture and whatever the embedder appends; the copy and float
|
||||
* gestures have no menu item — copying is an embedder API, floating is a drag
|
||||
* released clear of the surface. Presentational — it renders what its props
|
||||
* supply and dismisses itself on outside presses.
|
||||
* released clear of the surface. A menu that would hold no item at all renders
|
||||
* no popup, so a secondary press on a chip with nothing to offer shows nothing.
|
||||
* Presentational — it renders what its props supply and dismisses itself on
|
||||
* outside presses.
|
||||
*
|
||||
* It renders in a portal, positioned against the control that opened it. The tab
|
||||
* strip clips its overflow on purpose (so it never becomes a scroll container
|
||||
@@ -26,12 +28,11 @@ export interface TabMenuProps {
|
||||
readonly labels: DockLabels
|
||||
/** The control that opened the menu; the menu hangs below its left edge. */
|
||||
readonly anchor: HTMLElement
|
||||
/** Whether to offer close; custom items remain available when false. */
|
||||
readonly canCloseTab: boolean
|
||||
readonly onClose: () => void
|
||||
/** Close the tab; `undefined` removes the kit's item, leaving the extras only. */
|
||||
readonly onClose: (() => void) | undefined
|
||||
/** Dismiss without acting. */
|
||||
readonly onDismiss: () => void
|
||||
/** Embedder ARIA menu items, rendered after the kit's own; absent means none. */
|
||||
/** Embedder items, rendered after the kit's own; absent means none. */
|
||||
readonly extras: ReactNode
|
||||
}
|
||||
|
||||
@@ -49,18 +50,19 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties {
|
||||
}
|
||||
|
||||
/** The actions menu body, anchored to the control that opened it. */
|
||||
export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
|
||||
export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
|
||||
const self = useRef<HTMLDivElement | null>(null)
|
||||
const [position, setPosition] = useState<CSSProperties | undefined>(undefined)
|
||||
const hasItems = canCloseTab || Children.toArray(extras).some(item => item !== '')
|
||||
const hasItems = onClose !== undefined || Children.toArray(extras).some(item => item !== '')
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (self.current === null) return
|
||||
setPosition(placeMenu(anchor, self.current))
|
||||
}, [anchor, canCloseTab, hasItems])
|
||||
}, [anchor, hasItems])
|
||||
|
||||
useEffect(() => {
|
||||
const menu = self.current
|
||||
/* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
|
||||
if (menu === null) return undefined
|
||||
// A press anywhere but inside the menu dismisses it; one with no element
|
||||
// target (dispatched to the window itself) counts as outside.
|
||||
@@ -91,7 +93,7 @@ export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extra
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={(event) => { event.stopPropagation() }}
|
||||
>
|
||||
{canCloseTab && (
|
||||
{onClose !== undefined && (
|
||||
<button type="button" role="menuitem" className={css.menuItem} data-dockkit-menu-close onClick={onClose}>
|
||||
{labels.closeTab}
|
||||
</button>
|
||||
|
||||
@@ -3,48 +3,92 @@
|
||||
* active tab's body with the dock preview overlay. Presentational; every gesture
|
||||
* leaves through `PaneCallbacks`, and the body itself comes from `renderTab`.
|
||||
*
|
||||
* A chip is a capsule carrying an optional close control at its right end; the
|
||||
* context menu (secondary press) carries the same close plus whatever the
|
||||
* embedder appends. The chips sit in their own box, the strip's one shrinking
|
||||
* part: in a narrow pane they ellipsize and then clip there, so the add
|
||||
* control after them (drawn while the embedder's `canAddTab` allows), the
|
||||
* pane's split control, and the embedder's chrome keep their width and their
|
||||
* place at the strip's end.
|
||||
* A chip is a capsule carrying one control, its close, shown over its right
|
||||
* end while the chip is active, hovered, or focused; the context menu
|
||||
* (secondary press) carries the same close plus whatever the embedder appends.
|
||||
* Both close routes draw only while the embedder's `canCloseTab` allows, and
|
||||
* a pane's lone chip whose close is withheld draws quiet — no capsule, no
|
||||
* hover fill — since there is nothing to select against and nothing to do to
|
||||
* it.
|
||||
* Between neighbouring chips sits a slot: a fixed-width box drawing a
|
||||
* hairline, blank beside the active chip, and the drop caret when a drag
|
||||
* targets that index, so a caret never widens the row; the two end slots
|
||||
* exist only while targeted. The chips sit in their own box, the strip's one
|
||||
* shrinking part: in a narrow pane their titles fade at the clipped edge down
|
||||
* to the chip's floor and then the chips scroll there, keeping the active one
|
||||
* in view, so the add control after them (drawn while the embedder's
|
||||
* `canAddTab` allows), the pane's split control, and the embedder's chrome keep
|
||||
* their width and their place at the strip's end.
|
||||
*/
|
||||
import { Fragment, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Fragment, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { LayoutState, PaneNode, TabId } from '../contract/types.ts'
|
||||
import { IconCloseFill14, IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DockZone, LayoutState, PaneNode, TabId } from '../contract/types.ts'
|
||||
import { getTab } from '../engine/tree.ts'
|
||||
import type { PaneCallbacks, SplitBlock } from './render.ts'
|
||||
import { TabMenu } from './TabMenu.tsx'
|
||||
import { TabTitle } from './TabTitle.tsx'
|
||||
import css from './dockkit.module.css'
|
||||
|
||||
/** The split control's glyph: a frame divided by a vertical line, as the split itself is. */
|
||||
/**
|
||||
* The ic_ds_panel_left_outline_16 frame alone: its outer and inner rounded
|
||||
* rectangles as one even-odd ring, without the divider. The glyphs below draw
|
||||
* inside it so they read as siblings of the panel controls beside them.
|
||||
*/
|
||||
const PANEL_FRAME = 'M9.67272 0.522841C10.8339 0.522841 11.76 0.522714 12.4963 0.602493C13.2453 0.683657 13.8789 0.854248 14.4264 1.25197C14.7504 1.48739 15.0355 1.77247 15.2709 2.0965C15.6686 2.64394 15.8392 3.27758 15.9204 4.02655C16.0002 4.7629 16 5.68895 16 6.85014V9.14986C16 10.3111 16.0002 11.2371 15.9204 11.9735C15.8392 12.7224 15.6686 13.3561 15.2709 13.9035C15.0355 14.2275 14.7504 14.5126 14.4264 14.748C13.8789 15.1458 13.2453 15.3163 12.4963 15.3975C11.76 15.4773 10.8339 15.4772 9.67272 15.4772H6.3273C5.16611 15.4772 4.24006 15.4773 3.50371 15.3975C2.75474 15.3163 2.1211 15.1458 1.57366 14.748C1.24963 14.5126 0.964549 14.2275 0.729131 13.9035C0.331407 13.3561 0.160817 12.7224 0.0796529 11.9735C-0.000126137 11.2371 1.25338e-09 10.3111 1.25338e-09 9.14986V6.85014C1.25329e-09 5.68895 -0.000126137 4.7629 0.0796529 4.02655C0.160817 3.27758 0.331407 2.64394 0.729131 2.0965C0.964549 1.77247 1.24963 1.48739 1.57366 1.25197C2.1211 0.854248 2.75474 0.683657 3.50371 0.602493C4.24006 0.522714 5.16611 0.522841 6.3273 0.522841H9.67272ZM4.1828 14.0873L5.54303 14.1118C5.78636 14.1128 6.04709 14.1169 6.3273 14.1169H9.67272C10.8639 14.1169 11.7032 14.1164 12.3493 14.0465C12.9824 13.9779 13.3497 13.8494 13.6268 13.6482C13.8354 13.4966 14.0195 13.3125 14.1711 13.1039C14.3723 12.8268 14.5007 12.4595 14.5693 11.8264C14.6393 11.1803 14.6398 10.341 14.6398 9.14986V6.85014C14.6398 5.65896 14.6393 4.81967 14.5693 4.1736C14.5007 3.54048 14.3723 3.17318 14.1711 2.89609C14.0195 2.68747 13.8354 2.50337 13.6268 2.35179C13.3497 2.1506 12.9824 2.02212 12.3493 1.95353C11.7032 1.88358 10.8639 1.88307 9.67272 1.88307H6.3273C6.04709 1.88307 5.78636 1.8862 5.54303 1.88715L4.1828 1.91166C3.99125 1.9216 3.8148 1.93577 3.65076 1.95353C3.01764 2.02212 2.65034 2.1506 2.37325 2.35179C2.16463 2.50337 1.98052 2.68747 1.82895 2.89609C1.62776 3.17318 1.49928 3.54048 1.43069 4.1736C1.36074 4.81967 1.36023 5.65896 1.36023 6.85014V9.14986C1.36023 10.341 1.36074 11.1803 1.43069 11.8264C1.49928 12.4595 1.62776 12.8268 1.82895 13.1039C1.98052 13.3125 2.16463 13.4966 2.37325 13.6482C2.65034 13.8494 3.01764 13.9779 3.65076 14.0465C3.81478 14.0642 3.99127 14.0774 4.1828 14.0873Z'
|
||||
|
||||
/** The split control's glyph: the panel frame with its divider moved to the centre. */
|
||||
function SplitGlyph(): ReactNode {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<rect x="1.5" y="2" width="11" height="10" rx="1.5" stroke="currentColor" />
|
||||
<path d="M7 2v10" stroke="currentColor" />
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d={`${PANEL_FRAME}M7.31989 1.88307H8.68012V14.1169H7.31989V1.88307Z`} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The add control's glyph. */
|
||||
function PlusGlyph(): ReactNode {
|
||||
/**
|
||||
* The drop hint's fill per zone: the half or the whole a release would fill
|
||||
* drawn solid, so the hint names its zone before its caption is read. A half
|
||||
* is drawn out to the frame's outer edge, under the ring, so its visible edge
|
||||
* is exactly the ring's inner edge with no seam at the corners; the whole sits
|
||||
* one stroke inside the frame so the ring stays visible around it.
|
||||
*/
|
||||
const ZONE_FILL: Record<DockZone, string> = {
|
||||
center: 'M4.56 3.48H11.44A1.6 1.6 0 0 1 13.04 5.08V10.92A1.6 1.6 0 0 1 11.44 12.52H4.56A1.6 1.6 0 0 1 2.96 10.92V5.08A1.6 1.6 0 0 1 4.56 3.48Z',
|
||||
left: 'M4 0.523H8V15.477H4A4 4 0 0 1 0 11.477V4.523A4 4 0 0 1 4 0.523Z',
|
||||
right: 'M8 0.523H12A4 4 0 0 1 16 4.523V11.477A4 4 0 0 1 12 15.477H8Z',
|
||||
top: 'M0 8V4.523A4 4 0 0 1 4 0.523H12A4 4 0 0 1 16 4.523V8Z',
|
||||
bottom: 'M0 8H16V11.477A4 4 0 0 1 12 15.477H4A4 4 0 0 1 0 11.477Z',
|
||||
}
|
||||
|
||||
/** The drop hint's glyph: the panel frame with the zone's fill drawn solid. */
|
||||
function ZoneGlyph({ zone }: { readonly zone: DockZone }): ReactNode {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M6 1.5v9M1.5 6h9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d={PANEL_FRAME} fill="currentColor" />
|
||||
<path d={ZONE_FILL[zone]} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The close control's glyph. */
|
||||
function CloseGlyph(): ReactNode {
|
||||
/**
|
||||
* One landing card, inset inside the region a release would fill: a dashed
|
||||
* frame, the zone's glyph, and its caption. `active` is the region under the
|
||||
* pointer; a sibling shown for orientation only draws quieter.
|
||||
*/
|
||||
function DockHint({ zone, active, labels }: {
|
||||
readonly zone: DockZone
|
||||
readonly active: boolean
|
||||
readonly labels: PaneCallbacks['labels']
|
||||
}): ReactNode {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 1.5l7 7M8.5 1.5l-7 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className={css.dockHint} data-dockkit-dock-zone={zone} data-dockkit-drop-active={active || undefined}>
|
||||
<div className={css.dockHintCard}>
|
||||
<ZoneGlyph zone={zone} />
|
||||
<span className={css.dockHintLabel}>{labels.dropZone[zone]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,10 +122,86 @@ function selects(key: string): boolean {
|
||||
return key === 'Enter' || key === ' '
|
||||
}
|
||||
|
||||
/** The split control's title: what it does, or why it cannot right now. */
|
||||
function splitTitle(labels: PaneCallbacks['labels'], block: SplitBlock | undefined): string {
|
||||
/**
|
||||
* Which sides of the chip box hold chips scrolled out of view, as the
|
||||
* `data-dockkit-strip-scroll` value the stylesheet fades: `undefined` while
|
||||
* every chip is in view.
|
||||
*/
|
||||
function hiddenSides(box: HTMLElement): 'start' | 'end' | 'start end' | undefined {
|
||||
// Sub-pixel scroll positions: a side counts as hidden past one whole pixel.
|
||||
const start = box.scrollLeft > 1
|
||||
const end = box.scrollLeft + box.clientWidth < box.scrollWidth - 1
|
||||
if (start && end) return 'start end'
|
||||
if (start) return 'start'
|
||||
if (end) return 'end'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the chip box's `data-dockkit-strip-scroll` current: read after each
|
||||
* commit that can change the chips, on scroll, and on resize. Written to the
|
||||
* DOM directly rather than through state because a reading never changes
|
||||
* what renders, only how the stylesheet fades it.
|
||||
*
|
||||
* Known gap: a content-width change that alters neither `tabs` nor the box's
|
||||
* outer size — a live `renderTabTitle` growing a chip, or a drop-caret slot
|
||||
* mounting mid-drag — keeps the fade at its last reading until the next
|
||||
* scroll or resize. The fade is orientation chrome, so a stale edge fades a
|
||||
* few frames late rather than hiding anything.
|
||||
*/
|
||||
function useStripScrollFades(box: RefObject<HTMLDivElement | null>, tabs: readonly TabId[]): void {
|
||||
useLayoutEffect(() => {
|
||||
const element = box.current
|
||||
/* v8 ignore next -- the box is rendered unconditionally with the strip. */
|
||||
if (element === null) return undefined
|
||||
const apply = (): void => {
|
||||
const sides = hiddenSides(element)
|
||||
if (sides === undefined) delete element.dataset.dockkitStripScroll
|
||||
else element.dataset.dockkitStripScroll = sides
|
||||
}
|
||||
apply()
|
||||
element.addEventListener('scroll', apply, { passive: true })
|
||||
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply)
|
||||
observer?.observe(element)
|
||||
return () => {
|
||||
element.removeEventListener('scroll', apply)
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [box, tabs])
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the active chip into the chip box's view whenever the active tab or
|
||||
* the row of chips changes: a tab opened or selected past the box's edge, or
|
||||
* moved there by a close or a reorder, scrolls the box to it, with the fade
|
||||
* band (24px) cleared so the chip is not under it. A chip already in view
|
||||
* moves nothing. Direct DOM, like the fades above: the box's scroll position
|
||||
* renders nothing.
|
||||
*/
|
||||
function useActiveChipInView(
|
||||
box: RefObject<HTMLDivElement | null>,
|
||||
chips: ReadonlyMap<TabId, HTMLElement>,
|
||||
tabs: readonly TabId[],
|
||||
activeTabId: TabId | undefined,
|
||||
): void {
|
||||
useLayoutEffect(() => {
|
||||
const element = box.current
|
||||
const chip = activeTabId === undefined ? undefined : chips.get(activeTabId)
|
||||
/* v8 ignore next -- the box and the active tab's chip are rendered with the strip. */
|
||||
if (element === null || chip === undefined) return
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const rect = chip.getBoundingClientRect()
|
||||
if (rect.left < bounds.left) element.scrollLeft += rect.left - bounds.left - STRIP_FADE
|
||||
else if (rect.right > bounds.right) element.scrollLeft += rect.right - bounds.right + STRIP_FADE
|
||||
}, [box, chips, tabs, activeTabId])
|
||||
}
|
||||
|
||||
/** Width of the chip box's fade at a hidden side; mirrors the stylesheet's 24px. */
|
||||
const STRIP_FADE = 24
|
||||
|
||||
/** Why the split control cannot act right now. */
|
||||
function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock): string {
|
||||
switch (block) {
|
||||
case undefined: return labels.splitPane
|
||||
case 'budget': return labels.splitPaneDisabled
|
||||
case 'width': return labels.splitPaneNarrow
|
||||
}
|
||||
@@ -94,6 +214,9 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined)
|
||||
// The mounted chips by tab, for the keys that move focus between them.
|
||||
const [chips] = useState(() => new Map<TabId, HTMLElement>())
|
||||
const stripTabs = useRef<HTMLDivElement | null>(null)
|
||||
useStripScrollFades(stripTabs, pane.tabs)
|
||||
useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId)
|
||||
const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId)
|
||||
const block = callbacks.splitBlock(pane.id)
|
||||
const target = callbacks.dropTarget
|
||||
@@ -132,14 +255,22 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
}}
|
||||
>
|
||||
<div className={css.tabStrip} role="tablist" data-dockkit-strip={pane.id}>
|
||||
<div className={css.stripTabs} role="presentation" data-dockkit-strip-tabs={pane.id}>
|
||||
<div ref={stripTabs} className={css.stripTabs} role="presentation" data-dockkit-strip-tabs={pane.id}>
|
||||
{pane.tabs.map((tabId, index) => {
|
||||
const tab = getTab(state, tabId)
|
||||
const selected = tabId === pane.activeTabId
|
||||
const canClose = callbacks.canCloseTab(tabId)
|
||||
const closable = callbacks.canCloseTab(tabId)
|
||||
// A pane's lone unclosable chip is a label, not a choice: there is
|
||||
// no other tab to select against and nothing to do to it.
|
||||
const quiet = !closable && pane.tabs.length === 1
|
||||
return (
|
||||
<Fragment key={tabId}>
|
||||
{stripIndex === index && <div className={css.caret} data-dockkit-caret={index} />}
|
||||
{(index > 0 || stripIndex === index) && (
|
||||
<div
|
||||
className={clsx(css.slot, stripIndex === index && css.slotCaret)}
|
||||
data-dockkit-caret={stripIndex === index ? index : undefined}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
@@ -147,9 +278,11 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
className={clsx(
|
||||
css.tab,
|
||||
selected && css.tabActive,
|
||||
quiet && css.tabQuiet,
|
||||
callbacks.draggingTabId === tabId && css.tabDragging,
|
||||
)}
|
||||
data-dockkit-tab={tabId}
|
||||
data-dockkit-tab-quiet={quiet || undefined}
|
||||
ref={(element) => {
|
||||
if (element === null) chips.delete(tabId)
|
||||
else chips.set(tabId, element)
|
||||
@@ -188,8 +321,8 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor })
|
||||
}}
|
||||
>
|
||||
<span className={css.tabTitle} data-dockkit-tab-title>{callbacks.renderTabTitle?.(tab) ?? tab.title}</span>
|
||||
{canClose && (
|
||||
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabClose}
|
||||
@@ -203,15 +336,14 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
callbacks.onCloseTab(tabId)
|
||||
}}
|
||||
>
|
||||
<CloseGlyph />
|
||||
<IconCloseFill14 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{menu?.tabId === tabId && (
|
||||
<TabMenu
|
||||
labels={callbacks.labels}
|
||||
anchor={menu.anchor}
|
||||
canCloseTab={canClose}
|
||||
onClose={() => { setMenu(undefined); callbacks.onCloseTab(tabId) }}
|
||||
onClose={closable ? () => { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined}
|
||||
onDismiss={() => { setMenu(undefined) }}
|
||||
extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })}
|
||||
/>
|
||||
@@ -220,40 +352,45 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{stripIndex === pane.tabs.length && <div className={css.caret} data-dockkit-caret={stripIndex} />}
|
||||
{stripIndex === pane.tabs.length && <div className={clsx(css.slot, css.slotCaret)} data-dockkit-caret={stripIndex} />}
|
||||
</div>
|
||||
{callbacks.canAddTab(pane.id) && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.addTab}
|
||||
aria-label={callbacks.labels.addTab}
|
||||
title={callbacks.labels.addTab}
|
||||
data-dockkit-add-tab={pane.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onAddTab(pane.id)
|
||||
}}
|
||||
>
|
||||
<PlusGlyph />
|
||||
</button>
|
||||
<Tooltip label={callbacks.labels.addTab} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.addTab}
|
||||
aria-label={callbacks.labels.addTab}
|
||||
data-dockkit-add-tab={pane.id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onAddTab(pane.id)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className={css.stripFill} data-dockkit-strip-fill />
|
||||
{!(callbacks.hideSplitAtCapacity && block === 'budget') && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={callbacks.labels.splitPane}
|
||||
title={splitTitle(callbacks.labels, block)}
|
||||
disabled={block !== undefined}
|
||||
data-dockkit-split-button={pane.id}
|
||||
data-dockkit-split-blocked={block}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onSplitPane(pane.id)
|
||||
}}
|
||||
>
|
||||
<SplitGlyph />
|
||||
</button>
|
||||
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
|
||||
<Tooltip label={callbacks.labels.splitPane} side="bottom" delayMs={500} disabled={block !== undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={callbacks.labels.splitPane}
|
||||
// Disabled buttons fire no hover events, so the blocked reason
|
||||
// stays a native title.
|
||||
title={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
|
||||
disabled={block !== undefined}
|
||||
data-dockkit-split-button={pane.id}
|
||||
data-dockkit-split-blocked={block}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onSplitPane(pane.id)
|
||||
}}
|
||||
>
|
||||
<SplitGlyph />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* The embedder's surface-wide controls, in the top-right pane only: the
|
||||
strip is the surface's top edge, and this pane's end is its corner. */}
|
||||
@@ -273,12 +410,17 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
{active === undefined
|
||||
? <p className={css.empty}>{callbacks.labels.emptyPane}</p>
|
||||
: callbacks.renderTab(active)}
|
||||
{zone !== undefined && (callbacks.horizontalDrops && zone !== 'center'
|
||||
? <>
|
||||
<div className={css.dockHint} data-dockkit-dock-zone="left" data-dockkit-drop-active={zone === 'left' || undefined} />
|
||||
<div className={css.dockHint} data-dockkit-dock-zone="right" data-dockkit-drop-active={zone === 'right' || undefined} />
|
||||
{zone !== undefined && (
|
||||
<>
|
||||
<div className={css.dockScrim} data-dockkit-dock-scrim />
|
||||
{callbacks.horizontalDrops && zone !== 'center'
|
||||
? <>
|
||||
<DockHint zone="left" active={zone === 'left'} labels={callbacks.labels} />
|
||||
<DockHint zone="right" active={zone === 'right'} labels={callbacks.labels} />
|
||||
</>
|
||||
: <DockHint zone={zone} active labels={callbacks.labels} />}
|
||||
</>
|
||||
: <div className={css.dockHint} data-dockkit-dock-zone={zone} />)}
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* A chip's title: one line, clipped at the chip's inset, never ellipsized.
|
||||
* While the text is wider than its box the span carries
|
||||
* `data-dockkit-tab-clipped`, and the stylesheet fades the text out at the
|
||||
* clipped edge in place of an ellipsis. Written to the DOM directly rather
|
||||
* than through state: a reading changes nothing that renders, only how the
|
||||
* stylesheet paints it. Re-read after every commit (the text may have
|
||||
* changed) and whenever the span's box resizes (the chip shrank or grew).
|
||||
*/
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import css from './dockkit.module.css'
|
||||
|
||||
/** Set or clear the span's `data-dockkit-tab-clipped` from its current geometry. */
|
||||
function markClipped(element: HTMLElement): void {
|
||||
// Sub-pixel widths: the text counts as clipped past one whole pixel.
|
||||
if (element.scrollWidth > element.clientWidth + 1) element.dataset.dockkitTabClipped = ''
|
||||
else delete element.dataset.dockkitTabClipped
|
||||
}
|
||||
|
||||
/** The title span of a strip chip or a floating panel's header chip. */
|
||||
export function TabTitle({ children }: { readonly children: ReactNode }): ReactNode {
|
||||
const span = useRef<HTMLSpanElement | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
/* v8 ignore next -- the span is rendered unconditionally. */
|
||||
if (span.current !== null) markClipped(span.current)
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
const element = span.current
|
||||
/* v8 ignore next -- the span is rendered unconditionally. */
|
||||
if (element === null || typeof ResizeObserver === 'undefined') return undefined
|
||||
const observer = new ResizeObserver(() => { markClipped(element) })
|
||||
observer.observe(element)
|
||||
return () => { observer.disconnect() }
|
||||
}, [])
|
||||
return <span ref={span} className={css.tabTitle} data-dockkit-tab-title>{children}</span>
|
||||
}
|
||||
@@ -7,10 +7,11 @@
|
||||
* Colours come from the embedder's token layer; the kit names no literal. Type
|
||||
* follows the embedder's content axis (`--dsh-content-font-size` and its
|
||||
* secondary step) so a surface reads at the same size as the page around it.
|
||||
* Emphasis — a hovered divider, the drop caret, the drop-zone hint — takes the
|
||||
* platform's accent (`--dsw-alias-brand-primary-new-colorprimary-new-color`),
|
||||
* not `--dsw-alias-brand-primary`, which this platform binds to its
|
||||
* near-black (light) or near-white (dark) foreground.
|
||||
* Emphasis — the drop caret, the drop-zone hint — takes the platform's accent
|
||||
* (`--dsw-alias-brand-primary-new-colorprimary-new-color`), not
|
||||
* `--dsw-alias-brand-primary`, which this platform binds to its near-black
|
||||
* (light) or near-white (dark) foreground. A hovered divider takes the
|
||||
* caption label ink instead, reading as a handle rather than a highlight.
|
||||
*/
|
||||
|
||||
.split {
|
||||
@@ -35,25 +36,87 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The divider owns no layout room (`SPLIT_MINIMUMS.divider` mirrors the 0):
|
||||
the halves abut, so a rule a pane draws across its own edge — a header's
|
||||
hairline — runs unbroken past the seam. The visible rule is a 0.5px hairline
|
||||
centred on the seam, matching the embedder's other borders, and `::after`
|
||||
widens the pointer target to 8px by reaching 4px over each neighbour.
|
||||
`z-index` keeps that overhang above the later sibling, which would otherwise
|
||||
take the hit. */
|
||||
.divider {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
background: var(--dsw-alias-border-l1);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.divider::before {
|
||||
background: var(--dsw-alias-border-l4);
|
||||
}
|
||||
|
||||
.splitRow > .divider {
|
||||
width: 4px;
|
||||
width: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.splitRow > .divider::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -0.25px;
|
||||
width: 0.5px;
|
||||
}
|
||||
|
||||
.splitRow > .divider::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.splitColumn > .divider {
|
||||
height: 4px;
|
||||
height: 0;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.divider:hover {
|
||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
.splitColumn > .divider::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -0.25px;
|
||||
height: 0.5px;
|
||||
}
|
||||
|
||||
.splitColumn > .divider::after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
/* Hover: a 1px grip fades in over the hairline, its caption ink deepest at
|
||||
its middle and fading toward both ends. The grip is painted on the `::after`
|
||||
hit target, centred on the seam, and crossfaded through opacity, because a
|
||||
gradient cannot transition from the hairline's solid colour. */
|
||||
.divider::after {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.divider:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.splitRow > .divider::after {
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 1px 100% no-repeat;
|
||||
}
|
||||
|
||||
.splitColumn > .divider::after {
|
||||
background: linear-gradient(to right, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 100% 1px no-repeat;
|
||||
}
|
||||
|
||||
/* Both floors: a flex item's minimum is its content's, and a body's longest
|
||||
@@ -74,17 +137,18 @@
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.pane[data-dockkit-pane-active] {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* One centre line for everything in the strip: every child — chip, add
|
||||
control, split control, the embedder's chrome — is 24px tall, and the strip
|
||||
centres them, so chip text and control glyphs never sit at different heights.
|
||||
A child with another height would break that; keep them at 24px.
|
||||
/* A 38px row whose 28px content band sits at its bottom: chips fill the band,
|
||||
so they end flush with the strip's bottom edge, and the 28px add control, the
|
||||
28px split control, and the embedder's 28px chrome centre on the band's one
|
||||
centre line, so chip text and control glyphs never sit at different heights.
|
||||
The 10px above is the strip's own top margin, inside its box so the hit
|
||||
area stays one element; the 10px at the start is the first chip's inset
|
||||
from the edge, and the 6px at the end puts the last control's glyph 12px
|
||||
from the edge. The strip draws no border: a body
|
||||
that wants a rule under it draws its own. A floating panel's header is
|
||||
this same row.
|
||||
|
||||
The strip never clips: the chip box below is its one shrinking part, and
|
||||
every control after it is `flex: none`, so a narrow pane costs chips, never
|
||||
@@ -94,27 +158,47 @@
|
||||
flex: none;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 6px;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l1);
|
||||
height: 28px;
|
||||
padding: 10px 6px 0 10px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* The chips. Shrinks to nothing before any control after it moves; what no
|
||||
longer fits is clipped here. Deliberately not a scroller: a horizontal
|
||||
scroll container claims a press-and-move as its own gesture and cancels the
|
||||
pointer, which would abandon every tab drag in a narrow pane. Tabs shrink
|
||||
and ellipsize first. */
|
||||
/* The chips. Shrinks to nothing before any control after it moves; chips keep
|
||||
their 80px floor, so what no longer fits scrolls here on the wheel, with no
|
||||
scrollbar drawn. `touch-action: none` keeps a touch press-and-move a tab drag
|
||||
rather than a pan the scroller would claim and cancel the pointer for.
|
||||
`data-dockkit-strip-scroll` names the hidden sides (`start`, `end`, or
|
||||
both); each hidden side fades the chips out over 24px into whatever ground
|
||||
the pane draws, so the row reads as continuing under the controls. */
|
||||
.stripTabs {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* The scroll to a newly active chip glides rather than jumps. */
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.stripTabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stripTabs[data-dockkit-strip-scroll='end'] {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.stripTabs[data-dockkit-strip-scroll='start'] {
|
||||
mask-image: linear-gradient(to right, transparent, black 24px);
|
||||
}
|
||||
|
||||
.stripTabs[data-dockkit-strip-scroll='start end'] {
|
||||
mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
/* Takes the free space and gives it all back first: a zero basis shrinks
|
||||
nothing, so shortage lands on the chip box alone. */
|
||||
.stripFill {
|
||||
@@ -122,37 +206,64 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Embedder controls at the strip's end, set off from the kit's own split
|
||||
control by a hairline so the two groups read as two groups. */
|
||||
/* Embedder controls at the strip's end, spaced from the kit's own split
|
||||
control as they are from each other: the strip's 4px gap plus this 4px
|
||||
margin equals the 8px between the controls. */
|
||||
.stripChrome {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 2px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-left: 2px;
|
||||
padding-left: 4px;
|
||||
border-left: 0.5px solid var(--dsw-alias-border-l1);
|
||||
height: 28px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.caret {
|
||||
/* The slot between two chips: a 10px box with a hairline down its centre.
|
||||
Targeted by a drag, the same box draws the caret instead, so the chips
|
||||
around it never move. */
|
||||
.slot {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 10px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.slot::before {
|
||||
content: '';
|
||||
width: 0.5px;
|
||||
height: 14px;
|
||||
background: var(--dsw-alias-border-l4);
|
||||
}
|
||||
|
||||
/* The active chip is a filled capsule and needs no rule against it: the two
|
||||
slots beside it go blank, so the row reads as the capsule between bare
|
||||
chips. A caret in either slot still draws. */
|
||||
.tabActive + .slot:not(.slotCaret)::before,
|
||||
.slot:not(.slotCaret):has(+ .tabActive)::before {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.slotCaret::before {
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
/* The chip: a 28px capsule whose title runs to the right inset. The close
|
||||
control is not in the flow — it sits over the title's last 14px and shows
|
||||
while the chip is active, hovered, or holds focus — so a chip is the same
|
||||
width with and without it and nothing shifts on hover. */
|
||||
.tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-width: 44px;
|
||||
min-width: 80px;
|
||||
max-width: 170px;
|
||||
height: 24px;
|
||||
padding: 0 5px 0 10px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: 1;
|
||||
@@ -163,35 +274,82 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* A flex row with a 5px gap, so an embedder's leading glyph (`renderTabTitle`)
|
||||
centres on the text's line rather than sitting on its baseline and keeps
|
||||
one distance from it. The clip is horizontal in intent; the line box must
|
||||
hold the descenders it would otherwise cut, hence a line-height above the
|
||||
chip's own 1. No ellipsis: a title wider than its box (`TabTitle` sets
|
||||
`data-dockkit-tab-clipped`) fades out over its last 16px instead. A mask on
|
||||
the title rather than a painted gradient: the chip's fill is bare,
|
||||
translucent hover, or the active tag colour, and a mask matches every one
|
||||
without naming it. */
|
||||
.tabTitle {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tabTitle[data-dockkit-tab-clipped] {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
|
||||
}
|
||||
|
||||
/* While the close shows, the title is gone under the close's circle and fades
|
||||
out over the 16px before it: the circle spans the chip's last 24px and the
|
||||
title ends 10px inside the chip, so the title's last 14px are under it.
|
||||
Later than the clipped mask, so a clipped chip showing its close fades
|
||||
under the close. A floating panel's header chip has no close, so it keeps
|
||||
the clipped mask alone. */
|
||||
.tab:not(.floatTitle):hover .tabTitle,
|
||||
.tab:not(.floatTitle):focus-within .tabTitle,
|
||||
.tabActive .tabTitle {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 30px), transparent calc(100% - 14px));
|
||||
}
|
||||
|
||||
/* A 20px icon button inset 4px from the chip's end, holding a 14px glyph in
|
||||
the tertiary ink, so it sits back from the title beside it. */
|
||||
.tabClose {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
corner-shape: round;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
/* Hidden means untouchable too: without this a touch press on the chip's
|
||||
trailing 20px would close the tab instead of activating it. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Shown while the chip is hovered or holds focus, and always on the active
|
||||
chip: the tab in view is the one a reader closes next. */
|
||||
.tab:hover .tabClose,
|
||||
.tab:focus-within .tabClose,
|
||||
.tabActive .tabClose {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The chip's height and corner, so it reads as one more capsule in the row. */
|
||||
.addTab {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
line-height: 1;
|
||||
@@ -208,46 +366,72 @@
|
||||
|
||||
.tabClose:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The active chip is the filled capsule; the rest are bare text. */
|
||||
/* The active chip is the filled capsule, in the tag fill; the rest are bare text. */
|
||||
.tabActive {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
background: var(--dsw-alias-markdown-tag);
|
||||
}
|
||||
|
||||
/* A pane's lone unclosable chip is a label, not a choice: no capsule, no
|
||||
hover fill — the primary ink on the pane's own ground. Its title never
|
||||
yields to a close control, so it keeps the clipped fade alone. */
|
||||
.tab.tabQuiet,
|
||||
.tab.tabQuiet:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tab.tabQuiet .tabTitle,
|
||||
.tab.tabQuiet:hover .tabTitle,
|
||||
.tab.tabQuiet:focus-within .tabTitle {
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.tab.tabQuiet .tabTitle[data-dockkit-tab-clipped] {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
|
||||
}
|
||||
|
||||
.tabDragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* The geometry of a message's action row button (ui-chat MessageIconActions):
|
||||
a 28px circle around a 15px glyph. */
|
||||
.iconButton {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -288,12 +472,14 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* No padding of its own: a tab's body reaches the pane's edges and the strip's
|
||||
bottom edge, and keeps its own insets, so a header row it draws sits flush
|
||||
under the strip. */
|
||||
.paneBody {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: auto; /* This sheet draws elevated surfaces (the strip and the menu), so a scroller
|
||||
inside it rebinds the thumb indirection in a complete pair — a base-surface
|
||||
thumb on an elevated ground reads as a smudge. */
|
||||
@@ -303,14 +489,40 @@
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
}
|
||||
|
||||
/* The drop hint is a scrim and, over it, one box pair per landing region.
|
||||
The scrim (`.dockScrim`) covers the whole body with the translucent, blurred
|
||||
ground, so the insets between and around the cards dim the body's content
|
||||
instead of letting it show through raw. The outer box (`.dockHint`) is the
|
||||
region a release fills — the whole body, or 40% (edge bands) or 50%
|
||||
(horizontal halves) of it — and draws nothing; it is a padded frame that
|
||||
keeps the card inside it 8px clear of the body's edges and, between two
|
||||
horizontal halves, 4px clear of the seam, so neighbouring cards sit 8px
|
||||
apart like the body's own insets. The card (`.dockHintCard`) is what the eye
|
||||
lands on: a dashed frame with the zone's glyph and caption stacked at its
|
||||
centre. A card that is not under the pointer stays as a quiet outline so the
|
||||
reader sees where the other release would land. */
|
||||
.dockScrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
/* The ground the panes sit on, not a raised layer: in dark mode `bg-layer-2`
|
||||
is a lighter bluish step than the column's `bg-base`, which tinted the
|
||||
whole scrim away from the content beneath it. */
|
||||
background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: dockScrimIn 140ms ease-out;
|
||||
}
|
||||
|
||||
.dockHint {
|
||||
position: absolute;
|
||||
background: var(--dsw-alias-bg-multi-select);
|
||||
border: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -332,15 +544,82 @@
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'],
|
||||
[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] {
|
||||
[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'] {
|
||||
width: 50%;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left']:not([data-dockkit-drop-active]),
|
||||
[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right']:not([data-dockkit-drop-active]) {
|
||||
background: transparent;
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] {
|
||||
width: 50%;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.dockHintCard {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
border: 1.5px dashed var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
transition: color 120ms ease-out, background-color 120ms ease-out, border-color 120ms ease-out;
|
||||
animation: dockHintIn 140ms ease-out;
|
||||
}
|
||||
|
||||
/* The card under the pointer takes the accent: a tinted ground over the scrim,
|
||||
an accent dashed frame, and secondary ink for the glyph and caption. */
|
||||
.dockHint[data-dockkit-drop-active] .dockHintCard {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 8%, transparent);
|
||||
border-color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.dockHintCard svg {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* One line, clipped rather than wrapped: the card in a narrow half is still
|
||||
wide enough for its glyph, and the caption fades under the frame. */
|
||||
.dockHintLabel {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@keyframes dockHintIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The scrim fades in without the cards' scale: a full-body cover that shrinks
|
||||
would expose an uncovered rim of raw content on entry. */
|
||||
@keyframes dockScrimIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dockHint[data-dockkit-dock-zone='top'] {
|
||||
@@ -361,43 +640,50 @@
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border: 0.5px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-drop);
|
||||
/* The body inside is unpadded and reaches the edges, so the frame clips it
|
||||
to its own corners. */
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border-radius: 20px;
|
||||
/* The menu's shadow with the stroke rebound one step lighter than the
|
||||
default (l2): the panel reads as the same kind of raised surface, and the
|
||||
shadow's hairline outlines it, so the frame draws no border of its own. */
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
|
||||
box-shadow: var(--dsw-elevation-prominent);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The active panel keeps the same hairline: it is already on top of the z
|
||||
order and casts the same shadow, and a heavier or darker frame read as a
|
||||
defect. `data-dockkit-float-active` stays on the element for tests. */
|
||||
/* The active panel keeps the same frame: it is already on top of the z order
|
||||
and casts the same shadow, and a heavier or darker frame read as a defect.
|
||||
`data-dockkit-float-active` stays on the element for tests. */
|
||||
|
||||
/* The panel's header is the pane strip's row (`.tabStrip` supplies the
|
||||
metrics) and the whole of it is the move grip. */
|
||||
.floatHeader {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l1);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* The one tab, drawn as its chip would be in a strip but never selected,
|
||||
hovered, or closable from here: the frame's controls do that. */
|
||||
.floatTitle {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
flex: 0 1 auto;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.tab.floatTitle:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Mirrored so the filled pane sits at the right, where the docked surface is. */
|
||||
.dockGlyph {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Unpadded like `.paneBody`: the same body draws the same insets in a float. */
|
||||
.floatBody {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
overflow: auto; /* This sheet draws elevated surfaces (the strip and the menu), so a scroller
|
||||
inside it rebinds the thumb indirection in a complete pair — a base-surface
|
||||
thumb on an elevated ground reads as a smudge. */
|
||||
@@ -409,19 +695,35 @@
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: nwse-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* The grip is a quarter arc concentric with the frame's 20px corner (16px
|
||||
radius at a 4px inset), so it reads as part of the frame. It shows only
|
||||
while the pointer is over the panel — an idle panel keeps a clean corner —
|
||||
and `:active` keeps it lit while a drag holds the pointer capture. */
|
||||
.floatResize::after {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid var(--dsw-alias-label-tertiary);
|
||||
border-bottom: 2px solid var(--dsw-alias-label-tertiary);
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-right: 1.5px solid var(--dsw-alias-label-caption);
|
||||
border-bottom: 1.5px solid var(--dsw-alias-label-caption);
|
||||
border-bottom-right-radius: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.float:hover .floatResize::after,
|
||||
.floatResize:active::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.floatResize:hover::after {
|
||||
border-color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -63,12 +63,31 @@ function dividerSize(root: HTMLElement): number {
|
||||
return thickness > 0 ? thickness : SPLIT_MINIMUMS.divider
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered split control's footprint in the strip's fixed part: its box
|
||||
* plus the strip's own gap, both of which the strip sheds when the control
|
||||
* hides. 0 while the control is hidden or unmeasured.
|
||||
*/
|
||||
function splitControlFootprint(pane: HTMLElement): number {
|
||||
const control = pane.querySelector('[data-dockkit-split-button]')
|
||||
if (control === null) return 0
|
||||
const width = control.getBoundingClientRect().width
|
||||
if (!(width > 0)) return 0
|
||||
const strip = pane.querySelector('[data-dockkit-strip]')
|
||||
/* v8 ignore next -- the control only renders inside a strip. */
|
||||
return width + (strip === null ? 0 : px(getComputedStyle(strip).columnGap))
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure every docked pane under `root`.
|
||||
* @param root - the docked surface's element.
|
||||
* @param splitHiddenWhenBlocked - whether the embedder hides blocked split
|
||||
* controls (`hideSplitWhenBlocked`); the room rule then leaves the control's
|
||||
* footprint out of each strip's fixed part, so the reading cannot flip with
|
||||
* the control's visibility (see `PaneMeasure.splitControlWidth`).
|
||||
* @returns each pane's fit, keyed by pane id.
|
||||
*/
|
||||
export function measurePaneFits(root: HTMLElement): ReadonlyMap<PaneId, HalvesFit> {
|
||||
export function measurePaneFits(root: HTMLElement, splitHiddenWhenBlocked = false): ReadonlyMap<PaneId, HalvesFit> {
|
||||
const minimums: SplitMinimums = { divider: dividerSize(root), chip: chipMinimum(root), body: SPLIT_MINIMUMS.body }
|
||||
const fits = new Map<PaneId, HalvesFit>()
|
||||
for (const [paneId, pane] of paneElements(root)) {
|
||||
@@ -77,6 +96,7 @@ export function measurePaneFits(root: HTMLElement): ReadonlyMap<PaneId, HalvesFi
|
||||
strip: rectOf(pane.querySelector('[data-dockkit-strip]')),
|
||||
chipsWidth: rectOf(pane.querySelector('[data-dockkit-strip-tabs]')).width,
|
||||
fillWidth: rectOf(pane.querySelector('[data-dockkit-strip-fill]')).width,
|
||||
splitControlWidth: splitHiddenWhenBlocked ? splitControlFootprint(pane) : 0,
|
||||
}, minimums))
|
||||
}
|
||||
return fits
|
||||
|
||||
@@ -23,11 +23,11 @@ export interface PaneCallbacks {
|
||||
readonly onDividerPressed: (splitId: SplitId, index: number, event: ReactPointerEvent<HTMLElement>) => void
|
||||
/** Why a pane cannot split right now, or `undefined` while it can. */
|
||||
readonly splitBlock: (paneId: PaneId) => SplitBlock | undefined
|
||||
/** Hide budget-blocked split controls without hiding width-blocked controls. */
|
||||
readonly hideSplitAtCapacity?: boolean
|
||||
/** Hide blocked split controls instead of rendering them disabled. */
|
||||
readonly hideSplitWhenBlocked?: boolean
|
||||
/** Whether a pane's strip draws the add control. */
|
||||
readonly canAddTab: (paneId: PaneId) => boolean
|
||||
/** Whether a tab's chip and menu offer close. */
|
||||
/** Whether a tab draws its close control and its menu's close item. */
|
||||
readonly canCloseTab: (tabId: TabId) => boolean
|
||||
/** Live drop preview, or `undefined` while nothing is being dragged. */
|
||||
readonly dropTarget: DropTarget | undefined
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface DockLabels {
|
||||
readonly dockFloat: string
|
||||
/** Close a floating panel. */
|
||||
readonly closeFloat: string
|
||||
/** The drop hint's caption for each body zone a dragged tab can land on. */
|
||||
readonly dropZone: Readonly<Record<DockZone, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +47,9 @@ export type TabRenderer = (tab: TabRecord) => ReactNode
|
||||
*
|
||||
* The kit's own item is the close gesture; anything that means something about
|
||||
* the tab's content comes from here. An item that acts MUST call `dismiss`,
|
||||
* because the menu closes on its own items only.
|
||||
* because the menu closes on its own items only. Every rendered item MUST
|
||||
* carry `role="menuitem"`: the kit probes for that role to dismiss a menu
|
||||
* that would paint empty, so items without it count as an empty menu.
|
||||
* @param tab - the tab whose menu is open.
|
||||
* @param dismiss - close the menu without acting.
|
||||
* @returns extra actions with ARIA menuitem, menuitemcheckbox, or menuitemradio roles, or nothing.
|
||||
|
||||
@@ -71,6 +71,17 @@ export interface PaneMeasure {
|
||||
readonly chipsWidth: number
|
||||
/** Width of the fill: free space, not a control. */
|
||||
readonly fillWidth: number
|
||||
/**
|
||||
* Footprint of the rendered split control (its width plus the strip's gap)
|
||||
* for embedders that hide blocked split controls: a half too narrow to
|
||||
* split hides its own control, so the rule leaves the footprint out of the
|
||||
* fixed part. Leaving it out is also what keeps the reading stable — the
|
||||
* control hiding sheds the same footprint from the strip, and a reading
|
||||
* that counted it would flip with the control's visibility and re-render
|
||||
* forever. Absent or 0 keeps the control in the fixed part, for embedders
|
||||
* that render a blocked control disabled.
|
||||
*/
|
||||
readonly splitControlWidth?: number
|
||||
}
|
||||
|
||||
/** Pixel minimums the room rule holds each half to. */
|
||||
@@ -79,18 +90,19 @@ export interface SplitMinimums {
|
||||
readonly divider: number
|
||||
/** One chip at its minimum: the smallest strip that still names a tab. */
|
||||
readonly chip: number
|
||||
/** The smallest body under a strip: one secondary text line inside the body's padding. */
|
||||
/** The smallest body under a strip: one secondary text line inside 12px of the body's own insets. */
|
||||
readonly body: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimums where no computed style can be read, mirroring
|
||||
* `dockkit.module.css`: `.splitRow > .divider` is 4px wide; `.tab` is 44px of
|
||||
* content plus 10px + 5px of padding (content-box), 59px; the body's 12px
|
||||
* padding above and below one 13px secondary line at 1.6 line-height is 45px,
|
||||
* held to 48px.
|
||||
* `dockkit.module.css`: `.splitRow > .divider` takes no layout width (its
|
||||
* hairline is painted over the seam); `.tab` is 80px of content plus
|
||||
* 10px + 10px of padding (content-box), 100px; 12px above and below one 13px
|
||||
* secondary line at 1.6 line-height — the inset a body draws for itself, as
|
||||
* `.empty` does — is 45px, held to 48px.
|
||||
*/
|
||||
export const SPLIT_MINIMUMS: SplitMinimums = { divider: 4, chip: 59, body: 48 }
|
||||
export const SPLIT_MINIMUMS: SplitMinimums = { divider: 0, chip: 100, body: 48 }
|
||||
|
||||
/** Whether a pane's two halves after an equal split would each still work. */
|
||||
export interface HalvesFit {
|
||||
@@ -102,9 +114,10 @@ export interface HalvesFit {
|
||||
|
||||
/**
|
||||
* The room rule. After an equal split each half must hold what cannot shrink:
|
||||
* horizontally the strip's fixed part — its width minus the chip box and the
|
||||
* fill, which is the padding, the gaps, and every control that pane draws —
|
||||
* plus one chip at its minimum; vertically the strip plus a minimum body. The
|
||||
* horizontally the strip's fixed part — its width minus the chip box, the
|
||||
* fill, and `splitControlWidth`, which is the padding, the gaps, and every
|
||||
* control a half would still draw — plus one chip at its minimum; vertically
|
||||
* the strip plus a minimum body. The
|
||||
* borders are what the pane's box exceeds the strip's by. An unmeasured pane
|
||||
* (no layout, as under jsdom) fits: the rule only blocks on a positive reading.
|
||||
* @param measure - the pane's rectangles.
|
||||
@@ -115,7 +128,7 @@ export function halvesFit(measure: PaneMeasure, minimums: SplitMinimums = SPLIT_
|
||||
const { pane, strip } = measure
|
||||
if (!(pane.width > 0) || !(pane.height > 0) || !(strip.width > 0)) return { row: true, column: true }
|
||||
const borders = Math.max(0, pane.width - strip.width)
|
||||
const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth)
|
||||
const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth - (measure.splitControlWidth ?? 0))
|
||||
const halfWidth = (pane.width - minimums.divider) / 2 - borders
|
||||
const halfHeight = (pane.height - minimums.divider) / 2 - borders
|
||||
return {
|
||||
|
||||
@@ -260,14 +260,30 @@ describe('DockSurface', () => {
|
||||
expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
|
||||
})
|
||||
|
||||
it('hides capacity-blocked split controls when opted in and restores them when capacity returns', () => {
|
||||
it('names the enabled split control through the shared tooltip, not a native title', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
renderSurface(seededController(), spyIntents())
|
||||
const button = screen.getByRole('button', { name: TEST_LABELS.splitPane })
|
||||
expect(button.hasAttribute('title')).toBe(false)
|
||||
fireEvent.mouseEnter(button)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane)
|
||||
fireEvent.mouseLeave(button)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('hides blocked split controls when opted in and restores them when capacity returns', () => {
|
||||
const controller = seededController()
|
||||
controller.splitPane()
|
||||
const state = controller.getSnapshot().state
|
||||
layOut(dockPaneIds(state), 420)
|
||||
layOut(dockPaneIds(state), 520)
|
||||
const intents = spyIntents()
|
||||
const props: DockSurfaceProps = {
|
||||
state, canSplit: false, hideSplitAtCapacity: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
|
||||
state, canSplit: false, hideSplitWhenBlocked: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
|
||||
}
|
||||
const view = render(<DockSurface {...props} />)
|
||||
expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull()
|
||||
@@ -285,15 +301,15 @@ describe('DockSurface', () => {
|
||||
// jsdom lays nothing out, so the room rule reads the rectangles this spec
|
||||
// hands it: two panes, one wide enough for two halves and one not. The
|
||||
// strip's fixed part is 104px in both (the chrome pane's controls), the chip
|
||||
// minimum falls back to the stylesheet's 59px.
|
||||
it.each([false, true])('keeps the width-blocked split control and its title with hideSplitAtCapacity=%s', (hideSplitAtCapacity) => {
|
||||
// minimum falls back to the stylesheet's 100px.
|
||||
it.each([false, true])('disables or hides the width-blocked split control with hideSplitWhenBlocked=%s', (hideSplitWhenBlocked) => {
|
||||
const controller = seededController()
|
||||
controller.setExpanded(true)
|
||||
controller.splitPane()
|
||||
const snapshot = controller.getSnapshot()
|
||||
const [wide, narrow] = dockPaneIds(snapshot.state)
|
||||
if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes')
|
||||
const widths: Record<string, number> = { [wide]: 420, [narrow]: 208 }
|
||||
const widths: Record<string, number> = { [wide]: 520, [narrow]: 208 }
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const paneWidth = widths[this.closest<HTMLElement>('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0
|
||||
if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
|
||||
@@ -306,7 +322,7 @@ describe('DockSurface', () => {
|
||||
<DockSurface
|
||||
state={snapshot.state}
|
||||
canSplit
|
||||
hideSplitAtCapacity={hideSplitAtCapacity}
|
||||
hideSplitWhenBlocked={hideSplitWhenBlocked}
|
||||
intents={controller}
|
||||
labels={TEST_LABELS}
|
||||
renderTab={tab => <p>{tab.contentId}</p>}
|
||||
@@ -315,10 +331,14 @@ describe('DockSurface', () => {
|
||||
const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`)
|
||||
const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`)
|
||||
expect(wideButton?.hasAttribute('disabled')).toBe(false)
|
||||
expect(wideButton?.getAttribute('title')).toBe(TEST_LABELS.splitPane)
|
||||
expect(narrowButton?.hasAttribute('disabled')).toBe(true)
|
||||
expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
|
||||
expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
|
||||
expect(wideButton?.hasAttribute('title')).toBe(false)
|
||||
if (hideSplitWhenBlocked) {
|
||||
expect(narrowButton).toBeNull()
|
||||
} else {
|
||||
expect(narrowButton?.hasAttribute('disabled')).toBe(true)
|
||||
expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
|
||||
expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => {
|
||||
@@ -342,7 +362,7 @@ describe('DockSurface', () => {
|
||||
controller.splitPane()
|
||||
const snapshot = controller.getSnapshot()
|
||||
const panes = dockPaneIds(snapshot.state)
|
||||
let paneWidth = 420
|
||||
let paneWidth = 520
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
|
||||
if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
|
||||
@@ -379,6 +399,194 @@ describe('DockSurface', () => {
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// The room reading must not flip with the hidden control's own footprint:
|
||||
// hiding the width-blocked control widens the fill by the control plus the
|
||||
// strip's gap, and a reading that counted the control would fit again, show
|
||||
// it, and re-render forever (React's update-depth limit, which crashed the
|
||||
// surface). The rule leaves the footprint out, so the fill the fake hands
|
||||
// back here depends on whether the control is in the DOM — exactly the
|
||||
// feedback the fix breaks.
|
||||
it('reads the same room whether hideSplitWhenBlocked has hidden the split control or not', () => {
|
||||
class FakeResizeObserver implements ResizeObserver {
|
||||
static latest: FakeResizeObserver | undefined
|
||||
readonly observe = vi.fn()
|
||||
readonly unobserve = vi.fn()
|
||||
readonly disconnect = vi.fn()
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
FakeResizeObserver.latest = this
|
||||
}
|
||||
|
||||
/** What the platform does when the observed element's size changes. */
|
||||
fire(): void {
|
||||
this.callback([], this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
||||
const controller = seededController()
|
||||
controller.setExpanded(true)
|
||||
const snapshot = controller.getSnapshot()
|
||||
// In the band where only the control's 28px footprint decides the fit:
|
||||
// half 188px against 76px of other fixed controls plus the 100px chip.
|
||||
let paneWidth = 380
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const pane = this.closest<HTMLElement>('[data-dockkit-pane]')
|
||||
if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
|
||||
if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
|
||||
if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
|
||||
if (this.hasAttribute('data-dockkit-split-button')) return box(0, 0, 28, 28)
|
||||
if (this.hasAttribute('data-dockkit-strip-fill')) {
|
||||
const fixed = pane?.querySelector('[data-dockkit-split-button]') === null ? 76 : 104
|
||||
return box(0, 0, Math.max(0, paneWidth - 2 - 60 - fixed), 24)
|
||||
}
|
||||
return box(0, 0, 0, 0)
|
||||
})
|
||||
render(
|
||||
<DockSurface
|
||||
state={snapshot.state}
|
||||
canSplit
|
||||
hideSplitWhenBlocked
|
||||
intents={controller}
|
||||
labels={TEST_LABELS}
|
||||
renderTab={tab => <p>{tab.contentId}</p>}
|
||||
/>,
|
||||
)
|
||||
// Settled with the control shown: the discounted reading fits either way.
|
||||
expect(document.querySelector('[data-dockkit-split-button]')).not.toBeNull()
|
||||
|
||||
// Narrowed under the discounted minimum: hidden, and the reading without
|
||||
// the control settles hidden.
|
||||
const observer = FakeResizeObserver.latest
|
||||
if (observer === undefined) throw new Error('expected the surface to observe its own size')
|
||||
paneWidth = 300
|
||||
act(() => { observer.fire() })
|
||||
expect(document.querySelector('[data-dockkit-split-button]')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => {
|
||||
let scrollLeft = 0
|
||||
const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name =>
|
||||
[name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
|
||||
const strip = (element: Element): boolean => element.hasAttribute('data-dockkit-strip-tabs')
|
||||
Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get(this: Element) { return strip(this) ? scrollLeft : 0 } })
|
||||
Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return strip(this) ? 400 : 0 } })
|
||||
Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return strip(this) ? 200 : 0 } })
|
||||
try {
|
||||
const controller = seededController()
|
||||
renderSurface(controller, spyIntents())
|
||||
const box = document.querySelector<HTMLElement>('[data-dockkit-strip-tabs]')
|
||||
if (box === null) throw new Error('expected the chip box')
|
||||
expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('end')
|
||||
scrollLeft = 100
|
||||
fireEvent.scroll(box)
|
||||
expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start end')
|
||||
scrollLeft = 200
|
||||
fireEvent.scroll(box)
|
||||
expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start')
|
||||
} finally {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
|
||||
else Object.defineProperty(Element.prototype, name, descriptor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('scrolls the chip box to the active chip when it lies past either edge, clearing the fade band', () => {
|
||||
let scrollLeft = 0
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft')
|
||||
Object.defineProperty(Element.prototype, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get() { return scrollLeft },
|
||||
set(value: number) { scrollLeft = value },
|
||||
})
|
||||
// The box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says.
|
||||
let openedTab: TabId | undefined
|
||||
let openedAt = 250
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 200, 28)
|
||||
if (this.getAttribute('data-dockkit-tab') === openedTab) return box(openedAt, 0, 90, 28)
|
||||
if (this.hasAttribute('data-dockkit-tab')) return box(40, 0, 90, 28)
|
||||
return box(0, 0, 0, 0)
|
||||
})
|
||||
const surface = (controller: DockController) => (
|
||||
<DockSurface
|
||||
state={controller.getSnapshot().state}
|
||||
canSplit
|
||||
intents={controller}
|
||||
labels={TEST_LABELS}
|
||||
renderTab={tab => <p>{tab.contentId}</p>}
|
||||
/>
|
||||
)
|
||||
try {
|
||||
const controller = seededController()
|
||||
const seededTab = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId
|
||||
if (seededTab === undefined) throw new Error('expected the seeded tab')
|
||||
const { rerender } = render(surface(controller))
|
||||
// The seeded chip is in view: nothing moves.
|
||||
expect(scrollLeft).toBe(0)
|
||||
openedTab = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
|
||||
rerender(surface(controller))
|
||||
// Past the right edge by 140, plus the 24px fade.
|
||||
expect(scrollLeft).toBe(164)
|
||||
// Selected again once it lies 30 past the left edge: back by 30 plus the fade.
|
||||
openedAt = -30
|
||||
controller.focusTab(seededTab)
|
||||
rerender(surface(controller))
|
||||
controller.focusTab(openedTab)
|
||||
rerender(surface(controller))
|
||||
expect(scrollLeft).toBe(164 - 30 - 24)
|
||||
} finally {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft')
|
||||
else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor)
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a chip title clipped while its text is wider than its box, re-reading on resize', () => {
|
||||
class FakeResizeObserver implements ResizeObserver {
|
||||
static readonly all: FakeResizeObserver[] = []
|
||||
readonly observe = vi.fn()
|
||||
readonly unobserve = vi.fn()
|
||||
readonly disconnect = vi.fn()
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
FakeResizeObserver.all.push(this)
|
||||
}
|
||||
|
||||
fire(): void {
|
||||
this.callback([], this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
||||
let clientWidth = 200
|
||||
const descriptors = ['scrollWidth', 'clientWidth'].map(name =>
|
||||
[name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
|
||||
const title = (element: Element): boolean => element.hasAttribute('data-dockkit-tab-title')
|
||||
Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return title(this) ? 120 : 0 } })
|
||||
Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return title(this) ? clientWidth : 0 } })
|
||||
try {
|
||||
const controller = seededController()
|
||||
const { unmount } = renderSurface(controller, spyIntents())
|
||||
const span = document.querySelector<HTMLElement>('[data-dockkit-tab-title]')
|
||||
if (span === null) throw new Error('expected a chip title')
|
||||
const observer = FakeResizeObserver.all.find(candidate => candidate.observe.mock.calls.some(([target]) => target === span))
|
||||
if (observer === undefined) throw new Error('expected the title to observe its own size')
|
||||
expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
|
||||
// The chip narrowed under the text.
|
||||
clientWidth = 80
|
||||
act(() => { observer.fire() })
|
||||
expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(true)
|
||||
clientWidth = 200
|
||||
act(() => { observer.fire() })
|
||||
expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
|
||||
unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
|
||||
else Object.defineProperty(Element.prototype, name, descriptor)
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => {
|
||||
const controller = seededController()
|
||||
const intents = spyIntents()
|
||||
@@ -414,6 +622,59 @@ describe('DockSurface', () => {
|
||||
expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('withholds a tab\'s close control and menu close item where the embedder\'s canCloseTab denies, tab by tab', () => {
|
||||
const controller = seededController()
|
||||
controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
|
||||
const snapshot = controller.getSnapshot()
|
||||
const seedTabId = getPane(snapshot.state, snapshot.state.activePaneId).tabs[0]
|
||||
if (seedTabId === undefined) throw new Error('expected seeded tab')
|
||||
render(
|
||||
<DockSurface
|
||||
state={snapshot.state}
|
||||
canSplit
|
||||
canCloseTab={tabId => tabId !== seedTabId}
|
||||
intents={controller}
|
||||
labels={TEST_LABELS}
|
||||
renderTab={tab => <p>{tab.contentId}</p>}
|
||||
renderTabMenuItems={(_, dismiss) => <button type="button" role="menuitem" onClick={dismiss}>embedder item</button>}
|
||||
/>,
|
||||
)
|
||||
const [seedChip, fileChip] = screen.getAllByRole('tab')
|
||||
if (seedChip === undefined || fileChip === undefined) throw new Error('expected two chips')
|
||||
expect(seedChip.querySelector('[data-dockkit-tab-close]')).toBeNull()
|
||||
expect(fileChip.querySelector('[data-dockkit-tab-close]')).not.toBeNull()
|
||||
|
||||
// The menu still opens on the withheld chip: the embedder's items remain reachable.
|
||||
fireEvent.contextMenu(seedChip)
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['embedder item'])
|
||||
fireEvent.pointerDown(document.body)
|
||||
|
||||
fireEvent.contextMenu(fileChip)
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab, 'embedder item'])
|
||||
})
|
||||
|
||||
it('draws a lone unclosable chip quiet, and shows no menu on it when the embedder renders no item', () => {
|
||||
const controller = seededController()
|
||||
render(
|
||||
<DockSurface
|
||||
state={controller.getSnapshot().state}
|
||||
canSplit
|
||||
canCloseTab={() => false}
|
||||
intents={controller}
|
||||
labels={TEST_LABELS}
|
||||
renderTab={tab => <p>{tab.contentId}</p>}
|
||||
renderTabMenuItems={() => undefined}
|
||||
/>,
|
||||
)
|
||||
const [chip] = screen.getAllByRole('tab')
|
||||
if (chip === undefined) throw new Error('expected the seeded chip')
|
||||
// The lone unclosable chip draws quiet: no capsule, no hover fill.
|
||||
expect(chip.getAttribute('data-dockkit-tab-quiet')).toBe('true')
|
||||
// The menu would hold nothing, so it dismisses itself before painting.
|
||||
fireEvent.contextMenu(chip)
|
||||
expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => {
|
||||
const controller = seededController()
|
||||
controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
|
||||
@@ -648,13 +909,13 @@ describe('tab drags', () => {
|
||||
})
|
||||
|
||||
it('shows the dock hint on the pane under the pointer and reports the zone on release', () => {
|
||||
const { intents, second, fileTabId, chip } = twoPanes()
|
||||
drag(chip(fileTabId), FILE_CHIP, [420 + 210, 300], false)
|
||||
const { intents, second, fileTabId, chip } = twoPanes(520)
|
||||
drag(chip(fileTabId), FILE_CHIP, [520 + 260, 300], false)
|
||||
const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`)
|
||||
expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center')
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
|
||||
expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right')
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
|
||||
expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right')
|
||||
expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
|
||||
})
|
||||
@@ -672,6 +933,8 @@ describe('tab drags', () => {
|
||||
const { intents, second, fileTabId, chip } = twoPanes(208)
|
||||
drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false)
|
||||
expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 500 })
|
||||
expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom')
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
|
||||
expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top')
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
|
||||
@@ -764,10 +1027,10 @@ describe('tab drags', () => {
|
||||
|
||||
describe('horizontal workbench drops', () => {
|
||||
it.each([
|
||||
{ x: 450, y: 300, zone: 'left' },
|
||||
{ x: 810, y: 590, zone: 'right' },
|
||||
{ x: 550, y: 300, zone: 'left' },
|
||||
{ x: 1010, y: 590, zone: 'right' },
|
||||
] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => {
|
||||
const { intents, second, fileTabId, chip } = twoPanes(420, true, { dropZones: 'horizontal' })
|
||||
const { intents, second, fileTabId, chip } = twoPanes(520, true, { dropZones: 'horizontal' })
|
||||
drag(chip(fileTabId), FILE_CHIP, [x, y], false)
|
||||
const hints = document.querySelectorAll('[data-dockkit-dock-zone]')
|
||||
expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right'])
|
||||
|
||||
@@ -44,6 +44,7 @@ export const TEST_LABELS: DockLabels = {
|
||||
addTab: 'new tab',
|
||||
dockFloat: 'dock',
|
||||
closeFloat: 'close panel',
|
||||
dropZone: { center: 'move here', left: 'split left', right: 'split right', top: 'split top', bottom: 'split bottom' },
|
||||
}
|
||||
|
||||
/** Brand a literal a spec spells out: an id the kit would have minted. */
|
||||
|
||||
@@ -118,26 +118,26 @@ describe('halvesFit — the room rule', () => {
|
||||
})
|
||||
|
||||
it('needs each half to hold the strip\'s fixed controls plus one minimum chip', () => {
|
||||
// 420px: halves of 206px inside the borders, against 104 + 59.
|
||||
expect(halvesFit(measure(420, 600, 104)).row).toBe(true)
|
||||
// 208px: halves of 100px, short of 163.
|
||||
// 520px: halves of 258px inside the borders, against 104 + 100.
|
||||
expect(halvesFit(measure(520, 600, 104)).row).toBe(true)
|
||||
// 208px: halves of 102px, short of 204.
|
||||
expect(halvesFit(measure(208, 600, 104)).row).toBe(false)
|
||||
// The boundary is inclusive: 2 * (163 + 2 borders) + 4 divider = 334.
|
||||
expect(halvesFit(measure(334, 600, 104)).row).toBe(true)
|
||||
expect(halvesFit(measure(333, 600, 104)).row).toBe(false)
|
||||
// A strip with fewer controls needs less.
|
||||
expect(halvesFit(measure(208, 600, 44)).row).toBe(false)
|
||||
expect(halvesFit(measure(214, 600, 44)).row).toBe(true)
|
||||
// The boundary is inclusive: 2 * (204 + 2 borders) = 412; the divider takes no room.
|
||||
expect(halvesFit(measure(412, 600, 104)).row).toBe(true)
|
||||
expect(halvesFit(measure(411, 600, 104)).row).toBe(false)
|
||||
// A strip with fewer controls needs less: 2 * (144 + 2) = 292.
|
||||
expect(halvesFit(measure(291, 600, 44)).row).toBe(false)
|
||||
expect(halvesFit(measure(292, 600, 44)).row).toBe(true)
|
||||
})
|
||||
|
||||
it('needs each half to hold the strip plus a minimum body for a column split', () => {
|
||||
// Halves of (h - 4) / 2 - 2 against 36 + 48 = 84.
|
||||
expect(halvesFit(measure(420, 176, 104)).column).toBe(true)
|
||||
expect(halvesFit(measure(420, 175, 104)).column).toBe(false)
|
||||
// Halves of h / 2 - 2 against 36 + 48 = 84.
|
||||
expect(halvesFit(measure(420, 172, 104)).column).toBe(true)
|
||||
expect(halvesFit(measure(420, 171, 104)).column).toBe(false)
|
||||
})
|
||||
|
||||
it('takes the minimums it is given, and the stylesheet\'s by default', () => {
|
||||
expect(SPLIT_MINIMUMS).toEqual({ divider: 4, chip: 59, body: 48 })
|
||||
expect(SPLIT_MINIMUMS).toEqual({ divider: 0, chip: 100, body: 48 })
|
||||
expect(halvesFit(measure(208, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(false)
|
||||
expect(halvesFit(measure(220, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(true)
|
||||
})
|
||||
|
||||
@@ -57,21 +57,21 @@ describe('measurePaneFits', () => {
|
||||
const root = surface()
|
||||
const bare = document.createElement('section')
|
||||
bare.dataset.dockkitPane = 'bare'
|
||||
root.append(pane('wide', 420), bare)
|
||||
root.append(pane('wide', 520), bare)
|
||||
expect(paneElements(root).map(([id]) => id)).toEqual(['wide', 'bare'])
|
||||
const fits = measurePaneFits(root)
|
||||
expect(fits.get(asPane('wide'))).toEqual({ row: true, column: true })
|
||||
expect(fits.get(asPane('bare'))).toEqual({ row: true, column: true })
|
||||
})
|
||||
|
||||
// 308px: halves of 150px inside the borders, against 104px of controls plus one chip.
|
||||
// 308px: halves of 152px inside the borders, against 104px of controls plus one chip.
|
||||
it('reads the chip minimum from a rendered chip\'s computed style, padding included for a content box', () => {
|
||||
const root = surface()
|
||||
const narrow = pane('p', 308)
|
||||
root.append(narrow)
|
||||
// No chip rendered: the stylesheet's 59px, so 163 > 150.
|
||||
// No chip rendered: the stylesheet's 100px, so 204 > 152.
|
||||
expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
|
||||
// 44px of content plus 4px + 4px of padding is 52: 156 > 150.
|
||||
// 44px of content plus 4px + 4px of padding is 52: 156 > 152.
|
||||
const rendered = chip(narrow, { minWidth: '44px', paddingLeft: '4px', paddingRight: '4px', boxSizing: 'content-box' })
|
||||
expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
|
||||
// The same declaration as a border box is the whole footprint: 148 fits.
|
||||
@@ -84,10 +84,10 @@ describe('measurePaneFits', () => {
|
||||
expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
|
||||
})
|
||||
|
||||
// 336px: halves of 164px against 163 with the stylesheet's 4px divider, 162 with an 8px one.
|
||||
// 416px: halves of 204px against 204 with the stylesheet's zero divider, 202 with an 8px one.
|
||||
it('reads the divider\'s thickness from a rendered divider, and the stylesheet\'s before one exists', () => {
|
||||
const root = surface()
|
||||
root.append(pane('p', 336))
|
||||
root.append(pane('p', 416))
|
||||
expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true)
|
||||
const divider = document.createElement('div')
|
||||
divider.dataset.dockkitDivider = 's:0'
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"references": [
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* The split button sits beside the session-log capsule at the same compact
|
||||
scale (26px tall, pill radius, hairline l4 border, 11px primary-color label). */
|
||||
/* The split button matches the header's 28px control row (pill radius,
|
||||
hairline l4 border, 11px primary-color label) beside the round icon buttons. */
|
||||
|
||||
.split {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
height: 26px;
|
||||
height: 28px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: 13px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
font-family: var(--dsw-font-family);
|
||||
}
|
||||
|
||||
@@ -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-primitives/README.md
|
||||
README.md: 7b670394f6ca70ea369fc79766b26119f670f753
|
||||
README.zh.md: 8ddefde928a723992945f801fb7407a2d22f5f27
|
||||
README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73
|
||||
README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6
|
||||
|
||||
@@ -55,6 +55,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. |
|
||||
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
|
||||
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. |
|
||||
| `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. |
|
||||
|
||||
Three pairs are easy to confuse:
|
||||
|
||||
@@ -66,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel
|
||||
|
||||
### Controls and icons
|
||||
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order.
|
||||
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order.
|
||||
|
||||
### Rendering agent output
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ kind: "package-library"
|
||||
| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 |
|
||||
| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
|
||||
| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 |
|
||||
| `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 |
|
||||
|
||||
有三组容易混淆:
|
||||
|
||||
@@ -66,7 +67,7 @@ kind: "package-library"
|
||||
|
||||
### 控件与图标
|
||||
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。
|
||||
|
||||
### 渲染 agent 输出
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* The coloured file-type sheet: a 28×28 paper with a folded corner, filled in
|
||||
* the type's colour, carrying a white mark for the type. Unlike the `ic_ds_*`
|
||||
* glyphs it does not ride `currentColor` — the colour is the type's identity —
|
||||
* so a consumer that wants it muted applies a CSS filter. Design sources:
|
||||
* DSHFiles iconCode, iconHTML, iconImage, iconMD, iconPDF, iconExcel,
|
||||
* iconPPT, iconWord, iconOthers.
|
||||
*
|
||||
* TODO: interim. The sheets and `classifyFileType` are drawn for the Sidebar
|
||||
* only; the product-wide file-type standard that will replace them is described
|
||||
* in `file-extensions.ts`.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
CODE_EXTENSIONS, HTML_EXTENSIONS, IMAGE_EXTENSIONS, MARKDOWN_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS,
|
||||
SLIDES_EXTENSIONS, WORD_EXTENSIONS, fileExtension,
|
||||
} from './file-extensions.ts'
|
||||
import type { IconProps } from './icons/props.ts'
|
||||
|
||||
/** File types with their own sheet colour and mark; `other` is the grey sheet with text lines. */
|
||||
export type FileTypeKind = 'code' | 'html' | 'image' | 'markdown' | 'pdf' | 'sheet' | 'slides' | 'document' | 'other'
|
||||
|
||||
/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */
|
||||
export interface FileTypeIconProps extends IconProps {
|
||||
kind: FileTypeKind
|
||||
}
|
||||
|
||||
/** Extension sets in precedence order: a specific type wins over the code set it may also sit in. */
|
||||
const KIND_SETS: readonly (readonly [FileTypeKind, ReadonlySet<string>])[] = [
|
||||
['markdown', MARKDOWN_EXTENSIONS],
|
||||
['html', HTML_EXTENSIONS],
|
||||
['pdf', PDF_EXTENSIONS],
|
||||
['sheet', SHEET_EXTENSIONS],
|
||||
['slides', SLIDES_EXTENSIONS],
|
||||
['document', WORD_EXTENSIONS],
|
||||
['image', IMAGE_EXTENSIONS],
|
||||
['code', CODE_EXTENSIONS],
|
||||
]
|
||||
|
||||
/**
|
||||
* Derive a file path's type glyph from its extension. Unknown and missing
|
||||
* extensions fall to `other`.
|
||||
* @param path - File path or bare name, with either separator.
|
||||
* @returns The file's type.
|
||||
*/
|
||||
export function classifyFileType(path: string): FileTypeKind {
|
||||
const extension = fileExtension(path)
|
||||
return KIND_SETS.find(([, set]) => set.has(extension))?.[0] ?? 'other'
|
||||
}
|
||||
|
||||
const SHEET_PATH = 'M8.48949 28H19.511C21.6482 28 22.7167 28 23.5596 27.6509C24.6835 27.1853 25.5764 26.2924 26.042 25.1685C26.3911 24.3256 26.3911 23.257 26.3911 21.1199V8.79443C26.3911 8.32877 26.3911 8.09593 26.3473 7.87507C26.2889 7.58058 26.1733 7.30042 26.007 7.05048C25.8822 6.86303 25.718 6.69799 25.3895 6.36792L20.0613 1.01354C19.7307 0.681235 19.5653 0.515081 19.3771 0.38885C19.1263 0.220541 18.8446 0.103463 18.5483 0.0443412C18.3261 0 18.0917 0 17.6229 0H8.48949C6.35233 0 5.28376 0 4.44085 0.349145C3.31697 0.814671 2.42405 1.70759 1.95852 2.83147C1.60938 3.67438 1.60938 4.74296 1.60938 6.88011V21.1199C1.60938 23.257 1.60938 24.3256 1.95852 25.1685C2.42405 26.2924 3.31697 27.1853 4.44085 27.6509C5.28376 28 6.35233 28 8.48949 28Z'
|
||||
|
||||
const CORNER_PATH = 'M26.3911 7.37445L19.0527 0V3.77445C19.0527 4.89271 19.0527 5.45184 19.2354 5.89289C19.479 6.48096 19.9462 6.94818 20.5343 7.19176C20.9753 7.37445 21.5345 7.37445 22.6527 7.37445H26.3911Z'
|
||||
|
||||
/** One type's sheet colour and its white mark. */
|
||||
interface Face {
|
||||
readonly fill: string
|
||||
readonly mark: ReactNode
|
||||
}
|
||||
|
||||
const FACES: Readonly<Record<FileTypeKind, Face>> = {
|
||||
code: {
|
||||
fill: '#4176E6',
|
||||
mark: (
|
||||
<>
|
||||
<path d="M8.61 16.3601L11.76 18.3901V20.1401L7 17.0601V15.6601L11.76 12.5801V14.3301L8.61 16.3601Z" fill="white" />
|
||||
<path d="M16.1918 14.3301V12.5801L20.9518 15.6601V17.0601L16.1918 20.1401V18.3901L19.3418 16.3601L16.1918 14.3301Z" fill="white" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
html: {
|
||||
fill: '#4176E6',
|
||||
mark: (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M13.9983 9.68311C17.2109 9.68311 19.8156 12.2873 19.8157 15.4998C19.8157 18.7124 17.211 21.3172 13.9983 21.3172C10.7858 21.317 8.18164 18.7123 8.18164 15.4998C8.1818 12.2874 10.7859 9.68327 13.9983 9.68311ZM9.26104 16.0248C9.46915 17.9243 10.7925 19.4877 12.5628 20.0464C12.4189 19.7978 12.2941 19.5153 12.1868 19.2117C11.8839 18.3543 11.6919 17.2436 11.6413 16.0248H9.26104ZM16.3554 16.0248C16.3048 17.2435 16.1134 18.3543 15.8105 19.2117C15.7033 19.5153 15.5777 19.7972 15.4339 20.0457C17.2043 19.4871 18.5282 17.9243 18.7363 16.0248H16.3554ZM12.6927 16.0248C12.7428 17.146 12.9201 18.1335 13.1773 18.8617C13.3309 19.2963 13.5019 19.6102 13.6675 19.8051C13.8329 19.9998 13.9462 20.0257 13.9983 20.0259C14.0503 20.0259 14.164 20.0003 14.3299 19.8051C14.4955 19.6101 14.6665 19.2963 14.82 18.8617C15.0773 18.1335 15.2546 17.146 15.3047 16.0248H12.6927ZM13.9983 10.7331C13.9462 10.7332 13.8329 10.7599 13.6675 10.9546C13.5019 11.1495 13.3308 11.4634 13.1773 11.8979C12.9019 12.6778 12.7177 13.7546 12.6838 14.9748H15.3136C15.2797 13.7546 15.0955 12.6778 14.82 11.8979C14.6665 11.4634 14.4954 11.1495 14.3299 10.9546C14.164 10.7594 14.0503 10.7331 13.9983 10.7331ZM15.5877 11.0052C15.669 11.1758 15.7433 11.3577 15.8105 11.5479C16.1332 12.4615 16.3297 13.6621 16.3636 14.9748H18.7363C18.5341 13.1308 17.2806 11.6037 15.5877 11.0052ZM12.409 11.0052C10.7163 11.6038 9.46318 13.1309 9.26104 14.9748H11.6338C11.6677 13.6621 11.8641 12.4615 12.1868 11.5479C12.254 11.3578 12.3277 11.1757 12.409 11.0052Z"
|
||||
fill="white"
|
||||
/>
|
||||
),
|
||||
},
|
||||
image: {
|
||||
fill: '#8B76F6',
|
||||
mark: (
|
||||
<>
|
||||
<path d="M10.4211 15.9204C10.5755 15.6558 10.9578 15.6558 11.1121 15.9204L13.6491 20.2696C13.8047 20.5362 13.6123 20.8711 13.3036 20.8711H8.22962C7.9209 20.8711 7.72855 20.5362 7.88411 20.2696L10.4211 15.9204Z" fill="white" />
|
||||
<path d="M15.498 13.186C15.6504 12.9117 16.0449 12.9117 16.1973 13.186L20.1367 20.2769C20.2848 20.5435 20.092 20.8711 19.787 20.8711H11.9083C11.6033 20.8711 11.4105 20.5435 11.5587 20.2769L15.498 13.186Z" fill="white" />
|
||||
<path d="M11.8599 11.3997C11.8599 12.286 11.1415 13.0045 10.2552 13.0045C9.36887 13.0045 8.65039 12.286 8.65039 11.3997C8.65039 10.5134 9.36887 9.79492 10.2552 9.79492C11.1415 9.79492 11.8599 10.5134 11.8599 11.3997Z" fill="white" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
markdown: {
|
||||
fill: '#4176E6',
|
||||
mark: (
|
||||
<path
|
||||
d="M8.75904 19.5V14.6H9.90004L11.93 17.932H11.328L13.302 14.6H14.443L14.457 19.5H13.183L13.169 16.539H13.386L11.909 19.017H11.293L9.77404 16.539H10.04V19.5H8.75904ZM15.4378 19.5V14.6H17.7548C18.2961 14.6 18.7721 14.7003 19.1828 14.901C19.5934 15.1017 19.9131 15.384 20.1418 15.748C20.3751 16.112 20.4918 16.546 20.4918 17.05C20.4918 17.5493 20.3751 17.9833 20.1418 18.352C19.9131 18.716 19.5934 18.9983 19.1828 19.199C18.7721 19.3997 18.2961 19.5 17.7548 19.5H15.4378ZM16.8238 18.394H17.6988C17.9788 18.394 18.2214 18.3427 18.4268 18.24C18.6368 18.1327 18.8001 17.9787 18.9168 17.778C19.0334 17.5727 19.0918 17.33 19.0918 17.05C19.0918 16.7653 19.0334 16.5227 18.9168 16.322C18.8001 16.1213 18.6368 15.9697 18.4268 15.867C18.2214 15.7597 17.9788 15.706 17.6988 15.706H16.8238V18.394Z"
|
||||
fill="white"
|
||||
/>
|
||||
),
|
||||
},
|
||||
pdf: {
|
||||
fill: '#EC1313',
|
||||
mark: (
|
||||
<path
|
||||
d="M6.80641 19.5V14.6H9.04641C9.49441 14.6 9.87941 14.6723 10.2014 14.817C10.5281 14.9617 10.7801 15.1717 10.9574 15.447C11.1347 15.7177 11.2234 16.0397 11.2234 16.413C11.2234 16.7817 11.1347 17.1013 10.9574 17.372C10.7801 17.6427 10.5281 17.8527 10.2014 18.002C9.87941 18.1467 9.49441 18.219 9.04641 18.219H7.57641L8.19241 17.617V19.5H6.80641ZM8.19241 17.764L7.57641 17.127H8.96241C9.25174 17.127 9.46641 17.064 9.60641 16.938C9.75107 16.812 9.82341 16.637 9.82341 16.413C9.82341 16.1843 9.75107 16.007 9.60641 15.881C9.46641 15.755 9.25174 15.692 8.96241 15.692H7.57641L8.19241 15.055V17.764ZM11.8992 19.5V14.6H14.2162C14.7575 14.6 15.2335 14.7003 15.6442 14.901C16.0548 15.1017 16.3745 15.384 16.6032 15.748C16.8365 16.112 16.9532 16.546 16.9532 17.05C16.9532 17.5493 16.8365 17.9833 16.6032 18.352C16.3745 18.716 16.0548 18.9983 15.6442 19.199C15.2335 19.3997 14.7575 19.5 14.2162 19.5H11.8992ZM13.2852 18.394H14.1602C14.4402 18.394 14.6828 18.3427 14.8882 18.24C15.0982 18.1327 15.2615 17.9787 15.3782 17.778C15.4948 17.5727 15.5532 17.33 15.5532 17.05C15.5532 16.7653 15.4948 16.5227 15.3782 16.322C15.2615 16.1213 15.0982 15.9697 14.8882 15.867C14.6828 15.7597 14.4402 15.706 14.1602 15.706H13.2852V18.394ZM17.6824 19.5V14.6H21.5254V15.671H19.0684V19.5H17.6824ZM18.9704 17.82V16.749H21.2314V17.82H18.9704Z"
|
||||
fill="white"
|
||||
/>
|
||||
),
|
||||
},
|
||||
sheet: {
|
||||
fill: '#22C55E',
|
||||
mark: (
|
||||
<path
|
||||
d="M10.2935 20.5L13.3535 16.25L13.3435 17.66L10.4035 13.5H12.6335L14.5135 16.21L13.5635 16.22L15.4135 13.5H17.5535L14.6135 17.58V16.18L17.7135 20.5H15.4335L13.5235 17.65H14.4335L12.5535 20.5H10.2935Z"
|
||||
fill="white"
|
||||
/>
|
||||
),
|
||||
},
|
||||
slides: {
|
||||
fill: '#F59E0B',
|
||||
mark: (
|
||||
<path
|
||||
d="M11.0135 20.5V13.5H14.2135C14.8535 13.5 15.4035 13.6033 15.8635 13.81C16.3301 14.0167 16.6901 14.3167 16.9435 14.71C17.1968 15.0967 17.3235 15.5567 17.3235 16.09C17.3235 16.6167 17.1968 17.0733 16.9435 17.46C16.6901 17.8467 16.3301 18.1467 15.8635 18.36C15.4035 18.5667 14.8535 18.67 14.2135 18.67H12.1135L12.9935 17.81V20.5H11.0135ZM12.9935 18.02L12.1135 17.11H14.0935C14.5068 17.11 14.8135 17.02 15.0135 16.84C15.2201 16.66 15.3235 16.41 15.3235 16.09C15.3235 15.7633 15.2201 15.51 15.0135 15.33C14.8135 15.15 14.5068 15.06 14.0935 15.06H12.1135L12.9935 14.15V18.02Z"
|
||||
fill="white"
|
||||
/>
|
||||
),
|
||||
},
|
||||
document: {
|
||||
fill: '#5686FE',
|
||||
mark: (
|
||||
<path
|
||||
d="M10.512 20.5L8.24203 13.5H10.282L12.192 19.56H11.162L13.172 13.5H14.992L16.892 19.56H15.902L17.872 13.5H19.762L17.492 20.5H15.372L13.752 15.35H14.322L12.632 20.5H10.512Z"
|
||||
fill="white"
|
||||
/>
|
||||
),
|
||||
},
|
||||
other: {
|
||||
fill: '#CFD3D6',
|
||||
mark: (
|
||||
<>
|
||||
<rect x="7.98242" y="10.792" width="12.0368" height="1.46414" fill="#545557" />
|
||||
<rect x="7.98242" y="14.9221" width="12.0368" height="1.46414" fill="#545557" />
|
||||
<rect x="7.98242" y="19.0522" width="7.57257" height="1.46414" fill="#545557" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one file type's coloured sheet.
|
||||
* @param props - The type, optional size (default 28px, the drawn size), and
|
||||
* optional CSS class.
|
||||
* @returns The type's SVG, `aria-hidden`; the adjacent name carries the meaning.
|
||||
*/
|
||||
export function FileTypeIcon({ kind, size = 28, className }: FileTypeIconProps): ReactNode {
|
||||
const face = FACES[kind]
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d={SHEET_PATH} fill={face.fill} />
|
||||
{face.mark}
|
||||
{/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */}
|
||||
{kind === 'other'
|
||||
? <path d={CORNER_PATH} fill="#A2A4A6" />
|
||||
: <path d={CORNER_PATH} fill="white" fillOpacity="0.7" />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,10 @@
|
||||
* ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
CODE_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS,
|
||||
fileExtension,
|
||||
} from './file-extensions.ts'
|
||||
import type { IconProps } from './icons/props.ts'
|
||||
|
||||
/**
|
||||
@@ -24,36 +28,22 @@ export interface LinkIconProps extends IconProps {
|
||||
kind: LinkIconKind
|
||||
}
|
||||
|
||||
/** Code, web, and data extensions: all three categories share the code glyph. */
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less',
|
||||
'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml',
|
||||
'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx',
|
||||
'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql',
|
||||
'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart',
|
||||
])
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif',
|
||||
])
|
||||
|
||||
const DOCUMENT_EXTENSIONS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])
|
||||
/** Office-style documents share one paper-doc glyph on a link. */
|
||||
const DOCUMENT_SETS = [PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS]
|
||||
|
||||
/**
|
||||
* Derive a file path's link-icon category from its extension. Unknown and
|
||||
* missing extensions fall to `other` (the plain-paper glyph).
|
||||
* Derive a file path's link-icon category from its extension. Code, web, and
|
||||
* data files share the code glyph; unknown and missing extensions fall to
|
||||
* `other` (the plain-paper glyph).
|
||||
* @param path - File path as the producing tool spelled it (either separator).
|
||||
* @returns The file's glyph category; never `url` or `folder`.
|
||||
*/
|
||||
export function classifyLinkPath(path: string): LinkIconKind {
|
||||
const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot < 0) return 'other'
|
||||
const extension = name.slice(dot + 1).toLowerCase()
|
||||
const extension = fileExtension(path)
|
||||
if (extension === '') return 'other'
|
||||
if (CODE_EXTENSIONS.has(extension)) return 'code'
|
||||
if (IMAGE_EXTENSIONS.has(extension)) return 'image'
|
||||
return DOCUMENT_EXTENSIONS.has(extension) ? 'document' : 'other'
|
||||
return DOCUMENT_SETS.some(set => set.has(extension)) ? 'document' : 'other'
|
||||
}
|
||||
|
||||
const GlobeGlyph = ({ size, className }: IconProps) => (
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* File-extension vocabularies shared by the glyph pickers (`classifyLinkPath`,
|
||||
* `classifyFileType`), so a path classifies the same way on a link and on a
|
||||
* file-type icon.
|
||||
*
|
||||
* TODO: this is an interim rule. The product will settle one extension
|
||||
* standard for every place a file is drawn — an upload in the input bar, a
|
||||
* sent attachment, an artifact, the Sidebar's tree rows, the Sidebar's tab
|
||||
* chips and pane headers — and these sets, the glyph pickers over them, and
|
||||
* the sheets in `FileTypeIcon` are to be replaced by it. Until then the
|
||||
* surfaces differ: the Sidebar classifies by these sets, the conversation's
|
||||
* links fold the four office types into `document` (`LinkIcon`), and the
|
||||
* attachment cards (`ui-attachment` `FileCard`) draw one generic glyph with no
|
||||
* classification at all.
|
||||
*/
|
||||
|
||||
/** Code, web, and data extensions. */
|
||||
export const CODE_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less',
|
||||
'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml',
|
||||
'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx',
|
||||
'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql',
|
||||
'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart',
|
||||
])
|
||||
|
||||
/** Raster and vector image extensions. */
|
||||
export const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif',
|
||||
])
|
||||
|
||||
/** Web page extensions; a subset of {@link CODE_EXTENSIONS} with its own file-type glyph. */
|
||||
export const HTML_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm'])
|
||||
|
||||
/** Markdown extensions. */
|
||||
export const MARKDOWN_EXTENSIONS: ReadonlySet<string> = new Set(['md', 'mdx', 'markdown'])
|
||||
|
||||
/** PDF. */
|
||||
export const PDF_EXTENSIONS: ReadonlySet<string> = new Set(['pdf'])
|
||||
|
||||
/** Spreadsheet extensions; `csv`/`tsv` also sit in {@link CODE_EXTENSIONS}. */
|
||||
export const SHEET_EXTENSIONS: ReadonlySet<string> = new Set(['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'numbers'])
|
||||
|
||||
/** Slide-deck extensions. */
|
||||
export const SLIDES_EXTENSIONS: ReadonlySet<string> = new Set(['ppt', 'pptx', 'key'])
|
||||
|
||||
/** Word-processor document extensions. */
|
||||
export const WORD_EXTENSIONS: ReadonlySet<string> = new Set(['doc', 'docx', 'rtf', 'odt', 'pages'])
|
||||
|
||||
/**
|
||||
* A path's lowercase extension without the dot, taken from its last segment.
|
||||
* @param path - File path with either separator.
|
||||
* @returns The extension, or `''` for a name without a dot.
|
||||
*/
|
||||
export function fileExtension(path: string): string {
|
||||
const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot < 0 ? '' : name.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
@@ -241,10 +241,12 @@ export const IconCopyOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_refresh_outline_16 */
|
||||
/** ic_ds_refresh_outline_16, inset 10%: the exported glyph fills its box edge
|
||||
* to edge, one visual size above the neighbouring 16px glyphs. */
|
||||
export const IconRefreshOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
transform="translate(0.8 0.8) scale(0.9)"
|
||||
d="M7.92136 0.349152C10.3744 0.349234 12.5564 1.5052 13.9557 3.29894L15.1281 2.12759C15.3303 1.92546 15.6767 2.06943 15.6767 2.35538V5.53923C15.6766 5.71626 15.5329 5.85976 15.3559 5.86002H12.171C11.8854 5.8597 11.7426 5.51465 11.9443 5.31249L12.9641 4.29056C11.8237 2.74305 9.98908 1.74106 7.92136 1.74097C4.46436 1.74097 1.66233 4.543 1.66233 8C1.66233 11.457 4.46436 14.259 7.92136 14.259C11.3782 14.2589 14.1804 11.4569 14.1804 8H15.5722C15.5722 12.2251 12.1465 15.6507 7.92136 15.6508C3.69614 15.6508 0.270508 12.2252 0.270508 8C0.270508 3.77478 3.69614 0.349152 7.92136 0.349152Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,8 @@ export { ReferenceIcon } from './ReferenceIcon.tsx'
|
||||
export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx'
|
||||
export { LinkIcon, classifyLinkPath } from './LinkIcon.tsx'
|
||||
export type { LinkIconKind, LinkIconProps } from './LinkIcon.tsx'
|
||||
export { FileTypeIcon, classifyFileType } from './FileTypeIcon.tsx'
|
||||
export type { FileTypeKind, FileTypeIconProps } from './FileTypeIcon.tsx'
|
||||
export { projectUserText } from './user-text.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { FileTypeIcon, classifyFileType, type FileTypeKind } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const KINDS: FileTypeKind[] = ['code', 'html', 'image', 'markdown', 'pdf', 'sheet', 'slides', 'document', 'other']
|
||||
|
||||
describe('classifyFileType', () => {
|
||||
it.each([
|
||||
['src/render.tsx', 'code'],
|
||||
['site/index.HTML', 'html'],
|
||||
['README.md', 'markdown'],
|
||||
['data/export.csv', 'sheet'],
|
||||
['book.xlsx', 'sheet'],
|
||||
['shots/hero.png', 'image'],
|
||||
['report.pdf', 'pdf'],
|
||||
['deck.pptx', 'slides'],
|
||||
['C:\\work\\summary.docx', 'document'],
|
||||
['notes.unknownext', 'other'],
|
||||
['Makefile', 'other'],
|
||||
['archive.tar/.hidden', 'other'],
|
||||
] as [string, FileTypeKind][])('%s → %s', (path, kind) => {
|
||||
expect(classifyFileType(path)).toBe(kind)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileTypeIcon', () => {
|
||||
it.each(KINDS)('%s draws the shared sheet in its own colour, aria-hidden', (kind) => {
|
||||
const { container } = render(<FileTypeIcon kind={kind} />)
|
||||
const svg = container.querySelector('svg')!
|
||||
expect(svg.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(svg.getAttribute('width')).toBe('28')
|
||||
expect(svg.querySelector('path')!.getAttribute('fill')).toMatch(/^#[0-9A-F]{6}$/)
|
||||
})
|
||||
|
||||
it('every kind carries a distinct mark', () => {
|
||||
const marks = KINDS.map((kind) => {
|
||||
const { container } = render(<FileTypeIcon kind={kind} />)
|
||||
const [, mark] = Array.from(container.querySelectorAll('path, rect'))
|
||||
return `${mark!.getAttribute('d') ?? ''}${mark!.getAttribute('y') ?? ''}`
|
||||
})
|
||||
expect(new Set(marks).size).toBe(KINDS.length)
|
||||
})
|
||||
|
||||
it('folds a darker corner on the grey sheet and a translucent one elsewhere', () => {
|
||||
const grey = render(<FileTypeIcon kind="other" />).container.querySelectorAll('path')
|
||||
expect(grey[grey.length - 1]!.getAttribute('fill')).toBe('#A2A4A6')
|
||||
const blue = render(<FileTypeIcon kind="code" />).container.querySelectorAll('path')
|
||||
expect(blue[blue.length - 1]!.getAttribute('fill-opacity')).toBe('0.7')
|
||||
})
|
||||
|
||||
it('size and className land on the svg', () => {
|
||||
const { container } = render(<FileTypeIcon kind="pdf" size={16} className="x" />)
|
||||
const svg = container.querySelector('svg')!
|
||||
expect(svg.getAttribute('width')).toBe('16')
|
||||
expect(svg.getAttribute('height')).toBe('16')
|
||||
expect(svg.classList.contains('x')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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-sidebar-documentpreview/README.md
|
||||
README.md: b4bf0cadce772f3ae596f8e62ab98e33db82cb46
|
||||
README.zh.md: f4eea40230ed9d3ed7669b9980491dfe0afab79e
|
||||
README.md: 5b258c154258cee170b757c8a8a9599b61998bb4
|
||||
README.zh.md: 1c18763770df7c73b5b41fff3334c045e5204d3b
|
||||
|
||||
@@ -26,8 +26,8 @@ Preview readable files in the right Sidebar and choose among registered renderer
|
||||
<a id="what-it-registers"></a>
|
||||
## What it registers
|
||||
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path, and a dropdown for matching renderers plus plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling.
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling.
|
||||
- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
|
||||
|
||||
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations.
|
||||
@@ -43,7 +43,7 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or
|
||||
The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads:
|
||||
|
||||
- The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content.
|
||||
- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. Failures retain loaded content and offer a retry.
|
||||
- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, explanation, and retry; a later failure retains loaded content and adds the retry below it.
|
||||
- **Complete bytes** — PDF and HTML use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array<ArrayBuffer>` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results.
|
||||
- **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice.
|
||||
|
||||
@@ -74,7 +74,7 @@ No direct effect; what the user reads here never enters a model request.
|
||||
- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF and HTML require a complete result within the Host's `maxFileBytes` cap.
|
||||
- **Byte-view scroll state is not restored.** PDF and HTML can return to the top when their renderer remounts or reloads; HTML iframe scrolling belongs to its opaque browsing context.
|
||||
- **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe.
|
||||
- **Package-local wrap glyph.** `IconWrapOutline16` lives in `src/client/icons.tsx` until the shared icon set carries one; the props contract already matches.
|
||||
- **Package-local wrap glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract.
|
||||
- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -26,8 +26,8 @@ kind: "package-reference"
|
||||
<a id="what-it-registers"></a>
|
||||
## 注册了什么
|
||||
|
||||
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。
|
||||
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径,并提供匹配渲染器及纯文本的下拉选择。仅当所选渲染器声明 `wrap: true` 时显示换行开关;该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。
|
||||
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
|
||||
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。
|
||||
- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
|
||||
|
||||
文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
|
||||
@@ -43,7 +43,7 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
|
||||
正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取:
|
||||
|
||||
- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。
|
||||
- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。失败时保留已加载内容并提供重试。
|
||||
- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明与重试按钮填满正文;较晚的失败保留已有内容并在其下提供重试。
|
||||
- **完整字节** —— PDF 和 HTML 通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
|
||||
- **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。
|
||||
|
||||
@@ -74,7 +74,7 @@ HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`
|
||||
- **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF 和 HTML 必须取得 Host `maxFileBytes` 上限内的完整结果。
|
||||
- **字节视图不恢复滚动位置。** PDF 与 HTML 的渲染器重新挂载或重新载入时可能回到顶部;HTML iframe 的滚动属于其不透明浏览上下文。
|
||||
- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
|
||||
- **换行图标为包内自绘。** `IconWrapOutline16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;props 契约已经一致。
|
||||
- **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。
|
||||
- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -9,24 +9,57 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* One row: the path, then the type's controls at its end. */
|
||||
/* One 38px row flush under the pane's strip: the path, then the type's
|
||||
controls at its end. With the strip's 38px above, its rule lies where the
|
||||
conversation header's does (ui-conversation ConversationRoot `.header`,
|
||||
76px with the same rule), so the two lines meet at the column edge.
|
||||
|
||||
The same row, path, and controls are drawn by the files tab
|
||||
(ui-sidebar-files FilesBody `.header`); TODO: once the artifact and slot
|
||||
surfaces settle, one copy in ui-primitives could serve every pane header. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 3px 6px 3px 10px;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l1);
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
padding: 0 6px 0 16px;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* The path is never ellipsized. Its text sits at the left while it fits: the
|
||||
auto margin takes the free space. Once it is wider than the box, the auto
|
||||
margin is zero and `flex-end` holds the name at the right edge, so the
|
||||
directories run off the left and the box clips them there, faded by the
|
||||
mask while `data-textpreview-path-clipped` is set. The margin plus the row's
|
||||
gap put the text 16px clear of the first control. */
|
||||
.path {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.path[data-textpreview-path-clipped] {
|
||||
mask-image: linear-gradient(to right, transparent, black 28px);
|
||||
}
|
||||
|
||||
.pathText {
|
||||
flex: none;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.pathDirectory {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pathName {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Announced, not applied: the reader keeps the text they are looking at. */
|
||||
@@ -48,8 +81,13 @@
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 10px 0;
|
||||
/* The files tree's inset and 2px scrollbar offset (ui-sidebar-files
|
||||
FilesBody `.body`), so the two bodies start their text at one edge and
|
||||
both bars sit 2px clear of the pane's edge. */
|
||||
margin-right: 2px;
|
||||
padding: 8px 0 8px 8px;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
/* The notice and retry surfaces in this sheet are elevated, so the file body's
|
||||
scroller rebinds the thumb indirection in a complete pair. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
@@ -61,6 +99,13 @@
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* The bar also stops 2px short of the body's ends, matching its 2px edge
|
||||
offset (ui-sidebar-files FilesBody keeps the same rule). WebKit-only: the
|
||||
Firefox path has no track to inset. */
|
||||
.body::-webkit-scrollbar-track {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -78,6 +123,7 @@
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
/* The files tree row's 10px side inset, so a line's text sits where a row's does. */
|
||||
.line {
|
||||
padding: 0 10px;
|
||||
}
|
||||
@@ -86,6 +132,74 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The failure with nothing read yet: the whole body, centred a little above
|
||||
the middle (the spacer below). Fills the body's content box exactly (a
|
||||
percentage height resolves against it), so the body does not scroll. The
|
||||
body's monospace `pre` setting is undone here. */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: 0 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
font-family: var(--dsw-font-family);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* The lift, as a share of the pane's height (a column flex basis resolves
|
||||
against it): the centred column rises 6% of the pane, and a short pane
|
||||
gives it up before the content clips. */
|
||||
.empty::after {
|
||||
content: '';
|
||||
flex: 0 1 12%;
|
||||
}
|
||||
|
||||
/* The type's sheet keeps its shape but gives up its colour. */
|
||||
.emptyIcon {
|
||||
flex: none;
|
||||
opacity: 0.6;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.emptyLine {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* A capsule holding the refresh glyph and the retry word. `flex: none` keeps
|
||||
its height when the empty column runs out of room in a short panel. */
|
||||
.retry {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px 0 12px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
border: 0.5px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retry:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The type's 16px sheet before the chip's text; the chip's title row centres
|
||||
it on the text's line and spaces it. */
|
||||
.titleIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.statusLine {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -148,31 +262,33 @@
|
||||
}
|
||||
|
||||
/* Controls in the header row, sized like the docking kit's own pane controls. */
|
||||
/* The same icon button as the Sidebar's strip controls above it: a 28px
|
||||
circle around a 15px glyph. */
|
||||
.tool {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.tool:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.toolOn {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.viewerTool {
|
||||
width: auto;
|
||||
max-width: 160px;
|
||||
|
||||
@@ -10,15 +10,16 @@
|
||||
* with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of
|
||||
* the path row; the Sidebar's strip carries none of them.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconRefreshOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { FileTypeIcon, IconRefreshOutline16, Menu, Tooltip, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { TextInjected } from './face.ts'
|
||||
import { failureLine } from './failure-line.ts'
|
||||
import { IconWrapOutline16 } from './icons.tsx'
|
||||
import { IconNowrapFill16, IconWrapFill16 } from './icons.tsx'
|
||||
import { LoadingIndicator } from './LoadingIndicator.tsx'
|
||||
import { hostFileOf } from './rpc.ts'
|
||||
import type { TextStore } from './store.ts'
|
||||
@@ -32,6 +33,29 @@ import css from './TextPreview.module.css'
|
||||
export { linesOf, loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts'
|
||||
export type { LoadedPage } from './text/lines.ts'
|
||||
|
||||
/** Keep the path fade in sync with whether its full text fits the header row. */
|
||||
function usePathClipped(
|
||||
box: RefObject<HTMLDivElement | null>,
|
||||
text: RefObject<HTMLSpanElement | null>,
|
||||
path: string,
|
||||
shown: boolean,
|
||||
): void {
|
||||
useLayoutEffect(() => {
|
||||
const outer = box.current
|
||||
const inner = text.current
|
||||
if (outer === null || inner === null) return undefined
|
||||
const apply = (): void => {
|
||||
if (inner.offsetWidth > outer.clientWidth) outer.dataset.textpreviewPathClipped = ''
|
||||
else delete outer.dataset.textpreviewPathClipped
|
||||
}
|
||||
apply()
|
||||
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply)
|
||||
observer?.observe(outer)
|
||||
observer?.observe(inner)
|
||||
return () => { observer?.disconnect() }
|
||||
}, [box, text, path, shown])
|
||||
}
|
||||
|
||||
/** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */
|
||||
export interface TextPreviewInjected extends TextInjected {
|
||||
readonly hooks: { readonly documentPreviews: ObservableSnapshot<readonly DocumentPreviewDefinition[]> }
|
||||
@@ -70,7 +94,11 @@ export function TextPreview({
|
||||
const mode = selected?.loading
|
||||
const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const pathRef = useRef<HTMLDivElement | null>(null)
|
||||
const pathTextRef = useRef<HTMLSpanElement | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path
|
||||
usePathClipped(pathRef, pathTextRef, displayPath, state !== undefined)
|
||||
// Every tab of this type is a `file` resource address, so its params are the
|
||||
// `file` type's; the union is narrowed on the one field read, not validated.
|
||||
const line = navigation.params !== undefined && 'line' in navigation.params ? navigation.params.line : undefined
|
||||
@@ -142,14 +170,13 @@ export function TextPreview({
|
||||
)
|
||||
}
|
||||
const next = loadedThrough + 1
|
||||
const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path
|
||||
const { directory, name } = pathPartsOf(displayPath)
|
||||
const observedVersion = meta.value?.version
|
||||
const changed = current?.version !== undefined && observedVersion !== undefined
|
||||
&& observedVersion !== current.version && observedVersion !== current.observedVersion
|
||||
const loadNext = (): void => {
|
||||
if (!canRead || current?.loading || current?.eof) return
|
||||
if (mode === 'text-pages') loadPage(tab.id, file, next, signal, meta.value?.version)
|
||||
else loadAll(tab.id, file, signal, meta.value?.version)
|
||||
loadPage(tab.id, file, next, signal, meta.value?.version)
|
||||
}
|
||||
const reload = (): void => {
|
||||
if (!canRead) return
|
||||
@@ -158,10 +185,12 @@ export function TextPreview({
|
||||
}
|
||||
return (
|
||||
<div className={css.preview} data-textpreview-state="text" data-textpreview-url={tab.contentId} data-document-preview={selected.id}>
|
||||
{meta.failure !== undefined
|
||||
{meta.failure !== undefined && hasContent
|
||||
? (
|
||||
// The file's metadata failed — gone, or its workspace unknown — which
|
||||
// outranks a pending change; the pages already read stay under it.
|
||||
// With nothing read the body's own failure already says it, so the
|
||||
// bar would only repeat the same line.
|
||||
<p className={css.changed} data-textpreview-meta-failed={meta.failure.code}>
|
||||
<span>{failureLine(t, meta.failure)}</span>
|
||||
<button
|
||||
@@ -188,7 +217,12 @@ export function TextPreview({
|
||||
</p>
|
||||
)}
|
||||
<div className={css.header}>
|
||||
<div className={css.path} title={displayPath} data-textpreview-path>{displayPath}</div>
|
||||
<div ref={pathRef} className={css.path} title={displayPath} data-textpreview-path>
|
||||
<span ref={pathTextRef} className={css.pathText}>
|
||||
{directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
|
||||
<span className={css.pathName}>{name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
anchor={(
|
||||
@@ -205,28 +239,32 @@ export function TextPreview({
|
||||
dense
|
||||
/>
|
||||
{selected.wrap === true && (
|
||||
// The tooltip names the action while the stable aria name and
|
||||
// `aria-pressed` expose the control and its current state.
|
||||
<Tooltip label={t(state.wrap ? 'wrap.disable' : 'wrap.enable')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tool}
|
||||
aria-pressed={state.wrap}
|
||||
aria-label={t('wrap.aria')}
|
||||
data-textpreview-tool="wrap"
|
||||
onClick={() => { actions.toggledWrap(tab.id) }}
|
||||
>
|
||||
{state.wrap ? <IconNowrapFill16 /> : <IconWrapFill16 />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('reload')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.tool, state.wrap && css.toolOn)}
|
||||
aria-pressed={state.wrap}
|
||||
aria-label={t('wrap')}
|
||||
title={t('wrap')}
|
||||
data-textpreview-tool="wrap"
|
||||
onClick={() => { actions.toggledWrap(tab.id) }}
|
||||
className={css.tool}
|
||||
aria-label={t('reload')}
|
||||
data-textpreview-tool="reload"
|
||||
onClick={reload}
|
||||
>
|
||||
<IconWrapOutline16 />
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.tool}
|
||||
aria-label={t('reload')}
|
||||
title={t('reload')}
|
||||
data-textpreview-tool="reload"
|
||||
onClick={reload}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
@@ -249,19 +287,37 @@ export function TextPreview({
|
||||
entryKey: selected.id, hookContext: useTabInfo,
|
||||
fallback: <p className={css.statusLine}>{t('rendererUnavailable', { name: selected.title() })}</p>,
|
||||
})}
|
||||
{current?.failure !== undefined && (
|
||||
<p className={css.statusLine} data-textpreview-failed={current.failure.code}>
|
||||
<span>{failureLine(t, current.failure)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
data-textpreview-retry
|
||||
onClick={loadNext}
|
||||
>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{current?.failure !== undefined && (hasContent
|
||||
? (
|
||||
<p className={css.statusLine} data-textpreview-failed={current.failure.code}>
|
||||
<span>{failureLine(t, current.failure)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
data-textpreview-retry
|
||||
onClick={loadNext}
|
||||
>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
// With no content, retry the selected renderer's read; metadata
|
||||
// observation remains owned by the resource provider.
|
||||
<div className={css.empty} data-textpreview-failed={current.failure.code}>
|
||||
<FileTypeIcon kind={classifyFileType(name)} size={36} className={css.emptyIcon} />
|
||||
<p className={css.emptyLine}>{failureLine(t, current.failure)}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={css.retry}
|
||||
data-textpreview-retry
|
||||
onClick={reload}
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{mode === 'text-pages' && current !== undefined && loaded.length > 0 && !current.eof && current.failure === undefined && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The text type's chip title: the file type's coloured sheet before the name
|
||||
* the registry captured at open time. Registered under
|
||||
* `sidebar.right.pane.tab.title`; without it the chip would show the bare name.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { FileTypeIcon, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './TextPreview.module.css'
|
||||
|
||||
/**
|
||||
* The title as the chip and a floating panel's header show it.
|
||||
* @param props - the tab information hook.
|
||||
* @returns the type's 16px sheet followed by the tab's title text.
|
||||
*/
|
||||
export function TextTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>): ReactNode {
|
||||
const { tab } = useTabInfo()
|
||||
return (
|
||||
<>
|
||||
<FileTypeIcon kind={classifyFileType(tab.title)} size={16} className={css.titleIcon} />
|
||||
{tab.title}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,26 +2,42 @@
|
||||
* Glyphs this package draws that the shared icon set does not carry yet.
|
||||
* Same props contract as `@deepseek-ai/dsh-client-ui-primitives` icons, so a
|
||||
* shared replacement is a one-line import change.
|
||||
*
|
||||
* The wrap control swaps between the two glyphs below to preview the mode a
|
||||
* click switches to, so neither needs a pressed style.
|
||||
*/
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** Three text lines, the middle one turning back under itself. */
|
||||
export const IconWrapOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
/** Two margin bars, a straight arrow running to the right one: lines run past the edge. */
|
||||
export const IconNowrapFill16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M2.5 4h11" />
|
||||
<path d="M2.5 8h8.5a2.5 2.5 0 0 1 0 5H9.5" />
|
||||
<path d="M11 11.5 9.5 13l1.5 1.5" />
|
||||
<path d="M2.5 12h3.5" />
|
||||
<path
|
||||
d="M1.5 2.5H3.5V21.5H1.5V2.5ZM20.5 2.5H22.5V21.5H20.5V2.5ZM14 9L19 12L14 15V13H5V11H14V9Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Two margin bars, an arrow sweeping around and back left: lines turn under themselves. */
|
||||
export const IconWrapFill16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1.5 2.5H3.5V21.5H1.5V2.5ZM20.5 2.5H22.5V21.5H20.5V2.5ZM6.75 5H11.5A6 6 0 0 1 12 16.98V19L7 16L12 13V14.97A4 4 0 0 0 11.5 7H6.75V5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* Browser half: register `text` as a right-Sidebar tab type.
|
||||
*
|
||||
* The type reaches the Sidebar through its public path only: the definition into
|
||||
* `ctx.sidebarRightTabs` and the body into the keyed `sidebar.right.pane.tab`
|
||||
* seat under the definition's `id`. Nothing here reaches into the Sidebar's store, its
|
||||
* `ctx.sidebarRightTabs`, the body into the keyed `sidebar.right.pane.tab`
|
||||
* seat, and the chip title into `sidebar.right.pane.tab.title`, both under the
|
||||
* definition's `id`. Nothing here reaches into the Sidebar's store, its
|
||||
* panes, or its sequence. The file's metadata comes from the standard
|
||||
* `useResource`, served by the `file` provider; the content is this type's own
|
||||
* business, read through its face. Every import from another
|
||||
@@ -21,6 +22,7 @@ import type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
|
||||
import type { WorkspaceFileParams } from '@deepseek-ai/dsh-api-workspace-files/client'
|
||||
import { TextPreview } from './TextPreview.tsx'
|
||||
import type { TextPreviewInjected } from './TextPreview.tsx'
|
||||
import { TextTitle } from './TextTitle.tsx'
|
||||
import { TEXTPREVIEW_ID, textDefinition } from './definition.ts'
|
||||
import { textFace } from './face.ts'
|
||||
import { createReadPage } from './rpc.ts'
|
||||
@@ -76,7 +78,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the type, its dictionaries, and its body.
|
||||
* Client plugin body: register the type, its dictionaries, its body, and its chip title.
|
||||
* @param ctx - client root context carrying the registry, the slots, copy, and the Remote face.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -102,6 +104,10 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
TextPreview,
|
||||
)), 'ui-sidebar-documentpreview: text body')
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
|
||||
{ name: 'sidebar.right.pane.tab.title', key: TEXTPREVIEW_ID },
|
||||
TextTitle,
|
||||
)), 'ui-sidebar-documentpreview: text title')
|
||||
registerText(ctx)
|
||||
registerMarkdown(ctx)
|
||||
registerHtml(ctx)
|
||||
|
||||
@@ -10,18 +10,20 @@
|
||||
export const zh = {
|
||||
loading: '正在读取…',
|
||||
loadMore: '加载更多',
|
||||
changed: '文件已被修改,显示的还是旧内容。',
|
||||
changed: '文件已更新,当前显示为旧内容。',
|
||||
reloadNow: '重新载入',
|
||||
reload: '重新读取文件',
|
||||
wrap: '自动换行',
|
||||
'wrap.enable': '自动换行',
|
||||
'wrap.disable': '取消换行',
|
||||
'wrap.aria': '自动换行',
|
||||
openWith: '打开方式',
|
||||
'viewer.text': '纯文本',
|
||||
resourceUnavailable: '文件资源服务不可用。',
|
||||
rendererUnavailable: '预览器 {name} 不可用。',
|
||||
'error.notFound': '这个文件不在了。可能已被移动或删除。',
|
||||
'error.tooLarge': '请求读取的内容太大,超过了 {limit} 的上限。',
|
||||
'error.notText': '这不是文本文件,没法在这里查看。',
|
||||
'error.notRegularFile': '这不是一个普通文件,没有可显示的文本。',
|
||||
'error.notFound': '文件不存在,可能已被移动或删除。',
|
||||
'error.tooLarge': '单页内容超过 {limit} 上限,无法读取。',
|
||||
'error.notText': '非文本文件,暂时无法预览。',
|
||||
'error.notRegularFile': '该路径不是普通文件,没有可显示的内容。',
|
||||
'error.unavailable': '读取失败:{message}',
|
||||
retry: '重试',
|
||||
} satisfies Record<string, string>
|
||||
@@ -33,18 +35,20 @@ export type SidebarDocumentPreviewKey = keyof typeof zh
|
||||
export const en = {
|
||||
loading: 'Reading…',
|
||||
loadMore: 'Load more',
|
||||
changed: 'The file has changed; this is the older text.',
|
||||
changed: 'The file has changed, showing the previous content.',
|
||||
reloadNow: 'Reload',
|
||||
reload: 'Read the file again',
|
||||
wrap: 'Wrap lines',
|
||||
'wrap.enable': 'Turn on line wrap',
|
||||
'wrap.disable': 'Turn off line wrap',
|
||||
'wrap.aria': 'Line wrap',
|
||||
openWith: 'Open with',
|
||||
'viewer.text': 'Plain text',
|
||||
resourceUnavailable: 'The file resource service is unavailable.',
|
||||
rendererUnavailable: 'The {name} preview is unavailable.',
|
||||
'error.notFound': 'That file is gone. It may have been moved or deleted.',
|
||||
'error.tooLarge': 'The requested content exceeds the {limit} read limit.',
|
||||
'error.notText': 'That is not a text file, so it cannot be shown here.',
|
||||
'error.notRegularFile': 'That is not a regular file, so it has no text to show.',
|
||||
'error.notFound': 'File not found. It may have been moved or deleted.',
|
||||
'error.tooLarge': 'This page exceeds the {limit} limit and cannot be read.',
|
||||
'error.notText': 'Not a text file, preview is unavailable for now.',
|
||||
'error.notRegularFile': 'Not a regular file, nothing to display.',
|
||||
'error.unavailable': 'Read failed: {message}',
|
||||
retry: 'Retry',
|
||||
} satisfies Record<SidebarDocumentPreviewKey, string>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND } from '../src/client/definition.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as hostApply } from '../src/index.ts'
|
||||
import { TextPreview } from '../src/client/TextPreview.tsx'
|
||||
import { TextTitle } from '../src/client/TextTitle.tsx'
|
||||
import { TextBody } from '../src/client/text/TextBody.tsx'
|
||||
import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
|
||||
import { MarkdownBody } from '../src/client/markdown/MarkdownBody.tsx'
|
||||
@@ -31,9 +32,9 @@ import { FILE, SESSION, TAB_ID, page } from './fixtures.client.ts'
|
||||
interface Recorded {
|
||||
name: string
|
||||
key: string
|
||||
locale: string
|
||||
store: unknown
|
||||
inject: unknown
|
||||
locale?: string
|
||||
store?: unknown
|
||||
inject?: unknown
|
||||
component: unknown
|
||||
}
|
||||
|
||||
@@ -79,7 +80,7 @@ describe('ui-sidebar-documentpreview apply', () => {
|
||||
expect(hostApply).not.toThrow()
|
||||
})
|
||||
|
||||
it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => {
|
||||
it('registers the type, its dictionaries, and the body and title seats under the type\'s id, the body with a store and a face', async () => {
|
||||
const { tabs, registered, dictionaries } = await boot()
|
||||
expect(tabs.get(TEXTPREVIEW_KIND)?.priority).toBe('fallback')
|
||||
expect(tabs.get(TEXTPREVIEW_KIND)?.id).toBe(TEXTPREVIEW_ID)
|
||||
@@ -88,6 +89,7 @@ describe('ui-sidebar-documentpreview apply', () => {
|
||||
// take the kind over, and the seat must still find this body.
|
||||
expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
|
||||
['sidebar.right.pane.tab', TEXTPREVIEW_ID, 'sidebarDocumentPreview', TextPreview],
|
||||
['sidebar.right.pane.tab.title', TEXTPREVIEW_ID, undefined, TextTitle],
|
||||
['sidebar.right.tab.document', PLAIN_BODY_ID, undefined, TextBody],
|
||||
['sidebar.right.tab.document', MARKDOWN_BODY_ID, 'documentMarkdown', MarkdownBody],
|
||||
['sidebar.right.tab.document', HTML_BODY_ID, 'documentHtml', HtmlBody],
|
||||
|
||||
@@ -151,6 +151,56 @@ describe('TextPreview — pages', () => {
|
||||
expect(view.container.querySelector('[data-textpreview-path]')?.getAttribute('title')).toBe(ABSOLUTE_PATH)
|
||||
})
|
||||
|
||||
it('marks the path clipped while its text is wider than its box, re-reading on resize', async () => {
|
||||
class FakeResizeObserver implements ResizeObserver {
|
||||
static latest: FakeResizeObserver | undefined
|
||||
readonly observe = vi.fn()
|
||||
readonly unobserve = vi.fn()
|
||||
readonly disconnect = vi.fn()
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
FakeResizeObserver.latest = this
|
||||
}
|
||||
|
||||
fire(): void {
|
||||
this.callback([], this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
||||
let boxWidth = 300
|
||||
const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
|
||||
const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth })
|
||||
try {
|
||||
const h = harness({ 1: page(1, ['one'], true) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
const path = view.container.querySelector<HTMLElement>('[data-textpreview-path]')
|
||||
const text = path?.firstElementChild
|
||||
expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false)
|
||||
const observer = FakeResizeObserver.latest
|
||||
if (observer === undefined) throw new Error('expected the path to observe its size')
|
||||
expect(observer.observe).toHaveBeenCalledWith(path)
|
||||
expect(observer.observe).toHaveBeenCalledWith(text)
|
||||
|
||||
boxWidth = 120
|
||||
act(() => { observer.fire() })
|
||||
expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(true)
|
||||
|
||||
boxWidth = 300
|
||||
act(() => { observer.fire() })
|
||||
expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false)
|
||||
view.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
for (const [name, descriptor] of [['offsetWidth', offsetWidth], ['clientWidth', clientWidth]] as const) {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
else Object.defineProperty(HTMLElement.prototype, name, descriptor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the first page on first mount and draws its lines, offering the next', async () => {
|
||||
const h = harness({ 1: page(1, ['one', 'two', 'three'], false) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
@@ -211,8 +261,11 @@ describe('TextPreview — pages', () => {
|
||||
const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
|
||||
const failed = view.container.querySelector('[data-textpreview-failed]')
|
||||
expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
|
||||
expect(view.container.textContent).toContain('error.notText')
|
||||
// Nothing read yet: the failure stands as the body, under the file's type sheet.
|
||||
expect(failed?.querySelector('svg')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
|
||||
h.script(1, page(1, ['one'], true))
|
||||
click(view.container, '[data-textpreview-retry]')
|
||||
@@ -222,6 +275,23 @@ describe('TextPreview — pages', () => {
|
||||
expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
|
||||
})
|
||||
|
||||
it('says why a later page failed on a line under the pages already read', async () => {
|
||||
const h = harness({ 1: page(1, ['a'], false), 2: failure('workspace-file/too-large', { path: PATH, limit: 1024 }) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
click(view.container, '[data-textpreview-more]')
|
||||
await settle()
|
||||
const failed = view.container.querySelector('[data-textpreview-failed]')
|
||||
expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/too-large')
|
||||
expect(failed?.querySelector('svg')).toBeNull()
|
||||
expect(lines(view.container)).toEqual(['a\n'])
|
||||
h.script(2, page(2, ['b'], true))
|
||||
click(view.container, '[data-textpreview-retry]')
|
||||
await settle()
|
||||
expect(lines(view.container)).toEqual(['a\n', 'b\n'])
|
||||
expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
|
||||
})
|
||||
|
||||
it('announces a change and, on request, re-reads the pages keeping the reader\'s place', async () => {
|
||||
const h = harness({ 1: page(1, ['a', 'b'], true) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
@@ -326,6 +396,27 @@ describe('TextPreview — the file\'s metadata', () => {
|
||||
expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
|
||||
})
|
||||
|
||||
it('says a metadata-and-read failure once and retries the content read', async () => {
|
||||
const h = harness({ 1: failure('workspace-file/outside-workspace', { path: PATH }) })
|
||||
h.setFailure(new RemoteError('workspace-file/outside-workspace', 'outside', { path: PATH }))
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
// With nothing read the body's failure is the whole story: a metadata bar
|
||||
// above it would repeat the same line.
|
||||
expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed'))
|
||||
.toBe('workspace-file/outside-workspace')
|
||||
h.script(1, page(1, ['a'], true))
|
||||
click(view.container, '[data-textpreview-retry]')
|
||||
await settle()
|
||||
expect(h.read).toHaveBeenCalledTimes(2)
|
||||
expect(lines(view.container)).toEqual(['a\n'])
|
||||
h.setFailure(undefined)
|
||||
view.rerender(<TextPreview {...h.props()} />)
|
||||
expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
|
||||
})
|
||||
|
||||
it('draws a page holding one empty line as one line, and nothing for a page past the end', async () => {
|
||||
const h = harness({ 1: page(1, [''], false), 2: page(2, [], true) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The chip title: the file type's sheet, then the name the registry captured. */
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { TextTitle } from '../src/client/TextTitle.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function props(title: string): PropsRuntime<'sidebar.right.pane.tab.title'> {
|
||||
return { useTabInfo: () => ({ tab: { title } }) } as unknown as PropsRuntime<'sidebar.right.pane.tab.title'>
|
||||
}
|
||||
|
||||
describe('TextTitle', () => {
|
||||
it('draws the type sheet before the title text, sized to the chip line', () => {
|
||||
const { container } = render(<TextTitle {...props('README.md')} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg?.getAttribute('width')).toBe('16')
|
||||
expect(svg?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(container.textContent).toBe('README.md')
|
||||
})
|
||||
|
||||
it('picks the sheet from the title\'s extension', () => {
|
||||
const markdown = render(<TextTitle {...props('notes.md')} />).container.querySelector('path')?.getAttribute('fill')
|
||||
const pdf = render(<TextTitle {...props('paper.pdf')} />).container.querySelector('path')?.getAttribute('fill')
|
||||
expect(markdown).not.toBe(pdf)
|
||||
})
|
||||
})
|
||||
@@ -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-sidebar-files/README.md
|
||||
README.md: 6e9e305b15058797156f832a56a29c095b0641f3
|
||||
README.zh.md: 95a15444182a988fdf512735be4e0fbfcaad6bdd
|
||||
README.md: 89dc8ca0d3b34c7f2e0a720e959d9a163beb533f
|
||||
README.zh.md: 166e04bde8a61392248f14b43f71f2b88a4332bd
|
||||
|
||||
@@ -25,14 +25,15 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree,
|
||||
## What it registers
|
||||
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, titled from the `sidebarFiles` namespace) that opens the type.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: the tree, with its one control, reload, at the right of its header row.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the one control, reload, at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header.
|
||||
- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a 16px folder sheet before the type's label. The tree's own rows never draw this sheet.
|
||||
|
||||
Six source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `locales.ts` (what it says), and `index.ts` (the wiring).
|
||||
Seven source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring).
|
||||
|
||||
<a id="the-tree"></a>
|
||||
## The tree
|
||||
|
||||
The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and labelled by `workspaceTitleOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
|
||||
The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
|
||||
|
||||
| Entry type | Row |
|
||||
|---|---|
|
||||
|
||||
@@ -25,14 +25,15 @@ kind: "package-reference"
|
||||
## 注册了什么
|
||||
|
||||
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。
|
||||
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:树本身,以及它唯一的控件、位于标题行右端的重新读取。
|
||||
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。
|
||||
- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` 坑位:类型标签前的一枚 16px 文件夹图标。树本身的行不画这枚图标。
|
||||
|
||||
`src/client/` 下六个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`locales.ts`(它说什么)、`index.ts`(接线)。
|
||||
`src/client/` 下七个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
|
||||
|
||||
<a id="the-tree"></a>
|
||||
## 树
|
||||
|
||||
根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标签由 `@deepseek-ai/dsh-util-workspace-path` 的 `workspaceTitleOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
|
||||
根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
|
||||
|
||||
| 条目类型 | 行 |
|
||||
|---|---|
|
||||
|
||||
@@ -1,23 +1,89 @@
|
||||
/* The pane body is a block scroller with a definite height, so the tree takes
|
||||
that height outright: the header row stays put and the levels below are the
|
||||
one scroller. */
|
||||
.root {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 4px 0 8px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The header row, its path, and its control are the text preview's
|
||||
(ui-sidebar-documentpreview TextPreview `.header`, `.path`, `.tool`), copied so
|
||||
the two pane types read as one; keep the two sheets in step. TODO: once the
|
||||
artifact and slot surfaces settle, one copy in ui-primitives could serve
|
||||
every pane header.
|
||||
|
||||
One 38px row flush under the pane's strip: the root's path, then reload at
|
||||
its end. With the strip's 38px above, its rule lies where the conversation
|
||||
header's does (ui-conversation ConversationRoot `.header`, 76px with the
|
||||
same rule). */
|
||||
.header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 500;
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
padding: 0 6px 0 16px;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* The path is never ellipsized. Its text sits at the left while it fits: the
|
||||
auto margin takes the free space. Once it is wider than the box, the auto
|
||||
margin is zero and `flex-end` holds the name at the right edge, so the
|
||||
directories run off the left and the box clips them there, faded by the
|
||||
mask while `data-files-path-clipped` is set. The margin plus the row's gap
|
||||
put the text 16px clear of the first control. */
|
||||
.path {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path[data-files-path-clipped] {
|
||||
mask-image: linear-gradient(to right, transparent, black 28px);
|
||||
}
|
||||
|
||||
.pathText {
|
||||
flex: none;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.pathDirectory {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pathName {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
/* The workspace list's 2px scrollbar offset (ui-workspace WorkspaceBrowser
|
||||
`.list`): the bar sits 2px clear of the pane's edge, and the stable gutter
|
||||
stands in for the right padding so the rows keep their inset whether or
|
||||
not the tree overflows. */
|
||||
margin-right: 2px;
|
||||
padding: 8px 0 8px 8px;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* The bar also stops 2px short of the body's ends, matching its 2px edge
|
||||
offset. WebKit-only: the Firefox path has no track to inset. */
|
||||
.body::-webkit-scrollbar-track {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.level {
|
||||
@@ -28,7 +94,7 @@
|
||||
|
||||
/* Every nested level indents by one step; the root level sits under the header. */
|
||||
.level .level {
|
||||
padding-left: 14px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.item {
|
||||
@@ -42,13 +108,15 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 3px 10px;
|
||||
/* Rows abut, so the hover fill is the whole row; the tree's air is this
|
||||
inset, not a gap between rows. */
|
||||
padding: 5px 10px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -56,16 +124,15 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The folder glyph rides the tertiary ink, as the workspace tree's does (ui-workspace Rows `.slot`). */
|
||||
.icon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The document glyph is drawn 24×28; it rides the row at icon height. */
|
||||
/* The type's coloured sheet at the folder glyph's 16px; its colour is its own. */
|
||||
.fileIcon {
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -106,23 +173,36 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* The header's reload control, pushed to the row's right edge. */
|
||||
/* The same icon button as the Sidebar's strip controls above it: a 28px
|
||||
circle around a 15px glyph. */
|
||||
.tool {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.tool:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The folder sheet before the chip's text; the chip's title row centres it
|
||||
on the text's line and spaces it. */
|
||||
.titleIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -5,19 +5,20 @@
|
||||
* for goes through its injected face. The component itself only decides what to
|
||||
* draw for each absolute path and what a click means: a directory toggles, a
|
||||
* file opens through the owner's `tabActions` for a `file:` viewer to claim, and
|
||||
* anything else is shown but refuses to open. The header row carries the one
|
||||
* control: reload, which drops every listed level and asks again for the
|
||||
* expanded ones.
|
||||
* anything else is shown but refuses to open. The header row is the text
|
||||
* preview's: the root's path, directories greyed and the last segment in full
|
||||
* ink, then the one control at its end, reload, which drops every listed level
|
||||
* and asks again for the expanded ones.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
DocumentFileIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16,
|
||||
FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { fileAddressFor, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types'
|
||||
import { childPath } from './face.ts'
|
||||
import type { FilesInjected } from './face.ts'
|
||||
@@ -66,6 +67,39 @@ export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailu
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- the header row is the document preview's (ui-sidebar-documentpreview
|
||||
TextPreview `usePathClipped`), copied because a plugin bundle shares runtime code
|
||||
only through the platform modules. TODO: once the artifact and slot surfaces
|
||||
settle, one copy in ui-primitives could serve every pane header. */
|
||||
/**
|
||||
* Keep the path row's `data-files-path-clipped` current: set while the path's
|
||||
* text is wider than its box, so the stylesheet fades the clipped start. Read
|
||||
* after each commit that can change the path or mount the header, and whenever
|
||||
* either box resizes; written to the DOM directly because it changes only how
|
||||
* the stylesheet fades what is already rendered.
|
||||
*/
|
||||
function usePathClipped(
|
||||
box: RefObject<HTMLDivElement | null>,
|
||||
text: RefObject<HTMLSpanElement | null>,
|
||||
path: string | undefined,
|
||||
): void {
|
||||
useLayoutEffect(() => {
|
||||
const outer = box.current
|
||||
const inner = text.current
|
||||
if (outer === null || inner === null) return undefined
|
||||
const apply = (): void => {
|
||||
if (inner.offsetWidth > outer.clientWidth) outer.dataset.filesPathClipped = ''
|
||||
else delete outer.dataset.filesPathClipped
|
||||
}
|
||||
apply()
|
||||
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply)
|
||||
observer?.observe(outer)
|
||||
observer?.observe(inner)
|
||||
return () => { observer?.disconnect() }
|
||||
}, [box, text, path])
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** What every level shares: the tab's tree and the two gestures. */
|
||||
interface TreeContext {
|
||||
readonly state: FilesTabState
|
||||
@@ -93,7 +127,7 @@ function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirect
|
||||
return (
|
||||
<li className={css.item} data-files-entry="file" data-files-path={path}>
|
||||
<button type="button" className={css.row} onClick={() => { tree.onOpen(path) }}>
|
||||
<DocumentFileIcon className={css.fileIcon} />
|
||||
<FileTypeIcon kind={classifyFileType(entry.name)} size={16} className={css.fileIcon} />
|
||||
<span className={css.name}>{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -140,6 +174,9 @@ export function FilesBody({
|
||||
const { signal, actions: tabActions } = tab
|
||||
const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd)
|
||||
const state = useStore(store => store.byTab[tab.id])
|
||||
const pathRef = useRef<HTMLDivElement>(null)
|
||||
const pathTextRef = useRef<HTMLSpanElement>(null)
|
||||
usePathClipped(pathRef, pathTextRef, state?.root)
|
||||
useEffect(() => {
|
||||
// A bucket gone because the record aborted must not be re-seeded by a
|
||||
// component that has not unmounted yet.
|
||||
@@ -168,13 +205,17 @@ export function FilesBody({
|
||||
actions.reset(tab.id)
|
||||
for (const path of state.expanded) load(tab.id, path, signal)
|
||||
}
|
||||
// A separator-only root has no final segment; the root itself is the label then.
|
||||
const title = workspaceTitleOf(state.root) || state.root
|
||||
const { directory, name } = pathPartsOf(state.root)
|
||||
return (
|
||||
<div className={css.root} data-files-state="tree" data-files-root={state.root}>
|
||||
{/* jscpd:ignore-start -- the text preview's header row; see `usePathClipped`. */}
|
||||
<div className={css.header}>
|
||||
<IconFolderOpen16 className={css.icon} />
|
||||
<span className={css.name}>{title}</span>
|
||||
<div ref={pathRef} className={css.path} title={state.root} data-files-path>
|
||||
<span ref={pathTextRef} className={css.pathText}>
|
||||
{directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
|
||||
<span className={css.pathName}>{name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tool}
|
||||
@@ -186,7 +227,10 @@ export function FilesBody({
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
<ul className={css.level}><Level path={state.root} tree={tree} /></ul>
|
||||
{/* jscpd:ignore-end */}
|
||||
<div className={css.body}>
|
||||
<ul className={css.level}><Level path={state.root} tree={tree} /></ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The files type's chip title: the folder sheet before the type's label.
|
||||
* Registered under `sidebar.right.pane.tab.title`; without it the chip would
|
||||
* show the bare label. The tree in the body draws its own row glyphs and never
|
||||
* this one.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './FilesBody.module.css'
|
||||
|
||||
/** The folder: a 28-unit sheet in the folder colour, a white tab line across it. */
|
||||
function FolderGlyph(): ReactNode {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 28 28" fill="none" aria-hidden="true" className={css.titleIcon}>
|
||||
<path
|
||||
d="M1.40234 9.66489C1.40234 8.89627 1.40234 8.51197 1.46125 8.15149C1.61126 7.2335 2.04206 6.38455 2.69437 5.72144C2.95052 5.46106 3.26068 5.23414 3.88101 4.78031C4.20493 4.54333 4.36689 4.42484 4.53696 4.32686C4.96871 4.07811 5.4474 3.9217 5.94272 3.86753C6.13784 3.84619 6.33851 3.84619 6.73986 3.84619H11.3625C12.2767 3.84619 12.7338 3.84619 13.1688 3.95634C13.3131 3.99288 13.455 4.03833 13.5937 4.09244C14.0117 4.25555 14.3837 4.52115 15.1278 5.05235L16.5476 6.06601C16.8731 6.29841 17.0359 6.41461 17.2187 6.48597C17.2794 6.50964 17.3415 6.52953 17.4046 6.54551C17.5949 6.5937 17.7949 6.5937 18.1949 6.5937H20.5273C23.0584 6.5937 24.3239 6.5937 25.2111 7.23827C25.4976 7.44644 25.7496 7.69841 25.9578 7.98493C26.6023 8.8721 26.6023 10.1376 26.6023 12.6687V19.2466C26.6023 21.7777 26.6023 23.0432 25.9578 23.9304C25.7496 24.2169 25.4976 24.4688 25.2111 24.677C24.3239 25.3216 23.0584 25.3216 20.5273 25.3216H7.47734C4.94627 25.3216 3.68074 25.3216 2.79357 24.677C2.50705 24.4688 2.25508 24.2169 2.04691 23.9304C1.40234 23.0432 1.40234 21.7777 1.40234 19.2466V9.66489Z"
|
||||
fill="#F7AD31"
|
||||
/>
|
||||
<path d="M5.35156 12.8799H22.6483" stroke="white" strokeWidth="2.6775" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The title as the chip and a floating panel's header show it.
|
||||
* @param props - the tab information hook.
|
||||
* @returns the folder sheet followed by the tab's title text.
|
||||
*/
|
||||
export function FilesTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>): ReactNode {
|
||||
const { tab } = useTabInfo()
|
||||
return (
|
||||
<>
|
||||
<FolderGlyph />
|
||||
{tab.title}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab
|
||||
guide: [{
|
||||
order: 10,
|
||||
title: () => t('guide.title'),
|
||||
description: () => t('guide.description'),
|
||||
icon: IconFolderClose16,
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* Browser half: register `files` as a right-Sidebar tab type.
|
||||
*
|
||||
* The public two-stage path, unmodified: the type into `ctx.sidebarRightTabs`,
|
||||
* the body into the keyed `sidebar.right.pane.tab` seat under the type's `id`.
|
||||
* the body into the keyed `sidebar.right.pane.tab` seat and the chip title into
|
||||
* the keyed `sidebar.right.pane.tab.title` seat, both under the type's `id`.
|
||||
*
|
||||
* The file split is this package's layering: what the type IS
|
||||
* (`definition.ts`), what it keeps (`store.ts`), how it lists (`face.ts`), what
|
||||
* it draws (`FilesBody.tsx`), what it says (`locales.ts`), and this module,
|
||||
* which only wires them together.
|
||||
* it draws (`FilesBody.tsx`, `FilesTitle.tsx`), what it says (`locales.ts`),
|
||||
* and this module, which only wires them together.
|
||||
*/
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
@@ -17,6 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import { FILES_ID, filesDefinition } from './definition.ts'
|
||||
import { createList, filesFace } from './face.ts'
|
||||
import { FilesBody } from './FilesBody.tsx'
|
||||
import { FilesTitle } from './FilesTitle.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { createFilesStore } from './store.ts'
|
||||
|
||||
@@ -35,7 +37,7 @@ const NS = 'sidebarFiles'
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the type, its dictionaries, then its body.
|
||||
* Client plugin body: register the type, its dictionaries, its body, and its chip title.
|
||||
* @param ctx - client root context carrying the registry, the slots, and the Remote face.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
@@ -49,4 +51,8 @@ export function apply(ctx: ClientContext): void {
|
||||
{ name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject },
|
||||
FilesBody,
|
||||
)), 'ui-sidebar-files: files tab body')
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
|
||||
{ name: 'sidebar.right.pane.tab.title', key: FILES_ID },
|
||||
FilesTitle,
|
||||
)), 'ui-sidebar-files: files tab title')
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Simplified Chinese dictionary and key-set source of truth. */
|
||||
export const zh = {
|
||||
'type.label': '文件',
|
||||
'guide.title': '文件',
|
||||
'guide.description': '浏览这个会话工作区里的文件,点开就能查看。',
|
||||
'guide.title': '工作区文件',
|
||||
loading: '正在读取…',
|
||||
empty: '空目录',
|
||||
truncated: '条目太多,只显示了一部分。',
|
||||
@@ -41,11 +40,10 @@ export type SidebarFilesKey = keyof typeof zh
|
||||
/** English dictionary, checked against the Chinese key set. */
|
||||
export const en = {
|
||||
'type.label': 'Files',
|
||||
'guide.title': 'Files',
|
||||
'guide.description': 'Browse the files in this session\'s workspace and open any of them.',
|
||||
'guide.title': 'Workspace files',
|
||||
loading: 'Reading…',
|
||||
empty: 'Empty directory',
|
||||
truncated: 'Too many entries; showing only some of them.',
|
||||
truncated: 'Too many entries, showing only some of them.',
|
||||
noWorkspace: 'This session has no workspace directory.',
|
||||
reload: 'Reload',
|
||||
'entry.other': 'Not a file or a directory, so it cannot be opened.',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FILES_ID, FILES_KIND } from '../src/client/definition.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as hostApply } from '../src/index.ts'
|
||||
import { FilesBody } from '../src/client/FilesBody.tsx'
|
||||
import { FilesTitle } from '../src/client/FilesTitle.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
interface Recorded {
|
||||
@@ -62,18 +63,19 @@ describe('ui-sidebar-files apply', () => {
|
||||
expect(hostApply).not.toThrow()
|
||||
})
|
||||
|
||||
it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => {
|
||||
it('registers the type, its dictionaries, and the body and title seats under the type\'s id', async () => {
|
||||
const { tabs, registered, dictionaries } = await boot()
|
||||
const definition = tabs.get(FILES_KIND)
|
||||
expect(definition?.id).toBe(FILES_ID)
|
||||
expect(definition?.priority).toBe('builtin')
|
||||
expect(definition?.title('sidebar://files')).toBe('type.label')
|
||||
expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description()])).toEqual([[10, 'guide.title', 'guide.description']])
|
||||
expect(definition?.guide?.map(entry => [entry.order, entry.title()])).toEqual([[10, 'guide.title']])
|
||||
expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en })
|
||||
// The seat key is the implementation's id, not the kind: an extension may
|
||||
// take the kind over, and the seat must still find this body.
|
||||
expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
|
||||
['sidebar.right.pane.tab', FILES_ID, 'sidebarFiles', FilesBody],
|
||||
['sidebar.right.pane.tab.title', FILES_ID, undefined, FilesTitle],
|
||||
])
|
||||
expect(registered[0]?.store).toBeDefined()
|
||||
expect(typeof registered[0]?.inject).toBe('function')
|
||||
|
||||
@@ -31,7 +31,6 @@ describe('filesDefinition', () => {
|
||||
expect(entry?.order).toBe(10)
|
||||
expect(entry?.kind).toBe(FILES_KIND)
|
||||
expect(entry?.title()).toBe(zh['guide.title'])
|
||||
expect(entry?.description()).toBe(zh['guide.description'])
|
||||
expect(entry?.icon).toBeDefined()
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user