feat(web): unify code block styling and icon controls

This commit is contained in:
yixiangihsiang committed 2026-09-21 21:57:35 +08:00
1 parent b13bbc027c
commit 8d2cd0cf72
41 files changed
+449 -193

No files matched your search

+6 -1
View File
@@ -797,7 +797,12 @@ else process.exit(1);
})).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true })
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
await page.evaluate(() => navigator.clipboard.writeText(''))
await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()
const copyCode = codeBlock.getByRole('button', { name: 'Copy', exact: true })
expect(await codeBlock.getByRole('button').count()).toBe(1)
expect(await copyCode.textContent()).toBe('')
await copyCode.hover()
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor()
await copyCode.click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n'))
sections.push([
'## Code paging', '',
@@ -32,6 +32,8 @@
- text: Edit
- button "src/tokens.css"
- text: +1 -1
- text: css
- button "Wrap lines"
- button "Copy"
- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file"
- button "Inspect"
@@ -43,7 +45,8 @@
- button "Read docs/guide.md" [expanded]:
- text: Read
- button "docs/guide.md"
- text: docs/guide.md
- text: Code block docs/guide.md
- button "Wrap lines"
- button "Copy"
- text: "# Link style guide guide line 2 guide line 3 guide line 4"
- button "Expand 4 more lines": … 4 more lines
@@ -134,6 +137,7 @@
- paragraph:
- img "Token preview"
- text: css
- button "Do not wrap lines" [pressed]
- button "Copy"
- code: "--inline-code: #F5F5F5;"
- paragraph: LINK_GALLERY_DONE
@@ -0,0 +1,4 @@
- text: Code block
- button "Do not wrap lines" [pressed]
- button "Copy"
- code: echo WATERFALL
+23
View File
@@ -27,6 +27,7 @@ const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const CODE_CARD_EXPECTED = fileURLToPath(new URL('./expected/navigation-panes/code-card.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
@@ -218,6 +219,28 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
}, 90_000)
it.skipIf(MODE === 'record')('renders recorded Markdown with icon tooltips and switchable wrapping', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-code-card'))
await ensureSeedOpen(page)
const selector = '.md-code-block:has-text("echo WATERFALL")'
const block = page.locator(selector)
await block.waitFor({ timeout: 15_000 })
await compareOrRefreshGolden(CODE_CARD_EXPECTED, await captureStableAria(page, selector, scaffold.workspaceCwd), MODE)
const code = block.locator('pre')
const source = await code.textContent()
const copy = block.getByRole('button', { name: 'Copy', exact: true })
await copy.hover()
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor()
const unwrap = block.getByRole('button', { name: 'Do not wrap lines', exact: true })
await unwrap.hover()
await page.getByRole('tooltip', { name: 'Do not wrap lines', exact: true }).waitFor()
await unwrap.click()
await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre')
await block.getByRole('button', { name: 'Wrap lines', exact: true }).click()
await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap')
expect(await code.textContent()).toBe(source)
}, 60_000)
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
await ensureSeedOpen(page)
+3
View File
@@ -7,6 +7,9 @@ export const en = {
'close': 'Close',
'copy': 'Copy',
'copied': 'Copied',
'codeBlock.title': 'Code block',
'codeBlock.wrap': 'Wrap lines',
'codeBlock.unwrap': 'Do not wrap lines',
'copy.failed': 'Copy failed',
'copy.value': 'Copy value',
'copy.json': 'Copy JSON',
+3
View File
@@ -5,6 +5,9 @@ export const zh = {
'close': '关闭',
'copy': '复制',
'copied': '复制成功',
'codeBlock.title': '代码块',
'codeBlock.wrap': '自动换行',
'codeBlock.unwrap': '取消自动换行',
'copy.failed': '复制失败',
'copy.value': '复制值',
'copy.json': '复制 JSON',
@@ -1,6 +1,7 @@
/** Read-only help stays local to Settings and never changes the selected preset. */
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { KeyboardEvent, ReactNode } from 'react'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, IconCloseOutlineRegular, IconListPenOutlineRegular, MarkdownText, Modal, SegmentedTabs, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentPresetSettingsKey } from './locales.ts'
import css from './PresetGuideDialog.module.css'
@@ -35,10 +36,10 @@ export function presetGuide(id: string, trust: string): PresetGuide | undefined
/** Curated usage dictionaries contain only level-three example sections. */
function GuideUsage({ text, t }: {
text: string
t: (key: AgentPresetSettingsKey) => string
t: TranslateNS<'settings.agentPreset'>
}): ReactNode {
const labels = {
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('guideFootnotes'),
}
return text.split(/(?=^### )/m).map((section) => {
@@ -67,7 +68,7 @@ function GuideUsage({ text, t }: {
export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
guide: PresetGuide
initialPage: PresetGuidePage
t: (key: AgentPresetSettingsKey) => string
t: TranslateNS<'settings.agentPreset'>
onClose: () => void
}): ReactNode {
const [page, setPage] = useState(initialPage)
@@ -144,7 +145,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
<MarkdownText
text={t(guide.explanation)}
labels={{
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('guideFootnotes'),
}}
/>
@@ -10,7 +10,7 @@ import type { ChatViewSlotProps } from './contract/slots.ts'
*/
export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('markdown.footnotes'),
}
}
@@ -22,7 +22,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
const params = tab.tab.navigation.params
const plan = temporary ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value
const labels = useMemo(() => ({
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('markdown.footnotes'),
}), [t])
if (plan === undefined) return (
@@ -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: 7dafaf759ac329a4162b7138e58ed13ca5afd651
README.zh.md: db38df0a298f567287e9cc99e623287465213c74
README.md: 069aea78dd08175a9ebb1e590bb03e97dc08daab
README.zh.md: d235e19a9f9c829d4d5d6587fdd147fda9a2884e
+2
View File
@@ -87,6 +87,8 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
`CodeBlock.toolbarLabels` enables the shared code-card header and spacing. `ReadBlock` and `DiffBlock` use the same header: secondary-colored language text, copy and wrap icons, and hover or keyboard-focus tooltips. Missing or unsupported languages use the owner’s localized code-block title; mixed-language diffs use that title too. Code cards share typography and content insets. Wrapping is local to each mounted card and never changes copied text; code fences start wrapped, while reads and diffs preserve source columns until wrapping is enabled. Compact Markdown omits this toolbar. With `toolbarLabels`, source previews can pass `wrap` to follow their owner’s preference and show only the language and copy icon; owner CSS retains the source layout.
`DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from both summary and footer totals. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed.
`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
@@ -87,6 +87,8 @@ kind: "package-library"
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
`CodeBlock.toolbarLabels` 启用共享代码卡片的标题栏和间距。`ReadBlock` 与 `DiffBlock` 使用相同的标题栏:secondary 色的语言名称、复制与换行图标,以及悬停或键盘聚焦时的提示。语言缺失或不受支持时显示调用方提供的本地化代码块标题;混合语言的 Diff 也使用该标题。代码卡片共用字体和内容内边距。换行设置仅属于当前挂载的卡片,不改变复制的文本;代码围栏默认换行,读取和差异卡片则在启用换行前保留源码列位置。紧凑 Markdown 不使用此工具栏。源码预览传入 `toolbarLabels` 后,可用 `wrap` 跟随所属视图的换行设置,仅显示语言和复制图标;所属视图的 CSS 保留源码布局。
`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
@@ -0,0 +1,81 @@
.card {
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: 12px;
font: var(--dsw-font-markdown-code-block);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 18px 4px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
color: var(--dsw-alias-label-secondary);
font: 11px/18px var(--dsw-font-family);
}
.heading {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.language {
flex: none;
font-family: var(--ds-font-family-code);
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
align-items: center;
gap: 4px;
flex: none;
}
.status {
margin-right: 8px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 6px;
color: var(--dsw-alias-label-secondary);
background: transparent;
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.action:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.body {
padding: 6px 18px 16px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
}
@@ -0,0 +1,62 @@
/** Shared language, wrapping, and clipboard controls for code cards. */
import {
IconCheckOutlineRegular, IconCopyOutlineRegular, IconNowrapFillRegular, IconWrapFillRegular,
} from './icons/index.tsx'
import { Tooltip } from './Tooltip.tsx'
import { supportsHighlighting } from './markdown/highlight.ts'
import css from './CodeCard.module.css'
/** Localized language fallback and wrapping actions supplied by the card owner. */
export interface CodeToolbarLabels {
/** Title for an absent or unsupported language. */
codeLabel: string
/** Action that enables wrapping. */
wrapLabel: string
/** Action that preserves source columns with horizontal scrolling. */
unwrapLabel: string
}
/** Display state and callbacks for a code card's toolbar. */
interface CodeToolbarProps {
lang?: string | undefined
title?: string | undefined
status?: string | undefined
labels: CodeToolbarLabels
copyLabel: string
copiedLabel: string
copied: boolean
wrapped: boolean
onCopy?: (() => void) | undefined
onWrap?: (() => void) | undefined
}
/**
* Render a language label and keyboard-accessible icon actions with tooltips.
* @param props - Localized labels, current state, and card-owned actions.
* @returns The shared code-card header.
*/
export function CodeToolbar({ lang, title, status, labels, copyLabel, copiedLabel, copied, wrapped, onCopy, onWrap }: CodeToolbarProps) {
const wrapLabel = wrapped ? labels.unwrapLabel : labels.wrapLabel
const clipboardLabel = copied ? copiedLabel : copyLabel
return (
<div className={css.header} data-code-block-banner>
<div className={css.heading}>
<span className={css.language}>{supportsHighlighting(lang) ? lang : labels.codeLabel}</span>
{title !== undefined && <span className={css.title} title={title}>{title}</span>}
</div>
<div className={css.actions}>
{status !== undefined && <span className={css.status}>{status}</span>}
{onWrap !== undefined && <Tooltip label={wrapLabel} side="top" portal>
<button type="button" className={css.action} aria-label={wrapLabel} aria-pressed={wrapped} onClick={onWrap}>
{wrapped ? <IconNowrapFillRegular size={14} /> : <IconWrapFillRegular size={14} />}
</button>
</Tooltip>}
{onCopy !== undefined && <Tooltip label={clipboardLabel} side="top" portal>
<button type="button" className={css.action} aria-label={clipboardLabel} onClick={onCopy}>
{copied ? <IconCheckOutlineRegular size={14} /> : <IconCopyOutlineRegular size={14} />}
</button>
</Tooltip>}
</div>
</div>
)
}
@@ -1,79 +1,52 @@
.block {
--dsl-diff-radius: 12px;
--dsl-diff-line-height: 22px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-diff-radius);
}
/* The copy control floats in the top-right corner over the body, so the card
has no empty banner row above its first diff line (the TUI diff card has no
banner either — only the footer). The block is position: relative, so this
anchors to the card. */
.copyButton {
position: absolute;
top: 8px;
right: 12px;
z-index: 1;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
overflow: hidden;
}
.body {
padding: 12px 14px;
padding: 6px 0 16px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping, no word-break: a diff is read by its indentation. */
.line {
min-height: var(--dsl-diff-line-height);
box-sizing: border-box;
min-height: 1lh;
padding: 0 18px;
white-space: pre;
}
/* A file header: the path in the primary tone, set apart by weight. The copy
button floats over this first row's top-right corner, so reserve space at the
line's end for it — a long path scrolls under the button otherwise, and the
button's hit area would eat clicks on the path's tail. */
.block[data-code-wrap='true'] .line {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.path {
color: var(--dsw-alias-label-primary);
font-weight: 600;
padding-right: 56px;
color: var(--dsw-alias-label-secondary);
font-weight: 500;
}
/* A same-file second hunk's separator (a scattered edit), in the dim tone. */
.gap {
color: var(--dsw-alias-label-tertiary);
}
/* The diff's own meaning-carrying colors: removed on the error token, added on
the success token. A `- `/`+ ` prefix is drawn here so a copied line and the
shown line agree, and so the sign reads without relying on color alone. */
.del::before {
content: '- ';
color: var(--dsw-alias-state-error-primary);
}
.del {
color: var(--dsw-alias-state-error-primary);
background: var(--dsw-alias-code-diff-deleted);
box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary);
}
.add::before {
content: '+ ';
color: var(--dsw-alias-state-success-primary);
}
.add {
color: var(--dsw-alias-state-success-primary);
background: var(--dsw-alias-code-diff-added);
box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary);
}
.context {
@@ -87,9 +60,9 @@
.expand {
display: block;
width: 100%;
padding: 0;
padding: 0 18px;
border: none;
background-color: transparent;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
@@ -100,10 +73,8 @@
color: var(--dsw-alias-label-secondary);
}
/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same
footer the TUI transcript's diff card draws. */
.footer {
padding: 0 14px 12px;
padding: 0 18px 16px;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}
@@ -3,6 +3,9 @@ import clsx from 'clsx'
import { structuredPatch } from 'diff'
import { FoldToggle } from './FoldToggle.tsx'
import { writeClipboard } from './clipboard.ts'
import { CodeToolbar, type CodeToolbarLabels } from './CodeToolbar.tsx'
import { languageForPath } from './code-highlighting.ts'
import cardCss from './CodeCard.module.css'
import css from './DiffBlock.module.css'
/** Output lines shown before the height cap collapses the middle. */
@@ -33,7 +36,7 @@ export interface DiffBlockProps {
}
/** Localized chrome for {@link DiffBlock}. */
export interface DiffBlockLabels {
export interface DiffBlockLabels extends CodeToolbarLabels {
copy: string
copied: string
collapseAria: string
@@ -174,6 +177,9 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const [wrapped, setWrapped] = useState(false)
const firstLanguage = diffs[0] === undefined ? undefined : languageForPath(diffs[0].path)
const language = diffs.every(diff => languageForPath(diff.path) === firstLanguage) ? firstLanguage : undefined
const onCopy = useCallback(() => {
if (copied) return
@@ -198,10 +204,9 @@ export function DiffBlock({ diffs, labels, maxLines = DEFAULT_DIFF_MAX_LINES, cl
const tail = capped ? rows.slice(rows.length - tailLines) : []
return (
<div className={clsx(css.block, className)} data-diff="">
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? labels.copied : labels.copy}
</button>
<div className={clsx(cardCss.card, css.block, className)} data-diff="" data-code-wrap={wrapped}>
<CodeToolbar lang={language} labels={labels} copyLabel={labels.copy} copiedLabel={labels.copied}
copied={copied} wrapped={wrapped} onCopy={onCopy} onWrap={() => { setWrapped(value => !value) }} />
<div className={css.body}>
{head.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
@@ -1,105 +1,41 @@
.block {
--dsl-read-radius: 12px;
--dsl-read-line-height: 22px;
/* Fixed-width gutter column for the line numbers, so the content edge stays
put down the whole window regardless of how wide the numbers grow. */
--dsl-read-gutter: 48px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-read-radius);
--dsl-read-gutter: 3ch;
}
.banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-read-radius);
border-top-right-radius: var(--dsl-read-radius);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.count {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.lang {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.copyButton {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* One row per file line: a fixed gutter column, then the content. No wrapping —
a source line's leading whitespace is meaningful and scrolls sideways. */
.line {
display: flex;
min-height: var(--dsl-read-line-height);
line-height: var(--dsl-read-line-height);
min-height: 1lh;
white-space: pre;
}
.gutter {
flex: none;
width: var(--dsl-read-gutter);
padding-right: 14px;
margin-right: 12px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
/* The gutter is chrome, not content: keep it out of a text selection so a
copy of the visible rows carries the source, not the line numbers. */
user-select: none;
}
.content {
min-width: 0;
color: var(--dsw-alias-label-primary);
}
.block[data-code-wrap='true'] .line {
white-space: pre-wrap;
}
.block[data-code-wrap='true'] .content {
overflow-wrap: anywhere;
}
.expand {
display: block;
width: 100%;
padding: 0 0 0 var(--dsl-read-gutter);
padding: 0 0 0 calc(var(--dsl-read-gutter) + 12px);
border: none;
background-color: transparent;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
+11 -18
View File
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 're
import clsx from 'clsx'
import { FoldToggle } from './FoldToggle.tsx'
import { writeClipboard } from './clipboard.ts'
import { CodeToolbar, type CodeToolbarLabels } from './CodeToolbar.tsx'
import cardCss from './CodeCard.module.css'
import {
grammarLoadCount,
highlightLines,
@@ -44,7 +46,7 @@ export interface ReadBlockProps {
}
/** Localized chrome for {@link ReadBlock}. */
export interface ReadBlockLabels {
export interface ReadBlockLabels extends CodeToolbarLabels {
window: (shown: number, total: number) => string
copy: string
copied: string
@@ -88,6 +90,7 @@ export function ReadBlock({
)
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const [wrapped, setWrapped] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
@@ -120,23 +123,13 @@ export function ReadBlock({
[line, highlighted?.[index]])
return (
<div ref={rootRef} className={clsx(css.block, className)} data-read="">
<div className={css.banner}>
<div className={css.label}>{label ?? ''}</div>
<div className={css.action}>
{windowed && (
<span className={css.count}>{labels.window(lines.length, totalLines)}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* Empty files omit Copy to avoid replacing the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? labels.copied : labels.copy}
</button>
)}
</div>
</div>
<div className={css.body}>
<div ref={rootRef} className={clsx(cardCss.card, css.block, className)} data-read="" data-code-wrap={wrapped}>
<CodeToolbar
lang={lang} title={label} status={windowed ? labels.window(lines.length, totalLines) : undefined}
labels={labels} copyLabel={labels.copy} copiedLabel={labels.copied} copied={copied} wrapped={wrapped}
onCopy={lines.length > 0 ? onCopy : undefined} onWrap={() => { setWrapped(value => !value) }}
/>
<div className={cardCss.body}>
{rows(capped ? paired.slice(0, headLines) : paired)}
{hidden > 0 && (
<FoldToggle
@@ -104,6 +104,25 @@
color: var(--dsw-alias-label-primary);
}
.card {
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
}
.card :where(pre) {
padding: 6px 18px 16px;
word-break: normal;
overflow-wrap: anywhere;
}
.card[data-code-wrap='false'] {
--dsl-code-block-line-white-space: pre;
}
.card[data-code-wrap='false'] :where(pre) {
white-space: pre;
overflow-wrap: normal;
}
.numbered :where(pre) code {
display: block;
counter-reset: source-line;
@@ -2,6 +2,7 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore
import type { CSSProperties, ReactNode, Ref } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { CodeToolbar, type CodeToolbarLabels } from '../CodeToolbar.tsx'
import {
StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
} from './highlight.ts'
@@ -35,6 +36,10 @@ export interface CodeBlockProps {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Enable the shared card toolbar and spacing; omit for owner-styled source or compact views. */
toolbarLabels?: CodeToolbarLabels | undefined
/** With toolbarLabels, use the owner's wrapping preference and omit the toolbar's local wrap action. */
wrap?: boolean | undefined
}
/**
@@ -64,7 +69,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
}
export function CodeBlock({
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, toolbarLabels, wrap,
}: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
@@ -149,6 +154,8 @@ export function CodeBlock({
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
const [copied, setCopied] = useState(false)
const [localWrapped, setWrapped] = useState(true)
const wrapped = wrap ?? localWrapped
const onCopy = useCallback(() => {
if (copied) return
@@ -178,21 +185,26 @@ export function CodeBlock({
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, className)}
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, toolbarLabels !== undefined && css.card, className)}
data-line-numbers={lineNumbers || undefined}
data-code-wrap={toolbarLabels === undefined ? undefined : wrapped}
style={sourceLines === undefined ? undefined : {
'--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
} as CSSProperties}>
{/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
{showHeader && <div className={css.bannerWrap}>
<div className={css.banner} data-code-block-banner>
{toolbarLabels !== undefined ? <CodeToolbar
lang={lang} labels={toolbarLabels} copyLabel={copyLabel} copiedLabel={copiedLabel}
copied={copied} wrapped={wrapped} onCopy={onCopy}
onWrap={wrap === undefined ? () => { setWrapped(value => !value) } : undefined}
/> : <div className={css.banner} data-code-block-banner>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? copiedLabel : copyLabel}
</button>
</div>
</div>
</div>}
</div>}
<div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
</div>
@@ -172,7 +172,7 @@ class StreamingRenderer {
* absolute HTTP(S) images render directly.
*/
export const MarkdownText = memo(function MarkdownText({
text, streaming = false, labels, fileMentions, pathImages, variant = 'body',
text, streaming = false, labels: providedLabels, fileMentions, pathImages, variant = 'body',
}: {
text: string
streaming?: boolean
@@ -181,6 +181,9 @@ export const MarkdownText = memo(function MarkdownText({
pathImages?: MarkdownPathImages | undefined
variant?: 'body' | 'compact'
}) {
const labels = useMemo(() => variant === 'compact'
? { ...providedLabels, code: { ...providedLabels.code, toolbarLabels: undefined } }
: providedLabels, [providedLabels, variant])
const streamRef = useRef<StreamingRenderer | null>(null)
const streamLabelsRef = useRef<MarkdownLabels>(labels)
const children = useMemo(() => {
@@ -36,6 +36,8 @@ export interface MarkdownCodeLabels {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Shared card controls; omitted in compact and owner-styled source views. */
toolbarLabels?: import('../CodeToolbar.tsx').CodeToolbarLabels | undefined
}
/** Localized chrome for a Markdown document. */
@@ -399,6 +401,7 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
toolbarLabels={context.labels.code.toolbarLabels}
/>
)
}
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
import { ReadBlock } from '../src/ReadBlock.tsx'
import { DiffBlock } from '../src/DiffBlock.tsx'
import { diffBlockLabels, readBlockLabels } from './labels.client.ts'
const toolbarLabels = { codeLabel: 'Code block', wrapLabel: 'Wrap lines', unwrapLabel: 'Do not wrap lines' }
const labels = { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels }
afterEach(() => {
cleanup()
vi.clearAllTimers()
vi.useRealTimers()
})
describe('code-card controls', () => {
it.each([undefined, 'unregistered'])('labels the %s language with the localized code title', (lang) => {
render(<CodeBlock code="plain source" lang={lang} {...labels} />)
expect(screen.getByText('Code block')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('')
})
it('keeps a known grammar name while its highlighter is not yet visible', () => {
render(<CodeBlock code="const x = 1" lang="typescript" {...labels} />)
expect(screen.getByText('typescript')).toBeTruthy()
expect(screen.queryByText('Code block')).toBeNull()
})
it.each(['code', 'read', 'diff'] as const)('toggles %s wrapping without changing source text', (kind) => {
const code = 'const longName = "a long value whose source must stay intact"'
const view = render(kind === 'code'
? <CodeBlock code={code} lang="ts" {...labels} />
: kind === 'read'
? <ReadBlock lines={[{ number: 24, text: code }]} totalLines={30} lang="ts" labels={{ ...readBlockLabels, ...toolbarLabels }} />
: <DiffBlock diffs={[{ path: 'example.ts', oldText: 'old', newText: code }]} labels={{ ...diffBlockLabels, ...toolbarLabels }} />)
const button = screen.getByRole('button', { name: kind === 'code' ? 'Do not wrap lines' : 'Wrap lines' })
const wasWrapped = button.getAttribute('aria-pressed') === 'true'
fireEvent.click(button)
expect(button.getAttribute('aria-pressed')).toBe(String(!wasWrapped))
expect(view.container.textContent).toContain(code)
fireEvent.click(button)
expect(button.getAttribute('aria-pressed')).toBe(String(wasWrapped))
})
it('shows hover tooltips for copying and wrapping and updates the wrapping action', () => {
render(<CodeBlock code="source" {...labels} />)
const copy = screen.getByRole('button', { name: 'Copy' })
fireEvent.mouseEnter(copy)
expect(screen.getByRole('tooltip').textContent).toBe('Copy')
fireEvent.mouseLeave(copy)
const wrap = screen.getByRole('button', { name: 'Do not wrap lines' })
fireEvent.mouseEnter(wrap)
expect(screen.getByRole('tooltip').textContent).toBe('Do not wrap lines')
fireEvent.click(wrap)
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseEnter(wrap)
expect(screen.getByRole('tooltip').textContent).toBe('Wrap lines')
})
it('copies source through the icon and announces success', async () => {
vi.useFakeTimers()
const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
const writeText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue()
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
try {
render(<CodeBlock code={'const x = 1\n'} {...labels} />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Copy' })) })
expect(writeText).toHaveBeenCalledWith('const x = 1')
expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy()
} finally {
if (original === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', original)
}
})
it('preserves compact Markdown and owner-styled source controls', () => {
const view = render(<MarkdownText text={'```ts\nconst x = 1\n```'} variant="compact" labels={{ code: labels, footnotes: 'Footnotes' }} />)
expect(screen.queryByRole('button', { name: 'Wrap lines' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Do not wrap lines' })).toBeNull()
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('Copy')
view.rerender(<CodeBlock code="source" copyLabel="Copy" copiedLabel="Copied" />)
expect(screen.getByRole('button', { name: 'Copy' }).textContent).toBe('Copy')
})
it('uses the fallback title for diffs containing different languages', () => {
render(<DiffBlock diffs={[
{ path: 'example.ts', oldText: '', newText: 'export {}' },
{ path: 'example.py', oldText: '', newText: 'print(1)' },
]} labels={{ ...diffBlockLabels, ...toolbarLabels }} />)
expect(screen.getByText('Code block')).toBeTruthy()
})
})
@@ -14,6 +14,7 @@ export const markdownLabels: MarkdownLabels = {
}
export const diffBlockLabels: DiffBlockLabels = {
codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
copy: '复制', copied: '复制成功', collapseAria: '收起差异',
expandAria: hidden => `展开其余 ${hidden} 行差异`,
collapse: '收起', expand: hidden => `… 其余 ${hidden} 行`,
@@ -21,6 +22,7 @@ export const diffBlockLabels: DiffBlockLabels = {
}
export const readBlockLabels: ReadBlockLabels = {
codeLabel: '代码块', wrapLabel: '自动换行', unwrapLabel: '取消自动换行',
window: (shown, total) => `显示 ${shown} / ${total} 行`,
copy: '复制', copied: '复制成功', collapseAria: '收起内容',
expandAria: hidden => `展开其余 ${hidden} 行`,
@@ -134,10 +134,9 @@ describe('ReadBlock banner', () => {
expect(view.queryByText(/显示/u)).toBeNull()
})
it('draws an empty label and empty language when neither is given', () => {
const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
it('uses the localized code title when no language is given', () => {
render(<ReadBlock lines={lines(1)} totalLines={1} />)
expect(screen.getByText('代码块')).toBeTruthy()
})
})
@@ -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: 510e43fb723039b918672a58ec2bd26cf3b6023a
README.zh.md: 5bd9fba7c7f60f7f477afb82b0e88a5dd8b5ac30
README.md: f5d664db612f67d71ce509e44981a4678845b50d
README.zh.md: 0361adcc79d13f06ba14cb3f6c6072e3f78dcf18
@@ -61,7 +61,7 @@ PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` s
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls.
Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a secondary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
<a id="excel-preview"></a>
## Excel preview
@@ -61,7 +61,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 secondary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
<a id="excel-preview"></a>
## Excel 预览
@@ -43,6 +43,7 @@
.renderer .code pre {
box-sizing: border-box;
padding: 16px;
min-width: 100%;
overflow: visible;
white-space: pre;
@@ -24,9 +24,11 @@ export function CodeBody({ resourceAddress, content, wrap, scrollportRef, t }: C
code={content.text}
lang={language}
streaming={!content.eof}
wrap={wrap}
lineNumbers
copyLabel={t('copy')}
copiedLabel={t('copied')}
toolbarLabels={{ codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }}
/>
</div>
)
@@ -24,9 +24,12 @@ export function MarkdownBody({ content, resourceAddress, useResource, t }: Markd
const copyLabel = t('code.copy')
const copiedLabel = t('code.copied')
const footnotes = t('footnotes')
const codeLabel = t('codeBlock.title')
const wrapLabel = t('codeBlock.wrap')
const unwrapLabel = t('codeBlock.unwrap')
const labels = useMemo<MarkdownLabels>(() => ({
code: { copyLabel, copiedLabel }, footnotes,
}), [copyLabel, copiedLabel, footnotes])
code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes,
}), [copyLabel, copiedLabel, footnotes, codeLabel, wrapLabel, unwrapLabel])
if (content.kind !== 'text') return null
return (
<div className={css.document} data-document-markdown>
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import { CodeBody } from '../src/client/code/CodeBody.tsx'
@@ -84,7 +84,11 @@ describe('CodeBody', () => {
expect(view.getByText(language, { exact: true })).toBeTruthy()
expect(element(view.container, '.shiki').textContent).toBe('const answer = 42')
expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
expect(view.getByRole('button', { name: 'Copy' })).toBeTruthy()
const copy = view.getByRole('button', { name: 'Copy' })
expect(copy.textContent).toBe('')
expect(view.getAllByRole('button')).toHaveLength(1)
fireEvent.mouseEnter(copy)
expect(view.getByRole('tooltip').textContent).toBe('Copy')
})
it('carries multiline grammar state across pages and preserves completed lines through settlement', () => {
@@ -155,6 +159,7 @@ describe('CodeBody', () => {
const view = render(<CodeBody {...props(content)} />)
const pre = element(view.container, '.shiki')
const block = element(view.container, '.md-code-block')
expect(block.getAttribute('data-code-wrap')).toBe('false')
const scrollport = element(view.container, '[data-code-block-content]')
expect(getComputedStyle(block).marginTop).toBe('0px')
expect(getComputedStyle(block).position).toBe('static')
@@ -165,6 +170,8 @@ describe('CodeBody', () => {
expect(getComputedStyle(pre).overflow).toBe('visible')
expect(getComputedStyle(pre).wordBreak).toBe('normal')
view.rerender(<CodeBody {...props(content, { wrap: true })} />)
expect(block.getAttribute('data-code-wrap')).toBe('true')
expect(view.getAllByRole('button')).toHaveLength(1)
expect(element(view.container, '.shiki')).toBe(pre)
expect(element(view.container, '[data-code-block-content]')).toBe(scrollport)
expect(getComputedStyle(pre).whiteSpace).toBe('pre-wrap')
@@ -4,6 +4,11 @@
cascade as every other token sheet. Background/foreground deliberately
alias the markdown code-block tokens so highlighted and plain blocks agree. */
body {
--dsw-alias-code-diff-added: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent);
--dsw-alias-code-diff-deleted: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
}
:root {
--shiki-foreground: var(--dsw-alias-label-primary);
--shiki-background: var(--dsw-alias-markdown-code-block);
@@ -19,6 +24,8 @@
}
body[data-ds-dark-theme] {
--dsw-alias-code-diff-added: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);
--dsw-alias-code-diff-deleted: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
--shiki-token-constant: #4dabf7;
--shiki-token-string: #69db7c;
--shiki-token-comment: #adb5bd;
@@ -232,15 +232,13 @@
overflow: auto;
}
.root .item:has(> .code:only-child) { padding: 0; }
.root .item > .code:only-child { margin: 0; }
.root .group .item:has(> .code:only-child) {
padding: 4px 0 0 20px;
}
.root .group .item > .code:only-child { margin: 0; }
.root .code [data-code-block-banner] { padding: 4px 10px; }
.root .code pre { padding: 6px 10px 8px; }
.path {
flex: 1;
min-width: 0;
@@ -4,7 +4,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client'
import { markdownLabels } from '../models/primitive-labels.ts'
import { codeToolbarLabels, markdownLabels } from '../models/primitive-labels.ts'
import css from './ToolDetails.module.css'
/** One recorded entity, receipt, or collapsible group of related values. */
@@ -89,6 +89,7 @@ function DetailItem({ item, t, onOpenFile }: { item: ToolDetailItem } & DetailCo
{item.markdown !== undefined && <div className={css.prose}><MarkdownText text={item.markdown} labels={markdownLabels(t)} /></div>}
{item.code !== undefined && (
<CodeBlock
toolbarLabels={codeToolbarLabels(t)}
className={css.code} code={item.code.text} lang={item.code.language}
copyLabel={t('copy')} copiedLabel={t('copied')}
/>
@@ -324,12 +324,6 @@
color: var(--dsw-alias-label-tertiary);
}
/* In-row code renders at the smaller code size (12/18) via each primitive's
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
.codeBody {
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
}
/* The terminal card scrolls its OUTPUT inside its own surface (same l1
hairline as the IN/OUT card): the banner stays pinned and the scrollbar
never rides over it. 224px = the 260px card cap minus the ~36px banner. */
@@ -16,7 +16,7 @@ import {
localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel,
} from '../models/terminal-card-model.ts'
import {
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
codeToolbarLabels, diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
} from '../models/primitive-labels.ts'
import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts'
import {
@@ -289,7 +289,8 @@ export const ToolRow = memo(function ToolRow({
<>
{variant === 'code' && bodyText !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')}
toolbarLabels={codeToolbarLabels(t)} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
@@ -1,6 +1,7 @@
/** Localized copy adapters for Cordis-free UI primitives used by Tool cards. */
import type {
CodeBlockProps,
DiffBlockLabels,
MarkdownLabels,
ReadBlockLabels,
@@ -11,6 +12,15 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
type T = TranslateNS<'conversation'>
/**
* Localize the shared code-card toolbar.
* @param t - Conversation locale seat.
* @returns Language fallback and wrapping actions.
*/
export function codeToolbarLabels(t: T): NonNullable<CodeBlockProps['toolbarLabels']> {
return { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }
}
/**
* Build localized Markdown chrome labels.
* @param t - Conversation locale seat.
@@ -18,7 +28,7 @@ type T = TranslateNS<'conversation'>
*/
export function markdownLabels(t: T): MarkdownLabels {
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: codeToolbarLabels(t) },
footnotes: t('markdown.footnotes'),
}
}
@@ -30,6 +40,7 @@ export function markdownLabels(t: T): MarkdownLabels {
*/
export function diffBlockLabels(t: T): DiffBlockLabels {
return {
...codeToolbarLabels(t),
copy: t('copy'),
copied: t('copied'),
collapseAria: t('diff.collapseAria'),
@@ -47,6 +58,7 @@ export function diffBlockLabels(t: T): DiffBlockLabels {
*/
export function readBlockLabels(t: T): ReadBlockLabels {
return {
...codeToolbarLabels(t),
window: (shown, total) => t('read.window', { shown, total }),
copy: t('copy'),
copied: t('copied'),
@@ -224,7 +224,7 @@ describe('FileMutationRow diff card', () => {
toggleRow(view)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
expect(view.getByText('复制')).toBeTruthy()
expect(view.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('the summary is a path link that opens the tool path through the host', () => {
@@ -131,7 +131,7 @@ type QuestionFlowProps =
function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
const questions = pending.questions
const markdownLabels = useMemo(() => ({
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('markdown.footnotes'),
}), [t])
const initialProgress = useMemo<QuestionDraftProgress>(() => ({
@@ -134,6 +134,7 @@ export function CordisDefineRow({
aria-labelledby={`${sourcePanelId}-${activeSource}`}
>
<CodeBlock
toolbarLabels={{ codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') }}
code={activeCode}
lang="javascript"
copyLabel={t('body.copy')}