11'use client'
22
33import type React from 'react'
4+ import { parse } from 'tldts'
45import { Badge , Checkbox , Tooltip } from '@/components/emcn'
56import { cn } from '@/lib/core/utils/cn'
67import type { RowExecutionMetadata } from '@/lib/table'
78import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
89import { storageToDisplay } from '../../../utils'
910import type { DisplayColumn } from '../types'
1011
11- /**
12- * Discriminated union describing every shape a table cell can take.
13- *
14- * Workflow-output cells follow a status state machine: they always render
15- * *something* (a value, a status pill, or a dash), driven by the combination
16- * of `executions[groupId]` state and dep satisfaction. Plain (non-workflow)
17- * cells just render the typed value or empty.
18- *
19- * `'empty'` is the universal fallback used by both workflow cells (no exec,
20- * no value, no waiting) and plain cells (null/undefined value).
21- *
22- * Adding a new cell appearance is a three-step mechanical change: add a
23- * variant here, pick it in `resolveCellRender`, render it in `CellRender`.
24- * TypeScript's exhaustiveness check on the renderer's `switch` (the
25- * unreachable default) flags any branch you forgot.
26- */
2712export type CellRenderKind =
2813 // Workflow-output cells
2914 | { kind : 'value' ; text : string }
@@ -47,20 +32,9 @@ interface ResolveCellRenderInput {
4732 value : unknown
4833 exec : RowExecutionMetadata | undefined
4934 column : DisplayColumn
50- /** Empty / undefined → not waiting; non-empty → render the Waiting pill. */
5135 waitingOnLabels : string [ ] | undefined
5236}
5337
54- /**
55- * Decide which `CellRenderKind` to render for a cell. Pure — easily
56- * unit-testable in isolation, no JSX involved.
57- *
58- * Order matters for workflow cells: block-error wins over a value (the user
59- * cares about the failure), value wins over running/queued (we have data
60- * already), and the running/queued branch deliberately collapses pre-enqueue
61- * `pending` and post-enqueue `queued` into one `Queued` pill so the cell
62- * doesn't flicker as the row transitions from one to the other.
63- */
6438export function resolveCellRender ( {
6539 value,
6640 exec,
@@ -77,31 +51,20 @@ export function resolveCellRender({
7751
7852 if ( blockError ) return { kind : 'block-error' }
7953
80- // Active re-run of THIS column wins over its prior value — the value is
81- // about to be overwritten and the user should see the cell is changing.
8254 const inFlight =
8355 exec ?. status === 'running' || exec ?. status === 'queued' || exec ?. status === 'pending'
8456 if ( inFlight && blockRunning ) return { kind : 'running' }
8557
86- // Value wins over `pending-upstream`: once this column's output has
87- // landed, the cell is done from the user's perspective — even if the
88- // group is still running other blocks downstream. Without this, mid-run
89- // partial-write events (`status: 'running'` carrying outputs but tagging
90- // a different block as running) would flip a finished column back to the
91- // amber Pending pill until the terminal `completed` event arrives.
58+ // Value wins over pending-upstream: a finished column stays finished even
59+ // while other blocks in the group are still running.
9260 if ( ! isNull ) return { kind : 'value' , text : stringifyValue ( value ) }
9361
9462 if ( inFlight && ! ( groupHasBlockErrors && ! blockRunning ) ) {
9563 if ( exec ?. status === 'queued' || exec ?. status === 'pending' ) return { kind : 'queued' }
96- // `running` with this block not in `runningBlockIds` and no value yet =
97- // upstream block still going; surface as the amber Pending pill.
9864 return { kind : 'pending-upstream' }
9965 }
10066
101- // Waiting wins over a stale terminal state: if deps are unmet right now,
102- // the prior `cancelled` / `error` is informational at best — the cell
103- // can't actually run until the user fills the missing input. Surface the
104- // actionable state instead of the stale one.
67+ // Waiting wins over a stale terminal status — show the actionable state.
10568 if ( waitingOnLabels && waitingOnLabels . length > 0 ) {
10669 return { kind : 'waiting' , labels : waitingOnLabels }
10770 }
@@ -129,91 +92,32 @@ function stringifyValue(value: unknown): string {
12992 return JSON . stringify ( value )
13093}
13194
132- /** Matches bare hostnames: `microsoft.com`, `www.linkedin.com`, `sub.domain.co.uk` */
13395const BARE_DOMAIN_RE = / ^ ( [ a - z A - Z 0 - 9 ] ( [ a - z A - Z 0 - 9 - ] { 0 , 61 } [ a - z A - Z 0 - 9 ] ) ? \. ) + [ a - z A - Z ] { 2 , } $ /
13496
135- /**
136- * File extensions that are also valid TLDs but should never be treated as URLs.
137- * Without this, strings like `config.json` or `readme.md` would resolve as domains.
138- */
139- const FILE_EXTENSION_TLDS = new Set ( [
140- 'txt' ,
141- 'json' ,
142- 'yaml' ,
143- 'yml' ,
144- 'xml' ,
145- 'html' ,
146- 'htm' ,
147- 'css' ,
148- 'js' ,
149- 'ts' ,
150- 'tsx' ,
151- 'jsx' ,
152- 'md' ,
153- 'mdx' ,
154- 'csv' ,
155- 'pdf' ,
156- 'doc' ,
157- 'docx' ,
158- 'xls' ,
159- 'xlsx' ,
160- 'ppt' ,
161- 'pptx' ,
162- 'zip' ,
163- 'gz' ,
164- 'tar' ,
165- 'rar' ,
166- 'png' ,
167- 'jpg' ,
168- 'jpeg' ,
169- 'gif' ,
170- 'svg' ,
171- 'webp' ,
172- 'mp4' ,
173- 'mp3' ,
174- 'wav' ,
175- 'mov' ,
176- 'py' ,
177- 'rb' ,
178- 'go' ,
179- 'rs' ,
180- 'sh' ,
181- 'bat' ,
182- 'log' ,
183- 'env' ,
184- ] )
185-
18697function extractUrlInfo ( text : string ) : { href : string ; domain : string } | null {
187- if ( ! text ) return null
188- if ( / ^ h t t p s ? : \/ \/ / i. test ( text ) ) {
98+ const trimmed = text . trim ( )
99+ if ( ! trimmed ) return null
100+ if ( / ^ h t t p s ? : \/ \/ / i. test ( trimmed ) ) {
189101 try {
190- const url = new URL ( text )
191- return { href : text , domain : url . hostname }
102+ const url = new URL ( trimmed )
103+ return { href : trimmed , domain : url . hostname }
192104 } catch {
193105 return null
194106 }
195107 }
196- if ( BARE_DOMAIN_RE . test ( text ) ) {
197- const tld = text . split ( '.' ) . pop ( ) ?. toLowerCase ( ) ?? ''
198- if ( FILE_EXTENSION_TLDS . has ( tld ) ) return null
199- return { href : `https://${ text } ` , domain : text }
108+ if ( BARE_DOMAIN_RE . test ( trimmed ) ) {
109+ const parsed = parse ( trimmed )
110+ if ( ! parsed . isIcann ) return null
111+ return { href : `https://${ trimmed } ` , domain : trimmed }
200112 }
201113 return null
202114}
203115
204116interface CellRenderProps {
205117 kind : CellRenderKind
206- /** When true the static content sits underneath the InlineEditor overlay
207- * and should be visually hidden (but kept in flow to preserve cell size). */
208118 isEditing : boolean
209119}
210120
211- /**
212- * Pure renderer: takes a `CellRenderKind` and returns the JSX. No business
213- * logic — adding a new cell appearance means adding a new `case` here. The
214- * exhaustiveness check on the `switch` (the unreachable default) flags any
215- * variant you forgot to handle.
216- */
217121export function CellRender ( { kind, isEditing } : CellRenderProps ) : React . ReactElement | null {
218122 switch ( kind . kind ) {
219123 case 'value' :
@@ -320,7 +224,7 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
320224 return (
321225 < span className = { cn ( 'flex min-w-0 items-center gap-1.5' , isEditing && 'invisible' ) } >
322226 < img
323- src = { `https://www.google.com/s2/favicons?domain=${ kind . domain } &sz=16` }
227+ src = { `https://www.google.com/s2/favicons?domain=${ encodeURIComponent ( kind . domain ) } &sz=16` }
324228 alt = ''
325229 width = { 12 }
326230 height = { 12 }
@@ -333,8 +237,12 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
333237 href = { kind . href }
334238 target = '_blank'
335239 rel = 'noopener noreferrer'
336- className = 'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70'
240+ className = { cn (
241+ 'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70' ,
242+ isEditing && 'pointer-events-none'
243+ ) }
337244 onClick = { ( e ) => e . stopPropagation ( ) }
245+ onDoubleClick = { ( e ) => e . stopPropagation ( ) }
338246 >
339247 { kind . text }
340248 </ a >
@@ -357,20 +265,12 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
357265 return null
358266
359267 default : {
360- // Exhaustiveness guard: TypeScript flags this branch if a new
361- // `CellRenderKind` variant is added without a matching `case` above.
362268 const _exhaustive : never = kind
363269 return _exhaustive
364270 }
365271 }
366272}
367273
368- /**
369- * Workflow-output cells are hand-editable; while editing, the static content
370- * must stay in flow (so the cell doesn't collapse) but be visually hidden so
371- * the InlineEditor overlay shows through. Plain wrapper around any non-text
372- * variant.
373- */
374274function Wrap ( { isEditing, children } : { isEditing : boolean ; children : React . ReactNode } ) {
375275 if ( ! isEditing ) return < > { children } </ >
376276 return < div className = 'invisible' > { children } </ div >
0 commit comments