Просмотр исходного кода

refactor(ui-primitives): extract shared head/tail cap and copy-feedback helpers

The tail-header cap fix pushed SearchBlock's head/tail slicing arithmetic and
its copy-feedback hook over the duplication gate's threshold against the
byte-identical logic in TerminalBlock. Extract both into head-tail-cap.ts
(headTailCap) and use-copy-feedback.ts (useCopyFeedback) and consume them from
both blocks, deleting the clone rather than nudging it under the limit.
Chinesezjc 2 месяцев назад
Родитель
Сommit
e1408c2a44

+ 4 - 18
packages/client/ui-primitives/src/SearchBlock.tsx

@@ -10,7 +10,8 @@
 
 import { useCallback, useState, type ReactNode } from 'react'
 import clsx from 'clsx'
-import { writeClipboard } from './clipboard.ts'
+import { headTailCap } from './head-tail-cap.ts'
+import { useCopyFeedback } from './use-copy-feedback.ts'
 import css from './SearchBlock.module.css'
 
 /**
@@ -173,23 +174,13 @@ 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())
-  const [copied, setCopied] = useState(false)
 
   // `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 text = copyText(props)
-
-  const onCopy = useCallback(() => {
-    if (copied) return
-    void writeClipboard(text).then((ok) => {
-      if (!ok) return
-      setCopied(true)
-      window.setTimeout(() => { setCopied(false) }, 1000)
-    })
-  }, [copied, text])
+  const { copied, onCopy } = useCopyFeedback(copyText(props))
 
   const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
 
@@ -202,12 +193,7 @@ export function SearchBlock(props: SearchBlockProps) {
     })
   }, [])
 
-  const hidden = rows.length - maxLines
-  const capped = hidden > 0 && !expanded
-  // Same split arithmetic as TerminalBlock (and the TUI transcript's collapsed
-  // tool card), so a long result's head and tail slices agree across surfaces.
-  const headLines = Math.ceil(maxLines / 2)
-  const tailLines = maxLines - headLines
+  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

+ 6 - 19
packages/client/ui-primitives/src/TerminalBlock.tsx

@@ -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'
@@ -140,18 +141,9 @@ 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) }, [])
 
@@ -170,12 +162,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}>

+ 33 - 0
packages/client/ui-primitives/src/head-tail-cap.ts

@@ -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 }
+}

+ 37 - 0
packages/client/ui-primitives/src/use-copy-feedback.ts

@@ -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 }
+}