Merge remote-tracking branch 'origin/master' into worktree/web-remote-markdown-images
# Conflicts: # packages/client/ui-primitives/README.i18n.yaml # packages/client/ui-primitives/README.md # packages/client/ui-primitives/README.zh.md # packages/client/ui-primitives/src/markdown/MarkdownText.tsx # packages/client/ui-primitives/tests/markdown.spec.tsx
This commit is contained in:
2018 files changed
+163222
-14125
No files matched your search
@@ -1,5 +1,5 @@
|
||||
// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale +
|
||||
// "deepseek" letterforms + HARNESS badge plate in one svg. Native 182x24.
|
||||
// "deepseek-official" letterforms + HARNESS badge plate in one svg. Native 182x24.
|
||||
// Ink rides currentColor; the badge text is knocked out in the inverted
|
||||
// label color so the plate stays legible in both themes.
|
||||
|
||||
|
||||
@@ -8,9 +8,14 @@ import css from './ConnectionBanner.module.css'
|
||||
/**
|
||||
* Render the reconnecting banner.
|
||||
* @param props.reconnecting - true while the connection is in backoff/retry.
|
||||
* @param props.label - banner text; the owner passes localized copy (this
|
||||
* package is cordis-free, so copy arrives via props).
|
||||
* @returns the banner, or null when connected.
|
||||
*/
|
||||
export function ConnectionBanner({ reconnecting }: { reconnecting: boolean }) {
|
||||
export function ConnectionBanner({ reconnecting, label = '连接已断开,正在重连…' }: {
|
||||
reconnecting: boolean
|
||||
label?: string | undefined
|
||||
}) {
|
||||
if (!reconnecting) return null
|
||||
return <div className={css.banner}>连接已断开,正在重连…</div>
|
||||
return <div className={css.banner}>{label}</div>
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface +
|
||||
banner row, markdown code-block font) so a diff card reads as one family with
|
||||
a fenced block and a terminal card. The deliberate divergence, shared with
|
||||
TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally,
|
||||
because folding a source line destroys the indentation a diff is read by. */
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 14px;
|
||||
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);
|
||||
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. */
|
||||
.path {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 600;
|
||||
padding-right: 56px;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.add::before {
|
||||
content: '+ ';
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.add {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
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;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
|
||||
// control over one or more per-file hunks, each a bold path header followed by
|
||||
// the removed block (`-`, error color) and the added block (`+`, success
|
||||
// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors
|
||||
// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads
|
||||
// the same across front ends: the removed side is the old text in full, the
|
||||
// added side the new text in full, both split on the same terminator rule, and
|
||||
// the footer counts distinct paths on both ends. Output never soft-wraps — an
|
||||
// aligned source line keeps its indentation and scrolls horizontally instead of
|
||||
// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import css from './DiffBlock.module.css'
|
||||
|
||||
/**
|
||||
* Output lines shown before the height cap collapses the middle. Matches
|
||||
* {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a
|
||||
* long body at the same place.
|
||||
*/
|
||||
export const DEFAULT_DIFF_MAX_LINES = 16
|
||||
|
||||
/**
|
||||
* One file's change, in the shape {@link DiffBlock} draws. Structurally the
|
||||
* render-intent contract's `FileDiff`, redeclared here so this primitive stays
|
||||
* free of the tool contract (the terminal card's decoupling, applied to diffs).
|
||||
*/
|
||||
export interface DiffHunk {
|
||||
/** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
|
||||
path: string
|
||||
/** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
|
||||
oldText: string | null
|
||||
/** Content after the change (the added side). */
|
||||
newText: string
|
||||
}
|
||||
|
||||
export interface DiffBlockProps {
|
||||
/** One entry per applied hunk, in file order; empty renders nothing. */
|
||||
diffs: DiffHunk[]
|
||||
/** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** A single rendered body line and its role, so the height cap slices a flat list. */
|
||||
interface DiffRow {
|
||||
kind: 'path' | 'del' | 'add' | 'gap'
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */
|
||||
/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`unreachable diff row kind: ${String(value)}`)
|
||||
}
|
||||
|
||||
/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */
|
||||
const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
|
||||
path: css.path,
|
||||
del: css.del,
|
||||
add: css.add,
|
||||
gap: css.gap,
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the hunks into the body's rows plus the footer counts. A path header
|
||||
* opens each new file; a same-file second hunk (a scattered edit) opens with a
|
||||
* `⋯` gap instead of repeating the path. Every old-side line counts toward
|
||||
* `removed` and every new-side line toward `added`. The file count is of
|
||||
* DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file
|
||||
* read as `1 file` on both front ends.
|
||||
* @param diffs - the hunks to render.
|
||||
* @returns the body rows, the +/- totals, and the distinct-file count.
|
||||
*/
|
||||
function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
|
||||
const rows: DiffRow[] = []
|
||||
const paths = new Set<string>()
|
||||
let added = 0
|
||||
let removed = 0
|
||||
let prevPath: string | undefined
|
||||
for (const diff of diffs) {
|
||||
paths.add(diff.path)
|
||||
if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
|
||||
else rows.push({ kind: 'gap', text: '⋯' })
|
||||
prevPath = diff.path
|
||||
if (diff.oldText !== null) {
|
||||
for (const line of contentLines(diff.oldText)) {
|
||||
rows.push({ kind: 'del', text: line })
|
||||
removed++
|
||||
}
|
||||
}
|
||||
for (const line of contentLines(diff.newText)) {
|
||||
rows.push({ kind: 'add', text: line })
|
||||
added++
|
||||
}
|
||||
}
|
||||
return { rows, added, removed, files: paths.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a side's text into its content lines. Empty text is zero lines (a full
|
||||
* deletion's `newText` or a create's absent `oldText` side draws nothing), and a
|
||||
* single trailing newline is a line terminator rather than an extra empty line —
|
||||
* the same terminator rule TerminalBlock applies to command output. An interior
|
||||
* blank line (a genuine `\n\n`) survives.
|
||||
* @param text - the removed or added side's text.
|
||||
* @returns the content lines, without the terminating newline.
|
||||
*/
|
||||
function contentLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
const body = text.endsWith('\n') ? text.slice(0, -1) : text
|
||||
return body.split('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its
|
||||
* content, exactly what the card shows. The removed and added blocks are the
|
||||
* change; the path headers keep a multi-file copy attributable.
|
||||
* @param rows - the flattened body rows.
|
||||
* @returns the diff as plain text.
|
||||
*/
|
||||
function copyText(rows: DiffRow[]): string {
|
||||
return rows.map((row) => {
|
||||
switch (row.kind) {
|
||||
case 'del': return `- ${row.text}`
|
||||
case 'add': return `+ ${row.text}`
|
||||
case 'path': return row.text
|
||||
case 'gap': return row.text
|
||||
/* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */
|
||||
default: return assertNever(row.kind)
|
||||
}
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a file mutation as an inline diff surface.
|
||||
* @param props - see {@link DiffBlockProps}.
|
||||
* @returns the diff block element.
|
||||
*/
|
||||
export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
|
||||
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
void writeClipboard(copyText(rows)).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, rows])
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const hidden = rows.length - maxLines
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock and the TUI transcript's collapsed
|
||||
// card, so a body's head and tail slices agree across the front ends.
|
||||
const headLines = Math.ceil(maxLines / 2)
|
||||
const tailLines = maxLines - headLines
|
||||
const head = capped ? rows.slice(0, headLines) : rows
|
||||
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 ? '复制成功' : '复制'}
|
||||
</button>
|
||||
<div className={css.body}>
|
||||
{head.map((row, index) => (
|
||||
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
</button>
|
||||
)}
|
||||
{tail.map((row, index) => (
|
||||
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.footer}>└ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the
|
||||
* menu card's elevation. Surface is #2C2C2E in both themes (figma value,
|
||||
* light/dark identical), so a component-level variable, not a theme token. */
|
||||
* light/dark identical), so a component-level variable, not a theme token.
|
||||
* Hit-testable on purpose: resting the pointer on the card holds it open
|
||||
* (HoverCard's grace close), which a `pointer-events: none` card cannot do. */
|
||||
.card {
|
||||
--dsw-hovercard-bg: #2C2C2E;
|
||||
position: fixed;
|
||||
@@ -18,5 +20,35 @@
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-hovercard-bg);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.copyable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyable:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copied {
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,33 +1,73 @@
|
||||
// HoverCard: delayed hover-preview card portaled to document.body.
|
||||
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
|
||||
// the card is fixed-positioned at its right edge and repositions on
|
||||
// scroll/resize while open. Display-only — the card ignores pointer events
|
||||
// and closes the instant the pointer leaves the anchor (no close delay).
|
||||
// scroll/resize while open. The card is reachable: it takes pointer events,
|
||||
// and leaving the anchor only arms a grace-delayed close, so the pointer can
|
||||
// cross the 8px gap and settle on the card to read a clipped path or title.
|
||||
// The portaled card is a React child of the wrapper, so React's enter/leave
|
||||
// traversal already treats it as inside — one pair of wrapper handlers covers
|
||||
// anchor and card alike.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import { usePointerGrace } from './pointer-grace.ts'
|
||||
import css from './HoverCard.module.css'
|
||||
|
||||
/**
|
||||
* Render an anchor with a hover-triggered preview card.
|
||||
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
|
||||
* @param props.content - card content (display-only, no pointer interaction).
|
||||
* @param props.content - card content; the pointer may rest on it, so it is
|
||||
* readable and selectable, but it carries no dismissal affordance of its own.
|
||||
* @param props.openDelayMs - hover dwell before the card shows (default 500).
|
||||
* @param props.disabled - suppress opening; turning true closes an open card.
|
||||
* @param props.copyText - optional primary value copied by activation and
|
||||
* included in the card's accessible name.
|
||||
* @param props.copyLabel - accessible activation-label prefix (default "复制").
|
||||
* @param props.copiedLabel - visible success label (default "复制成功").
|
||||
* @returns anchor wrapper with the conditional portaled card.
|
||||
*/
|
||||
export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: {
|
||||
export function HoverCard({
|
||||
anchor, content, openDelayMs = 500, disabled = false,
|
||||
copyText, copyLabel = '复制', copiedLabel = '复制成功',
|
||||
}: {
|
||||
anchor: ReactNode
|
||||
content: ReactNode
|
||||
openDelayMs?: number
|
||||
disabled?: boolean
|
||||
copyText?: string | undefined
|
||||
copyLabel?: string | undefined
|
||||
copiedLabel?: string | undefined
|
||||
}) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const copyHeightRef = useRef<number | null>(null)
|
||||
const copyEpochRef = useRef(0)
|
||||
const copyingRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const clearCopied = useCallback(() => {
|
||||
if (copyTimerRef.current !== null) {
|
||||
clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = null
|
||||
}
|
||||
copyHeightRef.current = null
|
||||
setCopied(false)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
copyEpochRef.current += 1
|
||||
clearCopied()
|
||||
setOpen(false)
|
||||
}, [clearCopied])
|
||||
|
||||
const { arm: armClose, cancel: cancelClose } = usePointerGrace(close)
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current !== null) {
|
||||
@@ -40,10 +80,22 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
useEffect(() => {
|
||||
if (!disabled) return
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
}, [disabled])
|
||||
cancelClose()
|
||||
close()
|
||||
}, [disabled, cancelClose, close])
|
||||
|
||||
useEffect(() => clearTimer, [])
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
copyEpochRef.current += 1
|
||||
clearTimer()
|
||||
if (copyTimerRef.current !== null) {
|
||||
clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fixed-position from the anchor rect before paint; track the anchor while
|
||||
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
|
||||
@@ -79,9 +131,49 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
}
|
||||
}, [open, pos])
|
||||
|
||||
const copy = async (text: string): Promise<void> => {
|
||||
if (copied || copyingRef.current) return
|
||||
copyingRef.current = true
|
||||
const copyEpoch = copyEpochRef.current
|
||||
const accepted = await writeClipboard(text)
|
||||
copyingRef.current = false
|
||||
const card = cardRef.current
|
||||
if (!accepted || !mountedRef.current || copyEpoch !== copyEpochRef.current || card === null) return
|
||||
const height = card.offsetHeight
|
||||
copyHeightRef.current = height > 0 ? height : null
|
||||
setCopied(true)
|
||||
copyTimerRef.current = setTimeout(clearCopied, 1000)
|
||||
}
|
||||
|
||||
const copyable = copyText !== undefined
|
||||
const card = open && pos !== null && (
|
||||
<div ref={cardRef} className={css.card} style={pos}>
|
||||
{content}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${css.card}${copyable ? ` ${css.copyable}` : ''}${copied ? ` ${css.feedback}` : ''}`}
|
||||
style={{ ...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined }}
|
||||
role={copyable ? 'button' : undefined}
|
||||
tabIndex={copyable ? 0 : undefined}
|
||||
aria-label={copyable ? `${copyLabel}: ${copyText}` : undefined}
|
||||
onClick={copyable
|
||||
? (e) => {
|
||||
const selection = window.getSelection()
|
||||
if (selection !== null && !selection.isCollapsed) {
|
||||
for (let i = 0; i < selection.rangeCount; i += 1) {
|
||||
if (selection.getRangeAt(i).intersectsNode(e.currentTarget)) return
|
||||
}
|
||||
}
|
||||
void copy(copyText)
|
||||
}
|
||||
: undefined}
|
||||
onKeyDown={copyable
|
||||
? (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
void copy(copyText)
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{copied ? <span className={css.copied} aria-hidden="true">{copiedLabel}</span> : content}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -91,21 +183,33 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
className={css.root}
|
||||
onPointerEnter={() => {
|
||||
if (disabled) return
|
||||
// Coming back inside during the grace (the gap, or the card itself)
|
||||
// keeps the current card rather than restarting the dwell.
|
||||
cancelClose()
|
||||
if (open) return
|
||||
clearTimer()
|
||||
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
// Leaving a closed card schedules a no-op close; only arm while
|
||||
// open, matching Menu's shape.
|
||||
if (open) armClose()
|
||||
}}
|
||||
// Any press inside the anchor (row click, menu trigger) dismisses the
|
||||
// A press inside the anchor (row click, menu trigger) dismisses the
|
||||
// card immediately, without waiting for the owner to flip `disabled`.
|
||||
onPointerDownCapture={() => {
|
||||
// Capture presses reach this handler from the card too — it is a React
|
||||
// child of the wrapper — but a press there starts a selection, so the
|
||||
// card must stay mounted under it (and the browser's click with it).
|
||||
onPointerDownCapture={(e) => {
|
||||
if (cardRef.current?.contains(e.target as Node)) return
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
cancelClose()
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{anchor}
|
||||
{open && copyable && <span className={css.status} role="status">{copied ? copiedLabel : ''}</span>}
|
||||
{card !== false && createPortal(card, document.body)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
@@ -14,16 +14,64 @@ import css from './JsonTree.module.css'
|
||||
const OBJECT_PREVIEW_LIMIT = 4
|
||||
const ARRAY_PREVIEW_LIMIT = 5
|
||||
const PREVIEW_DEPTH_LIMIT = 2
|
||||
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'value', label: 'Copy value' },
|
||||
{ id: 'json', label: 'Copy JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'prettyJson', label: 'Copy pretty JSON' },
|
||||
{ id: 'json', label: 'Copy compact JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Display copy for the tree's copy affordance; the owner passes localized
|
||||
* labels (this package is cordis-free, so copy arrives via props). Every
|
||||
* field defaults to the current built-in value, so existing consumers render
|
||||
* unchanged.
|
||||
*/
|
||||
export interface JsonTreeLabels {
|
||||
/** Menu item: copy the raw primitive value. */
|
||||
copyValue: string
|
||||
/** Menu item: copy the value as compact JSON (primitive rows). */
|
||||
copyJson: string
|
||||
/** Menu item: copy the property path. */
|
||||
copyPath: string
|
||||
/** Menu item: copy the value as pretty-printed JSON. */
|
||||
copyPrettyJson: string
|
||||
/** Menu item: copy the value as compact JSON (object rows). */
|
||||
copyCompactJson: string
|
||||
/** Copy-button state label after a successful copy. */
|
||||
copied: string
|
||||
/** Copy-button state label after a failed copy. */
|
||||
copyFailed: string
|
||||
/** Expander aria label while expanded. */
|
||||
collapseNode: string
|
||||
/** Expander aria label while collapsed. */
|
||||
expandNode: string
|
||||
/** Copy-button tooltip, given the current action label. */
|
||||
copyButtonTitle: (action: string) => string
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: JsonTreeLabels = {
|
||||
copyValue: 'Copy value',
|
||||
copyJson: 'Copy JSON',
|
||||
copyPath: 'Copy property path',
|
||||
copyPrettyJson: 'Copy pretty JSON',
|
||||
copyCompactJson: 'Copy compact JSON',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Copy failed',
|
||||
collapseNode: 'Collapse JSON node',
|
||||
expandNode: 'Expand JSON node',
|
||||
copyButtonTitle: action => `${action}; right-click for copy options`,
|
||||
}
|
||||
|
||||
function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
|
||||
return [
|
||||
{ id: 'value', label: labels.copyValue },
|
||||
{ id: 'json', label: labels.copyJson },
|
||||
{ id: 'path', label: labels.copyPath },
|
||||
]
|
||||
}
|
||||
|
||||
function objectCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
|
||||
return [
|
||||
{ id: 'prettyJson', label: labels.copyPrettyJson },
|
||||
{ id: 'json', label: labels.copyCompactJson },
|
||||
{ id: 'path', label: labels.copyPath },
|
||||
]
|
||||
}
|
||||
|
||||
type JsonPath = readonly (number | string)[]
|
||||
|
||||
@@ -193,6 +241,7 @@ function NodeField({
|
||||
interface JsonTreeNodeProps {
|
||||
field?: string
|
||||
initialExpanded: boolean
|
||||
labels: JsonTreeLabels
|
||||
lastElement: boolean
|
||||
onClaimTabStop: (id: string) => void
|
||||
onRowHover: (row: HTMLElement, target: RowTarget) => void
|
||||
@@ -204,6 +253,7 @@ interface JsonTreeNodeProps {
|
||||
function JsonTreeNode({
|
||||
field,
|
||||
initialExpanded,
|
||||
labels,
|
||||
lastElement,
|
||||
onClaimTabStop,
|
||||
onRowHover,
|
||||
@@ -279,7 +329,7 @@ function JsonTreeNode({
|
||||
className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
|
||||
data-json-expander
|
||||
role="button"
|
||||
aria-label={expanded ? 'Collapse JSON node' : 'Expand JSON node'}
|
||||
aria-label={expanded ? labels.collapseNode : labels.expandNode}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expanded ? contentsId : undefined}
|
||||
tabIndex={tabStopId === nodeId ? 0 : -1}
|
||||
@@ -298,6 +348,7 @@ function JsonTreeNode({
|
||||
field={key}
|
||||
value={item}
|
||||
path={[...path, Array.isArray(value) ? index : key]}
|
||||
labels={labels}
|
||||
lastElement={index === entries.length - 1}
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
@@ -344,6 +395,8 @@ export interface JsonTreeProps {
|
||||
copyable?: boolean
|
||||
/** Whether the top-level object or array is always expanded. */
|
||||
expandTopLevel?: boolean
|
||||
/** Localized display copy; omitted fields keep the built-in defaults. */
|
||||
labels?: Partial<JsonTreeLabels> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,7 +410,12 @@ export function JsonTree({
|
||||
className,
|
||||
copyable = true,
|
||||
expandTopLevel = true,
|
||||
labels,
|
||||
}: JsonTreeProps) {
|
||||
const copyLabels = useMemo<JsonTreeLabels>(
|
||||
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
|
||||
[labels],
|
||||
)
|
||||
const rootEntries = entriesOf(data)
|
||||
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
|
||||
isExpandableValue(value) && entriesOf(value).length > 0
|
||||
@@ -486,10 +544,10 @@ export function JsonTree({
|
||||
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
|
||||
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
|
||||
const copyTitle = copyState === 'copied'
|
||||
? 'Copied'
|
||||
? copyLabels.copied
|
||||
: copyState === 'failed'
|
||||
? 'Copy failed'
|
||||
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
|
||||
? copyLabels.copyFailed
|
||||
: copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -525,6 +583,7 @@ export function JsonTree({
|
||||
field={key}
|
||||
value={value}
|
||||
path={[Array.isArray(data) ? index : key]}
|
||||
labels={copyLabels}
|
||||
lastElement={index === rootEntries.length - 1}
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
@@ -543,6 +602,7 @@ export function JsonTree({
|
||||
<JsonTreeNode
|
||||
value={data}
|
||||
path={[]}
|
||||
labels={copyLabels}
|
||||
lastElement
|
||||
initialExpanded
|
||||
tabStopId={tabStopId}
|
||||
@@ -570,7 +630,7 @@ export function JsonTree({
|
||||
data-json-copy-button
|
||||
data-state={copyState}
|
||||
aria-label={copyTitle}
|
||||
title={`${copyTitle}; right-click for copy options`}
|
||||
title={copyLabels.copyButtonTitle(copyTitle)}
|
||||
onClick={() => void copy(defaultCopyMode)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -584,7 +644,7 @@ export function JsonTree({
|
||||
: <IconCopyOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
|
||||
items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)}
|
||||
onSelect={(id) => {
|
||||
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
|
||||
copyMenuOpenRef.current = false
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16 } from './icons/index.tsx'
|
||||
import { usePointerGrace } from './pointer-grace.ts'
|
||||
import css from './Menu.module.css'
|
||||
|
||||
/** Selectable row (optionally with a nested submenu). */
|
||||
@@ -69,8 +70,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* from the anchor rect (repositions on scroll/resize while open). Use when an
|
||||
* ancestor's overflow clipping would crop the in-place list; default false
|
||||
* keeps the pure-CSS in-place behavior.
|
||||
* @param props.closeOnPointerLeave - close the list when the pointer leaves
|
||||
* it (default false keeps it open until outside click/Escape/selection).
|
||||
* @param props.closeOnPointerLeave - close the list once the pointer has left
|
||||
* both trigger and list for the pointer grace (default false keeps it open
|
||||
* until outside click/Escape/selection). The grace makes the 4px trigger->list
|
||||
* gap and a brief overshoot survivable; coming back cancels the close.
|
||||
* @param props.compact - use reduced menu typography and spacing.
|
||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||
@@ -102,6 +105,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
|
||||
const [fixedPos, setFixedPos] = useState<CSSProperties | null>(null)
|
||||
const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose)
|
||||
|
||||
// Portal mode: fixed-position the list from the anchor rect before paint;
|
||||
// track the anchor while open (capture-phase scroll catches nested panes).
|
||||
@@ -179,6 +183,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
// A close from selection/Escape/outside click outruns a pending grace close;
|
||||
// left armed it would shut a list reopened inside the grace window. Its own
|
||||
// effect, not the listener effect above: that one re-runs on every `onClose`
|
||||
// identity change and would cancel the grace mid-transit.
|
||||
useEffect(() => {
|
||||
if (!open) cancelClose()
|
||||
}, [open, cancelClose])
|
||||
|
||||
// The submenu card is absolutely positioned outside the list box; the
|
||||
// scroll clip would crop it, so only submenu-free menus get the height cap.
|
||||
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
|
||||
@@ -251,7 +263,6 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
// React portals bubble synthetic events through the REACT tree: without
|
||||
// this stop, an item click re-fires the anchor row's own onClick
|
||||
// (open/toggle) after onSelect.
|
||||
@@ -268,8 +279,17 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
</div>
|
||||
)
|
||||
|
||||
// Pointer-leave dismissal watches the WRAPPER, not the list: React's
|
||||
// enter/leave traversal runs over the React tree, so trigger and portaled
|
||||
// list are one region here. Aiming back at the trigger, or crossing the 4px
|
||||
// gap between them, therefore never counts as leaving.
|
||||
return (
|
||||
<span ref={rootRef} className={clsx(css.root, className)}>
|
||||
<span
|
||||
ref={rootRef}
|
||||
className={clsx(css.root, className)}
|
||||
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
|
||||
onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
|
||||
>
|
||||
{anchor}
|
||||
{portal ? (list !== false && createPortal(list, document.body)) : list}
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Modal: controlled full-viewport dialog (create-workspace and similar).
|
||||
// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
|
||||
// stays free of a react-dom dependency; mask tokens match figma 451:18655.
|
||||
// The overlay portals to this document's body so ancestor stacking contexts
|
||||
// cannot leave sticky page controls above the mask. This is still an in-page
|
||||
// WebUI dialog; it never creates or targets another browser/native window.
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16 } from './icons/index.tsx'
|
||||
import css from './Modal.module.css'
|
||||
@@ -13,22 +15,30 @@ import css from './Modal.module.css'
|
||||
* @param props.open - whether the dialog is showing.
|
||||
* @param props.onClose - Escape or mask click.
|
||||
* @param props.title - dialog heading (aria-label in every mode).
|
||||
* @param props.closeLabel - accessible close-button label.
|
||||
* @param props.description - optional supporting sentence under the title.
|
||||
* @param props.children - body (inputs, etc.).
|
||||
* @param props.footer - action row (Cancel / Create).
|
||||
* @param props.contentClassName - optional class for a scrollable content region.
|
||||
* @param props.headless - render children directly in the card (no default
|
||||
* header/close/body chrome) for dialogs whose figma frame owns its own
|
||||
* header structure; mask, card, Escape, and aria-label remain.
|
||||
* @param props.closeLabel - close-button aria label; the owner passes
|
||||
* localized copy (this package is cordis-free, so copy arrives via props).
|
||||
* @returns null when closed; otherwise the overlay tree.
|
||||
*/
|
||||
export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
|
||||
export function Modal({
|
||||
open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
closeLabel?: string
|
||||
description?: string
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
headless?: boolean
|
||||
}) {
|
||||
useEffect(() => {
|
||||
@@ -42,7 +52,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
return createPortal((
|
||||
<div className={css.root} role="presentation">
|
||||
<div className={css.mask} aria-hidden="true" onClick={onClose} />
|
||||
<div
|
||||
@@ -55,10 +65,10 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
||||
? children
|
||||
: (
|
||||
<>
|
||||
<div className={css.content}>
|
||||
<div className={clsx(css.content, contentClassName)}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
|
||||
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -72,5 +82,5 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
), document.body)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner row,
|
||||
markdown code-block font) so a read card and a fenced code block read as one
|
||||
family. Content keeps `white-space: pre` and scrolls horizontally rather than
|
||||
folding, because a source line's indentation is part of what a reader is
|
||||
reading. */
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
flex: none;
|
||||
width: var(--dsl-read-gutter);
|
||||
padding-right: 14px;
|
||||
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 {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 0 0 var(--dsl-read-gutter);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// ReadBlock: the file surface for a read tool result — a banner (label +
|
||||
// language + a "showing N of M" note when the read is a window + a copy
|
||||
// control) over line-numbered, syntax-highlighted source. Each row carries the
|
||||
// file's OWN line number in a gutter, so a windowed read past an offset keeps
|
||||
// its file numbering rather than re-counting from 1. Highlighting reuses the
|
||||
// CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter
|
||||
// needs; an unknown or absent language renders plain monospace. Long content is
|
||||
// height-capped with the same head/tail arithmetic TerminalBlock uses, so the
|
||||
// two cards collapse a long body at the same place. Colors resolve through
|
||||
// --shiki-*/--dsw-* tokens.
|
||||
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import {
|
||||
grammarLoadCount,
|
||||
highlightLines,
|
||||
subscribeGrammarLoaded,
|
||||
type HighlightSpan,
|
||||
} from './markdown/highlight.ts'
|
||||
import css from './ReadBlock.module.css'
|
||||
|
||||
/**
|
||||
* Content lines shown before the height cap collapses the middle. Matches
|
||||
* TerminalBlock's default so a long read and a long command output cut at the
|
||||
* same place in the same flow.
|
||||
*/
|
||||
export const DEFAULT_READ_MAX_LINES = 16
|
||||
|
||||
/** One line of the read window: its file line number and its text (no trailing newline). */
|
||||
export interface ReadBlockLine {
|
||||
/** 1-based line number in the file (a window past an offset keeps the file's own numbering). */
|
||||
number: number
|
||||
/** The line's text, already truncated to the read tool's per-line cap. */
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ReadBlockProps {
|
||||
/** Banner label (the file path, or a tool-supplied replacement title); omitted draws no label. */
|
||||
label?: string | undefined
|
||||
/** The returned window's lines, in file order, each keeping its file line number. */
|
||||
lines: readonly ReadBlockLine[]
|
||||
/** Exact total line count in the file, for the "showing N of M" note when the read is a window. */
|
||||
totalLines: number
|
||||
/** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */
|
||||
lang?: string | undefined
|
||||
/** Height cap in content lines before the middle collapses (default {@link DEFAULT_READ_MAX_LINES}). */
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one line's highlighted runs. The css-variables theme colors every run,
|
||||
* so each run is a styled span; a line with no highlighting at all takes the
|
||||
* bare-text path in the caller instead (an unknown or absent language).
|
||||
* @param spans - the line's styled runs.
|
||||
* @returns the line's children.
|
||||
*/
|
||||
function renderSpans(spans: readonly HighlightSpan[]) {
|
||||
return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a read tool result as a line-numbered, optionally syntax-highlighted
|
||||
* file view.
|
||||
* @param props - see {@link ReadBlockProps}.
|
||||
* @returns the read block element.
|
||||
*/
|
||||
export function ReadBlock({
|
||||
label,
|
||||
lines,
|
||||
totalLines,
|
||||
lang,
|
||||
maxLines = DEFAULT_READ_MAX_LINES,
|
||||
className,
|
||||
}: ReadBlockProps) {
|
||||
// The raw text the copy control writes and the highlighter tokenizes: the
|
||||
// window's lines joined by newlines, without the file numbers or any chrome.
|
||||
// Highlighting the whole window in one call (not line by line) keeps grammar
|
||||
// context across lines — a multi-line string or comment stays one construct.
|
||||
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||||
// Re-render when a lazy grammar finishes loading, so a read card that showed
|
||||
// plain text while its language's grammar imported picks up highlighting. The
|
||||
// snapshot value is opaque; only its change across renders drives the memo.
|
||||
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
|
||||
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
|
||||
// unknown/absent (or not-yet-loaded) language, when every line renders as
|
||||
// bare text.
|
||||
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
// The window's raw text, never the rendered tree: the gutter numbers and the
|
||||
// banner are chrome the file does not contain.
|
||||
void writeClipboard(raw).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, raw])
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
const hidden = lines.length - maxLines
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock's height cap, so a long read and a
|
||||
// long command output slice their head and tail at the same place.
|
||||
const headLines = Math.ceil(maxLines / 2)
|
||||
const tailLines = maxLines - headLines
|
||||
// A read is a window when its returned lines are fewer than the file's total;
|
||||
// the note states that so a reader is not misled that the file ends here.
|
||||
const windowed = lines.length < totalLines
|
||||
|
||||
/**
|
||||
* Render a slice of the line array as gutter-numbered rows.
|
||||
* @param slice - the lines to draw, each with its aligned run array.
|
||||
* @returns the row elements.
|
||||
*/
|
||||
const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) =>
|
||||
slice.map(([line, spans]) => (
|
||||
<div key={line.number} className={css.line}>
|
||||
<span className={css.gutter} aria-hidden>{line.number}</span>
|
||||
<span className={css.content}>{spans === undefined ? line.text : renderSpans(spans)}</span>
|
||||
</div>
|
||||
))
|
||||
|
||||
// Pair each line with its aligned run array up front, so head/tail slicing
|
||||
// keeps the two in step without re-indexing.
|
||||
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
|
||||
[line, highlighted?.[index]])
|
||||
|
||||
return (
|
||||
<div 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}>{`显示 ${lines.length} / ${totalLines} 行`}</span>
|
||||
)}
|
||||
<span className={css.lang}>{lang ?? ''}</span>
|
||||
{/* Hide copy on an empty window, matching TerminalBlock's empty-output
|
||||
guard: a successful read of an empty file returns lines: [] with
|
||||
card:'read', so this branch is reachable, and copying then would
|
||||
wipe the clipboard with an empty string. */}
|
||||
{lines.length > 0 && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{rows(capped ? paired.slice(0, headLines) : paired)}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起内容' : `展开其余 ${hidden} 行`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
</button>
|
||||
)}
|
||||
{capped && rows(paired.slice(paired.length - tailLines))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.confirmation {
|
||||
width: min(440px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.confirmationContent {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.confirmation {
|
||||
max-height: calc(100dvh - 48px);
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.warning p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.acknowledgement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acknowledgement input {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 3px 0 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acknowledgement input:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.acknowledgement input:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.confirmAction {
|
||||
min-width: 136px;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Controlled risk acknowledgement dialog shared by product surfaces that
|
||||
* must gate a sensitive action behind an explicit checkbox.
|
||||
*/
|
||||
import { Button } from './Button.tsx'
|
||||
import { IconWarningOutline16 } from './icons/index.tsx'
|
||||
import { Modal } from './Modal.tsx'
|
||||
import css from './RiskConfirmation.module.css'
|
||||
|
||||
export interface RiskConfirmationProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
acknowledgeLabel: string
|
||||
cancelLabel: string
|
||||
confirmLabel: string
|
||||
acknowledged: boolean
|
||||
disabled?: boolean
|
||||
onAcknowledgedChange: (acknowledged: boolean) => void
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one in-page confirmation whose primary action is unavailable until
|
||||
* the caller-controlled acknowledgement is checked.
|
||||
*/
|
||||
export function RiskConfirmation({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
acknowledgeLabel,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
acknowledged,
|
||||
disabled = false,
|
||||
onAcknowledgedChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RiskConfirmationProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
className={css.confirmation ?? ''}
|
||||
contentClassName={css.confirmationContent ?? ''}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction} onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.confirmAction}
|
||||
disabled={disabled || !acknowledged}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.warning}>
|
||||
<IconWarningOutline16 size={18} className={css.warningIcon} />
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<label className={css.acknowledgement}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span>{acknowledgeLabel}</span>
|
||||
</label>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/* Geometry mirrors CodeBlock and TerminalBlock (12px radius, code-block
|
||||
surface + banner row, markdown code-block font) so a search card reads as one
|
||||
family with them. The deliberate divergence they share: the result rows keep
|
||||
`white-space: pre` and scroll horizontally, because folding a long match line
|
||||
or path destroys the alignment a reader scans by. */
|
||||
|
||||
.block {
|
||||
--dsl-search-radius: 12px;
|
||||
--dsl-search-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-search-radius);
|
||||
}
|
||||
|
||||
/* The banner: result summary on the left, the copy control holding its
|
||||
intrinsic width on the right. */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
border-top-left-radius: var(--dsl-search-radius);
|
||||
border-top-right-radius: var(--dsl-search-radius);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
flex: none;
|
||||
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: 8px 14px 12px 0;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* No wrapping: a match line or a path keeps its content on one row and scrolls
|
||||
sideways instead of folding. */
|
||||
.line {
|
||||
min-height: var(--dsl-search-line-height);
|
||||
padding-left: 14px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* The 1-based line number ahead of a grep match line, dimmed so the match text
|
||||
stays the salient content. */
|
||||
.lineNumber {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* A file group's header: a bold path label plus its match count, the whole row
|
||||
the collapse control. */
|
||||
.fileHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: var(--dsl-search-line-height);
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filePath {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.fileCount {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px 14px;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// SearchBlock: the search surface for a completed content or path search — a
|
||||
// banner (result summary that folds the pre-cap total in when the tool capped
|
||||
// the result, plus a copy control), then either grep matches grouped by file
|
||||
// (each file a bold
|
||||
// path header with its `lineNumber: line` rows, the group collapsible) or a
|
||||
// flat glob path list. Both shapes flatten to one list of rows the height cap
|
||||
// slices head/tail over, and neither soft-wraps: a long match line or path
|
||||
// scrolls horizontally instead of folding. Geometry mirrors CodeBlock and
|
||||
// TerminalBlock so a search card reads as one family with them.
|
||||
|
||||
import { useCallback, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { headTailCap } from './head-tail-cap.ts'
|
||||
import { useCopyFeedback } from './use-copy-feedback.ts'
|
||||
import css from './SearchBlock.module.css'
|
||||
|
||||
/**
|
||||
* Result rows shown before the height cap collapses the middle. Matches
|
||||
* {@link DEFAULT_TERMINAL_MAX_LINES} so a search card and a terminal card cut a
|
||||
* long result at the same place.
|
||||
*/
|
||||
export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
|
||||
/** One matched line inside a {@link SearchFileGroup}: its 1-based line number and text. */
|
||||
export interface SearchBlockLineMatch {
|
||||
/** 1-based line number of the match within its file. */
|
||||
lineNumber: number
|
||||
/** The matched line text, as the tool surfaced it. */
|
||||
line: string
|
||||
}
|
||||
|
||||
/** One file's grouped matches, in first-seen file order. */
|
||||
export interface SearchFileGroup {
|
||||
/** The file the matches belong to (the display path). */
|
||||
path: string
|
||||
/** The file's matched lines, in output order. */
|
||||
matches: SearchBlockLineMatch[]
|
||||
}
|
||||
|
||||
/** Fields both search shapes carry (the render site positions; this component draws). */
|
||||
interface SearchBlockCommon {
|
||||
/**
|
||||
* Whether the tool capped the inline result: the shape carries only the
|
||||
* retained results, not every result the search found. The banner summary
|
||||
* folds the pre-cap `total` in (`显示 X / 共 N …`) so the card never presents a
|
||||
* capped result as complete.
|
||||
*/
|
||||
truncated: boolean
|
||||
/** Total results the search found before capping (equals the retained count when not `truncated`). */
|
||||
total: number
|
||||
/** Height cap in rows before the middle collapses (default {@link DEFAULT_SEARCH_MAX_LINES}). */
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** Props for the grouped-matches (`grep`) shape. */
|
||||
export interface SearchMatchesBlockProps extends SearchBlockCommon {
|
||||
kind: 'matches'
|
||||
/** Matched lines grouped by file, in first-seen file order. */
|
||||
files: SearchFileGroup[]
|
||||
}
|
||||
|
||||
/** Props for the flat-path (`glob`) shape. */
|
||||
export interface SearchPathsBlockProps extends SearchBlockCommon {
|
||||
kind: 'paths'
|
||||
/** The discovered paths, in the tool's result order (the retained page when `truncated`). */
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
/** {@link SearchBlock} props: one card, two `kind`-discriminated shapes. */
|
||||
export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps
|
||||
|
||||
/**
|
||||
* One flattened render row. A matches card produces a `file` header row per
|
||||
* group followed by a `match` row per retained line while the group is
|
||||
* expanded; a paths card produces one `path` row per path. The height cap
|
||||
* counts these rows uniformly, so a file header costs one row exactly as a
|
||||
* match line or a path does.
|
||||
*/
|
||||
type SearchRow =
|
||||
| { type: 'file'; path: string; count: number; index: number; collapsed: boolean }
|
||||
| { type: 'match'; lineNumber: number; line: string; key: string; fileIndex: number }
|
||||
| { type: 'path'; path: string }
|
||||
|
||||
/**
|
||||
* The plain-text form the copy control writes: the whole structured result
|
||||
* regardless of the height cap or which groups are collapsed, so the clipboard
|
||||
* carries the result rather than what the card happens to be showing.
|
||||
* @param props - the card's props.
|
||||
* @returns the copyable text, or the empty string for an empty result.
|
||||
*/
|
||||
function copyText(props: SearchBlockProps): string {
|
||||
if (props.kind === 'paths') return props.paths.join('\n')
|
||||
return props.files
|
||||
.map(file => [file.path, ...file.matches.map(m => `${m.lineNumber}: ${m.line}`)].join('\n'))
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of retained results the card holds: the matched-line count across all
|
||||
* files for a matches card, the path count for a paths card. This is the count
|
||||
* the banner summary reports against `total` when the result was capped.
|
||||
* @param props - the card's props.
|
||||
* @returns the retained result count.
|
||||
*/
|
||||
function shownCount(props: SearchBlockProps): number {
|
||||
return props.kind === 'paths'
|
||||
? props.paths.length
|
||||
: props.files.reduce((sum, file) => sum + file.matches.length, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner summary. When the search was capped it reads `显示 X / 共 N …` so
|
||||
* the retained count and the pre-cap total sit in one clause (mirroring the read
|
||||
* card's `显示 X / Y 行`); when it was not capped it is a plain count of what the
|
||||
* card holds. The unit — `处匹配 · K 个文件` for grep, `个路径` for glob — trails
|
||||
* the count either way.
|
||||
* @param props - the card's props.
|
||||
* @param shown - the retained result count from {@link shownCount}.
|
||||
* @param truncated - whether the search was capped.
|
||||
* @param total - the pre-cap total the truncation clause reports.
|
||||
* @returns the summary text.
|
||||
*/
|
||||
function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string {
|
||||
const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}`
|
||||
return props.kind === 'paths'
|
||||
? `${count} 个路径`
|
||||
: `${count} 处匹配 · ${props.files.length} 个文件`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a card's shape into its render rows, dropping a collapsed file
|
||||
* group's match rows.
|
||||
* @param props - the card's props.
|
||||
* @param collapsed - the set of collapsed file-group indices (matches only).
|
||||
* @returns the flattened rows in output order.
|
||||
*/
|
||||
function toRows(props: SearchBlockProps, collapsed: ReadonlySet<number>): SearchRow[] {
|
||||
if (props.kind === 'paths') return props.paths.map((path): SearchRow => ({ type: 'path', path }))
|
||||
const rows: SearchRow[] = []
|
||||
props.files.forEach((file, index) => {
|
||||
const isCollapsed = collapsed.has(index)
|
||||
rows.push({ type: 'file', path: file.path, count: file.matches.length, index, collapsed: isCollapsed })
|
||||
if (isCollapsed) return
|
||||
for (const match of file.matches) {
|
||||
rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}`, fileIndex: index })
|
||||
}
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable React key for a flattened render row: the group-scoped match key, a
|
||||
* file-index-scoped header key, or the path itself. Rows of different types
|
||||
* never collide, since each key carries its type prefix or the group index.
|
||||
* @param row - the flattened row.
|
||||
* @returns the key.
|
||||
*/
|
||||
function rowKey(row: SearchRow): string {
|
||||
switch (row.type) {
|
||||
case 'match': return `match:${row.key}`
|
||||
case 'file': return `file:${row.index}`
|
||||
case 'path': return `path:${row.path}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a completed search as a grouped-matches or flat-path card.
|
||||
* @param props - see {@link SearchBlockProps}.
|
||||
* @returns the search block element.
|
||||
*/
|
||||
export function SearchBlock(props: SearchBlockProps) {
|
||||
const { truncated, total, maxLines = DEFAULT_SEARCH_MAX_LINES, className } = props
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
|
||||
// `props` is a fresh object each render, so memoizing on it never hits; the
|
||||
// flatten is cheap, so it runs inline keyed on the collapse set instead.
|
||||
const rows = toRows(props, collapsed)
|
||||
const shown = shownCount(props)
|
||||
const empty = rows.length === 0
|
||||
const { copied, onCopy } = useCopyFeedback(copyText(props))
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
const toggleFile = useCallback((index: number) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(index)) next.delete(index)
|
||||
else next.add(index)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const { hidden, capped, headLines, tailLines } = headTailCap(rows.length, maxLines, expanded)
|
||||
const head = capped ? rows.slice(0, headLines) : rows
|
||||
const naturalTail = capped ? rows.slice(rows.length - tailLines) : []
|
||||
// When the tail slice begins inside a file's matches, its own header sits
|
||||
// above the cut and is not shown, so those rows could not be attributed to a
|
||||
// file. Restore the owning header at the top of the tail — unless the head
|
||||
// slice already carries it (a single large file), where it would duplicate.
|
||||
const tailLead = naturalTail[0]
|
||||
const tailHeader = tailLead?.type === 'match'
|
||||
&& !head.some(row => row.type === 'file' && row.index === tailLead.fileIndex)
|
||||
? rows.find((row): row is Extract<SearchRow, { type: 'file' }> =>
|
||||
row.type === 'file' && row.index === tailLead.fileIndex)
|
||||
: undefined
|
||||
// The restored header is itself a row. Left extra it would push the card to
|
||||
// maxLines + 1 and overstate `hidden` by one, so it consumes a tail slot: drop
|
||||
// the tail's first row (the match whose header this is) for it. Visible rows
|
||||
// hold at maxLines and `hidden` stays exact; the dropped match joins the
|
||||
// hidden middle.
|
||||
const tail = tailHeader === undefined ? naturalTail : naturalTail.slice(1)
|
||||
|
||||
const renderRow = (row: SearchRow): ReactNode => {
|
||||
if (row.type === 'path') return <div className={css.line}>{row.path}</div>
|
||||
if (row.type === 'match') {
|
||||
return (
|
||||
<div className={css.line}>
|
||||
<span className={css.lineNumber}>{row.lineNumber}: </span>
|
||||
{row.line}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileHeader}
|
||||
aria-expanded={!row.collapsed}
|
||||
onClick={() => { toggleFile(row.index) }}
|
||||
>
|
||||
<span className={css.filePath}>{row.path}</span>
|
||||
<span className={css.fileCount}>{row.count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-search={props.kind}>
|
||||
<div className={css.header}>
|
||||
<span className={css.summary}>{summaryText(props, shown, truncated, total)}</span>
|
||||
{!empty && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{empty
|
||||
? <div className={css.empty}>无结果</div>
|
||||
: (
|
||||
<div className={css.body}>
|
||||
{head.map(row => (
|
||||
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
</button>
|
||||
)}
|
||||
{tailHeader !== undefined && (
|
||||
<div key={`tailHeader:${rowKey(tailHeader)}`}>{renderRow(tailHeader)}</div>
|
||||
)}
|
||||
{tail.map(row => (
|
||||
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
.block {
|
||||
--dsl-terminal-radius: 12px;
|
||||
--dsl-terminal-line-height: 22px;
|
||||
/* Rebindable by consumers (CodeBlock's --dsl-code-block-content-font
|
||||
pattern): a surface wanting the smaller code size rebinds this together
|
||||
with --dsl-terminal-line-height on its own container. */
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block);
|
||||
/* The card's own left inset, holding the run-state dot in a column of its own
|
||||
so it never competes with the commands for horizontal space. */
|
||||
--dsl-terminal-gutter: 30px;
|
||||
@@ -22,26 +26,49 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-terminal-radius);
|
||||
/* Clip the banner to the card's own radius: when a consumer adds a border,
|
||||
the banner's equal corner radius no longer nests inside it and leaves a
|
||||
notch at the corner. Nothing inside renders out of the box. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top-aligned: the status pill and copy control stay on the first prompt row
|
||||
however many command lines the card carries. */
|
||||
/* The status pill and copy control top-align to the FIRST prompt row (their
|
||||
heights are capped to the prompt line, so on a multi-line command they sit
|
||||
with the first command instead of floating mid-banner). */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
/* Pulled back across the card's gutter padding so the banner background and
|
||||
its top-left radius span the FULL surface, then re-inset by the same amount
|
||||
so the prompt text and the dot keep their positions. A plain block child
|
||||
only reaches the content box, which left the gutter column painted in the
|
||||
body color and drew the card's top-left corner in it — invisible in the
|
||||
light theme, where banner and body share a token, and visible in the dark
|
||||
one, where they do not. */
|
||||
/* Pulled back across the card's gutter padding so the banner spans the FULL
|
||||
surface, then re-inset by the same amount so the prompt text and the dot
|
||||
keep their positions. The banner shares the card's own surface (no banner
|
||||
token): the l2 divider below is the section boundary. */
|
||||
margin-left: calc(-1 * var(--dsl-terminal-gutter));
|
||||
padding: 9px 14px 9px var(--dsl-terminal-gutter);
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
border-top-left-radius: var(--dsl-terminal-radius);
|
||||
border-top-right-radius: var(--dsl-terminal-radius);
|
||||
/* A long multi-line command scrolls inside the banner (same cap as the
|
||||
IN/OUT card's sections) instead of pushing the output off screen. */
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Banner scrollbar floats off the card edge like the output's. */
|
||||
.header::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.header::-webkit-scrollbar-track {
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
/* Full-width l2 hairline between the command banner and the body — the same
|
||||
divider the IN/OUT card draws between its sections. A running card is
|
||||
banner-only, so it draws none. */
|
||||
.block:not([data-running]) .header {
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* One row per command line. The prompt column is the only element allowed to
|
||||
@@ -51,7 +78,7 @@
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
font: var(--dsl-terminal-font);
|
||||
}
|
||||
|
||||
.promptLine {
|
||||
@@ -100,27 +127,59 @@
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Capped to the prompt's line height (Pill's own 24px height would exceed a
|
||||
smaller-font prompt row and stretch the banner). Sticky against the
|
||||
banner's own scroll so the pill and the copy control stay in reach while a
|
||||
long command scrolls underneath. */
|
||||
.status {
|
||||
flex: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: var(--dsl-terminal-line-height);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
flex: none;
|
||||
background-color: transparent;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Card surface, not transparent: the control is sticky over the banner's
|
||||
own scroll, so scrolled command text must not bleed through it. */
|
||||
background-color: var(--dsw-alias-markdown-code-block);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
line-height: var(--dsl-terminal-line-height);
|
||||
}
|
||||
|
||||
/* Vertical scrolling lives on the OUTPUT, not the card root: a root scroller
|
||||
would run its scrollbar over the banner (and the copy control), while here
|
||||
the banner stays pinned and the bar sits inside the output's right padding.
|
||||
Unset, the max-height is none and the auto overflow never engages. */
|
||||
.output {
|
||||
max-height: var(--dsl-terminal-output-max-height, none);
|
||||
padding: 12px 14px 12px 0;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
font: var(--dsl-terminal-font);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Both output scrollbars (vertical cap, horizontal pre overflow) float 2px
|
||||
off the card edge: a transparent border clips the thumb inward so it never
|
||||
hugs the rounded corner. */
|
||||
.output::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Track end-margins keep the thumb's travel out of the card's rounded
|
||||
corners in both directions. */
|
||||
.output::-webkit-scrollbar-track {
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
/* No wrapping, no word-break: alignment is the payload of terminal output. */
|
||||
@@ -147,6 +206,6 @@
|
||||
|
||||
.empty {
|
||||
padding: 12px 14px 12px 0;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
font: var(--dsl-terminal-font);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { parseAnsiLines, type AnsiLine } from './ansi.ts'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import { headTailCap } from './head-tail-cap.ts'
|
||||
import { useCopyFeedback } from './use-copy-feedback.ts'
|
||||
import { Pill } from './Pill.tsx'
|
||||
import { StateDot, type StateDotState } from './StateDot.tsx'
|
||||
import css from './TerminalBlock.module.css'
|
||||
@@ -20,6 +21,54 @@ import css from './TerminalBlock.module.css'
|
||||
*/
|
||||
export const DEFAULT_TERMINAL_MAX_LINES = 16
|
||||
|
||||
/**
|
||||
* Display copy for the terminal surface; the owner passes localized labels
|
||||
* (this package is cordis-free, so copy arrives via props). Every field
|
||||
* defaults to the current built-in value, so existing consumers render
|
||||
* unchanged.
|
||||
*/
|
||||
export interface TerminalBlockLabels {
|
||||
/** Status pill text for a signal-terminated command. */
|
||||
signal: (signal: string) => string
|
||||
/** Status pill text for a non-zero exit code. */
|
||||
exitCode: (exitCode: number) => string
|
||||
/** Run-state text while the command is still running. */
|
||||
running: string
|
||||
/** Run-state text for a signal or non-zero-exit settle. */
|
||||
failed: string
|
||||
/** Run-state text for a clean settle. */
|
||||
done: string
|
||||
/** Copy-button idle label. */
|
||||
copy: string
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copied: string
|
||||
/** Placeholder when a settled command produced no visible output. */
|
||||
noOutput: string
|
||||
/** Collapse-toggle aria label while expanded. */
|
||||
collapseAria: string
|
||||
/** Collapse-toggle text while expanded. */
|
||||
collapse: string
|
||||
/** Expand-toggle aria label while capped, given the hidden line count. */
|
||||
expandAria: (hidden: number) => string
|
||||
/** Expand-toggle text while capped, given the hidden line count. */
|
||||
expand: (hidden: number) => string
|
||||
}
|
||||
|
||||
const DEFAULT_LABELS: TerminalBlockLabels = {
|
||||
signal: signal => `信号 ${signal}`,
|
||||
exitCode: exitCode => `退出码 ${exitCode}`,
|
||||
running: '运行中',
|
||||
failed: '失败',
|
||||
done: '已完成',
|
||||
copy: '复制',
|
||||
copied: '复制成功',
|
||||
noOutput: '无输出',
|
||||
collapseAria: '收起输出',
|
||||
collapse: '收起',
|
||||
expandAria: hidden => `展开其余 ${hidden} 行输出`,
|
||||
expand: hidden => `… 其余 ${hidden} 行`,
|
||||
}
|
||||
|
||||
export interface TerminalBlockProps {
|
||||
/** The command line, rendered verbatim after the prompt label. */
|
||||
command: string
|
||||
@@ -35,10 +84,12 @@ export interface TerminalBlockProps {
|
||||
signal?: string | undefined
|
||||
/** The command is still running: the block shows the prompt line alone. */
|
||||
running?: boolean | undefined
|
||||
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
|
||||
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
/** Localized display copy; omitted fields keep the built-in defaults. */
|
||||
labels?: Partial<TerminalBlockLabels> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,11 +114,16 @@ function promptLabel(cwd: string, home: string | undefined): string {
|
||||
* distinction the bash tool's own exit-status markers draw.
|
||||
* @param exitCode - settled exit code, when known.
|
||||
* @param signal - settled terminating signal name, when known.
|
||||
* @param labels - display copy for the pill text.
|
||||
* @returns the pill text, or undefined for a clean exit.
|
||||
*/
|
||||
function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
|
||||
if (signal !== undefined) return `信号 ${signal}`
|
||||
if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
|
||||
function statusText(
|
||||
exitCode: number | undefined,
|
||||
signal: string | undefined,
|
||||
labels: TerminalBlockLabels,
|
||||
): string | undefined {
|
||||
if (signal !== undefined) return labels.signal(signal)
|
||||
if (exitCode !== undefined && exitCode !== 0) return labels.exitCode(exitCode)
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -84,16 +140,18 @@ function statusText(exitCode: number | undefined, signal: string | undefined): s
|
||||
* @param running - the command has not settled.
|
||||
* @param exitCode - settled exit code, when known.
|
||||
* @param signal - settled terminating signal name, when known.
|
||||
* @param labels - display copy for the text label.
|
||||
* @returns the dot's state and its text label, since the dot is aria-hidden.
|
||||
*/
|
||||
function runState(
|
||||
running: boolean,
|
||||
exitCode: number | undefined,
|
||||
signal: string | undefined,
|
||||
labels: TerminalBlockLabels,
|
||||
): { state: StateDotState; label: string } {
|
||||
if (running) return { state: 'ongoing', label: '运行中' }
|
||||
if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
|
||||
return { state: 'done', label: '已完成' }
|
||||
if (running) return { state: 'ongoing', label: labels.running }
|
||||
if (statusText(exitCode, signal, labels) !== undefined) return { state: 'error', label: labels.failed }
|
||||
return { state: 'done', label: labels.done }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +181,12 @@ export function TerminalBlock({
|
||||
running = false,
|
||||
maxLines = DEFAULT_TERMINAL_MAX_LINES,
|
||||
className,
|
||||
labels,
|
||||
}: TerminalBlockProps) {
|
||||
const copy = useMemo<TerminalBlockLabels>(
|
||||
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
|
||||
[labels],
|
||||
)
|
||||
const text = output ?? ''
|
||||
// A command's output ends with a newline; that terminator is not an extra
|
||||
// blank line to draw or to count against the height cap. The check runs on the
|
||||
@@ -140,23 +203,14 @@ export function TerminalBlock({
|
||||
return terminated ? parsed.slice(0, -1) : parsed
|
||||
}, [text])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
// The raw output, never the rendered tree: the prompt line and the status
|
||||
// pill are chrome the user did not run.
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, text])
|
||||
// The raw output, never the rendered tree: the prompt line and the status pill
|
||||
// are chrome the user did not run.
|
||||
const { copied, onCopy } = useCopyFeedback(text)
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
const status = statusText(exitCode, signal)
|
||||
const state = runState(running, exitCode, signal)
|
||||
const status = statusText(exitCode, signal, copy)
|
||||
const state = runState(running, exitCode, signal, copy)
|
||||
// A multi-line command gets one prompt row per line, so a two-command shell
|
||||
// snippet reads as the two commands it is instead of collapsing into one
|
||||
// ellipsized row. A trailing newline is a terminator, not an empty command.
|
||||
@@ -170,12 +224,7 @@ export function TerminalBlock({
|
||||
// the raw text drew an output box of blank rows plus a copy control for
|
||||
// invisible bytes, and hid the placeholder that belongs there.
|
||||
const empty = lines.every(line => line.every(span => span.text.trim() === ''))
|
||||
const hidden = lines.length - maxLines
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as the TUI transcript's collapsed tool card, so a
|
||||
// command's head and tail slices agree between the two front ends.
|
||||
const headLines = Math.ceil(maxLines / 2)
|
||||
const tailLines = maxLines - headLines
|
||||
const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded)
|
||||
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
|
||||
@@ -205,12 +254,12 @@ export function TerminalBlock({
|
||||
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
|
||||
{!running && !empty && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
{copied ? copy.copied : copy.copy}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!running && (empty
|
||||
? <div className={css.empty}>无输出</div>
|
||||
? <div className={css.empty}>{copy.noOutput}</div>
|
||||
: (
|
||||
<div className={css.output}>
|
||||
{(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
|
||||
@@ -221,10 +270,10 @@ export function TerminalBlock({
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
|
||||
aria-label={expanded ? copy.collapseAria : copy.expandAria(hidden)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
{expanded ? copy.collapse : copy.expand(hidden)}
|
||||
</button>
|
||||
)}
|
||||
{capped && lines.slice(lines.length - tailLines).map((line, index) => (
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
color: var(--dsw-static-neutral-bluish-00);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
white-space: pre-line;
|
||||
pointer-events: none;
|
||||
animation: tooltip-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
||||
// TODO: interaction is a placeholder (no show delay, no flip on viewport
|
||||
// collision, no arrow) — visuals and behavior get a proper pass later.
|
||||
// TODO: interaction is a placeholder (no flip on viewport collision or
|
||||
// arrow) — visuals and behavior get a proper pass later.
|
||||
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
||||
// attaching a tooltip never changes the anchor's layout context. The bubble is
|
||||
// position:fixed and coordinates come from the anchor's rect at show time, so
|
||||
@@ -27,12 +27,13 @@ interface AnchorProps {
|
||||
* Attach a hover/focus tooltip to an anchor element.
|
||||
* @param props.label - bubble text.
|
||||
* @param props.side - placement relative to the anchor (default 'right').
|
||||
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||
* toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||
// anchor in Tooltip never silently severs the owner's ref.
|
||||
@@ -43,15 +44,26 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
|
||||
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
|
||||
}, [childRef])
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
const triggers = useRef({ hover: false, focus: false })
|
||||
|
||||
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
|
||||
// must drop an already-visible bubble: no mouseleave fires.
|
||||
const cancelShow = useCallback(() => {
|
||||
if (showTimer.current === null) return
|
||||
clearTimeout(showTimer.current)
|
||||
showTimer.current = null
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) }
|
||||
}, [disabled])
|
||||
if (disabled) {
|
||||
cancelShow()
|
||||
triggers.current = { hover: false, focus: false }
|
||||
setPos(null)
|
||||
}
|
||||
return cancelShow
|
||||
}, [cancelShow, disabled])
|
||||
|
||||
const show = () => {
|
||||
if (disabled) return
|
||||
@@ -63,7 +75,19 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
|
||||
? { x: r.right + 10, y: r.top + r.height / 2 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
}
|
||||
const showAfterHoverDelay = () => {
|
||||
cancelShow()
|
||||
if (delayMs <= 0) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
showTimer.current = setTimeout(() => {
|
||||
showTimer.current = null
|
||||
show()
|
||||
}, delayMs)
|
||||
}
|
||||
const hide = () => {
|
||||
cancelShow()
|
||||
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
|
||||
}
|
||||
|
||||
@@ -71,9 +95,9 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
|
||||
<>
|
||||
{cloneElement(children, {
|
||||
ref: mergedRef,
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() },
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface,
|
||||
16px vertical margin) so a web card, a terminal card, and a fenced code block
|
||||
read as one family. A source list is prose, not aligned output, so it wraps
|
||||
normally rather than scrolling horizontally like a terminal card's output. */
|
||||
|
||||
.block {
|
||||
--dsl-web-radius: 12px;
|
||||
|
||||
margin: 16px 0;
|
||||
padding: 12px 14px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-web-radius);
|
||||
}
|
||||
|
||||
/* The provider answer reads as body prose above the citation list; its own
|
||||
MarkdownText margins are trimmed so the list sits tight under it. */
|
||||
.answer {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.answer > :global(div) > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.answer > :global(div) > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* The citation list: ordered so each source reads as a numbered reference. */
|
||||
.sources {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sourceLink {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sourceLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.snippet {
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.published {
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.expandItem {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.truncated {
|
||||
margin-top: 8px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The fetch card is a compact summary: the URL over a status/truncation row. */
|
||||
.fetch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fetchUrl {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fetchUrl:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fetchMeta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The fetch card's truncation note sits inline beside the status, so it drops
|
||||
the search card's top margin. */
|
||||
.fetch .truncated {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// WebBlock: the surface for a completed web retrieval. One component draws both
|
||||
// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an
|
||||
// optional provider answer above a citation list of sources (each a safe
|
||||
// external link labelled by its title, or its hostname when the provider gave
|
||||
// none, with the snippet and publication date below it), and a `fetch` shows a
|
||||
// compact retrieval summary (the linked final URL and its HTTP status). Both
|
||||
// mark a capped retrieval. Every link is a same-origin-safe external anchor:
|
||||
// only http(s) URLs become anchors (target/rel set) — the http(s) subset of the
|
||||
// allowlist MarkdownText applies to untrusted assistant-authored links (it also
|
||||
// permits mailto, excluded here); an unparseable or non-http URL renders as
|
||||
// plain text. Geometry, radius, and fonts mirror CodeBlock/TerminalBlock so a
|
||||
// web card reads as one family with them; a long source list caps at maxSources
|
||||
// with a head/tail collapse using the same arithmetic as TerminalBlock's output
|
||||
// cap.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
import css from './WebBlock.module.css'
|
||||
|
||||
/**
|
||||
* Sources shown before the height cap collapses the middle of a citation list.
|
||||
* Matches TerminalBlock's default output budget so both cards cut a long body
|
||||
* at the same place; the chat row narrows it through the maxSources prop.
|
||||
*/
|
||||
export const DEFAULT_WEB_MAX_SOURCES = 16
|
||||
|
||||
/**
|
||||
* One citeable source drawn in a search card: the projection of the contract's
|
||||
* `WebSource`, with the optional fields kept optional so a provider that
|
||||
* returned only a URL still renders (its hostname becomes the label).
|
||||
*/
|
||||
export interface WebSourceView {
|
||||
/** The source URL; becomes a safe external link when it is http(s). */
|
||||
url: string
|
||||
/** The source title; when absent the URL's hostname labels the link. */
|
||||
title?: string | undefined
|
||||
/** A short excerpt or summary shown under the link. */
|
||||
snippet?: string | undefined
|
||||
/** Publication/crawl timestamp, a provider-supplied string shown under the link. */
|
||||
publishedAt?: string | undefined
|
||||
}
|
||||
|
||||
/** A `web_search` card: an optional answer over a capped citation list. */
|
||||
export interface WebSearchBlockProps {
|
||||
kind: 'search'
|
||||
/** The provider-generated answer, rendered as markdown above the sources. */
|
||||
answer?: string | undefined
|
||||
/** The cited sources, in provider order. */
|
||||
sources: WebSourceView[]
|
||||
/** True when the tool cut the source list to its result cap. */
|
||||
truncated: boolean
|
||||
/** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */
|
||||
maxSources?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** A `web_fetch` card: the retrieval summary for one fetched URL. */
|
||||
export interface WebFetchBlockProps {
|
||||
kind: 'fetch'
|
||||
/** The final URL after allowed redirects; becomes a safe external link when http(s). */
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/** True when the provider or the output cap cut the fetched content. */
|
||||
truncated: boolean
|
||||
/**
|
||||
* Accepted and ignored, so both card kinds take one uniform prop set (a fetch
|
||||
* card has no source list to cap) — the same way TerminalBlock accepts one
|
||||
* `maxLines` across its arms. Lets a render site spread `maxSources` onto
|
||||
* either kind without a per-kind conditional.
|
||||
*/
|
||||
maxSources?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** A completed web retrieval card, discriminated by `kind`. */
|
||||
export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
|
||||
|
||||
/**
|
||||
* The URL to link to, or undefined when the URL must render as plain text. Only
|
||||
* http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:`
|
||||
* URL or an unparseable string never reaches the DOM as an href. This is the
|
||||
* http(s) subset of the allowlist MarkdownText applies to untrusted links —
|
||||
* MarkdownText also permits `mailto:`, deliberately excluded here since a
|
||||
* retrieval URL is never a mail address.
|
||||
* @param url - the source or fetch URL, from tool result content.
|
||||
* @returns the href to use, or undefined for plain text.
|
||||
*/
|
||||
function safeHref(url: string): string | undefined {
|
||||
try {
|
||||
const { protocol } = new URL(url)
|
||||
return protocol === 'http:' || protocol === 'https:' ? url : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The link's visible label: the title when the provider gave one, otherwise the
|
||||
* URL's hostname, falling back to the raw URL when it does not parse OR parses
|
||||
* to an empty hostname (a `file:`/`data:`/`javascript:` URL), so a label is
|
||||
* never blank.
|
||||
* @param url - the source URL.
|
||||
* @param title - the provider title, if any.
|
||||
* @returns the label text.
|
||||
*/
|
||||
function linkLabel(url: string, title: string | undefined): string {
|
||||
if (title !== undefined && title !== '') return title
|
||||
try {
|
||||
const { hostname } = new URL(url)
|
||||
return hostname === '' ? url : hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single URL rendered as a safe external anchor, or as plain text when the
|
||||
* URL is not an http(s) link.
|
||||
* @param props.url - the URL to render.
|
||||
* @param props.label - the visible label.
|
||||
* @param props.className - class for the anchor or the plain span.
|
||||
* @returns the anchor or span element.
|
||||
*/
|
||||
function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) {
|
||||
const href = safeHref(url)
|
||||
if (href === undefined) return <span className={className}>{label}</span>
|
||||
return (
|
||||
<a className={className} href={href} target="_blank" rel="noopener noreferrer">
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One source row in a search card: the safe link plus its snippet and date. The
|
||||
* `<li value>` pins the source's original 1-based position, so a collapsed list
|
||||
* whose tail is drawn after the head still numbers each source by its real
|
||||
* citation index rather than by its position in the visible subset.
|
||||
* @param props.source - the source to render.
|
||||
* @param props.ordinal - the source's 1-based position in the full list.
|
||||
* @returns the source list item.
|
||||
*/
|
||||
function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: number }) {
|
||||
return (
|
||||
<li className={css.source} value={ordinal}>
|
||||
<SafeLink url={source.url} label={linkLabel(source.url, source.title)} className={css.sourceLink} />
|
||||
{source.snippet !== undefined && source.snippet !== '' && (
|
||||
<div className={css.snippet}>{source.snippet}</div>
|
||||
)}
|
||||
{source.publishedAt !== undefined && source.publishedAt !== '' && (
|
||||
<div className={css.published}>{source.publishedAt}</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The search card body: the answer over the capped source list.
|
||||
* @param props - see {@link WebSearchBlockProps}.
|
||||
* @returns the search card element.
|
||||
*/
|
||||
function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
const hidden = sources.length - maxSources
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock's output cap, so a long body's head
|
||||
// and tail slices agree between the two cards.
|
||||
const headCount = Math.ceil(maxSources / 2)
|
||||
const tailCount = maxSources - headCount
|
||||
const head = capped ? sources.slice(0, headCount) : sources
|
||||
const tail = capped ? sources.slice(sources.length - tailCount) : []
|
||||
// A provider may legitimately return no answer and no sources; the chat WebRow
|
||||
// does not show the raw result content, so without this the user would see an
|
||||
// empty card. Mirror the backend's `No results found.` render text.
|
||||
const empty = (answer === undefined || answer === '') && sources.length === 0
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-web="search">
|
||||
{answer !== undefined && answer !== '' && (
|
||||
<div className={css.answer}><MarkdownText text={answer} /></div>
|
||||
)}
|
||||
{empty ? (
|
||||
<div className={css.empty}>未找到结果</div>
|
||||
) : (
|
||||
<ol className={css.sources}>
|
||||
{head.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
|
||||
{hidden > 0 && (
|
||||
<li className={css.expandItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 条来源`}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{tail.map((source, index) => (
|
||||
<SourceItem
|
||||
key={sources.length - tailCount + index}
|
||||
source={source}
|
||||
ordinal={sources.length - tailCount + index + 1}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{truncated && <div className={css.truncated}>来源列表已截断</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fetch card body: the linked URL and its HTTP status.
|
||||
* @param props - see {@link WebFetchBlockProps}.
|
||||
* @returns the fetch card element.
|
||||
*/
|
||||
function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) {
|
||||
return (
|
||||
<div className={clsx(css.block, css.fetch, className)} data-web="fetch">
|
||||
<SafeLink url={url} label={url} className={css.fetchUrl} />
|
||||
<div className={css.fetchMeta}>
|
||||
<span className={css.status}>HTTP {statusCode}</span>
|
||||
{truncated && <span className={css.truncated}>内容已截断</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a completed web retrieval as a structured card.
|
||||
* @param props - see {@link WebBlockProps}; `kind` selects the search or fetch body.
|
||||
* @returns the web card element.
|
||||
*/
|
||||
export function WebBlock(props: WebBlockProps) {
|
||||
return props.kind === 'search' ? <WebSearchBlock {...props} /> : <WebFetchBlock {...props} />
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Head/tail height-cap arithmetic shared by the block primitives (TerminalBlock,
|
||||
// SearchBlock) and matching the TUI transcript's collapsed tool card, so a long
|
||||
// result's head and tail slices agree across every surface. The split is
|
||||
// `ceil(maxLines / 2)` head rows and the remainder as tail rows; a result within
|
||||
// the cap shows every row and hides none.
|
||||
|
||||
/** The head/tail split metrics for a capped list. */
|
||||
export interface HeadTailCap {
|
||||
/** Rows beyond the cap (list length − maxLines); ≤ 0 means nothing is hidden. */
|
||||
hidden: number
|
||||
/** Whether the list is over the cap and not expanded, so it shows a head/tail slice. */
|
||||
capped: boolean
|
||||
/** Head-slice row count: `ceil(maxLines / 2)`. */
|
||||
headLines: number
|
||||
/** Tail-slice row count: the remainder after the head. */
|
||||
tailLines: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the head/tail cap metrics for a list of `total` rows against `maxLines`,
|
||||
* given whether the surface is expanded. Pure arithmetic; the caller slices its
|
||||
* own rows with `headLines`/`tailLines` so a block can layer its own concerns
|
||||
* (SearchBlock restores a tail file header) on top.
|
||||
* @param total - the list's row count.
|
||||
* @param maxLines - the collapsed-height cap in rows.
|
||||
* @param expanded - whether the surface is expanded (uncaps the list).
|
||||
* @returns the split metrics.
|
||||
*/
|
||||
export function headTailCap(total: number, maxLines: number, expanded: boolean): HeadTailCap {
|
||||
const hidden = total - maxLines
|
||||
const headLines = Math.ceil(maxLines / 2)
|
||||
return { hidden, capped: hidden > 0 && !expanded, headLines, tailLines: maxLines - headLines }
|
||||
}
|
||||
@@ -728,3 +728,18 @@ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<path d="M7.39455 9.44026V10.8109H6.16921V9.44026H7.39455Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_archive_outline_20 (figma extract): lidded box + label slot. The export's
|
||||
* 0.11px stroke ring around the box contour is dropped — it restates the same
|
||||
* contour in the same ink, which currentColor already carries. */
|
||||
export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.8659 2.05975C17.2603 2.05995 18.3913 3.19096 18.3914 4.58527V5.4874C18.3914 6.02747 18.2192 6.52672 17.9303 6.93735C17.9336 6.96524 17.9388 6.99318 17.9388 7.02195V12.8884C17.9388 13.6345 17.9395 14.2379 17.8996 14.7254C17.8642 15.1593 17.7936 15.5499 17.6373 15.9141L17.5654 16.0685C17.278 16.6328 16.8405 17.1046 16.3038 17.434L16.0679 17.5661C15.66 17.7739 15.2196 17.8598 14.7237 17.9003C14.2362 17.9401 13.6327 17.9405 12.8867 17.9405H7.11122C6.36511 17.9405 5.76171 17.9401 5.27418 17.9003C4.84051 17.8649 4.44949 17.7952 4.08545 17.6391L3.93104 17.5661C3.36673 17.2785 2.89392 16.8414 2.56465 16.3044L2.43245 16.0685C2.22473 15.6608 2.13878 15.2211 2.09825 14.7254C2.05841 14.2379 2.05912 13.6345 2.05912 12.8884V7.02195C2.05912 6.99284 2.06422 6.96449 2.06758 6.93629C1.77931 6.52592 1.60858 6.02687 1.60858 5.4874V4.58527C1.60876 3.19084 2.73962 2.05975 4.1341 2.05975H15.8659ZM16.4984 7.92936C16.296 7.98169 16.0847 8.01288 15.8659 8.01291H4.1341C3.91478 8.01291 3.70246 7.98194 3.49955 7.92936V12.8884C3.49955 13.6582 3.50053 14.1927 3.53445 14.608C3.56769 15.0146 3.62923 15.244 3.71635 15.415L3.7925 15.5514C3.98339 15.8627 4.25749 16.1165 4.58464 16.2833L4.72529 16.3435C4.88095 16.3993 5.08638 16.4402 5.39158 16.4651C5.80685 16.4991 6.34138 16.5001 7.11122 16.5001H12.8867C13.6564 16.5001 14.1911 16.499 14.6063 16.4651C15.0128 16.432 15.2423 16.3703 15.4133 16.2833L15.5508 16.2061C15.8618 16.0152 16.116 15.7419 16.2827 15.415L16.3429 15.2732C16.3985 15.1177 16.4396 14.9128 16.4645 14.608C16.4985 14.1927 16.4984 13.6583 16.4984 12.8884V7.92936ZM4.1341 3.50019C3.53511 3.50019 3.0492 3.98631 3.04902 4.58527V5.4874C3.04902 6.08649 3.535 6.57248 4.1341 6.57248H15.8659C16.4648 6.57228 16.951 6.08638 16.951 5.4874V4.58527C16.9509 3.98644 16.4647 3.50038 15.8659 3.50019H4.1341Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M12.7962 12.5661V11.0832H7.20548V12.5661L12.7962 12.5661Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Shared props for every ic_ds_* icon component. */
|
||||
export interface IconProps {
|
||||
/** Square edge in px; defaults to the glyph's native size (14 or 16). */
|
||||
/** Square edge in px; defaults to the glyph's own drawn size. */
|
||||
size?: number | undefined
|
||||
/** Extra class for layout placement; color rides currentColor.
|
||||
* (`| undefined` for exactOptionalPropertyTypes: callers forward their own optional prop.) */
|
||||
|
||||
@@ -13,18 +13,32 @@ export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
|
||||
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
|
||||
export { HoverCard } from './HoverCard.tsx'
|
||||
export { Modal } from './Modal.tsx'
|
||||
export { RiskConfirmation } from './RiskConfirmation.tsx'
|
||||
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
|
||||
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
|
||||
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
|
||||
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
|
||||
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
|
||||
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
|
||||
export type {
|
||||
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
|
||||
} from './SearchBlock.tsx'
|
||||
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
|
||||
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
// plain fallback for everything else. Chrome (language banner + copy) matches
|
||||
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from '../clipboard.ts'
|
||||
import { highlightToHtml } from './highlight.ts'
|
||||
import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
|
||||
import css from './CodeBlock.module.css'
|
||||
|
||||
export interface CodeBlockProps {
|
||||
@@ -17,11 +17,19 @@ export interface CodeBlockProps {
|
||||
lang?: string | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
|
||||
copyLabel?: string | undefined
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel?: string | undefined
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
|
||||
// text while its language's grammar imported picks up highlighting. The
|
||||
// snapshot value is opaque; only its change across renders drives the memo.
|
||||
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -55,7 +63,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
<div className={css.infostring}>{lang ?? ''}</div>
|
||||
<div className={css.action}>
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
{copied ? copiedLabel : copyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,17 @@ import css from './JsonBlock.module.css'
|
||||
|
||||
const MAX_CHARS = 20_000
|
||||
|
||||
export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
/** Default truncation footer; the owner passes a localized formatter. */
|
||||
function defaultTruncatedLabel(total: number): string {
|
||||
return `… 已截断,共 ${total} 字符`
|
||||
}
|
||||
|
||||
export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel = defaultTruncatedLabel }: {
|
||||
label: string
|
||||
payload: unknown
|
||||
defaultOpen?: boolean
|
||||
/** Footer appended when the body exceeds the char cap, given the full length (this package is cordis-free, so copy arrives via props). */
|
||||
truncatedLabel?: ((total: number) => string) | undefined
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const body = useMemo(() => {
|
||||
@@ -21,8 +28,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
|
||||
} catch {
|
||||
s = String(payload)
|
||||
}
|
||||
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n… 已截断,共 ${s.length} 字符` : s
|
||||
}, [open, payload])
|
||||
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n${truncatedLabel(s.length)}` : s
|
||||
}, [open, payload, truncatedLabel])
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
|
||||
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
|
||||
Modules. Cite pills, header anchors, and thinking-small variants are
|
||||
intentionally absent (no matching DOM). Token names match that sheet. */
|
||||
|
||||
.markdown {
|
||||
@@ -160,6 +160,12 @@
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.markdown :global(.katex-display) {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.markdown input[type='checkbox'] {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: var(--dsw-alias-label-secondary);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { isValidElement } from 'react'
|
||||
import { isValidElement, useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { CodeBlock } from './CodeBlock.tsx'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const remarkPlugins = [remarkGfm]
|
||||
const streamingRemarkPlugins = [remarkGfm]
|
||||
const settledRemarkPlugins = [remarkGfm, remarkMath]
|
||||
const settledRehypePlugins = [rehypeKatex]
|
||||
|
||||
function sanitizeUrl(url: string): string {
|
||||
try {
|
||||
@@ -24,6 +29,14 @@ function sanitizeUrl(url: string): string {
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
|
||||
export interface MarkdownCodeLabels {
|
||||
/** Copy-button idle label. */
|
||||
copyLabel?: string | undefined
|
||||
/** Copy-button label during the post-copy confirmation window. */
|
||||
copiedLabel?: string | undefined
|
||||
}
|
||||
|
||||
function remoteImageUrl(url: string): string | undefined {
|
||||
try {
|
||||
const protocol = new URL(url).protocol
|
||||
@@ -34,7 +47,7 @@ function remoteImageUrl(url: string): string | undefined {
|
||||
}
|
||||
|
||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||
function buildComponents(streaming: boolean): Components {
|
||||
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
|
||||
return {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
@@ -84,7 +97,14 @@ function buildComponents(streaming: boolean): Components {
|
||||
// keeps the stock <pre> rather than guessing.
|
||||
if (typeof raw !== 'string') return <pre>{children}</pre>
|
||||
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
|
||||
return <CodeBlock code={raw} lang={streaming ? undefined : lang} />
|
||||
return (
|
||||
<CodeBlock
|
||||
code={raw}
|
||||
lang={streaming ? undefined : lang}
|
||||
copyLabel={codeLabels?.copyLabel}
|
||||
copiedLabel={codeLabels?.copiedLabel}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -95,16 +115,32 @@ const streamingComponents = buildComponents(true)
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection;
|
||||
* `streaming` renders fences plain (highlighting lands on the finalize swap).
|
||||
* @returns A GFM document with raw HTML, relative destinations, and unsafe
|
||||
* protocols disabled; absolute HTTP(S) images render directly.
|
||||
* `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
|
||||
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
|
||||
* pass a reference-stable object (memoized per locale revision), because the
|
||||
* component table memoizes on its identity and a fresh literal per render
|
||||
* would rebuild it every streaming chunk.
|
||||
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
|
||||
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
|
||||
* images render directly.
|
||||
*/
|
||||
export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
|
||||
export function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
text: string
|
||||
streaming?: boolean
|
||||
codeLabels?: MarkdownCodeLabels | undefined
|
||||
}) {
|
||||
// The label-free tables stay module-level singletons so the common case
|
||||
// keeps referential stability across renders without a hook.
|
||||
const components = useMemo(() => {
|
||||
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
|
||||
return buildComponents(streaming, codeLabels)
|
||||
}, [streaming, codeLabels])
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={streaming ? streamingComponents : staticComponents}
|
||||
remarkPlugins={streaming ? streamingRemarkPlugins : settledRemarkPlugins}
|
||||
rehypePlugins={streaming ? undefined : settledRehypePlugins}
|
||||
components={components}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
{text}
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
* theme package's token sheets as `--shiki-*` custom properties (light and
|
||||
* dark blocks), never here — the repo's tokens-only styling rule.
|
||||
*
|
||||
* Grammars are the set the harness actually renders: TypeScript programs
|
||||
* (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
|
||||
* and JSON payloads. An unknown or absent language falls back to plain text
|
||||
* (no highlighting, still monospace) — never an error.
|
||||
* Only the three markdown-fence and `run_code` grammars (TypeScript, shell,
|
||||
* JSON) load into the singleton at boot — the set every session renders. The
|
||||
* read card's wider extension set (the file-extension language hints the read
|
||||
* tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
|
||||
* markup, …) is imported lazily and registered the first time such a language
|
||||
* is requested, so a session that never opens a read card in one of those
|
||||
* languages pays neither the ~1.6 MB of grammar modules nor their synchronous
|
||||
* init. The first render of a lazy language falls back to plain text while its
|
||||
* grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
|
||||
* with highlighting. An unknown or absent language falls back to plain text (no
|
||||
* highlighting, still monospace) — never an error.
|
||||
*/
|
||||
|
||||
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
|
||||
@@ -17,12 +24,69 @@ import langTs from '@shikijs/langs/typescript'
|
||||
import langBash from '@shikijs/langs/shellscript'
|
||||
import langJson from '@shikijs/langs/json'
|
||||
import type { HighlighterCore } from 'shiki/core'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
|
||||
* from a boot grammar so no direct `@shikijs/types` dependency is needed. */
|
||||
type LangModule = { default: typeof langTs }
|
||||
|
||||
/**
|
||||
* Language ids (and aliases) the singleton registers; everything else renders
|
||||
* Grammars the singleton loads at boot; each entry's own `name` is the id
|
||||
* `codeToTokens`/`codeToHtml` resolve. The JS-family aliases (js/jsx/ts/tsx)
|
||||
* resolve to the TypeScript grammar rather than a separate one: it tokenizes
|
||||
* plain TS/JS exactly, and JSX/TSX approximately (shiki's TS grammar is not the
|
||||
* dedicated TSX grammar, so JSX elements tokenize imperfectly) — an accepted
|
||||
* trade to keep the boot set to one JS-family grammar. The read card's wider
|
||||
* set loads lazily through {@link LAZY_GRAMMARS}.
|
||||
*/
|
||||
const LANGS = [langTs, langBash, langJson]
|
||||
|
||||
/**
|
||||
* The read card's extension grammars, each behind a dynamic import so its
|
||||
* module stays out of the boot chunk until a read of that language renders.
|
||||
* Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
|
||||
* `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
|
||||
* hands the whole array to `loadLanguageSync`, which registers each entry
|
||||
* (including embedded sub-grammars). The three boot grammars are absent —
|
||||
* already loaded, so no alias value ever points at a missing entry here.
|
||||
*/
|
||||
const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
|
||||
['python', () => import('@shikijs/langs/python')],
|
||||
['ruby', () => import('@shikijs/langs/ruby')],
|
||||
['go', () => import('@shikijs/langs/go')],
|
||||
['rust', () => import('@shikijs/langs/rust')],
|
||||
['java', () => import('@shikijs/langs/java')],
|
||||
['c', () => import('@shikijs/langs/c')],
|
||||
['cpp', () => import('@shikijs/langs/cpp')],
|
||||
['csharp', () => import('@shikijs/langs/csharp')],
|
||||
['kotlin', () => import('@shikijs/langs/kotlin')],
|
||||
['swift', () => import('@shikijs/langs/swift')],
|
||||
['php', () => import('@shikijs/langs/php')],
|
||||
['yaml', () => import('@shikijs/langs/yaml')],
|
||||
['toml', () => import('@shikijs/langs/toml')],
|
||||
['ini', () => import('@shikijs/langs/ini')],
|
||||
['markdown', () => import('@shikijs/langs/markdown')],
|
||||
['mdx', () => import('@shikijs/langs/mdx')],
|
||||
['html', () => import('@shikijs/langs/html')],
|
||||
['css', () => import('@shikijs/langs/css')],
|
||||
['scss', () => import('@shikijs/langs/scss')],
|
||||
['less', () => import('@shikijs/langs/less')],
|
||||
['sql', () => import('@shikijs/langs/sql')],
|
||||
['xml', () => import('@shikijs/langs/xml')],
|
||||
['lua', () => import('@shikijs/langs/lua')],
|
||||
])
|
||||
|
||||
/**
|
||||
* Language ids (and aliases) the highlighter accepts; everything else renders
|
||||
* plain. A Map, not an object: fence info strings are assistant-authored, so
|
||||
* a label like `constructor` or `__proto__` must miss instead of resolving an
|
||||
* inherited property and crashing the renderer inside shiki.
|
||||
* inherited property and crashing the renderer inside shiki. Keys cover both
|
||||
* the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids
|
||||
* the read tool's `langFromPath` emits, so both callers resolve the same
|
||||
* grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for
|
||||
* the JSX/TSX approximation), unchanged from when this was the only
|
||||
* non-shell/JSON grammar. A value not in {@link LANGS} names a
|
||||
* {@link LAZY_GRAMMARS} entry loaded on first use.
|
||||
*/
|
||||
const LANG_ALIASES = new Map<string, string>([
|
||||
['typescript', 'typescript'],
|
||||
@@ -30,6 +94,7 @@ const LANG_ALIASES = new Map<string, string>([
|
||||
['tsx', 'typescript'],
|
||||
['javascript', 'typescript'],
|
||||
['js', 'typescript'],
|
||||
['jsx', 'typescript'],
|
||||
['shellscript', 'shellscript'],
|
||||
['bash', 'shellscript'],
|
||||
['sh', 'shellscript'],
|
||||
@@ -37,6 +102,35 @@ const LANG_ALIASES = new Map<string, string>([
|
||||
['zsh', 'shellscript'],
|
||||
['json', 'json'],
|
||||
['jsonc', 'json'],
|
||||
['py', 'python'],
|
||||
['python', 'python'],
|
||||
['rb', 'ruby'],
|
||||
['ruby', 'ruby'],
|
||||
['go', 'go'],
|
||||
['rs', 'rust'],
|
||||
['rust', 'rust'],
|
||||
['java', 'java'],
|
||||
['c', 'c'],
|
||||
['cpp', 'cpp'],
|
||||
['cs', 'csharp'],
|
||||
['csharp', 'csharp'],
|
||||
['kotlin', 'kotlin'],
|
||||
['swift', 'swift'],
|
||||
['php', 'php'],
|
||||
['yaml', 'yaml'],
|
||||
['yml', 'yaml'],
|
||||
['toml', 'toml'],
|
||||
['ini', 'ini'],
|
||||
['md', 'markdown'],
|
||||
['markdown', 'markdown'],
|
||||
['mdx', 'mdx'],
|
||||
['html', 'html'],
|
||||
['css', 'css'],
|
||||
['scss', 'scss'],
|
||||
['less', 'less'],
|
||||
['sql', 'sql'],
|
||||
['xml', 'xml'],
|
||||
['lua', 'lua'],
|
||||
])
|
||||
|
||||
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
|
||||
@@ -52,12 +146,68 @@ let singleton: HighlighterCore | undefined
|
||||
function highlighter(): HighlighterCore {
|
||||
singleton ??= createHighlighterCoreSync({
|
||||
themes: [cssVariablesTheme],
|
||||
langs: [langTs, langBash, langJson],
|
||||
langs: LANGS,
|
||||
engine: createJavaScriptRegexEngine({ forgiving: true }),
|
||||
})
|
||||
return singleton
|
||||
}
|
||||
|
||||
/** Grammar ids whose lazy import is in flight or done, so it is requested once. */
|
||||
const requested = new Set<string>()
|
||||
/** Subscribers re-rendered after a lazy grammar registers (React callers). */
|
||||
const listeners = new Set<() => void>()
|
||||
/** Bumped on each lazy-grammar load; the `useSyncExternalStore` snapshot. */
|
||||
let loadCount = 0
|
||||
|
||||
/**
|
||||
* Subscribe to lazy-grammar load completions; `listener` fires after a
|
||||
* {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a
|
||||
* caller that rendered its plain fallback while the grammar loaded can
|
||||
* re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with
|
||||
* {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function.
|
||||
* @param listener - invoked (no args) on each grammar-load completion.
|
||||
* @returns a disposer that removes the listener.
|
||||
*/
|
||||
export function subscribeGrammarLoaded(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The lazy-grammar load counter — a value that changes on every load, so a
|
||||
* `useSyncExternalStore` snapshot re-renders the subscriber when a grammar
|
||||
* registers. Opaque: only its identity across renders matters.
|
||||
* @returns the current load count.
|
||||
*/
|
||||
export function grammarLoadCount(): number {
|
||||
return loadCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the grammar `resolved` names is registered. A boot grammar (not in
|
||||
* {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready
|
||||
* synchronously; a lazy grammar not yet loaded starts its import (once) and
|
||||
* reports not-ready, so the caller renders plain until a
|
||||
* {@link subscribeGrammarLoaded} listener fires.
|
||||
* @param resolved - the grammar id an alias resolved to.
|
||||
* @returns whether the grammar is registered and ready to tokenize now.
|
||||
*/
|
||||
function ensureGrammar(resolved: string): boolean {
|
||||
const load = LAZY_GRAMMARS.get(resolved)
|
||||
// A boot grammar (already registered) has no lazy loader; it is always ready.
|
||||
if (load === undefined) return true
|
||||
if (highlighter().getLoadedLanguages().includes(resolved)) return true
|
||||
if (!requested.has(resolved)) {
|
||||
requested.add(resolved)
|
||||
void load().then((mod) => {
|
||||
highlighter().loadLanguageSync(mod.default)
|
||||
loadCount += 1
|
||||
for (const listener of listeners) listener()
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Engine + grammar construction costs a long task (~120-175ms); building it
|
||||
// during the first finalized fence's render would jank exactly when a stream
|
||||
// completes. Warm the singleton in a deferred task at module load (= plugin
|
||||
@@ -70,13 +220,59 @@ const warmupTimer = setTimeout(() => { highlighter() }, 0)
|
||||
/**
|
||||
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
|
||||
* when `lang` maps to a registered grammar; `undefined` means the caller
|
||||
* renders its plain fallback.
|
||||
* renders its plain fallback. A lazy grammar not yet loaded returns `undefined`
|
||||
* for this call and loads in the background; subscribe with
|
||||
* {@link onGrammarLoaded} to re-highlight once it registers.
|
||||
* @param code - the source text.
|
||||
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
|
||||
* @returns the highlighted HTML, or `undefined` for unknown languages.
|
||||
* @returns the highlighted HTML, or `undefined` for unknown or not-yet-loaded languages.
|
||||
*/
|
||||
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
|
||||
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
|
||||
if (resolved === undefined) return undefined
|
||||
if (!ensureGrammar(resolved)) return undefined
|
||||
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
|
||||
}
|
||||
|
||||
/**
|
||||
* One highlighted run of a line: the text and the inline style shiki assigned
|
||||
* it. The css-variables theme colors every run through a `--shiki-*` custom
|
||||
* property, so `style.color` is always present; it is held as a style object
|
||||
* rather than a bare color so a run spreads onto a `<span style>` uniformly.
|
||||
*/
|
||||
export interface HighlightSpan {
|
||||
text: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
|
||||
* registered grammar; `undefined` means the caller renders its plain fallback.
|
||||
* A line-numbered view needs the token runs split per line (one gutter number
|
||||
* per line), which the single-`<pre>` {@link highlightToHtml} does not expose,
|
||||
* so this returns shiki's own 2D line/token structure narrowed to what a run
|
||||
* renders. Each run's color is a `--shiki-*` custom property, keeping token
|
||||
* colors on the theme package's sheets exactly as the HTML path does; the
|
||||
* css-variables theme carries no font-style bits, matching that path's
|
||||
* color-only output. The trailing newline shiki appends as a final empty line
|
||||
* is dropped so the run count matches the caller's own line array.
|
||||
* @param code - the source text.
|
||||
* @param lang - the language hint (a file-extension-derived language id).
|
||||
* @returns one entry per source line (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
|
||||
*/
|
||||
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
|
||||
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
|
||||
if (resolved === undefined) return undefined
|
||||
if (!ensureGrammar(resolved)) return undefined
|
||||
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
|
||||
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
|
||||
// third, empty line the caller's own line array does not carry. Drop that
|
||||
// one terminator line so the two structures stay in step. The explicit
|
||||
// `last !== undefined` (over `tokens[...]?.length`) keeps a single branch for
|
||||
// per-file coverage, matching TerminalBlock's terminator check.
|
||||
const last = tokens[tokens.length - 1]
|
||||
const lines = tokens.length > 1 && last !== undefined && last.length === 0
|
||||
? tokens.slice(0, -1)
|
||||
: tokens
|
||||
return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Shared close timing for pointer-dismissed popups (HoverCard, hover-closing
|
||||
// Menu). Both float free of their anchor, so the pointer has to cross ground
|
||||
// that belongs to neither on its way in; closing on the first pointerleave
|
||||
// makes the popup unreachable. The grace turns that transit into a cancelable
|
||||
// pending close.
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Grace before a pointer-dismissed popup closes. Covers the anchor->popup gap
|
||||
* (8px for HoverCard, 4px for Menu) at a hand's travel speed without leaving a
|
||||
* popup lingering once the pointer has genuinely moved on.
|
||||
*/
|
||||
export const POINTER_GRACE_MS = 200
|
||||
|
||||
/** Cancelable delayed close for a pointer-dismissed popup. */
|
||||
export interface PointerGrace {
|
||||
/** Schedule the close {@link POINTER_GRACE_MS} from now, replacing any pending one. */
|
||||
arm: () => void
|
||||
/** Abort a pending close (the pointer came back). */
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay a pointer-dismissed popup's close so the pointer can cross the gap
|
||||
* between anchor and popup. A pending close is dropped on unmount.
|
||||
* @param close - runs when the grace elapses with no re-entry; read at fire
|
||||
* time, so callers may pass a fresh closure each render.
|
||||
* @returns the {@link PointerGrace} handle.
|
||||
*/
|
||||
export function usePointerGrace(close: () => void): PointerGrace {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const closeRef = useRef(close)
|
||||
closeRef.current = close
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timerRef.current === null) return
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}, [])
|
||||
|
||||
const arm = useCallback(() => {
|
||||
cancel()
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
closeRef.current()
|
||||
}, POINTER_GRACE_MS)
|
||||
}, [cancel])
|
||||
|
||||
useEffect(() => cancel, [cancel])
|
||||
|
||||
return { arm, cancel }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// The copy-to-clipboard-with-feedback hook shared by the block primitives
|
||||
// (TerminalBlock, SearchBlock): write the given text, and on success flip a
|
||||
// transient `copied` flag that the caller renders as a "复制成功" label for one
|
||||
// second. A refused write leaves the flag untouched, so the control never claims
|
||||
// a copy the host declined.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
|
||||
/** How long the `copied` flag stays true after a successful write, in ms. */
|
||||
const COPIED_FEEDBACK_MS = 1000
|
||||
|
||||
/** The copy-feedback hook's return: the transient flag and the copy handler. */
|
||||
export interface CopyFeedback {
|
||||
/** True for {@link COPIED_FEEDBACK_MS} after a successful write; render the success label off it. */
|
||||
copied: boolean
|
||||
/** Copy the hook's text; no-op while `copied` is still true, silent on a refused write. */
|
||||
onCopy: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `text` to the clipboard with one-second success feedback.
|
||||
* @param text - the text to write on copy.
|
||||
* @returns the `copied` flag and the `onCopy` handler.
|
||||
*/
|
||||
export function useCopyFeedback(text: string): CopyFeedback {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, COPIED_FEEDBACK_MS)
|
||||
})
|
||||
}, [copied, text])
|
||||
return { copied, onCopy }
|
||||
}
|
||||
Reference in New Issue
Block a user