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.
)}