Merge pull request #4798 from deepseek-harness/fix/4670-review-tab-scroll-and-filename
fix(ui-deliverables): prevent edge rebound and preserve filename endings
This commit is contained in:
commit
c63e174b7b
31 files changed
+403
-243
No files matched your search
@@ -34,6 +34,7 @@
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
|
||||
@@ -5,8 +5,9 @@ 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 type { ChangesSummary } from '@deepseek-ai/dsh-client-ui-deliverables/src/changes.ts'
|
||||
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import {
|
||||
assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
|
||||
@@ -80,9 +81,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"]')
|
||||
@@ -307,6 +309,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) => {
|
||||
@@ -446,9 +482,154 @@ 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)
|
||||
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 previewTripwire = watchConsole(preview)
|
||||
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')
|
||||
expect(previewTripwire.pageErrors).toEqual([])
|
||||
expect(previewTripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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: 7bf55c474250b8e389a1767092be0f82b4073222
|
||||
README.zh.md: d8b1fc368487d96091b400c6034d159989352192
|
||||
README.md: 7611e613073457c7843cc2a969e2f295fc204bbf
|
||||
README.zh.md: 15162229dc4adc5c0143cd141481887a5c996be7
|
||||
@@ -44,7 +44,9 @@ 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.
|
||||
The selected-file title uses the shared `ui-primitives` [`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. 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
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ 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 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。
|
||||
|
||||
关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。
|
||||
当前文件标题使用 `ui-primitives` 的共享组件 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog):空间足够时完整靠左显示,否则从左侧裁剪并渐隐,保留尾部字符和扩展名。目录使用弱化颜色,文件名使用主色,悬停显示完整路径。调整面板宽度或切换文件时更新渐隐;下拉箭头、行数和工具栏操作保留各自的空间。
|
||||
|
||||
关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。各行背景(包括用于对齐的空行)覆盖所在列的完整可滚动宽度。两列在双轴上都禁用弹性边缘反馈和向外层传递滚动;各列可滚动范围内的原生惯性仍由浏览器控制。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。
|
||||
|
||||
### 行内代码链接
|
||||
|
||||
|
||||
@@ -5,7 +5,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; }
|
||||
/* 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); }
|
||||
|
||||
@@ -167,6 +167,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])
|
||||
|
||||
@@ -5,7 +5,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); }
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular,
|
||||
IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip,
|
||||
IconNowrapFillRegular, IconWrapFillRegular, Menu, PathLabel, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
@@ -117,7 +117,7 @@ export function ReviewTab({
|
||||
anchor={<button type="button" className={css.selectorButton} aria-haspopup="menu" aria-expanded={menuOpen}
|
||||
aria-label={t('review.selectFile')} title={file.display} data-review-file={file.path}
|
||||
onClick={() => { setMenuOpen(value => !value) }}>
|
||||
<span className={css.selectorText}>{file.display}</span>
|
||||
<PathLabel path={file.display} />
|
||||
<IconChevronDownOutlineRegular size={12} />
|
||||
</button>}
|
||||
items={files.map((entry, at) => ({ id: String(at), label: <span className={css.item}>
|
||||
|
||||
@@ -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: 7bba90f3e263c3e073c5478feb3d7dd4777d04a7
|
||||
README.zh.md: ceee6cb649672e5e47dc8325d1f277fa75953823
|
||||
README.md: 7dafaf759ac329a4162b7138e58ed13ca5afd651
|
||||
README.zh.md: db38df0a298f567287e9cc99e623287465213c74
|
||||
@@ -45,6 +45,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 whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. |
|
||||
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
|
||||
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
|
||||
|
||||
@@ -45,6 +45,7 @@ kind: "package-library"
|
||||
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
||||
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
|
||||
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
|
||||
| `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 |
|
||||
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 |
|
||||
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
|
||||
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
|
||||
|
||||
@@ -39,6 +39,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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<HTMLAttributes<HTMLSpanElement>, 'children' | 'title'>) {
|
||||
const boxRef = useRef<HTMLSpanElement>(null)
|
||||
const textRef = useRef<HTMLSpanElement>(null)
|
||||
const { directory, name } = pathPartsOf(path)
|
||||
useLayoutEffect(() => {
|
||||
// Both spans mount unconditionally before this layout effect runs.
|
||||
const outer = boxRef.current as HTMLSpanElement
|
||||
const inner = textRef.current as HTMLSpanElement
|
||||
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 (
|
||||
<span {...attributes} ref={boxRef} className={clsx(css.path, className)} title={path} data-path-label>
|
||||
<span ref={textRef} className={css.text}>
|
||||
{directory !== '' && <span className={css.directory}>{directory}</span>}
|
||||
<span className={css.name}>{name}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export { SegmentedTabs } from './SegmentedTabs.tsx'
|
||||
export type { SegmentedTab } from './SegmentedTabs.tsx'
|
||||
export { Tag } from './Tag.tsx'
|
||||
export type { TagTone } from './Tag.tsx'
|
||||
export { PathLabel } from './PathLabel.tsx'
|
||||
export { Switch } from './Switch.tsx'
|
||||
export { SegmentedControl } from './SegmentedControl.tsx'
|
||||
export type { SegmentedControlOption } from './SegmentedControl.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(<PathLabel path={path} data-testid="path" className="placement" />)
|
||||
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(<PathLabel path="src/filename.ts" />)
|
||||
const path = view.container.querySelector<HTMLElement>('[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(<PathLabel path="a.ts" />)
|
||||
const label = view.container.querySelector<HTMLElement>('[data-path-label]')!
|
||||
expect(label.hasAttribute('data-path-clipped')).toBe(false)
|
||||
view.rerender(<PathLabel path="src/long-filename-before.ts" />)
|
||||
expect(label.hasAttribute('data-path-clipped')).toBe(true)
|
||||
expect(label.title).toBe('src/long-filename-before.ts')
|
||||
view.rerender(<PathLabel path="src/long-filename-after.ts" />)
|
||||
expect(label.textContent).toBe('src/long-filename-after.ts')
|
||||
view.rerender(<PathLabel path="b.ts" />)
|
||||
expect(label.hasAttribute('data-path-clipped')).toBe(false)
|
||||
expect(label.title).toBe('b.ts')
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,9 @@
|
||||
"references": [
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../../util/workspace-path"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: 67136b8e5ff81dee26905be7734c981f18fc3b0a
|
||||
README.zh.md: 9686863b9d265e7902f47948e5d73476bd023726
|
||||
README.md: b82a1ca3179c6a7bb6bf9a67369a4e877d229ed3
|
||||
README.zh.md: 4313b815c01c1d2d0d9b9669a2fb6274b0ca61f8
|
||||
@@ -29,7 +29,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.
|
||||
|
||||
@@ -29,7 +29,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 和未匹配的扩展名保留纯文本回退,与加载方式无关。
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<HTMLDivElement | null>,
|
||||
text: RefObject<HTMLSpanElement | null>,
|
||||
path: string,
|
||||
shown: boolean,
|
||||
): void {
|
||||
useLayoutEffect(() => {
|
||||
const outer = box.current
|
||||
const inner = text.current
|
||||
if (outer === null || inner === null) return undefined
|
||||
const apply = (): void => {
|
||||
if (inner.offsetWidth > outer.clientWidth) outer.dataset.textpreviewPathClipped = ''
|
||||
else delete outer.dataset.textpreviewPathClipped
|
||||
}
|
||||
apply()
|
||||
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply)
|
||||
observer?.observe(outer)
|
||||
observer?.observe(inner)
|
||||
return () => { observer?.disconnect() }
|
||||
}, [box, text, path, shown])
|
||||
}
|
||||
|
||||
/** The header's path: directories greyed, the final segment in full ink, faded when clipped. */
|
||||
function HeaderPath({ pathRef, pathTextRef, path }: {
|
||||
pathRef: RefObject<HTMLDivElement>
|
||||
pathTextRef: RefObject<HTMLSpanElement>
|
||||
path: string
|
||||
}): ReactNode {
|
||||
const { directory, name } = pathPartsOf(path)
|
||||
return (
|
||||
<div ref={pathRef} className={css.path} title={path} data-textpreview-path>
|
||||
<span ref={pathTextRef} className={css.pathText}>
|
||||
{directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
|
||||
<span className={css.pathName}>{name}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */
|
||||
export interface TextPreviewInjected extends TextInjected {
|
||||
readonly hooks: { readonly documentPreviews: ObservableSnapshot<readonly DocumentPreviewDefinition[]> }
|
||||
@@ -129,14 +89,11 @@ export function TextPreview({
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollportRef = useRef<HTMLElement | null>(null)
|
||||
const storedScrollTopRef = useRef(0)
|
||||
const pathRef = useRef<HTMLDivElement | null>(null)
|
||||
const pathTextRef = useRef<HTMLSpanElement | null>(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 (
|
||||
<div className={css.preview} data-textpreview-state="unsupported" data-textpreview-url={tab.contentId}>
|
||||
<div className={css.header}>
|
||||
<HeaderPath pathRef={pathRef} pathTextRef={pathTextRef} path={displayPath} />
|
||||
<PathLabel path={displayPath} className={css.path} data-textpreview-path />
|
||||
{fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}
|
||||
</div>
|
||||
<div className={css.body} data-textpreview-body>
|
||||
@@ -305,7 +262,7 @@ export function TextPreview({
|
||||
</p>
|
||||
)}
|
||||
<div className={css.header}>
|
||||
<HeaderPath pathRef={pathRef} pathTextRef={pathTextRef} path={displayPath} />
|
||||
<PathLabel path={displayPath} className={css.path} data-textpreview-path />
|
||||
{candidates.length > 1
|
||||
&& (
|
||||
<Menu
|
||||
|
||||
@@ -146,56 +146,6 @@ describe('TextPreview — pages', () => {
|
||||
expect(view.container.querySelector('[data-textpreview-path]')?.getAttribute('title')).toBe(ABSOLUTE_PATH)
|
||||
})
|
||||
|
||||
it('marks the path clipped while its text is wider than its box, re-reading on resize', async () => {
|
||||
class FakeResizeObserver implements ResizeObserver {
|
||||
static latest: FakeResizeObserver | undefined
|
||||
readonly observe = vi.fn()
|
||||
readonly unobserve = vi.fn()
|
||||
readonly disconnect = vi.fn()
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
FakeResizeObserver.latest = this
|
||||
}
|
||||
|
||||
fire(): void {
|
||||
this.callback([], this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
||||
let boxWidth = 300
|
||||
const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
|
||||
const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth })
|
||||
try {
|
||||
const h = harness({ 1: page(1, ['one'], true) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
const path = view.container.querySelector<HTMLElement>('[data-textpreview-path]')
|
||||
const text = path?.firstElementChild
|
||||
expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false)
|
||||
const observer = FakeResizeObserver.latest
|
||||
if (observer === undefined) throw new Error('expected the path to observe its size')
|
||||
expect(observer.observe).toHaveBeenCalledWith(path)
|
||||
expect(observer.observe).toHaveBeenCalledWith(text)
|
||||
|
||||
boxWidth = 120
|
||||
act(() => { observer.fire() })
|
||||
expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(true)
|
||||
|
||||
boxWidth = 300
|
||||
act(() => { observer.fire() })
|
||||
expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false)
|
||||
view.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
for (const [name, descriptor] of [['offsetWidth', offsetWidth], ['clientWidth', clientWidth]] as const) {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
else Object.defineProperty(HTMLElement.prototype, name, descriptor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the first page on first mount and draws its lines, offering the next', async () => {
|
||||
const h = harness({ 1: page(1, ['one', 'two', 'three'], false) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-files/README.md
|
||||
README.md: 491072c1daa95dc33cb268e7c825a95d738fae7c
|
||||
README.zh.md: eceffb1c3ac0f7ba0437b23ee9e1ed6f905f5369
|
||||
README.md: 0340991f16a61b80cd616982c6a6bea6ff60beac
|
||||
README.zh.md: 016c74d801a3582785cb600f493eaeb92f7567b9
|
||||
@@ -25,7 +25,7 @@ Browse a Session's workspace tree and open files in Sidebar previews. The root a
|
||||
## What it registers
|
||||
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the visible reload control at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The shared [`PathLabel`](../ui-primitives/README.md#component-catalog) displays the root path with subdued directories and a primary final segment. A clipped path retains its trailing characters with a left-edge fade; hovering reveals the full path. The reload control stays at its right.
|
||||
- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet.
|
||||
|
||||
Source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (Remote reads and watches), `directory-node.ts` (open directories and their lifetimes), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring).
|
||||
@@ -33,7 +33,7 @@ Source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what
|
||||
<a id="the-tree"></a>
|
||||
## The tree
|
||||
|
||||
The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
|
||||
The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
|
||||
|
||||
| Entry type | Row |
|
||||
|---|---|
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
## 注册了什么
|
||||
|
||||
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。
|
||||
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端显示重新读取控件。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。
|
||||
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 显示根路径,目录使用弱化颜色,最后一段使用主色。路径过长时保留尾部字符并在左侧渐隐;悬停显示完整路径。重新读取控件位于右端。
|
||||
- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。
|
||||
|
||||
`src/client/` 下的源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(Remote 读取与监听)、`directory-node.ts`(打开的目录及其生命周期)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
|
||||
@@ -33,7 +33,7 @@ kind: "package-reference"
|
||||
<a id="the-tree"></a>
|
||||
## 树
|
||||
|
||||
根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
|
||||
根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
|
||||
|
||||
| 条目类型 | 行 |
|
||||
|---|---|
|
||||
|
||||
@@ -12,13 +12,7 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The header row, its path, and its control are the text preview's
|
||||
(ui-sidebar-documentpreview TextPreview `.header`, `.path`, `.tool`), copied so
|
||||
the two pane types read as one; keep the two sheets in step. TODO: once the
|
||||
artifact and slot surfaces settle, one copy in ui-primitives could serve
|
||||
every pane header.
|
||||
|
||||
One 38px row flush under the pane's strip: the root's path, then reload at
|
||||
/* One 38px row flush under the pane's strip: the root's path, then reload at
|
||||
its end. With the strip's 38px above, its rule lies where the conversation
|
||||
header's does (ui-conversation ConversationRoot `.header`, 76px with the
|
||||
same rule). */
|
||||
@@ -33,38 +27,9 @@
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* The path is never ellipsized. Its text sits at the left while it fits: the
|
||||
auto margin takes the free space. Once it is wider than the box, the auto
|
||||
margin is zero and `flex-end` holds the name at the right edge, so the
|
||||
directories run off the left and the box clips them there, faded by the
|
||||
mask while `data-files-path-clipped` is set. The margin plus the row's gap
|
||||
put the text 16px clear of the first control. */
|
||||
/* The margin plus the header's gap keeps the path 16px clear of reload. */
|
||||
.path {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
margin-right: 12px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path[data-files-path-clipped] {
|
||||
mask-image: linear-gradient(to right, transparent, black 28px);
|
||||
}
|
||||
|
||||
.pathText {
|
||||
flex: none;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.pathDirectory {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pathName {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
@@ -5,20 +5,19 @@
|
||||
* for goes through its injected face. The component itself only decides what to
|
||||
* draw for each absolute path and what a click means: a directory toggles, a
|
||||
* file opens through the owner's `tabActions` for a `file:` viewer to claim, and
|
||||
* anything else is shown but refuses to open. The header row is the text
|
||||
* preview's: the root's path, directories greyed and the last segment in full
|
||||
* ink, then reload, which refreshes the expanded directories in place.
|
||||
* anything else is shown but refuses to open. The header uses the shared
|
||||
* PathLabel for the root, followed by reload for the expanded directories.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, classifyFileType,
|
||||
IconPauseOutlineRegular, IconPlayOutlineRegular,
|
||||
IconPauseOutlineRegular, IconPlayOutlineRegular, PathLabel,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types'
|
||||
import { childPath } from './face.ts'
|
||||
import type { FilesInjected } from './face.ts'
|
||||
@@ -67,39 +66,6 @@ export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailu
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- the header row is the document preview's (ui-sidebar-documentpreview
|
||||
TextPreview `usePathClipped`), copied because a plugin bundle shares runtime code
|
||||
only through the platform modules. TODO: once the artifact and slot surfaces
|
||||
settle, one copy in ui-primitives could serve every pane header. */
|
||||
/**
|
||||
* Keep the path row's `data-files-path-clipped` current: set while the path's
|
||||
* text is wider than its box, so the stylesheet fades the clipped start. Read
|
||||
* after each commit that can change the path or mount the header, and whenever
|
||||
* either box resizes; written to the DOM directly because it changes only how
|
||||
* the stylesheet fades what is already rendered.
|
||||
*/
|
||||
function usePathClipped(
|
||||
box: RefObject<HTMLDivElement | null>,
|
||||
text: RefObject<HTMLSpanElement | null>,
|
||||
path: string | undefined,
|
||||
): void {
|
||||
useLayoutEffect(() => {
|
||||
const outer = box.current
|
||||
const inner = text.current
|
||||
if (outer === null || inner === null) return undefined
|
||||
const apply = (): void => {
|
||||
if (inner.offsetWidth > outer.clientWidth) outer.dataset.filesPathClipped = ''
|
||||
else delete outer.dataset.filesPathClipped
|
||||
}
|
||||
apply()
|
||||
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply)
|
||||
observer?.observe(outer)
|
||||
observer?.observe(inner)
|
||||
return () => { observer?.disconnect() }
|
||||
}, [box, text, path])
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** What every level shares: the tab's tree and the two gestures. */
|
||||
interface TreeContext {
|
||||
readonly state: FilesTabState
|
||||
@@ -175,11 +141,8 @@ export function FilesBody({
|
||||
const { signal, actions: tabActions } = tab
|
||||
const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd)
|
||||
const state = useStore(store => store.byTab[tab.id])
|
||||
const pathRef = useRef<HTMLDivElement>(null)
|
||||
const pathTextRef = useRef<HTMLSpanElement>(null)
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const scrollTopRef = useRef(0)
|
||||
usePathClipped(pathRef, pathTextRef, state?.root)
|
||||
// Come back where the reader was: loaded levels outlive the body in the
|
||||
// store, so a remounted tree lays out at its full height before this runs
|
||||
// and the stored offset re-lands exactly. A fresh tree stores 0.
|
||||
@@ -222,17 +185,10 @@ export function FilesBody({
|
||||
const reload = (): void => {
|
||||
refresh(tab.id)
|
||||
}
|
||||
const { directory, name } = pathPartsOf(state.root)
|
||||
return (
|
||||
<div className={css.root} data-files-state="tree" data-files-root={state.root}>
|
||||
{/* jscpd:ignore-start -- the text preview's header row; see `usePathClipped`. */}
|
||||
<div className={css.header}>
|
||||
<div ref={pathRef} className={css.path} title={state.root} data-files-path>
|
||||
<span ref={pathTextRef} className={css.pathText}>
|
||||
{directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
|
||||
<span className={css.pathName}>{name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<PathLabel path={state.root} className={css.path} data-files-path />
|
||||
<span hidden>
|
||||
<button type="button" className={css.tool} aria-label={t('autoRefresh')}
|
||||
aria-pressed={state.autoRefresh} data-files-auto-refresh
|
||||
@@ -252,7 +208,6 @@ export function FilesBody({
|
||||
<IconRefreshOutlineRegular />
|
||||
</button>
|
||||
</div>
|
||||
{/* jscpd:ignore-end */}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.body}
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('FilesBody', () => {
|
||||
expect(view.container.querySelector('[data-files-state="tree"]')?.getAttribute('data-files-root')).toBe(ROOT)
|
||||
const path = view.container.querySelector('[data-files-path]')
|
||||
expect(path?.getAttribute('title')).toBe(ROOT)
|
||||
expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/work/', 'app'])
|
||||
expect([...path?.firstElementChild?.children ?? []].map(span => span.textContent)).toEqual(['/work/', 'app'])
|
||||
expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`])
|
||||
const envIcon = view.container.querySelector(`[data-files-path="${ROOT}/.env"] svg`)?.innerHTML
|
||||
const readmeIcon = view.container.querySelector(`[data-files-path="${ROOT}/README.md"] svg`)?.innerHTML
|
||||
@@ -56,7 +56,7 @@ describe('FilesBody', () => {
|
||||
await act(() => script.watches.ready('/'))
|
||||
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
|
||||
const path = view.container.querySelector('[data-files-path]')
|
||||
expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/'])
|
||||
expect([...path?.firstElementChild?.children ?? []].map(span => span.textContent)).toEqual(['/'])
|
||||
expect(names(view.container)).toEqual(['/src', '/.env', '/pipe', '/README.md'])
|
||||
})
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('FilesBody', () => {
|
||||
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
|
||||
const path = view.container.querySelector<HTMLElement>('[data-files-path]')
|
||||
const text = path?.firstElementChild
|
||||
expect(path?.hasAttribute('data-files-path-clipped')).toBe(false)
|
||||
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)
|
||||
@@ -94,11 +94,11 @@ describe('FilesBody', () => {
|
||||
|
||||
boxWidth = 120
|
||||
act(() => { observer.fire() })
|
||||
expect(path?.hasAttribute('data-files-path-clipped')).toBe(true)
|
||||
expect(path?.hasAttribute('data-path-clipped')).toBe(true)
|
||||
|
||||
boxWidth = 300
|
||||
act(() => { observer.fire() })
|
||||
expect(path?.hasAttribute('data-files-path-clipped')).toBe(false)
|
||||
expect(path?.hasAttribute('data-path-clipped')).toBe(false)
|
||||
view.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
|
||||
Generated
+6
@@ -671,6 +671,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-deliverables':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-deliverables
|
||||
'@deepseek-ai/dsh-client-ui-dockkit':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client/ui-dockkit
|
||||
@@ -4053,6 +4056,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:^
|
||||
version: link:../store
|
||||
'@deepseek-ai/dsh-util-workspace-path':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/workspace-path
|
||||
'@shikijs/langs':
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1
|
||||
|
||||
Reference in new issue
Block a user