From 2c5d49645ba1b40f8cd1f896ab8258cfc214ddd1 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Mon, 21 Sep 2026 11:09:13 +0800 Subject: [PATCH 1/4] docs(ui-deliverables): plan review tab fixes for #4670 --- packages/client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 16 +++++++++++++++- packages/client/ui-deliverables/README.zh.md | 16 +++++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 7ea790985c..1e78c569c1 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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-deliverables/README.md -README.md: 71f3545b8fb3fed3ecd678258e6c50bbfa4b580c -README.zh.md: 3e59ed2b7d4ac7e9d64b7d1f2f3c477e3ca1c50e +README.md: cde375ad0e79db89d89fd19a1d5dbfa16d61dd01 +README.zh.md: eba1733c06b230857d8a71615707020f1d40afb9 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 71f3545b8f..cde375ad0e 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -120,7 +120,21 @@ These limits define the current deliverables vocabulary. They are current packag
Working context for maintainers — click to expand -None. +Working draft for Issue #4670, pending implementation and verification. This section does not describe shipped behavior. + +**Reported reproduction.** In macOS Chrome, open a completed turn's changed-file review with enough changed rows and long lines to scroll on both axes. Keep the pointer over either column and flick the trackpad toward each edge. The user reports that the operated column rebounds beyond the edge while the other stays at its limit. A long selected-file path also hides its filename suffix; the document preview provides the requested reference appearance. + +**Planned scrolling behavior.** In the unwrapped split view, suppress elastic overscroll at all four edges of both columns while retaining native momentum within the scrollable range. Start with `overscroll-behavior: none` on the actual column scroll containers in [ReviewTab.module.css](src/client/ReviewTab.module.css). Preserve the position synchronization in [ReviewTab.tsx](src/client/ReviewTab.tsx), including each column's horizontal limit and the long side's offset when the other side fits. The proposal does not introduce wheel cancellation, custom inertia, or shared content widths. Check scroll chaining to the surrounding pane before deciding whether any additional containment is needed. + +**Planned filename behavior.** Give the selected-file title the document preview's path presentation: left-aligned complete text when it fits; preserved trailing characters and extension with a left-edge fade only when clipped; subdued directories and normal filename text; and the complete displayed path on hover. Follow the layout and overflow measurement in [TextPreview.tsx](../ui-sidebar-documentpreview/src/client/TextPreview.tsx) and [TextPreview.module.css](../ui-sidebar-documentpreview/src/client/TextPreview.module.css). Recalculate clipping when the selected file or available width changes. Keep the selector arrow, line counts, and action buttons visible. Any shared presentation component belongs in `ui-primitives`, rather than a component import between feature plugins. + +**Acceptance criteria.** + +- From either column, rapid gestures toward the top, bottom, left, and right stop at the corresponding limit without elastic displacement. Releasing a gesture before an edge still permits native momentum, and reversing direction resumes normal synchronized scrolling. +- Equal and unequal horizontal content widths remain usable, including a comparison where only one side needs horizontal scrolling. Vertical scrolling preserves horizontal position. Unified and wrapped views remain usable, and reaching a split-column edge does not move the surrounding pane unexpectedly. +- Short titles remain complete and unfaded. Narrowing the pane preserves long filename endings with a left fade; widening it removes the fade once the path fits. Switching between files with matching prefixes and different suffixes keeps their endings distinguishable. The title remains selectable through the file menu, with the full displayed path available on hover. + +**Verification plan.** Extend the existing [recorded-session browser case](../../../apps/web/tests/changed-files-turn.e2e.ts) for the changed-file title and scroll geometry, and retain its unequal-range regression coverage. Add focused [component coverage](tests/review-tab.client.spec.tsx) for any new title behavior that does not require browser layout. The implementer must verify elastic overscroll and momentum with a real macOS trackpad in Chrome and record the browser version and result; programmatic scroll offsets or synthetic wheel events alone do not establish native rebound behavior. Record desktop validation separately if exercised. After implementation, update the review-tab usage section and affected JSDoc to the verified behavior, then remove this draft.
diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 3e59ed2b7d..eba1733c06 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -120,7 +120,21 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl
维护者的工作上下文——点击展开 -无。 +Issue #4670 的工作草稿,尚待实现与验证。本节不描述已交付行为。 + +**已报告的复现。** 在 macOS Chrome 中打开已完成轮次的改动文件 review,改动行数与单行长度足以支持双轴滚动。鼠标停在任一列,用触控板快速滑向各个边缘。用户报告,接受操作的一列会越过边缘回弹,另一列停在滚动终点。选中文件的路径过长时还会隐藏文件名尾部;文档预览提供了期望的参考效果。 + +**计划中的滚动行为。** 在关闭自动换行的左右对比中,禁止两列在上下左右四个边缘的弹性越界,同时保留可滚动范围内的原生惯性。先在 [ReviewTab.module.css](src/client/ReviewTab.module.css) 中实际承担滚动的列容器上设置 `overscroll-behavior: none`。保留 [ReviewTab.tsx](src/client/ReviewTab.tsx) 的位置同步,包括各列自己的横向终点,以及另一侧无需滚动时长内容一侧的偏移。方案不引入滚轮事件取消、自定义惯性或共享内容宽度。先检查滚动向外围面板传递的行为,再判断是否需要额外限制。 + +**计划中的文件名行为。** 选中文件的标题采用文档预览的路径展示:空间足够时完整靠左显示;裁切时保留尾部字符与扩展名,且仅在左侧渐隐;目录使用弱化颜色,文件名使用正常文字颜色;悬停显示完整的展示路径。沿用 [TextPreview.tsx](../ui-sidebar-documentpreview/src/client/TextPreview.tsx) 与 [TextPreview.module.css](../ui-sidebar-documentpreview/src/client/TextPreview.module.css) 的布局和溢出测量方式。所选文件或可用宽度变化时重新判断裁切。选择器箭头、行数统计与操作按钮保持可见。若提取共享展示组件,应放入 `ui-primitives`,而不是在功能插件之间导入组件。 + +**验收条件。** + +- 从任一列快速滑向上、下、左、右边缘,都停在对应终点,不产生弹性位移。在到达边缘前松手仍保留原生惯性,反向操作恢复正常同步滚动。 +- 两侧横向内容等宽或不等宽时均可使用,包括仅一侧需要横向滚动的对比。纵向滚动保留横向位置。单栏和自动换行视图仍可使用,双栏到达边缘时不会意外带动外围面板。 +- 短标题完整显示且不渐隐。缩窄面板时,长文件名保留尾部并在左侧渐隐;加宽到路径可完整显示时渐隐消失。在前缀相同、后缀不同的文件之间切换,仍能辨认各自尾部。文件菜单仍能选择标题对应的文件,悬停可查看完整展示路径。 + +**验证计划。** 扩展现有[录制会话浏览器用例](../../../apps/web/tests/changed-files-turn.e2e.ts),覆盖改动文件标题与滚动布局,并保留其滚动范围不等的回归覆盖。对不依赖浏览器布局的新标题行为补充聚焦的[组件覆盖](tests/review-tab.client.spec.tsx)。实现者必须在 Chrome 中用真实 macOS 触控板验证弹性越界与惯性,并记录浏览器版本和结果;仅设置滚动偏移或合成滚轮事件不能证明原生回弹行为。若验证桌面端,单独记录结果。实现完成后,把已验证行为写入 review tab 使用说明与相关 JSDoc,再删除本草稿。
From c84c908656da003e62261acaeaba0642fd03c9c2 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Mon, 21 Sep 2026 11:29:02 +0800 Subject: [PATCH 2/4] fix(ui-deliverables): prevent split diff edge rebound --- apps/web/tests/changed-files-turn.e2e.ts | 64 ++++++++++++++++++- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 10 ++- packages/client/ui-deliverables/README.zh.md | 10 ++- .../src/client/ReviewTab.module.css | 2 +- .../ui-deliverables/src/client/ReviewTab.tsx | 1 + 6 files changed, 75 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index a5b45a3511..0859d3bad3 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' import type {} from '@deepseek-ai/dsh-workspace-changes' import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import { @@ -354,6 +354,68 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(tripwire.warnings).toEqual([]) }) + it('keeps edge gestures inside either diff column and accepts reverse scrolling', async () => { + const review = page.locator('[data-changes-review]') + await review.getByRole('button', { name: '自动换行' }).click() + const body = review.locator('[data-review-view="split"]:not([data-review-wrap])') + await body.waitFor({ state: 'visible' }) + // A scrollable parent makes escaped edge gestures observable without native trackpad rebound. + const layout = await page.addStyleTag({ content: ` + [data-changes-review] { height: 220px !important; } + [data-review-view="split"] { display: block; max-width: 220px; } + [data-review-view="split"] > div { width: 160px; height: 60px; margin: 160px; } + ` }) + onTestFinished(async () => { + await layout.evaluate(element => element.parentNode!.removeChild(element)) + await body.evaluate((element) => { element.scrollTo(0, 0) }) + await review.getByRole('button', { name: '自动换行' }).click() + }) + const input = await page.context().newCDPSession(page) + try { + for (const side of ['left', 'right']) { + const column = review.locator(`[data-diff-side="${side}"]`) + for (const axis of ['x', 'y'] as const) { + for (const end of [false, true]) { + const parentPosition = await body.evaluate((element) => { + const x = (element.scrollWidth - element.clientWidth) / 2 + const y = (element.scrollHeight - element.clientHeight) / 2 + element.scrollTo(x, y) + return { x: element.scrollLeft, y: element.scrollTop } + }) + expect(parentPosition.x).toBeGreaterThan(0) + expect(parentPosition.y).toBeGreaterThan(0) + const edge = await column.evaluate((element, { axis, end }) => { + const maximum = axis === 'x' + ? element.scrollWidth - element.clientWidth + : element.scrollHeight - element.clientHeight + element.scrollTo(axis === 'x' && end ? maximum : 0, axis === 'y' && end ? maximum : 0) + return { maximum, offset: end ? maximum : 0 } + }, { axis, end }) + expect(edge.maximum).toBeGreaterThan(0) + const position = () => column.evaluate((element, axis) => axis === 'x' ? element.scrollLeft : element.scrollTop, axis) + await expect.poll(position).toBe(edge.offset) + const box = await column.boundingBox() + if (box === null) throw new Error('diff column has no visible bounds') + const gesture = { x: box.x + box.width / 2, y: box.y + box.height / 2, gestureSourceType: 'mouse' as const } + const distance = end ? -80 : 80 + // CDP acknowledges the completed gesture, including default scrolling, before observation. + await input.send('Input.synthesizeScrollGesture', { + ...gesture, xDistance: axis === 'x' ? distance : 0, yDistance: axis === 'y' ? distance : 0, + }) + expect(await body.evaluate(element => ({ x: element.scrollLeft, y: element.scrollTop }))).toEqual(parentPosition) + expect(await position()).toBe(edge.offset) + await input.send('Input.synthesizeScrollGesture', { + ...gesture, xDistance: axis === 'x' ? -distance : 0, yDistance: axis === 'y' ? -distance : 0, + }) + await expect.poll(position).not.toBe(edge.offset) + } + } + } + } finally { + await input.detach() + } + }) + it.skipIf(MODE === 'record')('replays the workspace and the Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd, { ignoredRootEntries: ['.git'] }) const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 1e78c569c1..67d40a19a0 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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-deliverables/README.md -README.md: cde375ad0e79db89d89fd19a1d5dbfa16d61dd01 -README.zh.md: eba1733c06b230857d8a71615707020f1d40afb9 +README.md: 7f2dd84bdab443c22b23ec62b87b35306e4eb360 +README.zh.md: ab3f351ce1f0499bc6abcbbd18eb7bcc5860d8a1 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index cde375ad0e..7f2dd84bda 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -44,7 +44,7 @@ The card renders the summary the Host serves for the turn's latest `workspace/ch A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use aligned success or error markers and background tints. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. -Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. +Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Both columns suppress elastic edge feedback and scroll chaining on both axes; the browser retains control of native momentum within each column’s scrollable range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. ### Inline-code links @@ -120,21 +120,19 @@ These limits define the current deliverables vocabulary. They are current packag
Working context for maintainers — click to expand -Working draft for Issue #4670, pending implementation and verification. This section does not describe shipped behavior. +Working context for Issue #4670: filename presentation remains unimplemented. The proposed filename behavior below is not a shipped guarantee. **Reported reproduction.** In macOS Chrome, open a completed turn's changed-file review with enough changed rows and long lines to scroll on both axes. Keep the pointer over either column and flick the trackpad toward each edge. The user reports that the operated column rebounds beyond the edge while the other stays at its limit. A long selected-file path also hides its filename suffix; the document preview provides the requested reference appearance. -**Planned scrolling behavior.** In the unwrapped split view, suppress elastic overscroll at all four edges of both columns while retaining native momentum within the scrollable range. Start with `overscroll-behavior: none` on the actual column scroll containers in [ReviewTab.module.css](src/client/ReviewTab.module.css). Preserve the position synchronization in [ReviewTab.tsx](src/client/ReviewTab.tsx), including each column's horizontal limit and the long side's offset when the other side fits. The proposal does not introduce wheel cancellation, custom inertia, or shared content widths. Check scroll chaining to the surrounding pane before deciding whether any additional containment is needed. +**Scroll verification.** The [recorded-session browser case](../../../apps/web/tests/changed-files-turn.e2e.ts) covers containment at all four edges from either column, reverse scrolling, and unequal horizontal ranges. On 2026-09-21, the reporting user confirmed with a real macOS trackpad in Chrome that edge rebound is absent and normal momentum remains. **Planned filename behavior.** Give the selected-file title the document preview's path presentation: left-aligned complete text when it fits; preserved trailing characters and extension with a left-edge fade only when clipped; subdued directories and normal filename text; and the complete displayed path on hover. Follow the layout and overflow measurement in [TextPreview.tsx](../ui-sidebar-documentpreview/src/client/TextPreview.tsx) and [TextPreview.module.css](../ui-sidebar-documentpreview/src/client/TextPreview.module.css). Recalculate clipping when the selected file or available width changes. Keep the selector arrow, line counts, and action buttons visible. Any shared presentation component belongs in `ui-primitives`, rather than a component import between feature plugins. **Acceptance criteria.** -- From either column, rapid gestures toward the top, bottom, left, and right stop at the corresponding limit without elastic displacement. Releasing a gesture before an edge still permits native momentum, and reversing direction resumes normal synchronized scrolling. -- Equal and unequal horizontal content widths remain usable, including a comparison where only one side needs horizontal scrolling. Vertical scrolling preserves horizontal position. Unified and wrapped views remain usable, and reaching a split-column edge does not move the surrounding pane unexpectedly. - Short titles remain complete and unfaded. Narrowing the pane preserves long filename endings with a left fade; widening it removes the fade once the path fits. Switching between files with matching prefixes and different suffixes keeps their endings distinguishable. The title remains selectable through the file menu, with the full displayed path available on hover. -**Verification plan.** Extend the existing [recorded-session browser case](../../../apps/web/tests/changed-files-turn.e2e.ts) for the changed-file title and scroll geometry, and retain its unequal-range regression coverage. Add focused [component coverage](tests/review-tab.client.spec.tsx) for any new title behavior that does not require browser layout. The implementer must verify elastic overscroll and momentum with a real macOS trackpad in Chrome and record the browser version and result; programmatic scroll offsets or synthetic wheel events alone do not establish native rebound behavior. Record desktop validation separately if exercised. After implementation, update the review-tab usage section and affected JSDoc to the verified behavior, then remove this draft. +**Remaining verification.** Extend the browser case for filename clipping, and add focused [component coverage](tests/review-tab.client.spec.tsx) for any new title behavior that does not require browser layout. Once the filename fix is verified, update the review-tab usage section and affected JSDoc, then remove this draft.
diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index eba1733c06..ab3f351ce1 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -44,7 +44,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除使用对齐的成功色或错误色标记和背景色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 -关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 +关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两列在双轴上都禁用弹性边缘反馈和向外层传递滚动;各列可滚动范围内的原生惯性仍由浏览器控制。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 ### 行内代码链接 @@ -120,21 +120,19 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl
维护者的工作上下文——点击展开 -Issue #4670 的工作草稿,尚待实现与验证。本节不描述已交付行为。 +Issue #4670 的工作上下文:文件名展示尚未实现。下述拟议的文件名行为不是已交付保证。 **已报告的复现。** 在 macOS Chrome 中打开已完成轮次的改动文件 review,改动行数与单行长度足以支持双轴滚动。鼠标停在任一列,用触控板快速滑向各个边缘。用户报告,接受操作的一列会越过边缘回弹,另一列停在滚动终点。选中文件的路径过长时还会隐藏文件名尾部;文档预览提供了期望的参考效果。 -**计划中的滚动行为。** 在关闭自动换行的左右对比中,禁止两列在上下左右四个边缘的弹性越界,同时保留可滚动范围内的原生惯性。先在 [ReviewTab.module.css](src/client/ReviewTab.module.css) 中实际承担滚动的列容器上设置 `overscroll-behavior: none`。保留 [ReviewTab.tsx](src/client/ReviewTab.tsx) 的位置同步,包括各列自己的横向终点,以及另一侧无需滚动时长内容一侧的偏移。方案不引入滚轮事件取消、自定义惯性或共享内容宽度。先检查滚动向外围面板传递的行为,再判断是否需要额外限制。 +**滚动验证。** [录制会话浏览器用例](../../../apps/web/tests/changed-files-turn.e2e.ts)覆盖从任一列向四个边缘滚动时的限制、反向滚动与不等的横向范围。2026-09-21,报告问题的用户在 Chrome 中使用真实 macOS 触控板确认,边缘回弹消失,正常惯性保留。 **计划中的文件名行为。** 选中文件的标题采用文档预览的路径展示:空间足够时完整靠左显示;裁切时保留尾部字符与扩展名,且仅在左侧渐隐;目录使用弱化颜色,文件名使用正常文字颜色;悬停显示完整的展示路径。沿用 [TextPreview.tsx](../ui-sidebar-documentpreview/src/client/TextPreview.tsx) 与 [TextPreview.module.css](../ui-sidebar-documentpreview/src/client/TextPreview.module.css) 的布局和溢出测量方式。所选文件或可用宽度变化时重新判断裁切。选择器箭头、行数统计与操作按钮保持可见。若提取共享展示组件,应放入 `ui-primitives`,而不是在功能插件之间导入组件。 **验收条件。** -- 从任一列快速滑向上、下、左、右边缘,都停在对应终点,不产生弹性位移。在到达边缘前松手仍保留原生惯性,反向操作恢复正常同步滚动。 -- 两侧横向内容等宽或不等宽时均可使用,包括仅一侧需要横向滚动的对比。纵向滚动保留横向位置。单栏和自动换行视图仍可使用,双栏到达边缘时不会意外带动外围面板。 - 短标题完整显示且不渐隐。缩窄面板时,长文件名保留尾部并在左侧渐隐;加宽到路径可完整显示时渐隐消失。在前缀相同、后缀不同的文件之间切换,仍能辨认各自尾部。文件菜单仍能选择标题对应的文件,悬停可查看完整展示路径。 -**验证计划。** 扩展现有[录制会话浏览器用例](../../../apps/web/tests/changed-files-turn.e2e.ts),覆盖改动文件标题与滚动布局,并保留其滚动范围不等的回归覆盖。对不依赖浏览器布局的新标题行为补充聚焦的[组件覆盖](tests/review-tab.client.spec.tsx)。实现者必须在 Chrome 中用真实 macOS 触控板验证弹性越界与惯性,并记录浏览器版本和结果;仅设置滚动偏移或合成滚轮事件不能证明原生回弹行为。若验证桌面端,单独记录结果。实现完成后,把已验证行为写入 review tab 使用说明与相关 JSDoc,再删除本草稿。 +**剩余验证。** 扩展浏览器用例以覆盖文件名裁切,并对不依赖浏览器布局的新标题行为补充聚焦的[组件覆盖](tests/review-tab.client.spec.tsx)。文件名修复通过验证后,更新 review tab 使用说明与相关 JSDoc,再删除本草稿。
diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index 8d9fd9ede4..cb859fb52e 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -25,7 +25,7 @@ .status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } .body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } .columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; } -.column { min-width: 0; overflow-x: scroll; } +.column { min-width: 0; overflow-x: scroll; overscroll-behavior: none; } .column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); } .sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; } .note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 01ac90f1fa..a0d159f45b 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -312,6 +312,7 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string { * lines and scroll together on both axes, so a long line on one side never * runs under the other and both sides show the same rows and columns of text. * Every line is one fixed-height row, which keeps the sides aligned. + * The columns suppress elastic overscroll while retaining native in-range scrolling. */ function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode { const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) From 60cdd547ae02f6e33d7190c56ea3b2848ef56f61 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Mon, 21 Sep 2026 14:45:18 +0800 Subject: [PATCH 3/4] fix(ui-deliverables): preserve filename endings in review headers --- apps/web/tests/changed-files-turn.e2e.ts | 84 ++++++++++++++++- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 21 +---- packages/client/ui-deliverables/README.zh.md | 21 +---- .../src/client/ReviewTab.module.css | 1 - .../ui-deliverables/src/client/ReviewTab.tsx | 4 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 1 + packages/client/ui-primitives/README.zh.md | 1 + packages/client/ui-primitives/package.json | 1 + .../ui-primitives/src/PathLabel.module.css | 28 ++++++ .../client/ui-primitives/src/PathLabel.tsx | 41 ++++++++ packages/client/ui-primitives/src/index.ts | 1 + .../tests/path-label.client.spec.tsx | 94 +++++++++++++++++++ packages/client/ui-primitives/tsconfig.json | 5 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/TextPreview.module.css | 30 ------ .../src/client/TextPreview.tsx | 53 +---------- .../tests/text-preview.client.spec.tsx | 50 ---------- pnpm-lock.yaml | 3 + 22 files changed, 277 insertions(+), 178 deletions(-) create mode 100644 packages/client/ui-primitives/src/PathLabel.module.css create mode 100644 packages/client/ui-primitives/src/PathLabel.tsx create mode 100644 packages/client/ui-primitives/tests/path-label.client.spec.tsx diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 0859d3bad3..b645d75d63 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' import type {} from '@deepseek-ai/dsh-workspace-changes' +import type { ChangesSummary } from '../../../packages/client/ui-deliverables/src/changes.ts' import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import { assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden, @@ -58,9 +59,10 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { }) await seedRepository(join(scaffold.workspaceCwd, 'workspace')) browser = await chromium.launch() - page = await browser.newPage({ + const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai', }) + page = await context.newPage() tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]') @@ -421,4 +423,84 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) }) + + it('preserves filename endings when the review narrows and updates the fade after resizing or selecting another file', async () => { + const preview = await page.context().newPage() + onTestFinished(async () => { await preview.close() }) + const prefix = 'src/components/review/' + 'long-filename-'.repeat(8) + const names = [`${prefix}before.ts`, `${prefix}after.ts`] as const + // Project long display names into a separate page; recorded paths, comparisons, and Session data stay intact. + await preview.route('**/api/changes.summary?*', async (route) => { + const response = await route.fetch() + const summary = await response.json() as ChangesSummary + await route.fulfill({ response, json: { + ...summary, files: summary.files.map((file, index) => ({ ...file, display: names[index] ?? file.display })), + } }) + }) + await preview.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await preview.locator('[data-changed-files]').getByRole('button', { name: '在侧边栏查看本轮改动' }).click() + const review = preview.locator('[data-changes-review]') + const selector = review.getByRole('button', { name: '选择要查看的文件' }) + const label = selector.locator('[data-path-label]') + const layout = await preview.addStyleTag({ content: '[data-sidebar-right-panel] { width: 340px !important; }' }) + const resize = async (width: number) => { + await layout.evaluate((element, width) => { + element.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` + }, width) + } + const metrics = () => label.evaluate((element) => { + const text = element.firstElementChild! + const box = element.getBoundingClientRect() + const textBox = text.getBoundingClientRect() + const filename = text.lastElementChild! + const suffix = document.createRange() + const node = filename.firstChild! + suffix.setStart(node, Math.max(0, node.textContent!.length - 'before.ts'.length)) + suffix.setEnd(node, node.textContent!.length) + const suffixBox = suffix.getBoundingClientRect() + return { + clipped: element.hasAttribute('data-path-clipped'), mask: getComputedStyle(element).maskImage, + left: textBox.left - box.left, right: box.right - textBox.right, + suffixVisible: suffixBox.left >= box.left && suffixBox.right <= box.right + 0.5, + directoryColor: getComputedStyle(text.firstElementChild!).color, + nameColor: getComputedStyle(filename).color, + } + }) + await expect.poll(async () => (await metrics()).clipped).toBe(true) + const clipped = await metrics() + expect(clipped.left).toBeLessThan(0) + expect(Math.abs(clipped.right)).toBeLessThanOrEqual(0.5) + expect(clipped.suffixVisible).toBe(true) + expect(clipped.mask).toContain('linear-gradient') + expect(clipped.directoryColor).not.toBe(clipped.nameColor) + expect(await label.getAttribute('title')).toBe(names[0]) + const controls = await selector.evaluate((button) => { + const header = button.parentElement!.parentElement! + const bounds = header.getBoundingClientRect() + const caret = button.querySelector('svg')! + const counts = button.parentElement!.nextElementSibling! + return [caret, counts, ...header.querySelectorAll('[data-review-tool]')].map((element) => { + const box = element.getBoundingClientRect() + return box.width > 0 && box.left >= bounds.left && box.right <= bounds.right + }) + }) + expect(controls.every(Boolean)).toBe(true) + await selector.click() + await preview.getByRole('menuitem').filter({ hasText: names[1] }).click() + await expect.poll(() => label.getAttribute('title')).toBe(names[1]) + expect((await metrics()).suffixVisible).toBe(true) + await resize(1400) + await expect.poll(async () => (await metrics()).clipped).toBe(false) + expect((await metrics()).left).toBeCloseTo(0, 1) + expect((await metrics()).mask).toBe('none') + await resize(340) + await expect.poll(async () => (await metrics()).clipped).toBe(true) + await selector.click() + await preview.getByRole('menuitem').filter({ hasText: 'src/util.ts' }).click() + await expect.poll(() => label.getAttribute('title')).toBe('src/util.ts') + await expect.poll(async () => (await metrics()).clipped).toBe(false) + expect((await metrics()).left).toBeCloseTo(0, 1) + expect((await metrics()).mask).toBe('none') + }) + }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 67d40a19a0..dbc8d93e71 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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-deliverables/README.md -README.md: 7f2dd84bdab443c22b23ec62b87b35306e4eb360 -README.zh.md: ab3f351ce1f0499bc6abcbbd18eb7bcc5860d8a1 +README.md: d3f0d391380f6f4ae9c95fdc8c0b4e8c4b00654a +README.zh.md: 1c25199512b5c97f041954995cf45452e58706ff diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 7f2dd84bda..d3f0d39138 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -44,6 +44,8 @@ The card renders the summary the Host serves for the turn's latest `workspace/ch A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use aligned success or error markers and background tints. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. +The selected-file title uses the document preview’s [`PathLabel`](../ui-primitives/README.md#component-catalog): complete and left-aligned when it fits, otherwise clipped at the left with a fade that preserves trailing characters and the extension. Directories use subdued text, the filename uses primary text, and hovering reveals the complete displayed path. Resizing the pane or selecting another file updates the fade; the dropdown arrow, line counts, and toolbar actions retain their space. + Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Both columns suppress elastic edge feedback and scroll chaining on both axes; the browser retains control of native momentum within each column’s scrollable range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. ### Inline-code links @@ -117,23 +119,4 @@ These limits define the current deliverables vocabulary. They are current packag ### Dev Note -
-Working context for maintainers — click to expand - -Working context for Issue #4670: filename presentation remains unimplemented. The proposed filename behavior below is not a shipped guarantee. - -**Reported reproduction.** In macOS Chrome, open a completed turn's changed-file review with enough changed rows and long lines to scroll on both axes. Keep the pointer over either column and flick the trackpad toward each edge. The user reports that the operated column rebounds beyond the edge while the other stays at its limit. A long selected-file path also hides its filename suffix; the document preview provides the requested reference appearance. - -**Scroll verification.** The [recorded-session browser case](../../../apps/web/tests/changed-files-turn.e2e.ts) covers containment at all four edges from either column, reverse scrolling, and unequal horizontal ranges. On 2026-09-21, the reporting user confirmed with a real macOS trackpad in Chrome that edge rebound is absent and normal momentum remains. - -**Planned filename behavior.** Give the selected-file title the document preview's path presentation: left-aligned complete text when it fits; preserved trailing characters and extension with a left-edge fade only when clipped; subdued directories and normal filename text; and the complete displayed path on hover. Follow the layout and overflow measurement in [TextPreview.tsx](../ui-sidebar-documentpreview/src/client/TextPreview.tsx) and [TextPreview.module.css](../ui-sidebar-documentpreview/src/client/TextPreview.module.css). Recalculate clipping when the selected file or available width changes. Keep the selector arrow, line counts, and action buttons visible. Any shared presentation component belongs in `ui-primitives`, rather than a component import between feature plugins. - -**Acceptance criteria.** - -- Short titles remain complete and unfaded. Narrowing the pane preserves long filename endings with a left fade; widening it removes the fade once the path fits. Switching between files with matching prefixes and different suffixes keeps their endings distinguishable. The title remains selectable through the file menu, with the full displayed path available on hover. - -**Remaining verification.** Extend the browser case for filename clipping, and add focused [component coverage](tests/review-tab.client.spec.tsx) for any new title behavior that does not require browser layout. Once the filename fix is verified, update the review-tab usage section and affected JSDoc, then remove this draft. - -
- **Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the filesystem owns file contents. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index ab3f351ce1..1c25199512 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -44,6 +44,8 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除使用对齐的成功色或错误色标记和背景色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 +当前文件标题使用文档预览页的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog):空间足够时完整靠左显示,否则从左侧裁剪并渐隐,保留尾部字符和扩展名。目录使用弱化颜色,文件名使用主色,悬停显示完整路径。调整面板宽度或切换文件时更新渐隐;下拉箭头、行数和工具栏操作保留各自的空间。 + 关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两列在双轴上都禁用弹性边缘反馈和向外层传递滚动;各列可滚动范围内的原生惯性仍由浏览器控制。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 ### 行内代码链接 @@ -117,23 +119,4 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl ### 开发备注 -
-维护者的工作上下文——点击展开 - -Issue #4670 的工作上下文:文件名展示尚未实现。下述拟议的文件名行为不是已交付保证。 - -**已报告的复现。** 在 macOS Chrome 中打开已完成轮次的改动文件 review,改动行数与单行长度足以支持双轴滚动。鼠标停在任一列,用触控板快速滑向各个边缘。用户报告,接受操作的一列会越过边缘回弹,另一列停在滚动终点。选中文件的路径过长时还会隐藏文件名尾部;文档预览提供了期望的参考效果。 - -**滚动验证。** [录制会话浏览器用例](../../../apps/web/tests/changed-files-turn.e2e.ts)覆盖从任一列向四个边缘滚动时的限制、反向滚动与不等的横向范围。2026-09-21,报告问题的用户在 Chrome 中使用真实 macOS 触控板确认,边缘回弹消失,正常惯性保留。 - -**计划中的文件名行为。** 选中文件的标题采用文档预览的路径展示:空间足够时完整靠左显示;裁切时保留尾部字符与扩展名,且仅在左侧渐隐;目录使用弱化颜色,文件名使用正常文字颜色;悬停显示完整的展示路径。沿用 [TextPreview.tsx](../ui-sidebar-documentpreview/src/client/TextPreview.tsx) 与 [TextPreview.module.css](../ui-sidebar-documentpreview/src/client/TextPreview.module.css) 的布局和溢出测量方式。所选文件或可用宽度变化时重新判断裁切。选择器箭头、行数统计与操作按钮保持可见。若提取共享展示组件,应放入 `ui-primitives`,而不是在功能插件之间导入组件。 - -**验收条件。** - -- 短标题完整显示且不渐隐。缩窄面板时,长文件名保留尾部并在左侧渐隐;加宽到路径可完整显示时渐隐消失。在前缀相同、后缀不同的文件之间切换,仍能辨认各自尾部。文件菜单仍能选择标题对应的文件,悬停可查看完整展示路径。 - -**剩余验证。** 扩展浏览器用例以覆盖文件名裁切,并对不依赖浏览器布局的新标题行为补充聚焦的[组件覆盖](tests/review-tab.client.spec.tsx)。文件名修复通过验证后,更新 review tab 使用说明与相关 JSDoc,再删除本草稿。 - -
- **运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,文件系统拥有文件内容。 diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index cb859fb52e..ae2c996cbb 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -7,7 +7,6 @@ .selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; line-height: 20px; } .selectorButton:hover, .selectorButton[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } .selectorButton svg { display: block; flex: none; } -.selectorText { min-width: 0; overflow: hidden; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } .item { display: flex; gap: 12px; align-items: center; justify-content: space-between; min-width: 0; } .itemPath { min-width: 0; overflow: hidden; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } .itemCounts, .counts { display: inline-flex; flex: none; gap: 6px; align-items: center; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index a0d159f45b..4828df79fb 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -7,7 +7,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode, UIEvent } from 'react' import { Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, - IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, Tooltip, + IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, PathLabel, Tooltip, languageForPath, useCodeHighlighter, } from '@deepseek-ai/dsh-client-ui-primitives' import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives' @@ -239,7 +239,7 @@ export function ReviewTab({ anchor={} items={files.map((entry, at) => ({ id: String(at), label: diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 52a8d99d49..12730640f8 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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: 09950210eaef57a290362037f3d603366fe00168 -README.zh.md: aa95cbf1ce7fa79c3d1d5466ca8ed4d6942ad03e +README.md: 725bb8c10d2f93cb183d03a4ef0eff01921c404b +README.zh.md: b1b6df5ad171a37abbbb8931c728c2c5bd2a6c52 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 09950210ea..725bb8c10d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -44,6 +44,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. | | `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. | | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | +| `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. | | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader. `aria-hidden`, so the render site owns the name. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index aa95cbf1ce..b1b6df5ad1 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -44,6 +44,7 @@ kind: "package-library" | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 | | `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 | | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | +| `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 | diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 1e6b7de54e..4b61f4e8ff 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -38,6 +38,7 @@ "micromark-util-symbol": "^2.0.1", "micromark-util-types": "^2.0.2", "shiki": "^4.3.1", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@shikijs/langs": "^4.3.1", "@types/mdast": "^4.0.4", "micromark-factory-space": "^2.0.1", diff --git a/packages/client/ui-primitives/src/PathLabel.module.css b/packages/client/ui-primitives/src/PathLabel.module.css new file mode 100644 index 0000000000..add09a8550 --- /dev/null +++ b/packages/client/ui-primitives/src/PathLabel.module.css @@ -0,0 +1,28 @@ +/* Auto margin keeps fitting text at the left. On overflow it becomes zero, + so flex-end preserves the filename suffix at the right edge. */ +.path { + display: flex; + flex: 1 1 auto; + justify-content: flex-end; + min-width: 0; + overflow: hidden; + font-size: 12px; + white-space: nowrap; +} + +.path[data-path-clipped] { + mask-image: linear-gradient(to right, transparent, black 28px); +} + +.text { + flex: none; + margin-right: auto; +} + +.directory { + color: var(--dsw-alias-label-tertiary); +} + +.name { + color: var(--dsw-alias-label-primary); +} diff --git a/packages/client/ui-primitives/src/PathLabel.tsx b/packages/client/ui-primitives/src/PathLabel.tsx new file mode 100644 index 0000000000..386324d21d --- /dev/null +++ b/packages/client/ui-primitives/src/PathLabel.tsx @@ -0,0 +1,41 @@ +/** A single-line file path whose trailing characters remain visible in narrow toolbars. */ +import { useLayoutEffect, useRef, type HTMLAttributes } from 'react' +import clsx from 'clsx' +import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' +import css from './PathLabel.module.css' + +/** + * Render subdued directories and a primary filename, with the complete path on hover. + * Fitting text is left-aligned; overflow clips and fades at the left edge. + * The fade updates on path changes and, when ResizeObserver is available, size changes. + * @param props - File path and attributes for its outer span; callers own toolbar spacing. + * @returns the path label. + */ +export function PathLabel({ path, className, ...attributes }: { + path: string +} & Omit, 'children' | 'title'>) { + const boxRef = useRef(null) + const textRef = useRef(null) + const { directory, name } = pathPartsOf(path) + useLayoutEffect(() => { + const outer = boxRef.current + const inner = textRef.current + if (outer === null || inner === null) return undefined + const apply = (): void => { + outer.toggleAttribute('data-path-clipped', inner.offsetWidth > outer.clientWidth) + } + apply() + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply) + observer?.observe(outer) + observer?.observe(inner) + return () => { observer?.disconnect() } + }, [path]) + return ( + + + {directory !== '' && {directory}} + {name} + + + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 25ba54ea24..affdd22985 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -12,6 +12,7 @@ export { Pill } from './Pill.tsx' export { SegmentedTabs } from './SegmentedTabs.tsx' export type { SegmentedTab } from './SegmentedTabs.tsx' export { Tag } from './Tag.tsx' +export { PathLabel } from './PathLabel.tsx' export type { TagTone } from './Tag.tsx' export { Switch } from './Switch.tsx' export { Checkbox } from './Checkbox.tsx' diff --git a/packages/client/ui-primitives/tests/path-label.client.spec.tsx b/packages/client/ui-primitives/tests/path-label.client.spec.tsx new file mode 100644 index 0000000000..3336338b68 --- /dev/null +++ b/packages/client/ui-primitives/tests/path-label.client.spec.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +/** Path labels retain their full hover text and update clipping with layout and file changes. */ +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PathLabel } from '../src/PathLabel.tsx' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('PathLabel', () => { + it.each([ + ['src/filename.ts', 'src/', 'filename.ts'], + ['C:\\src\\filename.ts', 'C:\\src\\', 'filename.ts'], + ['filename.ts', '', 'filename.ts'], + ])('shows %s with a separate filename and full hover text', (path, directory, name) => { + const view = render() + const label = view.getByTestId('path') + expect(label.textContent).toBe(path) + expect(label.title).toBe(path) + expect(label.classList.contains('placement')).toBe(true) + expect(label.firstElementChild?.lastElementChild?.textContent).toBe(name) + expect(label.firstElementChild?.children.length).toBe(directory === '' ? 1 : 2) + }) + + it('marks the path clipped while its text is wider than its box, re-reading on resize', () => { + 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 view = render() + const path = view.container.querySelector('[data-path-label]') + const text = path?.firstElementChild + expect(path?.hasAttribute('data-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-path-clipped')).toBe(true) + + boxWidth = 300 + act(() => { observer.fire() }) + expect(path?.hasAttribute('data-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('rechecks clipping and hover text immediately when the selected path changes without ResizeObserver', () => { + vi.stubGlobal('ResizeObserver', undefined) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + return (this.textContent?.length ?? 0) * 10 + }) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100) + const view = render() + const label = view.container.querySelector('[data-path-label]')! + expect(label.hasAttribute('data-path-clipped')).toBe(false) + view.rerender() + expect(label.hasAttribute('data-path-clipped')).toBe(true) + expect(label.title).toBe('src/long-filename-before.ts') + view.rerender() + expect(label.textContent).toBe('src/long-filename-after.ts') + view.rerender() + expect(label.hasAttribute('data-path-clipped')).toBe(false) + expect(label.title).toBe('b.ts') + }) +}) diff --git a/packages/client/ui-primitives/tsconfig.json b/packages/client/ui-primitives/tsconfig.json index efd9866d5e..b589ca31b1 100644 --- a/packages/client/ui-primitives/tsconfig.json +++ b/packages/client/ui-primitives/tsconfig.json @@ -9,5 +9,10 @@ ], "exclude": [ "**/*.legacy.*" + ], + "references": [ + { + "path": "../../util/workspace-path" + } ] } diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 141c7e0474..00a5560428 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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: 2797d6fd6be8f429043c8de1413f90e64bda3876 -README.zh.md: a04571ed6d76ca812aa0a70ce6f3c403ad80947d +README.md: cda3e8bb4fa2c4228028e48fcd4508a4a343471e +README.zh.md: b4959526beb8d0830d0fce79d60f5da2c6ac73ae diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 2797d6fd6b..cda3e8bb4f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -28,7 +28,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo ## 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, 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 appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). 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. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **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; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). 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. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, 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, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. 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. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index a04571ed6d..b4959526be 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -28,7 +28,7 @@ kind: "package-reference" ## 注册了什么 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),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 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index 490ec8e67c..afc740dc5e 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -28,38 +28,8 @@ 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; - font-size: 12px; - white-space: nowrap; -} - -.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. */ diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 34efd79edb..b9e03e1748 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -9,14 +9,14 @@ * 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode, RefObject } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } 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 { FileTypeIcon, IconNowrapFillRegular, IconPauseOutlineRegular, IconPlayOutlineRegular, - IconRefreshOutlineRegular, IconWrapFillRegular, Menu, Tooltip, classifyFileType, + IconRefreshOutlineRegular, IconWrapFillRegular, Menu, PathLabel, Tooltip, classifyFileType, } from '@deepseek-ai/dsh-client-ui-primitives' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { TextInjected } from './face.ts' @@ -35,46 +35,6 @@ 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, - text: RefObject, - 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]) -} - -/** The header's path: directories greyed, the final segment in full ink, faded when clipped. */ -function HeaderPath({ pathRef, pathTextRef, path }: { - pathRef: RefObject - pathTextRef: RefObject - path: string -}): ReactNode { - const { directory, name } = pathPartsOf(path) - return ( -
- - {directory !== '' && {directory}} - {name} - -
- ) -} - /** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */ export interface TextPreviewInjected extends TextInjected { readonly hooks: { readonly documentPreviews: ObservableSnapshot } @@ -129,14 +89,11 @@ export function TextPreview({ const bodyRef = useRef(null) const scrollportRef = useRef(null) const storedScrollTopRef = useRef(0) - const pathRef = useRef(null) - const pathTextRef = useRef(null) const [menuOpen, setMenuOpen] = useState(false) const absolutePath = meta.value?.absolutePath ?? current?.complete?.absolutePath const displayPath = absolutePath ?? file.path // Contributions that hand the file to the Host wait for its Host path. const fileOwner = absolutePath === undefined ? undefined : { absolutePath } - 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 @@ -243,7 +200,7 @@ export function TextPreview({ return (
- + {fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}
@@ -305,7 +262,7 @@ export function TextPreview({

)}
- + {candidates.length > 1 && ( { 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() - await settle() - const path = view.container.querySelector('[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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6e9d6331f..5e5b24cd58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3836,6 +3836,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 From 795e5024bc6f0da2f7b2c59e33906508a11aa64d Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Mon, 21 Sep 2026 15:05:43 +0800 Subject: [PATCH 4/4] fix(ui-deliverables): fill diff row backgrounds across scroll width --- apps/web/tests/changed-files-turn.e2e.ts | 34 +++++++++++++++++++ .../client/ui-deliverables/README.i18n.yaml | 4 +-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/ReviewTab.module.css | 3 +- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index b645d75d63..22b3edaee8 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -217,6 +217,40 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { return [center(text), center(caret), center(counts)].map(value => Math.abs(value - buttonCenter)) }) for (const offset of headerAlignment) expect(offset).toBeLessThanOrEqual(0.5) + // The appended row has no old-side text; constrain the columns so the hunk header overflows it. + const emptyRowLayout = await page.addStyleTag({ content: ` + [data-diff-side="left"] { width: 90px; } + [data-diff-side="right"] { width: 120px; } + ` }) + try { + const left = review.locator('[data-diff-side="left"]') + const emptyRow = left.locator('[data-diff-line="add"]') + const maximum = await left.evaluate(element => element.scrollWidth - element.clientWidth) + expect(maximum).toBeGreaterThan(0) + await left.evaluate((element) => { element.scrollLeft = element.scrollWidth }) + await expect.poll(() => left.evaluate(element => element.scrollLeft)).toBe(maximum) + const fill = await emptyRow.evaluate((row) => { + const column = row.closest('[data-diff-side]')! + const columnBox = column.getBoundingClientRect() + const rowBox = row.getBoundingClientRect() + const y = rowBox.top + rowBox.height / 2 + return { + rowWidth: rowBox.width, + contentWidth: column.scrollWidth, + background: getComputedStyle(row).backgroundColor, + coversViewport: [columnBox.left + 4, columnBox.right - 4].every(x => + document.elementFromPoint(x, y)?.closest('[data-diff-line]') === row), + } + }) + expect(fill.background).not.toBe('rgba(0, 0, 0, 0)') + expect(fill.rowWidth).toBeGreaterThanOrEqual(fill.contentWidth - 0.5) + expect(fill.coversViewport).toBe(true) + } finally { + await emptyRowLayout.evaluate(element => element.parentNode!.removeChild(element)) + await review.locator('[data-diff-side]').evaluateAll((elements) => { + for (const element of elements) element.scrollLeft = 0 + }) + } const addedLine = review.locator('[data-diff-side="right"] [data-diff-line="add"]') const rightContextLine = review.locator('[data-diff-side="right"] [data-diff-line="context"]') const addedRule = await addedLine.evaluate((line) => { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index dbc8d93e71..7a114571d5 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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-deliverables/README.md -README.md: d3f0d391380f6f4ae9c95fdc8c0b4e8c4b00654a -README.zh.md: 1c25199512b5c97f041954995cf45452e58706ff +README.md: c537911c8ff32239278059c067e86c5775aa0a12 +README.zh.md: 2a0c78ef2c741f65c37f31a53db82c9e7a523853 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index d3f0d39138..c537911c8f 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -46,7 +46,7 @@ A row opens the turn's `changes-review` tab, addressed by the viewed Session and The selected-file title uses the document preview’s [`PathLabel`](../ui-primitives/README.md#component-catalog): complete and left-aligned when it fits, otherwise clipped at the left with a fade that preserves trailing characters and the extension. Directories use subdued text, the filename uses primary text, and hovering reveals the complete displayed path. Resizing the pane or selecting another file updates the fade; the dropdown arrow, line counts, and toolbar actions retain their space. -Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Both columns suppress elastic edge feedback and scroll chaining on both axes; the browser retains control of native momentum within each column’s scrollable range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. +Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Line backgrounds, including empty alignment rows, cover each column’s full scrollable width. Both columns suppress elastic edge feedback and scroll chaining on both axes; the browser retains control of native momentum within each column’s scrollable range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. ### Inline-code links diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 1c25199512..2a0c78ef2c 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -46,7 +46,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 当前文件标题使用文档预览页的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog):空间足够时完整靠左显示,否则从左侧裁剪并渐隐,保留尾部字符和扩展名。目录使用弱化颜色,文件名使用主色,悬停显示完整路径。调整面板宽度或切换文件时更新渐隐;下拉箭头、行数和工具栏操作保留各自的空间。 -关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两列在双轴上都禁用弹性边缘反馈和向外层传递滚动;各列可滚动范围内的原生惯性仍由浏览器控制。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 +关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。各行背景(包括用于对齐的空行)覆盖所在列的完整可滚动宽度。两列在双轴上都禁用弹性边缘反馈和向外层传递滚动;各列可滚动范围内的原生惯性仍由浏览器控制。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 ### 行内代码链接 diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index ae2c996cbb..a62d52e8fb 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -24,7 +24,8 @@ .status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } .body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } .columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; } -.column { min-width: 0; overflow-x: scroll; overscroll-behavior: none; } +/* One intrinsic-width track keeps every hunk and empty row painted across the scrollable content. */ +.column { display: grid; grid-template-columns: minmax(max-content, 100%); align-content: start; min-width: 0; overflow-x: scroll; overscroll-behavior: none; } .column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); } .sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; } .note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); }