11import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions" ;
2- import { MarkdownFindContext , findRevealRef } from "./chat/markdownFindContext" ;
32import {
43 buildFileLinkParentSuffixByPath ,
54 fileLinkLabel ,
@@ -26,7 +25,6 @@ import {
2625 encodeComposerContextClipboardHtml ,
2726} from "@t3tools/shared/composerContextClipboard" ;
2827import {
29- ChevronRightIcon ,
3028 FileSpreadsheetIcon ,
3129 FileTextIcon ,
3230 GlobeIcon ,
@@ -42,7 +40,6 @@ import {
4240 TriangleAlertIcon ,
4341 type LucideIcon ,
4442} from "lucide-react" ;
45- import { Check , Copy , Maximize2 , Minimize2 } from "lucide" ;
4643import {
4744 AssetWorkspaceAssetNotFoundError ,
4845 AuthPreviewOperateScope ,
@@ -85,7 +82,6 @@ import React, {
8582 type ClipboardEvent as ReactClipboardEvent ,
8683 type KeyboardEvent as ReactKeyboardEvent ,
8784 type MouseEvent as ReactMouseEvent ,
88- isValidElement ,
8985 use ,
9086 memo ,
9187 useEffect ,
@@ -136,11 +132,7 @@ import {
136132} from "./chat/externalLinkContextMenu" ;
137133import { Tooltip , TooltipPopup , TooltipTrigger } from "./ui/tooltip" ;
138134import { Button } from "./ui/button" ;
139- import { MorphIcon } from "~/components/MorphIcon" ;
140135import { ContextChip } from "./ContextChip" ;
141- import { Collapsible , CollapsiblePanel , CollapsibleTrigger } from "./ui/collapsible" ;
142- import { ScrollArea } from "./ui/scroll-area" ;
143- import { Menu , MenuItem , MenuPopup , MenuTrigger } from "./ui/menu" ;
144136import { stackedThreadToast , toastManager } from "./ui/toast" ;
145137import { recordVisitForThread } from "../browserHistoryStore" ;
146138import {
@@ -153,12 +145,8 @@ import { resolveDiffThemeName } from "../lib/diffRendering";
153145import { GitHubIcon } from "./Icons" ;
154146import { RenderErrorBoundary } from "./RenderErrorBoundary" ;
155147import { useRenderedAppearance } from "../hooks/useTheme" ;
156- import { getClientSettings , useClientSettings } from "../hooks/useSettings" ;
157- import {
158- chatMarkdownClipboardPayload ,
159- serializeTableElementToCsv ,
160- serializeTableElementToMarkdown ,
161- } from "../markdown-clipboard" ;
148+ import { useClientSettings } from "../hooks/useSettings" ;
149+ import { chatMarkdownClipboardPayload } from "../markdown-clipboard" ;
162150import {
163151 resolveInlineCodeFileLinkMeta ,
164152 resolveMarkdownFileLinkMeta ,
@@ -212,7 +200,6 @@ import {
212200 resolvePullRequestPreviewTarget ,
213201 useOpenChangeRequestLink ,
214202} from "~/lib/openPullRequestLink" ;
215- import { observeResize } from "~/lib/observeResize" ;
216203import { useOpenLink } from "../browser/useOpenLink" ;
217204import { writeTextToClipboard } from "../hooks/useCopyToClipboard" ;
218205import { isPreviewAvailableFor } from "../browser/previewRuntime" ;
@@ -227,6 +214,8 @@ import { resolveLinkTarget } from "../browser/browserLinkTarget";
227214import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview" ;
228215import { MarkdownCodeBlock , SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock" ;
229216import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock" ;
217+ import { MarkdownTable } from "./chat/MarkdownTable" ;
218+ import { MarkdownDetails } from "./chat/MarkdownDetails" ;
230219import {
231220 extractCodeBlock ,
232221 extractFenceLanguage ,
@@ -450,211 +439,6 @@ const GITHUB_ALERT_PRESENTATIONS: Record<
450439 } ,
451440} ;
452441
453- /**
454- * Whether any cell is clipped in the unwrapped layout. Only then does the wrap
455- * toggle change anything, so short tables hide it. Measures with the container
456- * briefly unwrapped and restores it before the browser paints.
457- */
458- function tableHasClippedCells ( container : HTMLElement , table : HTMLTableElement ) : boolean {
459- const viewport = table . closest < HTMLElement > ( '[data-slot="scroll-area-viewport"]' ) ;
460- const scrollLeft = viewport ?. scrollLeft ?? 0 ;
461- const previous = container . dataset . expanded ;
462- container . dataset . expanded = "false" ;
463- try {
464- for ( const row of table . rows ) {
465- for ( const cell of row . cells ) {
466- if ( cell . scrollWidth > cell . clientWidth ) return true ;
467- }
468- }
469- return false ;
470- } finally {
471- if ( previous === undefined ) delete container . dataset . expanded ;
472- else container . dataset . expanded = previous ;
473- // The unwrapped layout can be narrower and clamp a sideways scroll.
474- if ( viewport && scrollLeft > 0 ) viewport . scrollLeft = scrollLeft ;
475- }
476- }
477-
478- function MarkdownTable ( { children, ...props } : React . ComponentProps < "table" > ) {
479- const containerRef = useRef < HTMLDivElement | null > ( null ) ;
480- const tableRef = useRef < HTMLTableElement | null > ( null ) ;
481- const [ expanded , setExpanded ] = useState ( ( ) => getClientSettings ( ) . wordWrap ) ;
482- const [ hasClippedCells , setHasClippedCells ] = useState ( false ) ;
483- const [ copied , setCopied ] = useState ( false ) ;
484- const copiedTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
485- const expandLabel = expanded ? "Collapse table cells" : "Expand table cells" ;
486- const copyLabel = copied ? "Copied" : "Copy table" ;
487-
488- const measureClippedCells = ( ) => {
489- const container = containerRef . current ;
490- const table = tableRef . current ;
491- if ( ! container || ! table ) return ;
492- setHasClippedCells ( tableHasClippedCells ( container , table ) ) ;
493- } ;
494-
495- // A streamed cell can grow until it clips while the table keeps its size,
496- // so remeasure after every render, not only on resize.
497- useLayoutEffect ( measureClippedCells ) ;
498-
499- // Column width changes can clip a cell without rendering this table.
500- useLayoutEffect ( ( ) => {
501- const table = tableRef . current ;
502- if ( ! table ) return ;
503- return observeResize ( table , measureClippedCells ) ;
504- } , [ measureClippedCells ] ) ;
505-
506- const handleCopy = ( format : "markdown" | "csv" ) => {
507- const table = containerRef . current ?. querySelector ( "table" ) ;
508- if ( ! table || typeof navigator === "undefined" || navigator . clipboard == null ) {
509- return ;
510- }
511- const text =
512- format === "markdown"
513- ? serializeTableElementToMarkdown ( table )
514- : serializeTableElementToCsv ( table ) ;
515- void navigator . clipboard
516- . writeText ( text )
517- . then ( ( ) => {
518- if ( copiedTimerRef . current != null ) {
519- clearTimeout ( copiedTimerRef . current ) ;
520- }
521- setCopied ( true ) ;
522- copiedTimerRef . current = setTimeout ( ( ) => {
523- setCopied ( false ) ;
524- copiedTimerRef . current = null ;
525- } , 1200 ) ;
526- } )
527- . catch ( ( cause ) => {
528- reportMarkdownActionFailure ( { operation : "copy-table" , format } , cause ) ;
529- } ) ;
530- } ;
531-
532- useEffect (
533- ( ) => ( ) => {
534- if ( copiedTimerRef . current != null ) {
535- clearTimeout ( copiedTimerRef . current ) ;
536- copiedTimerRef . current = null ;
537- }
538- } ,
539- [ ] ,
540- ) ;
541-
542- return (
543- < div
544- ref = { containerRef }
545- className = "chat-markdown-table-container"
546- data-expanded = { expanded ? "true" : "false" }
547- >
548- < ScrollArea radius = "none" chainVerticalScroll scrollFade className = "w-full max-w-full" >
549- < table ref = { tableRef } { ...props } >
550- { children }
551- </ table >
552- </ ScrollArea >
553- < div className = "mt-0.5 flex items-center justify-between select-none" >
554- { hasClippedCells ? (
555- < Tooltip >
556- < TooltipTrigger
557- render = {
558- < Button
559- type = "button"
560- variant = { expanded ? "secondary" : "ghost-muted" }
561- size = "icon-xs"
562- aria-pressed = { expanded }
563- onClick = { ( ) => setExpanded ( ( value ) => ! value ) }
564- aria-label = { expandLabel }
565- />
566- }
567- >
568- < MorphIcon className = "size-3" icon = { expanded ? Minimize2 : Maximize2 } />
569- </ TooltipTrigger >
570- < TooltipPopup side = "top" > { expandLabel } </ TooltipPopup >
571- </ Tooltip >
572- ) : (
573- < span />
574- ) }
575- < Menu >
576- < Tooltip >
577- < TooltipTrigger
578- render = {
579- < MenuTrigger
580- render = {
581- < Button
582- type = "button"
583- variant = "ghost-muted"
584- size = "icon-xs"
585- aria-label = { copyLabel }
586- />
587- }
588- />
589- }
590- >
591- < MorphIcon className = "size-3" icon = { copied ? Check : Copy } />
592- </ TooltipTrigger >
593- < TooltipPopup side = "top" > { copyLabel } </ TooltipPopup >
594- </ Tooltip >
595- < MenuPopup align = "end" >
596- < MenuItem onClick = { ( ) => handleCopy ( "markdown" ) } > Copy as Markdown</ MenuItem >
597- < MenuItem onClick = { ( ) => handleCopy ( "csv" ) } > Copy as CSV</ MenuItem >
598- </ MenuPopup >
599- </ Menu >
600- </ div >
601- </ div >
602- ) ;
603- }
604-
605- function MarkdownDetails ( {
606- children,
607- open = false ,
608- } : Pick < React . ComponentProps < "details" > , "children" | "open" > ) {
609- const [ isOpen , setIsOpen ] = useState ( open ) ;
610- const searching = use ( MarkdownFindContext ) ;
611- const expanded = isOpen ;
612- const revealForFind = ( ) => setIsOpen ( true ) ;
613- // Base UI only listens for `beforematch` on a panel mounted at its first
614- // render; a closed panel mounts later, when find starts, so listen here.
615- const revealRef = findRevealRef ( revealForFind ) ;
616- const childNodes = Children . toArray ( children ) ;
617- const summaryIndex = childNodes . findIndex (
618- ( child ) => isValidElement ( child ) && child . type === "summary" ,
619- ) ;
620- const summaryNode = summaryIndex >= 0 ? childNodes [ summaryIndex ] : null ;
621- const summary =
622- isValidElement < { children ?: ReactNode } > ( summaryNode ) && summaryNode . props . children
623- ? summaryNode . props . children
624- : "Details" ;
625- const content = childNodes . filter ( ( _ , index ) => index !== summaryIndex ) ;
626-
627- return (
628- < div className = "my-2 border-y border-border/60" >
629- < Collapsible
630- open = { expanded }
631- onOpenChange = { setIsOpen }
632- data-markdown-details = ""
633- data-markdown-details-open = { expanded ? "true" : "false" }
634- >
635- < CollapsibleTrigger
636- className = "flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90"
637- data-markdown-details-summary = ""
638- >
639- < ChevronRightIcon
640- className = "size-4 shrink-0 text-muted-foreground transition-transform"
641- aria-hidden
642- />
643- < span > { summary } </ span >
644- </ CollapsibleTrigger >
645- < CollapsiblePanel ref = { revealRef } hiddenUntilFound = { searching } >
646- < div
647- className = "pb-3 ps-6 text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)]"
648- data-markdown-details-content = ""
649- >
650- { content }
651- </ div >
652- </ CollapsiblePanel >
653- </ Collapsible >
654- </ div >
655- ) ;
656- }
657-
658442interface MarkdownFileLinkProps {
659443 environmentId : EnvironmentId | null ;
660444 href : string ;
0 commit comments