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