From bbad44bf391e37205bc20ff177f0068027abf314 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 12:25:24 -0700 Subject: [PATCH 01/13] improvement(files): theme mermaid diagrams with app tokens --- .../file-viewer/mermaid-diagram.tsx | 7 +- .../components/file-viewer/mermaid-theme.ts | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx index 10019822f72..1e3e8c1015b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx @@ -4,6 +4,7 @@ import { memo, useEffect, useState } from 'react' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { useTheme } from 'next-themes' +import { readMermaidThemeVariables } from '@/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme' import { PreviewLoadingFrame } from './preview-shared' import { ZoomablePreview } from './zoomable-preview' @@ -127,7 +128,11 @@ export const MermaidDiagram = memo(function MermaidDiagram({ mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', - theme: mermaidTheme, + theme: 'base', + themeVariables: readMermaidThemeVariables( + document.documentElement, + mermaidTheme === 'dark' + ), }) mermaid.setParseErrorHandler?.(() => undefined) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts new file mode 100644 index 00000000000..1408660d99a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts @@ -0,0 +1,68 @@ +/** + * Mermaid `base` theme variables drawn from the app's tokens, so diagrams match the editor and + * charts instead of Mermaid's stock palette. Mermaid derives shades from these with its own colour + * library, so every token read here must resolve to a plain hex or rgb value. + */ +export function readMermaidThemeVariables( + element: Element, + darkMode: boolean +): Record { + const styles = getComputedStyle(element) + const token = (name: string) => { + const value = styles.getPropertyValue(name).trim() + if (!value) throw new Error(`Missing diagram theme token ${name}`) + return value + } + const background = token('--bg') + const node = token('--surface-2') + const muted = token('--surface-4') + const subtle = token('--surface-5') + const border = token('--border') + const text = token('--text-primary') + const body = token('--text-body') + const line = token('--text-icon') + return { + darkMode, + fontFamily: getComputedStyle(document.body).fontFamily, + fontSize: '14px', + background, + primaryColor: node, + primaryTextColor: text, + primaryBorderColor: border, + secondaryColor: muted, + secondaryTextColor: body, + secondaryBorderColor: border, + tertiaryColor: subtle, + tertiaryTextColor: body, + tertiaryBorderColor: border, + mainBkg: node, + nodeBorder: border, + textColor: body, + lineColor: line, + clusterBkg: token('--surface-3'), + clusterBorder: border, + edgeLabelBackground: background, + noteBkgColor: muted, + noteTextColor: body, + noteBorderColor: border, + actorBkg: node, + actorBorder: border, + actorTextColor: text, + actorLineColor: line, + signalColor: line, + signalTextColor: body, + labelBoxBkgColor: subtle, + labelBoxBorderColor: border, + labelTextColor: body, + loopTextColor: body, + sequenceNumberColor: background, + pie1: body, + pie2: token('--text-subtle'), + pie3: token('--surface-7'), + pie4: line, + pieStrokeColor: background, + pieTitleTextColor: text, + pieSectionTextColor: background, + pieLegendTextColor: body, + } +} From d21378a37bd9522c915df8b44ae847e5f8481059 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 12:25:26 -0700 Subject: [PATCH 02/13] feat(files): render diff fences with source-checked state --- .../rich-markdown-editor/code-block.tsx | 19 +- apps/sim/components/diff/diff-embed.tsx | 197 ++++++++++++++ apps/sim/components/diff/diff-view.tsx | 257 ++++++++++++++++++ apps/sim/hooks/queries/kb/knowledge.ts | 32 +++ .../sim/hooks/queries/utils/knowledge-keys.ts | 2 + apps/sim/lib/diff/unified.test.ts | 91 +++++++ apps/sim/lib/diff/unified.ts | 147 ++++++++++ ...check-tool-registry-boundary.baseline.json | 16 +- 8 files changed, 751 insertions(+), 10 deletions(-) create mode 100644 apps/sim/components/diff/diff-embed.tsx create mode 100644 apps/sim/components/diff/diff-view.tsx create mode 100644 apps/sim/lib/diff/unified.test.ts create mode 100644 apps/sim/lib/diff/unified.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index 227331565a1..fc7575f134a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -12,6 +12,7 @@ import { Check, ChevronDown, Code, Duplicate, Eye, Wrap } from '@sim/emcn/icons' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language' +import { DIFF_EMBED_LANGUAGE } from '@/lib/diff/unified' import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context' import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram' import { MarkdownCodeBlock } from './code-block-schema' @@ -22,6 +23,9 @@ import { useEditorEditable } from './use-editor-editable' const DashboardEmbed = lazy(() => import('@/components/dashboards/dashboard-embed').then((m) => ({ default: m.DashboardEmbed })) ) +const DiffEmbed = lazy(() => + import('@/components/diff/diff-embed').then((m) => ({ default: m.DiffEmbed })) +) const PLAIN = 'plain' const MERMAID = 'mermaid' @@ -74,7 +78,8 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView const text = node.textContent const isMermaid = explicitLanguage === MERMAID || (!explicitLanguage && looksLikeMermaid(text)) const isDashboard = explicitLanguage === DASHBOARD_EMBED_LANGUAGE - const isRendered = isMermaid || isDashboard + const isDiff = explicitLanguage === DIFF_EMBED_LANGUAGE + const isRendered = isMermaid || isDashboard || isDiff // Editable Mermaid shows source while the caret is focused inside the block and re-renders the // diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by @@ -148,7 +153,13 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView + ))} + + + + + ) +} + +/** + * A ```diff fence: the change is written into the document, so it renders without any history. + * When its header names a workspace file or knowledge document, the hunks are matched against + * that source's current text and the diff is marked proposed or outdated when it no longer holds. + * A public share has no workspace session, so it renders the diff without checking. + */ +export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { + const params = useParams() + const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : null + const parsed = useMemo(() => { + try { + return { diff: parseUnifiedDiff(source) } + } catch (error) { + return { error: getErrorMessage(error, 'Invalid diff') } + } + }, [source]) + if (isStreaming) return The diff loads when Sim finishes writing. + if (!parsed.diff) return {parsed.error} + const { diff } = parsed + return ( + + ) +} diff --git a/apps/sim/components/diff/diff-view.tsx b/apps/sim/components/diff/diff-view.tsx new file mode 100644 index 00000000000..567575b9bbf --- /dev/null +++ b/apps/sim/components/diff/diff-view.tsx @@ -0,0 +1,257 @@ +'use client' + +import { Fragment, useState } from 'react' +import { cn } from '@sim/emcn' +import { diffWordsWithSpace } from 'diff' +import type { DiffHunk, DiffLine } from '@/lib/diff/unified' + +/** Unchanged lines kept visible on each side of a collapsed run. */ +const CONTEXT_EDGE = 3 +/** A run of unchanged lines collapses once it is longer than this. */ +const COLLAPSE_AFTER = CONTEXT_EDGE * 2 + 2 + +const ADD_ROW = 'bg-[color-mix(in_srgb,var(--badge-success-bg)_40%,transparent)]' +const DEL_ROW = 'bg-[color-mix(in_srgb,var(--badge-error-bg)_40%,transparent)]' +const ADD_WORD = 'rounded-sm bg-[var(--badge-success-bg)]' +const DEL_WORD = 'rounded-sm bg-[var(--badge-error-bg)]' +const GUTTER = 'select-none px-2 text-right text-[var(--text-muted)] tabular-nums' + +export type DiffViewMode = 'unified' | 'split' + +interface Segment { + text: string + changed: boolean +} +interface DiffViewProps { + hunks: DiffHunk[] + mode: DiffViewMode +} +interface LineTextProps { + line: DiffLine + segments: Segment[] | undefined +} +interface CollapsedRunProps { + count: number + onExpand: () => void +} + +/** + * Pairs each run of removed lines with the added lines that follow it, so a reworded line shows + * which words changed. Unpaired lines keep whole-line highlighting. + */ +function wordSegments(lines: DiffLine[]): Map { + const segments = new Map() + for (let index = 0; index < lines.length; ) { + if (lines[index].type !== 'del') { + index++ + continue + } + const dels: DiffLine[] = [] + while (lines[index]?.type === 'del') dels.push(lines[index++]) + const adds: DiffLine[] = [] + while (lines[index]?.type === 'add') adds.push(lines[index++]) + for (let pair = 0; pair < Math.min(dels.length, adds.length); pair++) { + const parts = diffWordsWithSpace(dels[pair].text, adds[pair].text) + segments.set( + dels[pair], + parts + .filter((part) => !part.added) + .map((part) => ({ text: part.value, changed: !!part.removed })) + ) + segments.set( + adds[pair], + parts + .filter((part) => !part.removed) + .map((part) => ({ text: part.value, changed: !!part.added })) + ) + } + } + return segments +} + +function LineText({ line, segments }: LineTextProps) { + if (!segments) return <>{line.text || ' '} + const word = line.type === 'add' ? ADD_WORD : DEL_WORD + return ( + <> + {segments.map((segment, index) => ( + + {segment.text} + + ))} + + ) +} + +function CollapsedRun({ count, onExpand }: CollapsedRunProps) { + return ( + + ) +} + +/** Splits a hunk into visible lines and collapsed runs of unchanged lines. */ +function visibleItems(lines: DiffLine[], expanded: boolean) { + const items: Array<{ line: DiffLine } | { collapsed: number; key: number }> = [] + for (let index = 0; index < lines.length; ) { + if (lines[index].type !== 'context') { + items.push({ line: lines[index++] }) + continue + } + const start = index + while (lines[index]?.type === 'context') index++ + const run = lines.slice(start, index) + const leading = start === 0 + const trailing = index === lines.length + const keepBefore = leading ? 0 : CONTEXT_EDGE + const keepAfter = trailing ? 0 : CONTEXT_EDGE + if (expanded || run.length <= COLLAPSE_AFTER || run.length <= keepBefore + keepAfter + 1) { + for (const line of run) items.push({ line }) + continue + } + for (const line of run.slice(0, keepBefore)) items.push({ line }) + items.push({ collapsed: run.length - keepBefore - keepAfter, key: start }) + for (const line of run.slice(run.length - keepAfter)) items.push({ line }) + } + return items +} + +function rowClass(line: DiffLine | undefined) { + return line?.type === 'add' ? ADD_ROW : line?.type === 'del' ? DEL_ROW : undefined +} + +function marker(line: DiffLine | undefined) { + return line?.type === 'add' ? '+' : line?.type === 'del' ? '−' : ' ' +} + +/** Aligns removed and added runs side by side; unchanged lines fill both columns. */ +function splitRows(items: ReturnType) { + const rows: Array<{ left?: DiffLine; right?: DiffLine } | { collapsed: number; key: number }> = [] + for (let index = 0; index < items.length; ) { + const item = items[index] + if (!('line' in item)) { + rows.push(item) + index++ + continue + } + if (item.line.type === 'context') { + rows.push({ left: item.line, right: item.line }) + index++ + continue + } + const dels: DiffLine[] = [] + const adds: DiffLine[] = [] + while (index < items.length) { + const next = items[index] + if (!('line' in next) || next.line.type === 'context') break + if (next.line.type === 'del' && adds.length === 0) dels.push(next.line) + else if (next.line.type === 'add') adds.push(next.line) + else break + index++ + } + for (let pair = 0; pair < Math.max(dels.length, adds.length); pair++) + rows.push({ left: dels[pair], right: adds[pair] }) + } + return rows +} + +/** + * A unified diff rendered with word-level highlights, collapsed unchanged runs, and line numbers + * when the hunk headers carry them. Split mode aligns removals and additions side by side. + */ +export function DiffView({ hunks, mode }: DiffViewProps) { + const [expanded, setExpanded] = useState>(() => new Set()) + const numbered = hunks.some((hunk) => + hunk.lines.some((line) => line.oldLine !== undefined || line.newLine !== undefined) + ) + const columns = + mode === 'split' + ? numbered + ? 'grid-cols-[auto_auto_minmax(0,1fr)_auto_auto_minmax(0,1fr)]' + : 'grid-cols-[auto_minmax(0,1fr)_auto_minmax(0,1fr)]' + : numbered + ? 'grid-cols-[auto_auto_auto_minmax(0,1fr)]' + : 'grid-cols-[auto_minmax(0,1fr)]' + return ( +
+ {hunks.map((hunk, hunkIndex) => { + const segments = wordSegments(hunk.lines) + const items = visibleItems(hunk.lines, expanded.has(hunkIndex)) + const expand = () => setExpanded((current) => new Set(current).add(hunkIndex)) + return ( + + {(hunk.heading || hunkIndex > 0) && ( +
+ {hunk.heading || '⋯'} +
+ )} + {mode === 'unified' + ? items.map((item, index) => + 'line' in item ? ( +
+ {numbered && {item.line.oldLine ?? ''}} + {numbered && {item.line.newLine ?? ''}} + + {marker(item.line)} + + + + +
+ ) : ( + + ) + ) + : splitRows(items).map((row, index) => + 'collapsed' in row ? ( + + ) : ( +
+ {[row.left, row.right].map((line, side) => ( + + {numbered && ( + + {(side === 0 ? line?.oldLine : line?.newLine) ?? ''} + + )} + + {line ? marker(line) : ''} + + + {line && } + + + ))} +
+ ) + )} +
+ ) + })} +
+ ) +} diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 8a9a4e1d349..5e19b4137c5 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -89,6 +89,9 @@ export const KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME = 60 * 1000 export const KNOWLEDGE_DOCUMENT_LIST_STALE_TIME = 60 * 1000 export const KNOWLEDGE_CHUNK_LIST_STALE_TIME = 60 * 1000 export const KNOWLEDGE_CHUNK_SEARCH_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_DOCUMENT_TEXT_STALE_TIME = 60 * 1000 +/** Bounds how much of a document is read to reconstruct its text (100 chunks per page). */ +const DOCUMENT_TEXT_MAX_PAGES = 20 export const WORKSPACE_KNOWLEDGE_SEARCH_STALE_TIME = 60 * 1000 export const KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000 export const KNOWLEDGE_TAG_USAGE_STALE_TIME = 60 * 1000 @@ -319,6 +322,35 @@ export const serializeChunkParams = (params: KnowledgeChunksParams) => sortOrder: params.sortOrder ?? 'asc', }) +/** A document's stored text as its chunks in order; fails rather than reading a partial document. */ +async function fetchKnowledgeDocumentText( + knowledgeBaseId: string, + documentId: string, + signal?: AbortSignal +): Promise { + const chunks: string[] = [] + for (let page = 0; page < DOCUMENT_TEXT_MAX_PAGES; page++) { + const result = await requestJson(listKnowledgeChunksContract, { + params: { id: knowledgeBaseId, documentId }, + query: { limit: 100, offset: page * 100, sortBy: 'chunkIndex', sortOrder: 'asc' }, + signal, + }) + chunks.push(...result.data.map((chunk) => chunk.content)) + if (!result.pagination.hasMore) return chunks + } + throw new Error('This document is too large to compare') +} + +export function useKnowledgeDocumentText(knowledgeBaseId?: string, documentId?: string) { + return useQuery({ + queryKey: knowledgeKeys.documentText(knowledgeBaseId ?? '', documentId ?? ''), + queryFn: ({ signal }) => + fetchKnowledgeDocumentText(knowledgeBaseId as string, documentId as string, signal), + enabled: Boolean(knowledgeBaseId && documentId), + staleTime: KNOWLEDGE_DOCUMENT_TEXT_STALE_TIME, + }) +} + export function useKnowledgeChunksQuery( params: KnowledgeChunksParams, options?: { diff --git a/apps/sim/hooks/queries/utils/knowledge-keys.ts b/apps/sim/hooks/queries/utils/knowledge-keys.ts index 944b2783986..2f69e9b7d62 100644 --- a/apps/sim/hooks/queries/utils/knowledge-keys.ts +++ b/apps/sim/hooks/queries/utils/knowledge-keys.ts @@ -86,4 +86,6 @@ export const knowledgeKeys = { [...knowledgeKeys.document(knowledgeBaseId, documentId), 'chunks', paramsKey] as const, chunkSearch: (knowledgeBaseId: string, documentId: string, searchKey: string) => [...knowledgeKeys.document(knowledgeBaseId, documentId), 'search', searchKey] as const, + documentText: (knowledgeBaseId: string, documentId: string) => + [...knowledgeKeys.document(knowledgeBaseId, documentId), 'text'] as const, } diff --git a/apps/sim/lib/diff/unified.test.ts b/apps/sim/lib/diff/unified.test.ts new file mode 100644 index 00000000000..6b9ed2cb85a --- /dev/null +++ b/apps/sim/lib/diff/unified.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { matchUnifiedDiff, parseUnifiedDiff } from '@/lib/diff/unified' + +const runbook = `--- sim:file/wf_runbook ++++ sim:file/wf_runbook +@@ Re-authorizing @@ + ## Re-authorizing +-Tokens never expire, so rotation is not required. ++Tokens rotate every 12h; the refresh job handles it.` + +describe('parseUnifiedDiff', () => { + it('reads the source from sim: header paths and hunks without line numbers', () => { + const diff = parseUnifiedDiff(runbook) + expect(diff.source).toEqual({ kind: 'file', fileId: 'wf_runbook' }) + expect(diff.hunks).toEqual([ + { + heading: 'Re-authorizing', + lines: [ + { type: 'context', text: '## Re-authorizing' }, + { type: 'del', text: 'Tokens never expire, so rotation is not required.' }, + { type: 'add', text: 'Tokens rotate every 12h; the refresh job handles it.' }, + ], + }, + ]) + }) + + it('numbers lines from git hunk headers and skips git metadata', () => { + const diff = parseUnifiedDiff( + 'diff --git a/x.ts b/x.ts\nindex 1..2 100644\n--- a/x.ts\n+++ b/x.ts\n@@ -10,2 +10,2 @@ fn\n keep\n-old\n+new\n\\ No newline at end of file' + ) + expect(diff.source).toBeNull() + expect(diff.hunks[0].heading).toBe('fn') + expect(diff.hunks[0].lines).toEqual([ + { type: 'context', text: 'keep', oldLine: 10, newLine: 10 }, + { type: 'del', text: 'old', oldLine: 11, newLine: undefined }, + { type: 'add', text: 'new', oldLine: undefined, newLine: 11 }, + ]) + }) + + it('reads knowledge document sources', () => { + expect( + parseUnifiedDiff('--- sim:knowledge/kb_1/doc_2\n+++ sim:knowledge/kb_1/doc_2\n-a\n+b').source + ).toEqual({ kind: 'knowledge', knowledgeBaseId: 'kb_1', documentId: 'doc_2' }) + }) + + it.each([ + [ + 'two different sources', + '--- sim:file/a\n+++ sim:file/b\n-x\n+y', + 'must name the same resource', + ], + ['a knowledge source without a document', '--- sim:knowledge/kb_1\n-x\n+y', 'sim:knowledge/'], + ['a line without a marker', '@@ x @@\n-a\nplain', 'Line 3'], + ['no changes', ' just context', 'at least one + or - line'], + ])('rejects %s', (_, text, message) => { + expect(() => parseUnifiedDiff(text)).toThrow(message) + }) +}) + +describe('matchUnifiedDiff', () => { + const diff = parseUnifiedDiff(runbook) + + it('is current when the result is in the source, wherever it moved', () => { + expect( + matchUnifiedDiff(diff, [ + '# Slack\n\nIntro moved things around.\n\n## Re-authorizing\nTokens rotate every 12h; the refresh job handles it.\n', + ]) + ).toBe('current') + }) + + it('is proposed while the source still holds the original', () => { + expect( + matchUnifiedDiff(diff, [ + '## Re-authorizing\nTokens never expire, so rotation is not required.', + ]) + ).toBe('proposed') + }) + + it('is outdated when the source matches neither side', () => { + expect(matchUnifiedDiff(diff, ['## Re-authorizing\nTokens rotate hourly.'])).toBe('outdated') + }) + + it('matches across stored segments', () => { + expect( + matchUnifiedDiff(diff, [ + '## Re-authorizing', + 'Tokens rotate every 12h; the refresh job handles it.', + ]) + ).toBe('current') + }) +}) diff --git a/apps/sim/lib/diff/unified.ts b/apps/sim/lib/diff/unified.ts new file mode 100644 index 00000000000..31e352fa87b --- /dev/null +++ b/apps/sim/lib/diff/unified.ts @@ -0,0 +1,147 @@ +/** The fence language that renders a unified diff in markdown; kept apart from the parser. */ +export const DIFF_EMBED_LANGUAGE = 'diff' + +/** A workspace resource a diff was taken from, named in its `---` / `+++` header lines. */ +export type DiffSource = + | { kind: 'file'; fileId: string } + | { kind: 'knowledge'; knowledgeBaseId: string; documentId: string } + +export interface DiffLine { + type: 'context' | 'add' | 'del' + text: string + /** Line numbers, present only when the hunk header carries them. */ + oldLine?: number + newLine?: number +} + +export interface DiffHunk { + /** Free text after the `@@ … @@` range, often the enclosing heading or function. */ + heading: string + lines: DiffLine[] +} + +export interface UnifiedDiff { + source: DiffSource | null + hunks: DiffHunk[] +} + +/** + * How a diff relates to its source's current text. `current`: every hunk's result is present. + * `proposed`: every hunk's original is present and none of its results, so the change is not + * applied. `outdated`: the source no longer matches either side. + */ +export type DiffMatch = 'current' | 'proposed' | 'outdated' + +/** Git metadata that precedes `---`/`+++` in `git diff` output and carries nothing to render. */ +const GIT_HEADER = + /^(?:diff --git |index |new file mode |deleted file mode |similarity |rename |old mode |new mode |Binary files )/ +const HUNK_HEADER = /^@@(?: -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@)?\s?(.*?)(?:\s*@@)?$/ +const SIM_SOURCE = /^sim:(file|knowledge)\/([^/\s]+)(?:\/([^/\s]+))?$/ + +function parseSource(path: string): DiffSource | null { + const match = SIM_SOURCE.exec(path.trim().split(/\s/)[0] ?? '') + if (!match) return null + const [, kind, first, second] = match + if (kind === 'file') { + if (second) throw new Error(`A file source is sim:file/, got ${path.trim()}`) + return { kind: 'file', fileId: first } + } + if (!second) + throw new Error( + `A knowledge source is sim:knowledge//, got ${path}` + ) + return { kind: 'knowledge', knowledgeBaseId: first, documentId: second } +} + +function sameSource(a: DiffSource, b: DiffSource): boolean { + return a.kind === 'file' && b.kind === 'file' + ? a.fileId === b.fileId + : a.kind === 'knowledge' && + b.kind === 'knowledge' && + a.knowledgeBaseId === b.knowledgeBaseId && + a.documentId === b.documentId +} + +/** + * Parses a unified diff. `---`/`+++` paths written as `sim:file/` or + * `sim:knowledge//` name the source; other paths (`a/src/x.ts`) render without a source. + * Hunk headers may omit line numbers (`@@ Heading @@`), and a diff with no `@@` is one hunk. + */ +export function parseUnifiedDiff(text: string): UnifiedDiff { + const rows = text.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n') + let source: DiffSource | null = null + const hunks: DiffHunk[] = [] + let hunk: DiffHunk | null = null + let oldLine: number | undefined + let newLine: number | undefined + + for (const [index, row] of rows.entries()) { + if (!hunk && (row.startsWith('--- ') || row.startsWith('+++ '))) { + const parsed = parseSource(row.slice(4)) + if (parsed && source && !sameSource(source, parsed)) + throw new Error('A diff compares one source: --- and +++ must name the same resource') + source = parsed ?? source + continue + } + if (!hunk && GIT_HEADER.test(row)) continue + const header = HUNK_HEADER.exec(row) + if (row.startsWith('@@') && header) { + hunk = { heading: header[3] ?? '', lines: [] } + hunks.push(hunk) + oldLine = header[1] ? Number(header[1]) : undefined + newLine = header[2] ? Number(header[2]) : undefined + continue + } + if (row.startsWith('\\')) continue + if (!hunk) { + hunk = { heading: '', lines: [] } + hunks.push(hunk) + } + const marker = row[0] + if (marker !== ' ' && marker !== '+' && marker !== '-' && row !== '') + throw new Error(`Line ${index + 1}: diff lines start with a space, + or -`) + const type = marker === '+' ? 'add' : marker === '-' ? 'del' : 'context' + hunk.lines.push({ + type, + text: row.slice(1), + oldLine: type === 'add' ? undefined : oldLine, + newLine: type === 'del' ? undefined : newLine, + }) + if (type !== 'add' && oldLine !== undefined) oldLine++ + if (type !== 'del' && newLine !== undefined) newLine++ + } + + if (!hunks.some((entry) => entry.lines.some((line) => line.type !== 'context'))) + throw new Error('A diff needs at least one + or - line') + return { source, hunks } +} + +/** Whitespace-insensitive, so reflowed indentation or line endings never read as a change. */ +function normalize(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function sideText(hunk: DiffHunk, side: 'before' | 'after'): string { + const skip = side === 'before' ? 'add' : 'del' + return normalize( + hunk.lines + .filter((line) => line.type !== skip) + .map((line) => line.text) + .join('\n') + ) +} + +/** + * Matches each hunk against the source by content rather than line numbers, so edits elsewhere in + * the source never mark a diff outdated. `segments` are the pieces a source is stored in (a + * knowledge document's chunks); a hunk may match inside one segment or across their concatenation. + */ +export function matchUnifiedDiff(diff: UnifiedDiff, segments: readonly string[]): DiffMatch { + const whole = normalize(segments.join('\n')) + const pieces = segments.map(normalize) + const contains = (text: string) => + text === '' || whole.includes(text) || pieces.some((piece) => piece.includes(text)) + if (diff.hunks.every((hunk) => contains(sideText(hunk, 'after')))) return 'current' + if (diff.hunks.every((hunk) => contains(sideText(hunk, 'before')))) return 'proposed' + return 'outdated' +} diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index b3b2fc3e2c9..8c4f7d9d4e2 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -311,16 +311,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1792, + "modules": 1831, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1776, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1815, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 558, "apps/sim/triggers/registry.ts": 528, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 519, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 467, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 461, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 399, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 390, - "apps/sim/blocks/registry.ts": 357 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 506, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 500, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 438, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 429, + "apps/sim/blocks/registry.ts": 355 } }, "app/workspace/[workspaceId]/not-found.tsx": { From e2458c1afd49e268974f748e5fbd867ce209a362 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 13:00:46 -0700 Subject: [PATCH 03/13] improvement(files): frame diffs as cards with tinted change rows --- .../rich-markdown-editor/code-block.tsx | 2 +- .../rich-markdown-editor.css | 7 + apps/sim/components/diff/diff-embed.tsx | 167 +++++++++--------- apps/sim/components/diff/diff-view.tsx | 161 ++++++++--------- apps/sim/lib/diff/unified.test.ts | 1 + apps/sim/lib/diff/unified.ts | 10 +- 6 files changed, 177 insertions(+), 171 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index fc7575f134a..153b63d5ef1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -236,7 +236,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView // Select the whole node instead of placing a caret, which would flip the block to source.
{ const target = event.target if (!(target instanceof Element) || !event.currentTarget.contains(target)) return diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 7fd223915c6..acb96fc7795 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -391,6 +391,13 @@ margin: 1rem 0; } +/* A diff's links are chips, so they keep their own colour instead of the prose link style. */ +.rich-markdown-prose .diff-embed a, +.rich-markdown-prose .diff-embed a:hover { + color: revert-layer; + text-decoration: revert-layer; +} + /* A dashboard results table sizes its own columns and sits flush in its panel. */ .rich-markdown-nodes .dashboard-embed table { table-layout: auto; diff --git a/apps/sim/components/diff/diff-embed.tsx b/apps/sim/components/diff/diff-embed.tsx index ad85e0e85e0..f05e913fe0b 100644 --- a/apps/sim/components/diff/diff-embed.tsx +++ b/apps/sim/components/diff/diff-embed.tsx @@ -1,14 +1,13 @@ 'use client' import { type ReactNode, useMemo, useState } from 'react' -import { cn } from '@sim/emcn' +import { ChipLink, cn } from '@sim/emcn' +import { ArrowUpRight } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' -import Link from 'next/link' import { useParams } from 'next/navigation' import { DiffView, type DiffViewMode } from '@/components/diff/diff-view' import { type DiffMatch, - type DiffSource, matchUnifiedDiff, parseUnifiedDiff, type UnifiedDiff, @@ -17,6 +16,8 @@ import { workspaceResourcePath } from '@/lib/resources' import { useDocumentQuery, useKnowledgeDocumentText } from '@/hooks/queries/kb/knowledge' import { useWorkspaceFileContent, useWorkspaceFileRecord } from '@/hooks/queries/workspace-files' +type DiffState = DiffMatch | 'unavailable' | null + interface DiffEmbedProps { /** The fence body: a unified diff, optionally naming its `sim:` source in `---`/`+++`. */ source: string @@ -27,26 +28,23 @@ interface NoticeProps { children: ReactNode tone?: 'muted' | 'error' } -interface SourceHeaderProps { +interface DiffFrameProps { + diff: UnifiedDiff + title: string + href?: string + state?: DiffState +} +interface SourceDiffProps { diff: UnifiedDiff workspaceId: string } -interface FileSourceHeaderProps extends SourceHeaderProps { +interface FileSourceDiffProps extends SourceDiffProps { fileId: string } -interface KnowledgeSourceHeaderProps extends SourceHeaderProps { +interface KnowledgeSourceDiffProps extends SourceDiffProps { knowledgeBaseId: string documentId: string } -interface SourceLabelProps { - label: string | undefined - href: string - state: DiffMatch | 'unavailable' | null -} -interface DiffBodyProps { - diff: UnifiedDiff - header: ReactNode -} const NOTICE_CLASS = 'rounded-lg bg-[var(--surface-5)] p-4 pr-16 text-caption dark:bg-[var(--surface-4)]' @@ -72,45 +70,80 @@ function Notice({ children, tone = 'muted' }: NoticeProps) { ) } -function SourceLabel({ label, href, state }: SourceLabelProps) { +function changeCounts(diff: UnifiedDiff): string { + let added = 0 + let removed = 0 + for (const hunk of diff.hunks) + for (const line of hunk.lines) { + if (line.type === 'add') added++ + else if (line.type === 'del') removed++ + } + return `+${added} −${removed}` +} + +/** + * The bordered frame every diff renders in: title and status in a header bar, the view below. The + * header reserves its right edge for the code block's hover controls. + */ +function DiffFrame({ diff, title, href, state }: DiffFrameProps) { + const [mode, setMode] = useState('unified') return ( -

- - {label ?? 'Source'} - - {state && state !== 'current' && ( - {STATE_LABELS[state]} - )} -

+
+
+ {title} + + {state && state !== 'current' ? STATE_LABELS[state] : changeCounts(diff)} + + {(['unified', 'split'] as const).map((option) => ( + + ))} + {href && ( + + Open + + )} +
+ +
) } -function FileSourceHeader({ diff, workspaceId, fileId }: FileSourceHeaderProps) { +function FileSourceDiff({ diff, workspaceId, fileId }: FileSourceDiffProps) { const record = useWorkspaceFileRecord(workspaceId, fileId) const content = useWorkspaceFileContent(workspaceId, fileId, record.data?.key ?? '') const state = - record.isSuccess && !record.data + (record.isSuccess && !record.data) || content.error ? 'unavailable' - : content.error - ? 'unavailable' - : content.data === undefined - ? null - : matchUnifiedDiff(diff, [content.data]) + : content.data === undefined + ? null + : matchUnifiedDiff(diff, [content.data]) return ( - ) } -function KnowledgeSourceHeader({ +function KnowledgeSourceDiff({ diff, workspaceId, knowledgeBaseId, documentId, -}: KnowledgeSourceHeaderProps) { +}: KnowledgeSourceDiffProps) { const record = useDocumentQuery(knowledgeBaseId, documentId) const text = useKnowledgeDocumentText(knowledgeBaseId, documentId) const state = @@ -120,55 +153,15 @@ function KnowledgeSourceHeader({ ? null : matchUnifiedDiff(diff, text.data) return ( - ) } -function sourceHeader(source: DiffSource, diff: UnifiedDiff, workspaceId: string) { - return source.kind === 'file' ? ( - - ) : ( - - ) -} - -function DiffBody({ diff, header }: DiffBodyProps) { - const [mode, setMode] = useState('unified') - return ( -
-
-
{header}
-
- {(['unified', 'split'] as const).map((option) => ( - - ))} -
-
- -
- ) -} - /** * A ```diff fence: the change is written into the document, so it renders without any history. * When its header names a workspace file or knowledge document, the hunks are matched against @@ -188,10 +181,16 @@ export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { if (isStreaming) return The diff loads when Sim finishes writing. if (!parsed.diff) return {parsed.error} const { diff } = parsed - return ( - - ) + if (diff.source && workspaceId) + return diff.source.kind === 'file' ? ( + + ) : ( + + ) + return } diff --git a/apps/sim/components/diff/diff-view.tsx b/apps/sim/components/diff/diff-view.tsx index 567575b9bbf..7f80d134343 100644 --- a/apps/sim/components/diff/diff-view.tsx +++ b/apps/sim/components/diff/diff-view.tsx @@ -10,11 +10,14 @@ const CONTEXT_EDGE = 3 /** A run of unchanged lines collapses once it is longer than this. */ const COLLAPSE_AFTER = CONTEXT_EDGE * 2 + 2 -const ADD_ROW = 'bg-[color-mix(in_srgb,var(--badge-success-bg)_40%,transparent)]' -const DEL_ROW = 'bg-[color-mix(in_srgb,var(--badge-error-bg)_40%,transparent)]' -const ADD_WORD = 'rounded-sm bg-[var(--badge-success-bg)]' -const DEL_WORD = 'rounded-sm bg-[var(--badge-error-bg)]' -const GUTTER = 'select-none px-2 text-right text-[var(--text-muted)] tabular-nums' +const ADD_ROW = 'bg-[var(--badge-success-bg)] text-[var(--badge-success-text)]' +const DEL_ROW = 'bg-[var(--badge-error-bg)] text-[var(--badge-error-text)]' +const CONTEXT_ROW = 'text-[var(--text-body)]' +const ADD_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-success-text)_18%,transparent)]' +const DEL_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-error-text)_18%,transparent)]' +const GUTTER = 'select-none pl-3 text-right text-[var(--text-muted)] tabular-nums' +const MARKER = 'select-none pl-3' +const TEXT = 'whitespace-pre py-0.5 pr-3 pl-1' export type DiffViewMode = 'unified' | 'split' @@ -88,7 +91,7 @@ function CollapsedRun({ count, onExpand }: CollapsedRunProps) { @@ -122,11 +125,11 @@ function visibleItems(lines: DiffLine[], expanded: boolean) { } function rowClass(line: DiffLine | undefined) { - return line?.type === 'add' ? ADD_ROW : line?.type === 'del' ? DEL_ROW : undefined + return line?.type === 'add' ? ADD_ROW : line?.type === 'del' ? DEL_ROW : line && CONTEXT_ROW } function marker(line: DiffLine | undefined) { - return line?.type === 'add' ? '+' : line?.type === 'del' ? '−' : ' ' + return line?.type === 'add' ? '+' : line?.type === 'del' ? '-' : ' ' } /** Aligns removed and added runs side by side; unchanged lines fill both columns. */ @@ -172,86 +175,76 @@ export function DiffView({ hunks, mode }: DiffViewProps) { const columns = mode === 'split' ? numbered - ? 'grid-cols-[auto_auto_minmax(0,1fr)_auto_auto_minmax(0,1fr)]' - : 'grid-cols-[auto_minmax(0,1fr)_auto_minmax(0,1fr)]' + ? 'grid-cols-[auto_auto_1fr_auto_auto_1fr]' + : 'grid-cols-[auto_1fr_auto_1fr]' : numbered - ? 'grid-cols-[auto_auto_auto_minmax(0,1fr)]' - : 'grid-cols-[auto_minmax(0,1fr)]' + ? 'grid-cols-[auto_auto_auto_1fr]' + : 'grid-cols-[auto_1fr]' return ( -
- {hunks.map((hunk, hunkIndex) => { - const segments = wordSegments(hunk.lines) - const items = visibleItems(hunk.lines, expanded.has(hunkIndex)) - const expand = () => setExpanded((current) => new Set(current).add(hunkIndex)) - return ( - - {(hunk.heading || hunkIndex > 0) && ( -
- {hunk.heading || '⋯'} -
- )} - {mode === 'unified' - ? items.map((item, index) => - 'line' in item ? ( -
- {numbered && {item.line.oldLine ?? ''}} - {numbered && {item.line.newLine ?? ''}} - - {marker(item.line)} - - - - -
- ) : ( - +
+
+ {hunks.map((hunk, hunkIndex) => { + const segments = wordSegments(hunk.lines) + const items = visibleItems(hunk.lines, expanded.has(hunkIndex)) + const expand = () => setExpanded((current) => new Set(current).add(hunkIndex)) + return ( + + {(hunk.heading || hunkIndex > 0) && ( +
+ {hunk.heading || '⋯'} +
+ )} + {mode === 'unified' + ? items.map((item, index) => + 'line' in item ? ( +
+ {numbered && {item.line.oldLine ?? ''}} + {numbered && {item.line.newLine ?? ''}} + {marker(item.line)} + + + +
+ ) : ( + + ) ) - ) - : splitRows(items).map((row, index) => - 'collapsed' in row ? ( - - ) : ( -
- {[row.left, row.right].map((line, side) => ( - - {numbered && ( - - {(side === 0 ? line?.oldLine : line?.newLine) ?? ''} - - )} - - {line ? marker(line) : ''} - - + 'collapsed' in row ? ( + + ) : ( +
+ {[row.left, row.right].map((line, side) => ( + + {numbered && ( + + {(side === 0 ? line?.oldLine : line?.newLine) ?? ''} + )} - > - {line && } - - - ))} -
- ) - )} -
- ) - })} + + {line ? marker(line) : ''} + + + {line && } + + + ))} +
+ ) + )} +
+ ) + })} +
) } diff --git a/apps/sim/lib/diff/unified.test.ts b/apps/sim/lib/diff/unified.test.ts index 6b9ed2cb85a..cdb70fb35f8 100644 --- a/apps/sim/lib/diff/unified.test.ts +++ b/apps/sim/lib/diff/unified.test.ts @@ -29,6 +29,7 @@ describe('parseUnifiedDiff', () => { 'diff --git a/x.ts b/x.ts\nindex 1..2 100644\n--- a/x.ts\n+++ b/x.ts\n@@ -10,2 +10,2 @@ fn\n keep\n-old\n+new\n\\ No newline at end of file' ) expect(diff.source).toBeNull() + expect(diff.path).toBe('x.ts') expect(diff.hunks[0].heading).toBe('fn') expect(diff.hunks[0].lines).toEqual([ { type: 'context', text: 'keep', oldLine: 10, newLine: 10 }, diff --git a/apps/sim/lib/diff/unified.ts b/apps/sim/lib/diff/unified.ts index 31e352fa87b..ed656cc5387 100644 --- a/apps/sim/lib/diff/unified.ts +++ b/apps/sim/lib/diff/unified.ts @@ -22,6 +22,8 @@ export interface DiffHunk { export interface UnifiedDiff { source: DiffSource | null + /** The file path from a plain `+++ b/` header, for diffs without a `sim:` source. */ + path: string | null hunks: DiffHunk[] } @@ -70,6 +72,7 @@ function sameSource(a: DiffSource, b: DiffSource): boolean { export function parseUnifiedDiff(text: string): UnifiedDiff { const rows = text.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n') let source: DiffSource | null = null + let path: string | null = null const hunks: DiffHunk[] = [] let hunk: DiffHunk | null = null let oldLine: number | undefined @@ -77,10 +80,13 @@ export function parseUnifiedDiff(text: string): UnifiedDiff { for (const [index, row] of rows.entries()) { if (!hunk && (row.startsWith('--- ') || row.startsWith('+++ '))) { - const parsed = parseSource(row.slice(4)) + const target = row.slice(4).trim() + const parsed = parseSource(target) if (parsed && source && !sameSource(source, parsed)) throw new Error('A diff compares one source: --- and +++ must name the same resource') source = parsed ?? source + if (!parsed && target !== '/dev/null' && (row.startsWith('+++ ') || !path)) + path = target.replace(/^[ab]\//, '').split('\t')[0] continue } if (!hunk && GIT_HEADER.test(row)) continue @@ -113,7 +119,7 @@ export function parseUnifiedDiff(text: string): UnifiedDiff { if (!hunks.some((entry) => entry.lines.some((line) => line.type !== 'context'))) throw new Error('A diff needs at least one + or - line') - return { source, hunks } + return { source, path: source ? null : path, hunks } } /** Whitespace-insensitive, so reflowed indentation or line endings never read as a change. */ From 83a6378731b33e2477c26fff7f7b16ec60f7caec Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 13:06:24 -0700 Subject: [PATCH 04/13] improvement(files): render prose diffs as source excerpt cards and compare two documents --- apps/sim/components/diff/diff-embed.tsx | 285 +++++++++++++++++------- apps/sim/lib/diff/unified.test.ts | 51 ++++- apps/sim/lib/diff/unified.ts | 59 +++-- 3 files changed, 282 insertions(+), 113 deletions(-) diff --git a/apps/sim/components/diff/diff-embed.tsx b/apps/sim/components/diff/diff-embed.tsx index f05e913fe0b..991d1b640fa 100644 --- a/apps/sim/components/diff/diff-embed.tsx +++ b/apps/sim/components/diff/diff-embed.tsx @@ -1,15 +1,19 @@ 'use client' -import { type ReactNode, useMemo, useState } from 'react' +import { type ComponentType, type ReactNode, useMemo, useState } from 'react' import { ChipLink, cn } from '@sim/emcn' -import { ArrowUpRight } from '@sim/emcn/icons' +import { ArrowUpRight, Database, File } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' +import Link from 'next/link' import { useParams } from 'next/navigation' import { DiffView, type DiffViewMode } from '@/components/diff/diff-view' import { + type DiffLine, type DiffMatch, + type DiffSource, matchUnifiedDiff, parseUnifiedDiff, + sameSource, type UnifiedDiff, } from '@/lib/diff/unified' import { workspaceResourcePath } from '@/lib/resources' @@ -19,7 +23,7 @@ import { useWorkspaceFileContent, useWorkspaceFileRecord } from '@/hooks/queries type DiffState = DiffMatch | 'unavailable' | null interface DiffEmbedProps { - /** The fence body: a unified diff, optionally naming its `sim:` source in `---`/`+++`. */ + /** The fence body: a unified diff, optionally naming its `sim:` sources in `---`/`+++`. */ source: string /** True while an agent is still writing the document, so the fence may be incomplete. */ isStreaming: boolean @@ -28,26 +32,39 @@ interface NoticeProps { children: ReactNode tone?: 'muted' | 'error' } -interface DiffFrameProps { - diff: UnifiedDiff +/** A diff source resolved to what the card header shows and the text its hunks are matched to. */ +interface ResolvedSource { + title: string + kindLabel: string + icon: ComponentType<{ className?: string }> + updated: string | null + href: string + segments: readonly string[] | undefined + unavailable: boolean +} +interface CardHeading { title: string + meta: string + icon?: ComponentType<{ className?: string }> href?: string - state?: DiffState } -interface SourceDiffProps { +interface ExcerptCardProps { + heading: CardHeading diff: UnifiedDiff - workspaceId: string + side: 'edit' | 'old' | 'new' } -interface FileSourceDiffProps extends SourceDiffProps { - fileId: string +interface ProseLineProps { + line: DiffLine + side: ExcerptCardProps['side'] } -interface KnowledgeSourceDiffProps extends SourceDiffProps { - knowledgeBaseId: string - documentId: string +interface CodeDiffProps { + diff: UnifiedDiff + heading: CardHeading } const NOTICE_CLASS = 'rounded-lg bg-[var(--surface-5)] p-4 pr-16 text-caption dark:bg-[var(--surface-4)]' +const MARK = 'rounded-[3px] px-1 text-[var(--text-primary)]' const STATE_LABELS: Record | 'unavailable', string> = { proposed: 'Proposed · not applied', @@ -81,19 +98,130 @@ function changeCounts(diff: UnifiedDiff): string { return `+${added} −${removed}` } +function formatDate(value: string | Date | null | undefined): string | null { + if (!value) return null + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)) +} + /** - * The bordered frame every diff renders in: title and status in a header bar, the view below. The - * header reserves its right edge for the code block's hover controls. + * Resolves a `sim:` source for a header and its current text. Every query is disabled unless the + * source is of its kind, so one hook serves both kinds and either side of a comparison. */ -function DiffFrame({ diff, title, href, state }: DiffFrameProps) { +function useDiffSource( + workspaceId: string | null, + source: DiffSource | null +): ResolvedSource | null { + const fileId = workspaceId && source?.kind === 'file' ? source.fileId : '' + const knowledgeBaseId = + workspaceId && source?.kind === 'knowledge' ? source.knowledgeBaseId : undefined + const documentId = workspaceId && source?.kind === 'knowledge' ? source.documentId : undefined + const fileRecord = useWorkspaceFileRecord(workspaceId ?? '', fileId) + const fileContent = useWorkspaceFileContent(workspaceId ?? '', fileId, fileRecord.data?.key ?? '') + const documentRecord = useDocumentQuery(knowledgeBaseId, documentId) + const documentText = useKnowledgeDocumentText(knowledgeBaseId, documentId) + if (!workspaceId || !source) return null + if (source.kind === 'file') + return { + title: fileRecord.data?.name ?? 'File', + kindLabel: 'Workspace file', + icon: File, + updated: formatDate(fileRecord.data?.updatedAt), + href: workspaceResourcePath(workspaceId, 'file', source.fileId), + segments: fileContent.data === undefined ? undefined : [fileContent.data], + unavailable: (fileRecord.isSuccess && !fileRecord.data) || Boolean(fileContent.error), + } + return { + title: documentRecord.data?.filename ?? 'Document', + kindLabel: 'Knowledge base', + icon: Database, + updated: formatDate(documentRecord.data?.uploadedAt), + href: `${workspaceResourcePath(workspaceId, 'knowledge', source.knowledgeBaseId)}/${encodeURIComponent(source.documentId)}`, + segments: documentText.data, + unavailable: documentRecord.isError || documentText.isError, + } +} + +function heading(resolved: ResolvedSource | null, fallback: string, detail: string): CardHeading { + if (!resolved) return { title: fallback, meta: detail } + return { + title: resolved.title, + meta: [resolved.kindLabel, resolved.updated && `Updated ${resolved.updated}`, detail] + .filter(Boolean) + .join(' · '), + icon: resolved.icon, + href: resolved.href, + } +} + +/** Markdown headings read as headings; everything else is a paragraph of the source. */ +function ProseLine({ line, side }: ProseLineProps) { + const headingMatch = /^#{1,6}\s+(.*)$/.exec(line.text) + const text = headingMatch ? headingMatch[1] : line.text + const weight = headingMatch && 'font-medium' + if (line.type === 'context') + return

{text}

+ const highlight = + side !== 'edit' + ? 'bg-[var(--badge-amber-bg)]' + : line.type === 'add' + ? 'bg-[var(--badge-success-bg)]' + : 'bg-[var(--badge-error-bg)] line-through decoration-[var(--badge-error-text)]' + return ( +

+ {text} +

+ ) +} + +/** + * A source excerpt in the shape of the page it quotes: unchanged text muted, changed text marked. + * `old`/`new` show one side of a two-document comparison; `edit` shows both sides of one edit. + */ +function ExcerptCard({ heading, diff, side }: ExcerptCardProps) { + const Icon = heading.icon + const skip = side === 'old' ? 'add' : side === 'new' ? 'del' : null + return ( +
+
+ {Icon && } +
+ {heading.title} + {heading.meta} +
+ {heading.href && ( + + + + )} +
+
+ {diff.hunks.map((hunk, hunkIndex) => ( +
+ {hunkIndex > 0 &&

⋯

} + {hunk.lines + .filter((line) => line.type !== skip && line.text.trim() !== '') + .map((line, index) => ( + + ))} +
+ ))} +
+
+ ) +} + +/** Line-numbered diffs are code: a monospace view with a unified/split toggle. */ +function CodeDiff({ diff, heading }: CodeDiffProps) { const [mode, setMode] = useState('unified') return (
- {title} - - {state && state !== 'current' ? STATE_LABELS[state] : changeCounts(diff)} - + {heading.title} + {heading.meta} {(['unified', 'split'] as const).map((option) => (
@@ -297,9 +289,9 @@ function parse( /** * A ```diff fence: the change is written into the document, so it renders without any history. * When its header names workspace files or knowledge documents, the hunks are matched against - * their current text and marked proposed or outdated when they no longer hold. Line-numbered - * diffs render as code; everything else renders as excerpts of the pages it quotes. A public - * share has no workspace session, so it renders without checking. + * their current text and marked proposed or outdated when they no longer hold. An edit renders + * line by line; two different documents render as side-by-side excerpts. A public share has no + * workspace session, so it renders without checking. */ export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { const params = useParams() @@ -323,12 +315,7 @@ export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { ? null : matchUnifiedDiff(diff, oldSide.segments, newSide?.segments) const detail = state && state !== 'current' ? STATE_LABELS[state] : changeCounts(diff) - const numbered = diff.hunks.some((hunk) => - hunk.lines.some((line) => line.oldLine !== undefined || line.newLine !== undefined) - ) - if (numbered) - return if (comparison) return (
@@ -336,13 +323,5 @@ export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) {
) - return ( -
- -
- ) + return } diff --git a/apps/sim/components/diff/diff-view.tsx b/apps/sim/components/diff/diff-view.tsx index 7f80d134343..be93f0c0b4f 100644 --- a/apps/sim/components/diff/diff-view.tsx +++ b/apps/sim/components/diff/diff-view.tsx @@ -10,11 +10,13 @@ const CONTEXT_EDGE = 3 /** A run of unchanged lines collapses once it is longer than this. */ const COLLAPSE_AFTER = CONTEXT_EDGE * 2 + 2 -const ADD_ROW = 'bg-[var(--badge-success-bg)] text-[var(--badge-success-text)]' -const DEL_ROW = 'bg-[var(--badge-error-bg)] text-[var(--badge-error-text)]' +const ADD_ROW = + 'bg-[color-mix(in_srgb,var(--badge-success-bg)_35%,transparent)] text-[var(--text-primary)]' +const DEL_ROW = + 'bg-[color-mix(in_srgb,var(--badge-error-bg)_35%,transparent)] text-[var(--text-primary)]' const CONTEXT_ROW = 'text-[var(--text-body)]' -const ADD_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-success-text)_18%,transparent)]' -const DEL_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-error-text)_18%,transparent)]' +const ADD_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-success-bg)_90%,transparent)]' +const DEL_WORD = 'rounded-sm bg-[color-mix(in_srgb,var(--badge-error-bg)_90%,transparent)]' const GUTTER = 'select-none pl-3 text-right text-[var(--text-muted)] tabular-nums' const MARKER = 'select-none pl-3' const TEXT = 'whitespace-pre py-0.5 pr-3 pl-1' @@ -128,6 +130,14 @@ function rowClass(line: DiffLine | undefined) { return line?.type === 'add' ? ADD_ROW : line?.type === 'del' ? DEL_ROW : line && CONTEXT_ROW } +function markerClass(line: DiffLine | undefined) { + return line?.type === 'add' + ? 'text-[var(--badge-success-text)]' + : line?.type === 'del' + ? 'text-[var(--badge-error-text)]' + : undefined +} + function marker(line: DiffLine | undefined) { return line?.type === 'add' ? '+' : line?.type === 'del' ? '-' : ' ' } @@ -203,7 +213,9 @@ export function DiffView({ hunks, mode }: DiffViewProps) { > {numbered && {item.line.oldLine ?? ''}} {numbered && {item.line.newLine ?? ''}} - {marker(item.line)} + + {marker(item.line)} + @@ -224,7 +236,7 @@ export function DiffView({ hunks, mode }: DiffViewProps) { {(side === 0 ? line?.oldLine : line?.newLine) ?? ''} )} - + {line ? marker(line) : ''} Date: Thu, 1 Oct 2026 13:23:47 -0700 Subject: [PATCH 08/13] improvement(files): draw mermaid flowcharts like workflow blocks and edges --- .../components/file-viewer/mermaid-diagram.tsx | 7 ++++++- .../files/components/file-viewer/mermaid-theme.ts | 15 ++++++++++++++- .../rich-markdown-editor/rich-markdown-editor.css | 15 +++++++++------ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx index 1e3e8c1015b..ba88427a390 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx @@ -4,7 +4,10 @@ import { memo, useEffect, useState } from 'react' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { useTheme } from 'next-themes' -import { readMermaidThemeVariables } from '@/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme' +import { + MERMAID_WORKFLOW_CSS, + readMermaidThemeVariables, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme' import { PreviewLoadingFrame } from './preview-shared' import { ZoomablePreview } from './zoomable-preview' @@ -129,6 +132,8 @@ export const MermaidDiagram = memo(function MermaidDiagram({ startOnLoad: false, securityLevel: 'strict', theme: 'base', + themeCSS: MERMAID_WORKFLOW_CSS, + flowchart: { curve: 'step', padding: 12, nodeSpacing: 36, rankSpacing: 48 }, themeVariables: readMermaidThemeVariables( document.documentElement, mermaidTheme === 'dark' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts index 1408660d99a..5f18c171e4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts @@ -38,7 +38,7 @@ export function readMermaidThemeVariables( mainBkg: node, nodeBorder: border, textColor: body, - lineColor: line, + lineColor: token('--workflow-edge'), clusterBkg: token('--surface-3'), clusterBorder: border, edgeLabelBackground: background, @@ -66,3 +66,16 @@ export function readMermaidThemeVariables( pieLegendTextColor: body, } } + +/** + * Flowcharts drawn like workflow canvas blocks and edges: rounded cards with a 1.5px outline, + * 1.5px edges without arrowheads, and subgraphs as rounded subflow containers. Scoped to + * flowchart classes so sequence and other diagrams keep their arrows. + */ +export const MERMAID_WORKFLOW_CSS = ` + .node rect, .node polygon, .node circle, .node path { stroke-width: 1.5px; } + .node rect { rx: 10px; ry: 10px; } + .cluster rect { rx: 14px; ry: 14px; stroke-width: 1.5px; } + .flowchart-link { stroke-width: 1.5px; marker-end: none !important; } + .edgeLabel, .edgeLabel p { font-size: 12px; } +` diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index acb96fc7795..bc88c462e0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -276,19 +276,23 @@ color: var(--brand-secondary); } -.rich-markdown-prose pre, -.rich-markdown-prose .mermaid-diagram-frame { +.rich-markdown-prose pre { background: var(--surface-5); border-radius: 8px; padding: 1rem; overflow-x: auto; } -/* A rendered Mermaid diagram wears the same frame as a code block (single source of truth above), - centered within it. */ +/* A rendered Mermaid diagram sits on the workflow canvas surface: flat page background, a hairline + frame, centered. */ .rich-markdown-prose .mermaid-diagram-frame { display: flex; justify-content: center; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; + overflow-x: auto; } /* Override ProseMirror's built-in `.ProseMirror pre { white-space: pre-wrap }` (the @@ -309,8 +313,7 @@ overflow-wrap: anywhere; } -.dark .rich-markdown-prose pre, -.dark .rich-markdown-prose .mermaid-diagram-frame { +.dark .rich-markdown-prose pre { background: var(--code-bg); } From daa963ce94b01f50b8bad8a2a38371e3af9a1dbb Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 13:26:17 -0700 Subject: [PATCH 09/13] fix(files): keep mermaid labels inside their nodes and fork edges cleanly --- .../file-viewer/mermaid-diagram.tsx | 21 ++++++++++++------- .../components/file-viewer/mermaid-theme.ts | 15 +++++++++---- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx index ba88427a390..9e8c3c634c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-diagram.tsx @@ -5,7 +5,7 @@ import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { useTheme } from 'next-themes' import { - MERMAID_WORKFLOW_CSS, + mermaidWorkflowCss, readMermaidThemeVariables, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme' import { PreviewLoadingFrame } from './preview-shared' @@ -128,16 +128,23 @@ export const MermaidDiagram = memo(function MermaidDiagram({ const { default: mermaid } = await import('mermaid') if (cancelled) return + const themeVariables = readMermaidThemeVariables( + document.documentElement, + mermaidTheme === 'dark' + ) mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'base', - themeCSS: MERMAID_WORKFLOW_CSS, - flowchart: { curve: 'step', padding: 12, nodeSpacing: 36, rankSpacing: 48 }, - themeVariables: readMermaidThemeVariables( - document.documentElement, - mermaidTheme === 'dark' - ), + themeCSS: mermaidWorkflowCss(String(themeVariables.fontFamily)), + flowchart: { + curve: 'basis', + padding: 12, + nodeSpacing: 36, + rankSpacing: 56, + wrappingWidth: 180, + }, + themeVariables, }) mermaid.setParseErrorHandler?.(() => undefined) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts index 5f18c171e4e..9188328858a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts @@ -69,13 +69,20 @@ export function readMermaidThemeVariables( /** * Flowcharts drawn like workflow canvas blocks and edges: rounded cards with a 1.5px outline, - * 1.5px edges without arrowheads, and subgraphs as rounded subflow containers. Scoped to - * flowchart classes so sequence and other diagrams keep their arrows. + * 1.5px edges without arrowheads, and subgraphs as rounded subflow containers. Labels are pinned + * to the font Mermaid measured them with; otherwise they inherit the surrounding document's font + * and line height and overflow their boxes. Edge rules are scoped to flowchart classes so sequence + * and other diagrams keep their arrows. */ -export const MERMAID_WORKFLOW_CSS = ` +export function mermaidWorkflowCss(fontFamily: string): string { + return ` + .label, .nodeLabel, .edgeLabel, .label p, .nodeLabel p, .edgeLabel p { + font-family: ${fontFamily}; font-size: 14px; line-height: 1.5; letter-spacing: normal; + } + .edgeLabel, .edgeLabel p { font-size: 12px; } .node rect, .node polygon, .node circle, .node path { stroke-width: 1.5px; } .node rect { rx: 10px; ry: 10px; } .cluster rect { rx: 14px; ry: 14px; stroke-width: 1.5px; } .flowchart-link { stroke-width: 1.5px; marker-end: none !important; } - .edgeLabel, .edgeLabel p { font-size: 12px; } ` +} From 343193561fe323ac3f17d321dbf389ebc1399e3c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 13:53:54 -0700 Subject: [PATCH 10/13] improvement(files): drop diff source checks and split view, size tables to content --- .../components/dashboards/dashboard-panel.tsx | 4 +- apps/sim/components/diff/diff-embed.tsx | 76 +++--------- apps/sim/components/diff/diff-view.tsx | 113 ++++-------------- apps/sim/hooks/queries/kb/knowledge.ts | 32 ----- .../sim/hooks/queries/utils/knowledge-keys.ts | 2 - apps/sim/lib/diff/unified.test.ts | 57 +-------- apps/sim/lib/diff/unified.ts | 55 --------- 7 files changed, 41 insertions(+), 298 deletions(-) diff --git a/apps/sim/components/dashboards/dashboard-panel.tsx b/apps/sim/components/dashboards/dashboard-panel.tsx index f75d4f924aa..ac808777880 100644 --- a/apps/sim/components/dashboards/dashboard-panel.tsx +++ b/apps/sim/components/dashboards/dashboard-panel.tsx @@ -57,7 +57,7 @@ interface ResultsTableProps { } function ResultsTable({ data, timeField, timeZone }: ResultsTableProps) { return ( -
+
@@ -195,7 +195,7 @@ export function DashboardPanel({ : barChartHeight !== null ? 'relative' : 'relative h-[240px]' - : 'relative h-[400px]') + : 'relative grid min-h-[120px]') )} style={!timeSeries && barChartHeight !== null ? { height: barChartHeight } : undefined} > diff --git a/apps/sim/components/diff/diff-embed.tsx b/apps/sim/components/diff/diff-embed.tsx index cf0f184b58c..f8c0ca9de23 100644 --- a/apps/sim/components/diff/diff-embed.tsx +++ b/apps/sim/components/diff/diff-embed.tsx @@ -1,26 +1,22 @@ 'use client' -import { type ComponentType, type ReactNode, useMemo, useState } from 'react' +import { type ComponentType, type ReactNode, useMemo } from 'react' import { cn } from '@sim/emcn' import { ArrowUpRight, Database, File } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import Link from 'next/link' import { useParams } from 'next/navigation' -import { DiffView, type DiffViewMode } from '@/components/diff/diff-view' +import { DiffView } from '@/components/diff/diff-view' import { type DiffLine, - type DiffMatch, type DiffSource, - matchUnifiedDiff, parseUnifiedDiff, sameSource, type UnifiedDiff, } from '@/lib/diff/unified' import { workspaceResourcePath } from '@/lib/resources' -import { useDocumentQuery, useKnowledgeDocumentText } from '@/hooks/queries/kb/knowledge' -import { useWorkspaceFileContent, useWorkspaceFileRecord } from '@/hooks/queries/workspace-files' - -type DiffState = DiffMatch | 'unavailable' | null +import { useDocumentQuery } from '@/hooks/queries/kb/knowledge' +import { useWorkspaceFileRecord } from '@/hooks/queries/workspace-files' interface DiffEmbedProps { /** The fence body: a unified diff, optionally naming its `sim:` sources in `---`/`+++`. */ @@ -32,15 +28,13 @@ interface NoticeProps { children: ReactNode tone?: 'muted' | 'error' } -/** A diff source resolved to what the card header shows and the text its hunks are matched to. */ +/** A diff source resolved to what its card header shows. */ interface ResolvedSource { title: string kindLabel: string icon: ComponentType<{ className?: string }> updated: string | null href: string - segments: readonly string[] | undefined - unavailable: boolean } interface CardHeading { title: string @@ -68,12 +62,6 @@ const NOTICE_CLASS = 'rounded-lg bg-[var(--surface-5)] p-4 pr-16 text-caption dark:bg-[var(--surface-4)]' const MARK = 'rounded-[3px] px-1 text-[var(--text-primary)]' -const STATE_LABELS: Record | 'unavailable', string> = { - proposed: 'Proposed · not applied', - outdated: 'Outdated · source changed', - unavailable: 'Source unavailable', -} - function Notice({ children, tone = 'muted' }: NoticeProps) { return (
('unified') return (
@@ -256,22 +237,8 @@ function CodeDiff({ diff, heading }: CodeDiffProps) { )} {heading.meta} - {(['unified', 'split'] as const).map((option) => ( - - ))}
- +
) } @@ -288,10 +255,9 @@ function parse( /** * A ```diff fence: the change is written into the document, so it renders without any history. - * When its header names workspace files or knowledge documents, the hunks are matched against - * their current text and marked proposed or outdated when they no longer hold. An edit renders - * line by line; two different documents render as side-by-side excerpts. A public share has no - * workspace session, so it renders without checking. + * `sim:` sources in its header name the workspace files or knowledge documents it quotes, shown + * with a link in each card header. An edit renders line by line; two different documents render + * as side-by-side excerpts. A public share has no workspace session, so it shows no source names. */ export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { const params = useParams() @@ -306,22 +272,14 @@ export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { if (isStreaming) return The diff loads when Sim finishes writing. if (!diff) return {parsed.error} - const state: DiffState = - !oldSide || (comparison && !newSide) - ? null - : oldSide.unavailable || newSide?.unavailable - ? 'unavailable' - : !oldSide.segments || (comparison && !newSide?.segments) - ? null - : matchUnifiedDiff(diff, oldSide.segments, newSide?.segments) - const detail = state && state !== 'current' ? STATE_LABELS[state] : changeCounts(diff) - if (comparison) return (
- - + +
) - return + return ( + + ) } diff --git a/apps/sim/components/diff/diff-view.tsx b/apps/sim/components/diff/diff-view.tsx index be93f0c0b4f..6b2b35097a2 100644 --- a/apps/sim/components/diff/diff-view.tsx +++ b/apps/sim/components/diff/diff-view.tsx @@ -21,15 +21,12 @@ const GUTTER = 'select-none pl-3 text-right text-[var(--text-muted)] tabular-num const MARKER = 'select-none pl-3' const TEXT = 'whitespace-pre py-0.5 pr-3 pl-1' -export type DiffViewMode = 'unified' | 'split' - interface Segment { text: string changed: boolean } interface DiffViewProps { hunks: DiffHunk[] - mode: DiffViewMode } interface LineTextProps { line: DiffLine @@ -142,54 +139,16 @@ function marker(line: DiffLine | undefined) { return line?.type === 'add' ? '+' : line?.type === 'del' ? '-' : ' ' } -/** Aligns removed and added runs side by side; unchanged lines fill both columns. */ -function splitRows(items: ReturnType) { - const rows: Array<{ left?: DiffLine; right?: DiffLine } | { collapsed: number; key: number }> = [] - for (let index = 0; index < items.length; ) { - const item = items[index] - if (!('line' in item)) { - rows.push(item) - index++ - continue - } - if (item.line.type === 'context') { - rows.push({ left: item.line, right: item.line }) - index++ - continue - } - const dels: DiffLine[] = [] - const adds: DiffLine[] = [] - while (index < items.length) { - const next = items[index] - if (!('line' in next) || next.line.type === 'context') break - if (next.line.type === 'del' && adds.length === 0) dels.push(next.line) - else if (next.line.type === 'add') adds.push(next.line) - else break - index++ - } - for (let pair = 0; pair < Math.max(dels.length, adds.length); pair++) - rows.push({ left: dels[pair], right: adds[pair] }) - } - return rows -} - /** * A unified diff rendered with word-level highlights, collapsed unchanged runs, and line numbers - * when the hunk headers carry them. Split mode aligns removals and additions side by side. + * when the hunk headers carry them. */ -export function DiffView({ hunks, mode }: DiffViewProps) { +export function DiffView({ hunks }: DiffViewProps) { const [expanded, setExpanded] = useState>(() => new Set()) const numbered = hunks.some((hunk) => hunk.lines.some((line) => line.oldLine !== undefined || line.newLine !== undefined) ) - const columns = - mode === 'split' - ? numbered - ? 'grid-cols-[auto_auto_1fr_auto_auto_1fr]' - : 'grid-cols-[auto_1fr_auto_1fr]' - : numbered - ? 'grid-cols-[auto_auto_auto_1fr]' - : 'grid-cols-[auto_1fr]' + const columns = numbered ? 'grid-cols-[auto_auto_auto_1fr]' : 'grid-cols-[auto_1fr]' return (
@@ -204,55 +163,23 @@ export function DiffView({ hunks, mode }: DiffViewProps) { {hunk.heading || '⋯'}
)} - {mode === 'unified' - ? items.map((item, index) => - 'line' in item ? ( -
- {numbered && {item.line.oldLine ?? ''}} - {numbered && {item.line.newLine ?? ''}} - - {marker(item.line)} - - - - -
- ) : ( - - ) - ) - : splitRows(items).map((row, index) => - 'collapsed' in row ? ( - - ) : ( -
- {[row.left, row.right].map((line, side) => ( - - {numbered && ( - - {(side === 0 ? line?.oldLine : line?.newLine) ?? ''} - - )} - - {line ? marker(line) : ''} - - - {line && } - - - ))} -
- ) - )} + {items.map((item, index) => + 'line' in item ? ( +
+ {numbered && {item.line.oldLine ?? ''}} + {numbered && {item.line.newLine ?? ''}} + {marker(item.line)} + + + +
+ ) : ( + + ) + )} ) })} diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 5e19b4137c5..8a9a4e1d349 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -89,9 +89,6 @@ export const KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME = 60 * 1000 export const KNOWLEDGE_DOCUMENT_LIST_STALE_TIME = 60 * 1000 export const KNOWLEDGE_CHUNK_LIST_STALE_TIME = 60 * 1000 export const KNOWLEDGE_CHUNK_SEARCH_STALE_TIME = 60 * 1000 -export const KNOWLEDGE_DOCUMENT_TEXT_STALE_TIME = 60 * 1000 -/** Bounds how much of a document is read to reconstruct its text (100 chunks per page). */ -const DOCUMENT_TEXT_MAX_PAGES = 20 export const WORKSPACE_KNOWLEDGE_SEARCH_STALE_TIME = 60 * 1000 export const KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000 export const KNOWLEDGE_TAG_USAGE_STALE_TIME = 60 * 1000 @@ -322,35 +319,6 @@ export const serializeChunkParams = (params: KnowledgeChunksParams) => sortOrder: params.sortOrder ?? 'asc', }) -/** A document's stored text as its chunks in order; fails rather than reading a partial document. */ -async function fetchKnowledgeDocumentText( - knowledgeBaseId: string, - documentId: string, - signal?: AbortSignal -): Promise { - const chunks: string[] = [] - for (let page = 0; page < DOCUMENT_TEXT_MAX_PAGES; page++) { - const result = await requestJson(listKnowledgeChunksContract, { - params: { id: knowledgeBaseId, documentId }, - query: { limit: 100, offset: page * 100, sortBy: 'chunkIndex', sortOrder: 'asc' }, - signal, - }) - chunks.push(...result.data.map((chunk) => chunk.content)) - if (!result.pagination.hasMore) return chunks - } - throw new Error('This document is too large to compare') -} - -export function useKnowledgeDocumentText(knowledgeBaseId?: string, documentId?: string) { - return useQuery({ - queryKey: knowledgeKeys.documentText(knowledgeBaseId ?? '', documentId ?? ''), - queryFn: ({ signal }) => - fetchKnowledgeDocumentText(knowledgeBaseId as string, documentId as string, signal), - enabled: Boolean(knowledgeBaseId && documentId), - staleTime: KNOWLEDGE_DOCUMENT_TEXT_STALE_TIME, - }) -} - export function useKnowledgeChunksQuery( params: KnowledgeChunksParams, options?: { diff --git a/apps/sim/hooks/queries/utils/knowledge-keys.ts b/apps/sim/hooks/queries/utils/knowledge-keys.ts index 2f69e9b7d62..944b2783986 100644 --- a/apps/sim/hooks/queries/utils/knowledge-keys.ts +++ b/apps/sim/hooks/queries/utils/knowledge-keys.ts @@ -86,6 +86,4 @@ export const knowledgeKeys = { [...knowledgeKeys.document(knowledgeBaseId, documentId), 'chunks', paramsKey] as const, chunkSearch: (knowledgeBaseId: string, documentId: string, searchKey: string) => [...knowledgeKeys.document(knowledgeBaseId, documentId), 'search', searchKey] as const, - documentText: (knowledgeBaseId: string, documentId: string) => - [...knowledgeKeys.document(knowledgeBaseId, documentId), 'text'] as const, } diff --git a/apps/sim/lib/diff/unified.test.ts b/apps/sim/lib/diff/unified.test.ts index 0b81e1dfcce..82735cc7d31 100644 --- a/apps/sim/lib/diff/unified.test.ts +++ b/apps/sim/lib/diff/unified.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { matchUnifiedDiff, parseUnifiedDiff } from '@/lib/diff/unified' +import { parseUnifiedDiff } from '@/lib/diff/unified' const runbook = `--- sim:file/wf_runbook +++ sim:file/wf_runbook @@ -56,40 +56,7 @@ describe('parseUnifiedDiff', () => { }) }) -describe('matchUnifiedDiff', () => { - const diff = parseUnifiedDiff(runbook) - - it('is current when the result is in the source, wherever it moved', () => { - expect( - matchUnifiedDiff(diff, [ - '# Slack\n\nIntro moved things around.\n\n## Re-authorizing\nTokens rotate every 12h; the refresh job handles it.\n', - ]) - ).toBe('current') - }) - - it('is proposed while the source still holds the original', () => { - expect( - matchUnifiedDiff(diff, [ - '## Re-authorizing\nTokens never expire, so rotation is not required.', - ]) - ).toBe('proposed') - }) - - it('is outdated when the source matches neither side', () => { - expect(matchUnifiedDiff(diff, ['## Re-authorizing\nTokens rotate hourly.'])).toBe('outdated') - }) - - it('matches across stored segments', () => { - expect( - matchUnifiedDiff(diff, [ - '## Re-authorizing', - 'Tokens rotate every 12h; the refresh job handles it.', - ]) - ).toBe('current') - }) -}) - -describe('comparing two documents', () => { +describe('two-document comparisons', () => { const diff = parseUnifiedDiff( '--- sim:knowledge/kb/old\n+++ sim:knowledge/kb/new\n-Refunds are available within 14 days.\n+Annual plans can be refunded within 30 days.' ) @@ -100,24 +67,4 @@ describe('comparing two documents', () => { { kind: 'knowledge', knowledgeBaseId: 'kb', documentId: 'new' }, ]) }) - - it('is current while each document still says its side', () => { - expect( - matchUnifiedDiff( - diff, - ['Refunds are available within 14 days. Issued in 5 days.'], - ['Annual plans can be refunded within 30 days.'] - ) - ).toBe('current') - }) - - it('is outdated once either document changes', () => { - expect( - matchUnifiedDiff( - diff, - ['Refunds within 30 days.'], - ['Annual plans can be refunded within 30 days.'] - ) - ).toBe('outdated') - }) }) diff --git a/apps/sim/lib/diff/unified.ts b/apps/sim/lib/diff/unified.ts index 0cdff56dd2d..ae9005fdd27 100644 --- a/apps/sim/lib/diff/unified.ts +++ b/apps/sim/lib/diff/unified.ts @@ -30,14 +30,6 @@ export interface UnifiedDiff { hunks: DiffHunk[] } -/** - * How a diff relates to its sources' current text. For an edit to one resource, `current`: every - * hunk's result is present; `proposed`: every hunk's original is present and not its result; - * `outdated`: neither. For a comparison of two resources, `current` while each still contains - * its side. - */ -export type DiffMatch = 'current' | 'proposed' | 'outdated' - /** Git metadata that precedes `---`/`+++` in `git diff` output and carries nothing to render. */ const GIT_HEADER = /^(?:diff --git |index |new file mode |deleted file mode |similarity |rename |old mode |new mode |Binary files )/ @@ -127,50 +119,3 @@ export function parseUnifiedDiff(text: string): UnifiedDiff { newSource ??= oldSource return { oldSource, newSource, path: oldSource ? null : path, hunks } } - -/** Whitespace-insensitive, so reflowed indentation or line endings never read as a change. */ -function normalize(text: string): string { - return text.replace(/\s+/g, ' ').trim() -} - -function sideText(hunk: DiffHunk, side: 'before' | 'after'): string { - const skip = side === 'before' ? 'add' : 'del' - return normalize( - hunk.lines - .filter((line) => line.type !== skip) - .map((line) => line.text) - .join('\n') - ) -} - -/** - * Matches each hunk by content rather than line numbers, so edits elsewhere never mark a diff - * outdated. Segments are the pieces a source is stored in (a knowledge document's chunks); a hunk - * may match inside one segment or across their concatenation. Pass `newSegments` when the diff - * compares two different resources. - */ -export function matchUnifiedDiff( - diff: UnifiedDiff, - oldSegments: readonly string[], - newSegments?: readonly string[] -): DiffMatch { - const inOld = containsIn(oldSegments) - if (newSegments) { - const inNew = containsIn(newSegments) - return diff.hunks.every( - (hunk) => inOld(sideText(hunk, 'before')) && inNew(sideText(hunk, 'after')) - ) - ? 'current' - : 'outdated' - } - if (diff.hunks.every((hunk) => inOld(sideText(hunk, 'after')))) return 'current' - if (diff.hunks.every((hunk) => inOld(sideText(hunk, 'before')))) return 'proposed' - return 'outdated' -} - -function containsIn(segments: readonly string[]) { - const whole = normalize(segments.join('\n')) - const pieces = segments.map(normalize) - return (text: string) => - text === '' || whole.includes(text) || pieces.some((piece) => piece.includes(text)) -} From 9735ca8b59479320ea151884e528731a8858a224 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 14:10:43 -0700 Subject: [PATCH 11/13] improvement(files): keep the diff parser out of the editor bundle --- .../rich-markdown-editor/code-block.tsx | 2 +- .../rich-markdown-editor.css | 3 +-- apps/sim/components/diff/diff-embed.tsx | 5 ++-- apps/sim/lib/diff/embed-language.ts | 2 ++ apps/sim/lib/diff/unified.test.ts | 23 ++++++++----------- apps/sim/lib/diff/unified.ts | 3 --- 6 files changed, 16 insertions(+), 22 deletions(-) create mode 100644 apps/sim/lib/diff/embed-language.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx index 153b63d5ef1..80b86ad0d98 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx @@ -12,7 +12,7 @@ import { Check, ChevronDown, Code, Duplicate, Eye, Wrap } from '@sim/emcn/icons' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language' -import { DIFF_EMBED_LANGUAGE } from '@/lib/diff/unified' +import { DIFF_EMBED_LANGUAGE } from '@/lib/diff/embed-language' import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context' import { looksLikeMermaid, MermaidDiagram } from '../mermaid-diagram' import { MarkdownCodeBlock } from './code-block-schema' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index bc88c462e0b..902baad1c02 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -283,8 +283,7 @@ overflow-x: auto; } -/* A rendered Mermaid diagram sits on the workflow canvas surface: flat page background, a hairline - frame, centered. */ +/* A rendered Mermaid diagram matches the workflow canvas surface. */ .rich-markdown-prose .mermaid-diagram-frame { display: flex; justify-content: center; diff --git a/apps/sim/components/diff/diff-embed.tsx b/apps/sim/components/diff/diff-embed.tsx index f8c0ca9de23..8509a0fb5d5 100644 --- a/apps/sim/components/diff/diff-embed.tsx +++ b/apps/sim/components/diff/diff-embed.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ComponentType, type ReactNode, useMemo } from 'react' +import type { ComponentType, ReactNode } from 'react' import { cn } from '@sim/emcn' import { ArrowUpRight, Database, File } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' @@ -223,7 +223,6 @@ function ExcerptCard({ heading, diff, side }: ExcerptCardProps) { ) } -/** An edit, line by line: tinted rows with word-level changes. */ function CodeDiff({ diff, heading }: CodeDiffProps) { const Icon = heading.icon return ( @@ -262,7 +261,7 @@ function parse( export function DiffEmbed({ source, isStreaming }: DiffEmbedProps) { const params = useParams() const workspaceId = typeof params.workspaceId === 'string' ? params.workspaceId : null - const parsed = useMemo(() => parse(source), [source]) + const parsed = parse(source) const diff = parsed.diff const comparison = Boolean( diff?.oldSource && diff.newSource && !sameSource(diff.oldSource, diff.newSource) diff --git a/apps/sim/lib/diff/embed-language.ts b/apps/sim/lib/diff/embed-language.ts new file mode 100644 index 00000000000..1c1ee2651b6 --- /dev/null +++ b/apps/sim/lib/diff/embed-language.ts @@ -0,0 +1,2 @@ +/** The fence language that renders a unified diff in markdown; kept apart from the parser. */ +export const DIFF_EMBED_LANGUAGE = 'diff' diff --git a/apps/sim/lib/diff/unified.test.ts b/apps/sim/lib/diff/unified.test.ts index 82735cc7d31..9f79ee1d9a4 100644 --- a/apps/sim/lib/diff/unified.test.ts +++ b/apps/sim/lib/diff/unified.test.ts @@ -47,6 +47,16 @@ describe('parseUnifiedDiff', () => { }) }) + it('names each side of a two-document comparison', () => { + const diff = parseUnifiedDiff( + '--- sim:knowledge/kb/old\n+++ sim:knowledge/kb/new\n-Refunds are available within 14 days.\n+Annual plans can be refunded within 30 days.' + ) + expect([diff.oldSource, diff.newSource]).toEqual([ + { kind: 'knowledge', knowledgeBaseId: 'kb', documentId: 'old' }, + { kind: 'knowledge', knowledgeBaseId: 'kb', documentId: 'new' }, + ]) + }) + it.each([ ['a knowledge source without a document', '--- sim:knowledge/kb_1\n-x\n+y', 'sim:knowledge/'], ['a line without a marker', '@@ x @@\n-a\nplain', 'Line 3'], @@ -55,16 +65,3 @@ describe('parseUnifiedDiff', () => { expect(() => parseUnifiedDiff(text)).toThrow(message) }) }) - -describe('two-document comparisons', () => { - const diff = parseUnifiedDiff( - '--- sim:knowledge/kb/old\n+++ sim:knowledge/kb/new\n-Refunds are available within 14 days.\n+Annual plans can be refunded within 30 days.' - ) - - it('names each side', () => { - expect([diff.oldSource, diff.newSource]).toEqual([ - { kind: 'knowledge', knowledgeBaseId: 'kb', documentId: 'old' }, - { kind: 'knowledge', knowledgeBaseId: 'kb', documentId: 'new' }, - ]) - }) -}) diff --git a/apps/sim/lib/diff/unified.ts b/apps/sim/lib/diff/unified.ts index ae9005fdd27..b8036a34fa1 100644 --- a/apps/sim/lib/diff/unified.ts +++ b/apps/sim/lib/diff/unified.ts @@ -1,6 +1,3 @@ -/** The fence language that renders a unified diff in markdown; kept apart from the parser. */ -export const DIFF_EMBED_LANGUAGE = 'diff' - /** A workspace resource a diff was taken from, named in its `---` / `+++` header lines. */ export type DiffSource = | { kind: 'file'; fileId: string } From 26020947a9e4248480657b1648b902192fd5aac8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 14:28:56 -0700 Subject: [PATCH 12/13] fix(files): parse multi-file and copy git diffs, cap word diffs, readable pie labels --- .../components/file-viewer/mermaid-theme.ts | 6 ++--- apps/sim/components/diff/diff-view.tsx | 8 ++++-- apps/sim/lib/diff/unified.test.ts | 11 ++++++++ apps/sim/lib/diff/unified.ts | 26 ++++++++++++++----- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts index 9188328858a..e5f43e0802b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/mermaid-theme.ts @@ -57,9 +57,9 @@ export function readMermaidThemeVariables( loopTextColor: body, sequenceNumberColor: background, pie1: body, - pie2: token('--text-subtle'), - pie3: token('--surface-7'), - pie4: line, + pie2: line, + pie3: token('--text-subtle'), + pie4: token('--text-muted'), pieStrokeColor: background, pieTitleTextColor: text, pieSectionTextColor: background, diff --git a/apps/sim/components/diff/diff-view.tsx b/apps/sim/components/diff/diff-view.tsx index 6b2b35097a2..200dbadf9da 100644 --- a/apps/sim/components/diff/diff-view.tsx +++ b/apps/sim/components/diff/diff-view.tsx @@ -9,6 +9,8 @@ import type { DiffHunk, DiffLine } from '@/lib/diff/unified' const CONTEXT_EDGE = 3 /** A run of unchanged lines collapses once it is longer than this. */ const COLLAPSE_AFTER = CONTEXT_EDGE * 2 + 2 +/** Word-level comparison is skipped past this many characters, so huge lines stay cheap. */ +const WORD_DIFF_MAX_CHARS = 1000 const ADD_ROW = 'bg-[color-mix(in_srgb,var(--badge-success-bg)_35%,transparent)] text-[var(--text-primary)]' @@ -53,6 +55,7 @@ function wordSegments(lines: DiffLine[]): Map { const adds: DiffLine[] = [] while (lines[index]?.type === 'add') adds.push(lines[index++]) for (let pair = 0; pair < Math.min(dels.length, adds.length); pair++) { + if (dels[pair].text.length + adds[pair].text.length > WORD_DIFF_MAX_CHARS) continue const parts = diffWordsWithSpace(dels[pair].text, adds[pair].text) segments.set( dels[pair], @@ -158,9 +161,10 @@ export function DiffView({ hunks }: DiffViewProps) { const expand = () => setExpanded((current) => new Set(current).add(hunkIndex)) return ( - {(hunk.heading || hunkIndex > 0) && ( + {(hunk.file || hunk.heading || hunkIndex > 0) && (
- {hunk.heading || '⋯'} + {hunk.file && {hunk.file} } + {hunk.heading || (hunk.file ? '' : '⋯')}
)} {items.map((item, index) => diff --git a/apps/sim/lib/diff/unified.test.ts b/apps/sim/lib/diff/unified.test.ts index 9f79ee1d9a4..5e296edd368 100644 --- a/apps/sim/lib/diff/unified.test.ts +++ b/apps/sim/lib/diff/unified.test.ts @@ -47,6 +47,17 @@ describe('parseUnifiedDiff', () => { }) }) + it('splits a multi-file git diff into hunks per file', () => { + const diff = parseUnifiedDiff( + 'diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1,1 +1,1 @@\n-one\n+uno\ndiff --git a/b.ts b/c.ts\nsimilarity index 90%\ncopy from b.ts\ncopy to c.ts\n--- a/b.ts\n+++ b/c.ts\n@@ -3,1 +3,1 @@\n-two\n+dos' + ) + expect(diff.path).toBe('2 files') + expect(diff.hunks.map((hunk) => [hunk.file, hunk.lines.map((line) => line.text)])).toEqual([ + ['a.ts', ['one', 'uno']], + ['c.ts', ['two', 'dos']], + ]) + }) + it('names each side of a two-document comparison', () => { const diff = parseUnifiedDiff( '--- sim:knowledge/kb/old\n+++ sim:knowledge/kb/new\n-Refunds are available within 14 days.\n+Annual plans can be refunded within 30 days.' diff --git a/apps/sim/lib/diff/unified.ts b/apps/sim/lib/diff/unified.ts index b8036a34fa1..baf6e5aba5a 100644 --- a/apps/sim/lib/diff/unified.ts +++ b/apps/sim/lib/diff/unified.ts @@ -14,6 +14,8 @@ export interface DiffLine { export interface DiffHunk { /** Free text after the `@@ … @@` range, often the enclosing heading or function. */ heading: string + /** The file a hunk belongs to, set only when a git diff spans several files. */ + file?: string lines: DiffLine[] } @@ -29,7 +31,7 @@ export interface UnifiedDiff { /** Git metadata that precedes `---`/`+++` in `git diff` output and carries nothing to render. */ const GIT_HEADER = - /^(?:diff --git |index |new file mode |deleted file mode |similarity |rename |old mode |new mode |Binary files )/ + /^(?:index |new file mode |deleted file mode |similarity |dissimilarity |rename |copy |old mode |new mode |Binary files )/ const HUNK_HEADER = /^@@(?: -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@)?\s?(.*?)(?:\s*@@)?$/ const SIM_SOURCE = /^sim:(file|knowledge)\/([^/\s]+)(?:\/([^/\s]+))?$/ @@ -72,20 +74,27 @@ export function parseUnifiedDiff(text: string): UnifiedDiff { let oldLine: number | undefined let newLine: number | undefined + let file: string | null = null for (const [index, row] of rows.entries()) { + if (row.startsWith('diff --git ')) { + hunk = null + file = null + continue + } if (!hunk && (row.startsWith('--- ') || row.startsWith('+++ '))) { const target = row.slice(4).trim() const parsed = parseSource(target) - if (row.startsWith('--- ')) oldSource = parsed - else newSource = parsed - if (!parsed && target !== '/dev/null' && (row.startsWith('+++ ') || !path)) - path = target.replace(/^[ab]\//, '').split('\t')[0] + if (row.startsWith('--- ')) oldSource = parsed ?? oldSource + else newSource = parsed ?? newSource + if (!parsed && target !== '/dev/null' && (row.startsWith('+++ ') || !file)) + file = target.replace(/^[ab]\//, '').split('\t')[0] + path ??= file continue } if (!hunk && GIT_HEADER.test(row)) continue const header = HUNK_HEADER.exec(row) if (row.startsWith('@@') && header) { - hunk = { heading: header[3] ?? '', lines: [] } + hunk = { heading: header[3] ?? '', file: file ?? undefined, lines: [] } hunks.push(hunk) oldLine = header[1] ? Number(header[1]) : undefined newLine = header[2] ? Number(header[2]) : undefined @@ -93,7 +102,7 @@ export function parseUnifiedDiff(text: string): UnifiedDiff { } if (row.startsWith('\\')) continue if (!hunk) { - hunk = { heading: '', lines: [] } + hunk = { heading: '', file: file ?? undefined, lines: [] } hunks.push(hunk) } const marker = row[0] @@ -114,5 +123,8 @@ export function parseUnifiedDiff(text: string): UnifiedDiff { throw new Error('A diff needs at least one + or - line') oldSource ??= newSource newSource ??= oldSource + const files = new Set(hunks.map((entry) => entry.file)) + if (files.size <= 1) for (const entry of hunks) entry.file = undefined + else path = `${files.size} files` return { oldSource, newSource, path: oldSource ? null : path, hunks } } From 321d23a0749199d5a7efc72f10cd6da4dc9c655a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 1 Oct 2026 14:43:06 -0700 Subject: [PATCH 13/13] fix(files): title renames by their new path and expand collapsed lines one run at a time --- apps/sim/components/diff/diff-view.tsx | 22 +++++++++++++------ apps/sim/lib/diff/unified.test.ts | 18 ++++++++++++++++ apps/sim/lib/diff/unified.ts | 29 +++++++++++++++++++------- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/sim/components/diff/diff-view.tsx b/apps/sim/components/diff/diff-view.tsx index 200dbadf9da..9f84d92e858 100644 --- a/apps/sim/components/diff/diff-view.tsx +++ b/apps/sim/components/diff/diff-view.tsx @@ -101,7 +101,7 @@ function CollapsedRun({ count, onExpand }: CollapsedRunProps) { } /** Splits a hunk into visible lines and collapsed runs of unchanged lines. */ -function visibleItems(lines: DiffLine[], expanded: boolean) { +function visibleItems(lines: DiffLine[], isExpanded: (start: number) => boolean) { const items: Array<{ line: DiffLine } | { collapsed: number; key: number }> = [] for (let index = 0; index < lines.length; ) { if (lines[index].type !== 'context') { @@ -115,7 +115,11 @@ function visibleItems(lines: DiffLine[], expanded: boolean) { const trailing = index === lines.length const keepBefore = leading ? 0 : CONTEXT_EDGE const keepAfter = trailing ? 0 : CONTEXT_EDGE - if (expanded || run.length <= COLLAPSE_AFTER || run.length <= keepBefore + keepAfter + 1) { + if ( + isExpanded(start) || + run.length <= COLLAPSE_AFTER || + run.length <= keepBefore + keepAfter + 1 + ) { for (const line of run) items.push({ line }) continue } @@ -147,7 +151,8 @@ function marker(line: DiffLine | undefined) { * when the hunk headers carry them. */ export function DiffView({ hunks }: DiffViewProps) { - const [expanded, setExpanded] = useState>(() => new Set()) + /** Expanded runs, keyed `hunk:start` so each collapsed run opens on its own. */ + const [expanded, setExpanded] = useState>(() => new Set()) const numbered = hunks.some((hunk) => hunk.lines.some((line) => line.oldLine !== undefined || line.newLine !== undefined) ) @@ -157,8 +162,7 @@ export function DiffView({ hunks }: DiffViewProps) {
{hunks.map((hunk, hunkIndex) => { const segments = wordSegments(hunk.lines) - const items = visibleItems(hunk.lines, expanded.has(hunkIndex)) - const expand = () => setExpanded((current) => new Set(current).add(hunkIndex)) + const items = visibleItems(hunk.lines, (start) => expanded.has(`${hunkIndex}:${start}`)) return ( {(hunk.file || hunk.heading || hunkIndex > 0) && ( @@ -181,7 +185,13 @@ export function DiffView({ hunks }: DiffViewProps) {
) : ( - + + setExpanded((current) => new Set(current).add(`${hunkIndex}:${item.key}`)) + } + /> ) )}
diff --git a/apps/sim/lib/diff/unified.test.ts b/apps/sim/lib/diff/unified.test.ts index 5e296edd368..b41bb6f7c49 100644 --- a/apps/sim/lib/diff/unified.test.ts +++ b/apps/sim/lib/diff/unified.test.ts @@ -58,6 +58,19 @@ describe('parseUnifiedDiff', () => { ]) }) + it('titles a renamed file by its new path', () => { + expect(parseUnifiedDiff('--- a/old.ts\n+++ b/new.ts\n@@ -1 +1 @@\n-a\n+b').path).toBe('new.ts') + }) + + it('reads a changed line starting with --- after the headers as a change', () => { + expect( + parseUnifiedDiff('--- a/x.md\n+++ b/x.md\n--- divider\n+=== divider').hunks[0].lines + ).toEqual([ + { type: 'del', text: '-- divider' }, + { type: 'add', text: '=== divider' }, + ]) + }) + it('names each side of a two-document comparison', () => { const diff = parseUnifiedDiff( '--- sim:knowledge/kb/old\n+++ sim:knowledge/kb/new\n-Refunds are available within 14 days.\n+Annual plans can be refunded within 30 days.' @@ -72,6 +85,11 @@ describe('parseUnifiedDiff', () => { ['a knowledge source without a document', '--- sim:knowledge/kb_1\n-x\n+y', 'sim:knowledge/'], ['a line without a marker', '@@ x @@\n-a\nplain', 'Line 3'], ['no changes', ' just context', 'at least one + or - line'], + [ + 'a sim: source mixed with file paths', + 'diff --git a/x b/x\n--- sim:file/f1\n+++ sim:file/f1\n-a\n+b\ndiff --git a/y b/y\n--- a/y.ts\n+++ b/y.ts\n-c\n+d', + 'cannot also include ordinary file paths', + ], ])('rejects %s', (_, text, message) => { expect(() => parseUnifiedDiff(text)).toThrow(message) }) diff --git a/apps/sim/lib/diff/unified.ts b/apps/sim/lib/diff/unified.ts index baf6e5aba5a..7f43af6f983 100644 --- a/apps/sim/lib/diff/unified.ts +++ b/apps/sim/lib/diff/unified.ts @@ -68,27 +68,38 @@ export function parseUnifiedDiff(text: string): UnifiedDiff { const rows = text.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n') let oldSource: DiffSource | null = null let newSource: DiffSource | null = null - let path: string | null = null const hunks: DiffHunk[] = [] let hunk: DiffHunk | null = null let oldLine: number | undefined let newLine: number | undefined let file: string | null = null + let namesPath = false + /** Each file takes one `---` and one `+++` header; a later line starting that way is a change. */ + let seenOld = false + let seenNew = false for (const [index, row] of rows.entries()) { if (row.startsWith('diff --git ')) { hunk = null file = null + seenOld = false + seenNew = false continue } - if (!hunk && (row.startsWith('--- ') || row.startsWith('+++ '))) { + const isOld = row.startsWith('--- ') + const isNew = row.startsWith('+++ ') + if (!hunk && ((isOld && !seenOld) || (isNew && !seenNew))) { + if (isOld) seenOld = true + else seenNew = true const target = row.slice(4).trim() const parsed = parseSource(target) - if (row.startsWith('--- ')) oldSource = parsed ?? oldSource - else newSource = parsed ?? newSource - if (!parsed && target !== '/dev/null' && (row.startsWith('+++ ') || !file)) - file = target.replace(/^[ab]\//, '').split('\t')[0] - path ??= file + if (parsed) { + if (isOld) oldSource = parsed + else newSource = parsed + } else if (target !== '/dev/null') { + namesPath = true + if (isNew || !file) file = target.replace(/^[ab]\//, '').split('\t')[0] + } continue } if (!hunk && GIT_HEADER.test(row)) continue @@ -121,10 +132,12 @@ export function parseUnifiedDiff(text: string): UnifiedDiff { if (!hunks.some((entry) => entry.lines.some((line) => line.type !== 'context'))) throw new Error('A diff needs at least one + or - line') + if (namesPath && (oldSource || newSource)) + throw new Error('A diff with a sim: source cannot also include ordinary file paths') oldSource ??= newSource newSource ??= oldSource const files = new Set(hunks.map((entry) => entry.file)) + const path = files.size > 1 ? `${files.size} files` : (hunks[0]?.file ?? file) if (files.size <= 1) for (const entry of hunks) entry.file = undefined - else path = `${files.size} files` return { oldSource, newSource, path: oldSource ? null : path, hunks } }