diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.test.ts new file mode 100644 index 00000000000..f0b4d508757 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node + */ +import { Schema } from '@tiptap/pm/model' +import { AllSelection, TextSelection } from '@tiptap/pm/state' +import { describe, expect, it } from 'vitest' +import { bubbleMenuAnchorRange } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating' + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*' }, + text: { inline: true }, + }, +}) + +const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('first line'))]) + +describe('bubbleMenuAnchorRange', () => { + it('collapses a whole-document selection to its leading position', () => { + const selection = new AllSelection(doc) + + expect(bubbleMenuAnchorRange(selection)).toEqual({ + from: selection.from, + to: selection.from, + }) + }) + + it('preserves ordinary text-selection geometry', () => { + const selection = TextSelection.create(doc, 1, 6) + + expect(bubbleMenuAnchorRange(selection)).toEqual({ from: 1, to: 6 }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.ts index a72d1189a97..9c3411fe5ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.ts @@ -1,7 +1,18 @@ import { useCallback } from 'react' import { posToDOMRect } from '@tiptap/core' +import { AllSelection, type Selection } from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' +/** + * A whole-document selection has a viewport-sized bounding box, which gives Floating UI no viable + * side to flip to and leaves the toolbar clipped above the editor. Anchor that semantic selection to + * the document's leading position; every ordinary selection keeps its complete range geometry. + */ +export function bubbleMenuAnchorRange(selection: Selection): { from: number; to: number } { + if (selection instanceof AllSelection) return { from: selection.from, to: selection.from } + return { from: selection.from, to: selection.to } +} + /** * A Floating UI virtual element anchored to the current selection. The rect is recomputed on every * call rather than cached by selection: the same `from`/`to` maps to a different screen position as @@ -11,7 +22,7 @@ import type { Editor } from '@tiptap/react' function selectionVirtualElement(editor: Editor) { const { view, state } = editor if (!view.dom.isConnected) return null - const { from, to } = state.selection + const { from, to } = bubbleMenuAnchorRange(state.selection) const rect = posToDOMRect(view, from, to) return { getBoundingClientRect: () => rect, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-sync.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-sync.test.tsx new file mode 100644 index 00000000000..1cf90eea515 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-sync.test.tsx @@ -0,0 +1,153 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ComponentProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { TextEditor } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor' + +interface MockMonacoProps { + onChange?: (value: string | undefined) => void + onMount?: (editor: unknown, monaco: unknown) => void + options?: unknown +} + +const state = vi.hoisted(() => ({ + content: 'initial', + editorProps: null as MockMonacoProps | null, +})) + +vi.mock('next/dynamic', () => ({ + default: () => (props: MockMonacoProps) => { + state.editorProps = props + return
+ }, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content', + () => ({ + useEditableFileContent: () => ({ + content: state.content, + setDraftContent: (content: string) => { + state.content = content + }, + isStreamInteractionLocked: false, + isContentLoading: false, + hasContentError: false, + saveImmediately: vi.fn(), + }), + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge', + () => ({ useSelectionCopyBridge: vi.fn() }) +) + +vi.mock('@/hooks/use-add-to-chat', () => ({ useAddToChat: () => vi.fn() })) + +const file: WorkspaceFileRecord = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'example.txt', + key: 'workspace/file-1', + path: '/workspace/file-1', + size: 7, + type: 'text/plain', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +const props: ComponentProps = { + file, + workspaceId: file.workspaceId, + canEdit: true, + previewMode: 'editor', + disableStreamingAutoScroll: false, +} + +function createEditor() { + let editorValue = 'initial' + const getValue = vi.fn(() => editorValue) + const applyEdits = vi.fn((edits: Array<{ text: string }>) => { + editorValue = edits[0]?.text ?? editorValue + }) + const model = { + getValue, + setValue: vi.fn((value: string) => { + editorValue = value + }), + applyEdits, + getFullModelRange: vi.fn(() => ({})), + } + const editor = { + getModel: vi.fn(() => model), + addCommand: vi.fn(), + getSelection: vi.fn(() => null), + onContextMenu: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(), + } + const monaco = { + KeyMod: { CtrlCmd: 1 }, + KeyCode: { KeyS: 2 }, + } + + return { editor, monaco, model, getValue, applyEdits } +} + +function renderEditor(): { rerender: () => void; root: Root } { + const root = createRoot(document.createElement('div')) + act(() => root.render()) + return { + rerender: () => act(() => root.render()), + root, + } +} + +describe('TextEditor content synchronization', () => { + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + state.content = 'initial' + state.editorProps = null + }) + + it('does not reread the complete Monaco model after a local edit', () => { + const view = renderEditor() + const { editor, monaco, getValue } = createEditor() + + act(() => { + state.editorProps?.onMount?.(editor, monaco) + }) + const initialOptions = state.editorProps?.options + getValue.mockClear() + + act(() => { + state.editorProps?.onChange?.('local edit') + }) + view.rerender() + + expect(getValue).not.toHaveBeenCalled() + expect(state.editorProps?.options).toBe(initialOptions) + act(() => view.root.unmount()) + }) + + it('still reconciles an external update when the editor has no local changes', () => { + const view = renderEditor() + const { editor, monaco, getValue, applyEdits } = createEditor() + + act(() => { + state.editorProps?.onMount?.(editor, monaco) + }) + getValue.mockClear() + + state.content = 'server update' + view.rerender() + + expect(getValue).toHaveBeenCalledOnce() + expect(applyEdits).toHaveBeenCalledWith([{ range: {}, text: 'server update' }]) + act(() => view.root.unmount()) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 54667452d27..8a4feedd697 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -5,6 +5,7 @@ import { type ClipboardEvent as ReactClipboardEvent, useCallback, useEffect, + useMemo, useRef, useState, } from 'react' @@ -33,6 +34,44 @@ import { useSelectionCopyBridge } from './use-selection-copy-bridge' /** File ids observed rendering as Sim pages this session (see the sticky lock). */ const KNOWN_PAGE_FILE_IDS = new Set() +const TEXT_EDITOR_OPTIONS = { + largeFileOptimizations: true, + maxTokenizationLineLength: 20_000, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'on', + fontSize: 13, + lineNumbers: 'on', + padding: { top: 24, bottom: 24 }, + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + tabSize: 2, + automaticLayout: true, + renderLineHighlight: 'line', + occurrencesHighlight: 'singleFile', + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + scrollbar: { + verticalScrollbarSize: 6, + horizontalScrollbarSize: 6, + }, + quickSuggestions: false, + suggestOnTriggerCharacters: false, + wordBasedSuggestions: 'currentDocument', + parameterHints: { enabled: false }, + codeLens: false, + lightbulb: { + enabled: 'off' as MonacoEditorTypes.ShowLightbulbIconMode, + }, + inlayHints: { enabled: 'off' }, + contextmenu: false, + fixedOverflowWidgets: true, + glyphMargin: false, + stickyScroll: { enabled: false }, + bracketPairColorization: { enabled: false }, + unicodeHighlight: { ambiguousCharacters: false }, +} satisfies MonacoEditorTypes.IStandaloneEditorConstructionOptions + const SIM_DARK_RULES: MonacoEditorTypes.ITokenThemeRule[] = [ { token: 'comment', foreground: '606060', fontStyle: 'italic' }, { token: 'string', foreground: '3ab872' }, @@ -382,6 +421,7 @@ export const TextEditor = memo(function TextEditor({ }: TextEditorProps) { const containerRef = useRef(null) const monacoEditorRef = useRef[0] | null>(null) + const lastEditorValueRef = useRef('') const lastSyncedContentRef = useRef('') const hasAutoFocusedRef = useRef(false) const contentRef = useRef('') @@ -456,11 +496,14 @@ export const TextEditor = memo(function TextEditor({ useSelectionCopyBridge(containerRef, buildSelectionContext, !isContentLoading) useEffect(() => { + if (lastEditorValueRef.current === content) return + const editor = monacoEditorRef.current if (!editor) return const model = editor.getModel() if (!model) return const monacoValue = model.getValue() + lastEditorValueRef.current = monacoValue if (monacoValue === content) return if (isStreamInteractionLocked || monacoValue === lastSyncedContentRef.current) { @@ -491,6 +534,7 @@ export const TextEditor = memo(function TextEditor({ model.applyEdits([{ range: model.getFullModelRange(), text: content }]) } suppressScrollListenerRef.current = false + lastEditorValueRef.current = content lastSyncedContentRef.current = content } }, [content, isStreamInteractionLocked]) @@ -563,9 +607,12 @@ export const TextEditor = memo(function TextEditor({ const model = editor.getModel() const currentContent = contentRef.current - if (model && currentContent && model.getValue() !== currentContent) { - model.setValue(currentContent) + if (model) { + if (model.getValue() !== currentContent) { + model.setValue(currentContent) + } lastSyncedContentRef.current = currentContent + lastEditorValueRef.current = currentContent } if (autoFocus && !hasAutoFocusedRef.current) { @@ -588,6 +635,7 @@ export const TextEditor = memo(function TextEditor({ const handleEditorChange = useCallback( (value: string | undefined) => { const nextValue = value ?? '' + lastEditorValueRef.current = nextValue contentRef.current = nextValue setDraftContent(nextValue) }, @@ -633,6 +681,10 @@ export const TextEditor = memo(function TextEditor({ const isStreaming = isStreamInteractionLocked const isEditorReadOnly = isStreamInteractionLocked || !canEdit + const editorOptions = useMemo( + () => ({ ...TEXT_EDITOR_OPTIONS, readOnly: isEditorReadOnly }), + [isEditorReadOnly] + ) const previewType = resolvePreviewType(file.type, file.name) const isIframeRendered = previewType === 'html' || previewType === 'svg' @@ -697,44 +749,7 @@ export const TextEditor = memo(function TextEditor({ defaultValue={content} language={monacoLanguage} theme={monacoTheme} - options={{ - readOnly: isEditorReadOnly, - largeFileOptimizations: true, - maxTokenizationLineLength: 20_000, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: 'on', - fontSize: 13, - lineNumbers: 'on', - padding: { top: 24, bottom: 24 }, - fontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - tabSize: 2, - automaticLayout: true, - renderLineHighlight: 'line', - occurrencesHighlight: 'singleFile', - overviewRulerLanes: 0, - hideCursorInOverviewRuler: true, - scrollbar: { - verticalScrollbarSize: 6, - horizontalScrollbarSize: 6, - }, - quickSuggestions: false, - suggestOnTriggerCharacters: false, - wordBasedSuggestions: 'currentDocument', - parameterHints: { enabled: false }, - codeLens: false, - lightbulb: { - enabled: 'off' as MonacoEditorTypes.ShowLightbulbIconMode, - }, - inlayHints: { enabled: 'off' }, - contextmenu: false, - fixedOverflowWidgets: true, - glyphMargin: false, - stickyScroll: { enabled: false }, - bracketPairColorization: { enabled: false }, - unicodeHighlight: { ambiguousCharacters: false }, - }} + options={editorOptions} onChange={handleEditorChange} onMount={handleEditorMount} className='h-full' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts new file mode 100644 index 00000000000..ea6108923db --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as XLSX from 'xlsx' +import { + readXlsxPreviewData, + XLSX_MAX_COLUMNS, + XLSX_MAX_ROWS, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data' + +describe('readXlsxPreviewData', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('bounds conversion to the rows the preview can display', () => { + const sheet = XLSX.utils.aoa_to_sheet([ + ['header-a', 'header-b'], + ['row-1-a', 'row-1-b'], + ['row-2-a', 'row-2-b'], + ]) + sheet['!ref'] = 'A1:B200000' + const toJson = vi.spyOn(XLSX.utils, 'sheet_to_json') + + const result = readXlsxPreviewData(XLSX, sheet) + const options = toJson.mock.calls[0][1] as { + range: { s: { r: number }; e: { r: number } } + } + + expect(options.range.e.r - options.range.s.r).toBe(XLSX_MAX_ROWS) + expect(result.headers).toEqual(['header-a', 'header-b']) + expect(result.rows).toHaveLength(XLSX_MAX_ROWS) + expect(result.rows.slice(0, 2)).toEqual([ + ['row-1-a', 'row-1-b'], + ['row-2-a', 'row-2-b'], + ]) + expect(result.rowTruncated).toBe(true) + expect(result.columnTruncated).toBe(false) + }) + + it('does not mark a sheet at the existing display boundary as truncated', () => { + const sheet = XLSX.utils.aoa_to_sheet([ + ['header'], + ...Array.from({ length: XLSX_MAX_ROWS }, (_, index) => [`row-${index}`]), + ]) + + const result = readXlsxPreviewData(XLSX, sheet) + + expect(result.rows).toHaveLength(XLSX_MAX_ROWS) + expect(result.rowTruncated).toBe(false) + expect(result.columnTruncated).toBe(false) + }) + + it('bounds conversion for extremely wide declared ranges', () => { + const sheet = XLSX.utils.aoa_to_sheet([ + ['header-a', 'header-b'], + ['row-1-a', 'row-1-b'], + ]) + sheet['!ref'] = 'A1:XFD2' + const toJson = vi.spyOn(XLSX.utils, 'sheet_to_json') + + const result = readXlsxPreviewData(XLSX, sheet) + const options = toJson.mock.calls[0][1] as { + range: { s: { c: number }; e: { c: number } } + } + + expect(options.range.e.c - options.range.s.c + 1).toBe(XLSX_MAX_COLUMNS) + expect(result.headers).toEqual(['header-a', 'header-b']) + expect(result.rows).toEqual([['row-1-a', 'row-1-b']]) + expect(result.rowTruncated).toBe(false) + expect(result.columnTruncated).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts new file mode 100644 index 00000000000..a661d7f3cf7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts @@ -0,0 +1,35 @@ +import type { WorkSheet } from 'xlsx' + +export const XLSX_MAX_ROWS = 1_000 +export const XLSX_MAX_COLUMNS = 200 + +interface XlsxModule { + utils: Pick +} + +interface XlsxPreviewData { + headers: string[] + rows: string[][] + rowTruncated: boolean + columnTruncated: boolean +} + +export function readXlsxPreviewData(XLSX: XlsxModule, sheet: WorkSheet): XlsxPreviewData { + const declaredRange = XLSX.utils.decode_range(sheet['!ref'] || 'A1') + const lastPreviewRow = Math.min(declaredRange.e.r, declaredRange.s.r + XLSX_MAX_ROWS) + const lastPreviewColumn = Math.min(declaredRange.e.c, declaredRange.s.c + XLSX_MAX_COLUMNS - 1) + const previewRows = XLSX.utils.sheet_to_json(sheet, { + header: 1, + range: { + s: declaredRange.s, + e: { r: lastPreviewRow, c: lastPreviewColumn }, + }, + }) + + return { + headers: previewRows[0] ?? [], + rows: previewRows.slice(1), + rowTruncated: declaredRange.e.r > lastPreviewRow, + columnTruncated: declaredRange.e.c > lastPreviewColumn, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx index 3fbc5993826..431b5f213bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx @@ -8,19 +8,23 @@ import type { WorkBook } from 'xlsx' import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll' +import { + readXlsxPreviewData, + XLSX_MAX_COLUMNS, + XLSX_MAX_ROWS, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data' import { DataTable } from './data-table' import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared' import { useDocPreviewBinary } from './use-doc-preview-binary' const logger = createLogger('XlsxPreview') -const XLSX_MAX_ROWS = 1_000 - interface XlsxSheet { name: string headers: string[] rows: string[][] - truncated: boolean + rowTruncated: boolean + columnTruncated: boolean } export const XlsxPreview = memo(function XlsxPreview({ @@ -83,16 +87,14 @@ export const XlsxPreview = memo(function XlsxPreview({ const workbook = workbookRef.current! const name = sheetNames[activeSheet] const sheet = workbook.Sheets[name] - const allRows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) - const headers = (allRows[0] ?? []) as string[] - const dataRows = allRows.slice(1) as string[][] - const truncated = dataRows.length > XLSX_MAX_ROWS + const { headers, rows, rowTruncated, columnTruncated } = readXlsxPreviewData(XLSX, sheet) if (!cancelled) { setCurrentSheet({ name, headers, - rows: truncated ? dataRows.slice(0, XLSX_MAX_ROWS) : dataRows, - truncated, + rows, + rowTruncated, + columnTruncated, }) } } catch (err) { @@ -134,9 +136,14 @@ export const XlsxPreview = memo(function XlsxPreview({
- {currentSheet.truncated && ( + {(currentSheet.rowTruncated || currentSheet.columnTruncated) && (

- Showing first {XLSX_MAX_ROWS.toLocaleString()} rows. Download the file to view all data. + {currentSheet.rowTruncated && currentSheet.columnTruncated + ? `Showing first ${XLSX_MAX_ROWS.toLocaleString()} rows and ${XLSX_MAX_COLUMNS.toLocaleString()} columns.` + : currentSheet.rowTruncated + ? `Showing first ${XLSX_MAX_ROWS.toLocaleString()} rows.` + : `Showing first ${XLSX_MAX_COLUMNS.toLocaleString()} columns.`}{' '} + Download the file to view all data.

)}