Skip to content

Commit 5682c6c

Browse files
authored
fix(files): fix large file editor performance and selection toolbar placement (#7171)
* fix(files): streamline large editor updates and previews * fix(files): bound wide spreadsheet previews
1 parent 9efbe36 commit 5682c6c

7 files changed

Lines changed: 381 additions & 52 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { Schema } from '@tiptap/pm/model'
5+
import { AllSelection, TextSelection } from '@tiptap/pm/state'
6+
import { describe, expect, it } from 'vitest'
7+
import { bubbleMenuAnchorRange } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating'
8+
9+
const schema = new Schema({
10+
nodes: {
11+
doc: { content: 'paragraph+' },
12+
paragraph: { content: 'text*' },
13+
text: { inline: true },
14+
},
15+
})
16+
17+
const doc = schema.node('doc', null, [schema.node('paragraph', null, schema.text('first line'))])
18+
19+
describe('bubbleMenuAnchorRange', () => {
20+
it('collapses a whole-document selection to its leading position', () => {
21+
const selection = new AllSelection(doc)
22+
23+
expect(bubbleMenuAnchorRange(selection)).toEqual({
24+
from: selection.from,
25+
to: selection.from,
26+
})
27+
})
28+
29+
it('preserves ordinary text-selection geometry', () => {
30+
const selection = TextSelection.create(doc, 1, 6)
31+
32+
expect(bubbleMenuAnchorRange(selection)).toEqual({ from: 1, to: 6 })
33+
})
34+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { useCallback } from 'react'
22
import { posToDOMRect } from '@tiptap/core'
3+
import { AllSelection, type Selection } from '@tiptap/pm/state'
34
import type { Editor } from '@tiptap/react'
45

6+
/**
7+
* A whole-document selection has a viewport-sized bounding box, which gives Floating UI no viable
8+
* side to flip to and leaves the toolbar clipped above the editor. Anchor that semantic selection to
9+
* the document's leading position; every ordinary selection keeps its complete range geometry.
10+
*/
11+
export function bubbleMenuAnchorRange(selection: Selection): { from: number; to: number } {
12+
if (selection instanceof AllSelection) return { from: selection.from, to: selection.from }
13+
return { from: selection.from, to: selection.to }
14+
}
15+
516
/**
617
* A Floating UI virtual element anchored to the current selection. The rect is recomputed on every
718
* 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'
1122
function selectionVirtualElement(editor: Editor) {
1223
const { view, state } = editor
1324
if (!view.dom.isConnected) return null
14-
const { from, to } = state.selection
25+
const { from, to } = bubbleMenuAnchorRange(state.selection)
1526
const rect = posToDOMRect(view, from, to)
1627
return {
1728
getBoundingClientRect: () => rect,
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ComponentProps } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
8+
import { TextEditor } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor'
9+
10+
interface MockMonacoProps {
11+
onChange?: (value: string | undefined) => void
12+
onMount?: (editor: unknown, monaco: unknown) => void
13+
options?: unknown
14+
}
15+
16+
const state = vi.hoisted(() => ({
17+
content: 'initial',
18+
editorProps: null as MockMonacoProps | null,
19+
}))
20+
21+
vi.mock('next/dynamic', () => ({
22+
default: () => (props: MockMonacoProps) => {
23+
state.editorProps = props
24+
return <div data-testid='monaco-editor' />
25+
},
26+
}))
27+
28+
vi.mock(
29+
'@/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content',
30+
() => ({
31+
useEditableFileContent: () => ({
32+
content: state.content,
33+
setDraftContent: (content: string) => {
34+
state.content = content
35+
},
36+
isStreamInteractionLocked: false,
37+
isContentLoading: false,
38+
hasContentError: false,
39+
saveImmediately: vi.fn(),
40+
}),
41+
})
42+
)
43+
44+
vi.mock(
45+
'@/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge',
46+
() => ({ useSelectionCopyBridge: vi.fn() })
47+
)
48+
49+
vi.mock('@/hooks/use-add-to-chat', () => ({ useAddToChat: () => vi.fn() }))
50+
51+
const file: WorkspaceFileRecord = {
52+
id: 'file-1',
53+
workspaceId: 'workspace-1',
54+
name: 'example.txt',
55+
key: 'workspace/file-1',
56+
path: '/workspace/file-1',
57+
size: 7,
58+
type: 'text/plain',
59+
uploadedBy: 'user-1',
60+
uploadedAt: new Date('2026-01-01T00:00:00.000Z'),
61+
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
62+
}
63+
64+
const props: ComponentProps<typeof TextEditor> = {
65+
file,
66+
workspaceId: file.workspaceId,
67+
canEdit: true,
68+
previewMode: 'editor',
69+
disableStreamingAutoScroll: false,
70+
}
71+
72+
function createEditor() {
73+
let editorValue = 'initial'
74+
const getValue = vi.fn(() => editorValue)
75+
const applyEdits = vi.fn((edits: Array<{ text: string }>) => {
76+
editorValue = edits[0]?.text ?? editorValue
77+
})
78+
const model = {
79+
getValue,
80+
setValue: vi.fn((value: string) => {
81+
editorValue = value
82+
}),
83+
applyEdits,
84+
getFullModelRange: vi.fn(() => ({})),
85+
}
86+
const editor = {
87+
getModel: vi.fn(() => model),
88+
addCommand: vi.fn(),
89+
getSelection: vi.fn(() => null),
90+
onContextMenu: vi.fn(() => ({ dispose: vi.fn() })),
91+
onDidDispose: vi.fn(),
92+
}
93+
const monaco = {
94+
KeyMod: { CtrlCmd: 1 },
95+
KeyCode: { KeyS: 2 },
96+
}
97+
98+
return { editor, monaco, model, getValue, applyEdits }
99+
}
100+
101+
function renderEditor(): { rerender: () => void; root: Root } {
102+
const root = createRoot(document.createElement('div'))
103+
act(() => root.render(<TextEditor {...props} />))
104+
return {
105+
rerender: () => act(() => root.render(<TextEditor {...props} file={{ ...file }} />)),
106+
root,
107+
}
108+
}
109+
110+
describe('TextEditor content synchronization', () => {
111+
beforeEach(() => {
112+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
113+
state.content = 'initial'
114+
state.editorProps = null
115+
})
116+
117+
it('does not reread the complete Monaco model after a local edit', () => {
118+
const view = renderEditor()
119+
const { editor, monaco, getValue } = createEditor()
120+
121+
act(() => {
122+
state.editorProps?.onMount?.(editor, monaco)
123+
})
124+
const initialOptions = state.editorProps?.options
125+
getValue.mockClear()
126+
127+
act(() => {
128+
state.editorProps?.onChange?.('local edit')
129+
})
130+
view.rerender()
131+
132+
expect(getValue).not.toHaveBeenCalled()
133+
expect(state.editorProps?.options).toBe(initialOptions)
134+
act(() => view.root.unmount())
135+
})
136+
137+
it('still reconciles an external update when the editor has no local changes', () => {
138+
const view = renderEditor()
139+
const { editor, monaco, getValue, applyEdits } = createEditor()
140+
141+
act(() => {
142+
state.editorProps?.onMount?.(editor, monaco)
143+
})
144+
getValue.mockClear()
145+
146+
state.content = 'server update'
147+
view.rerender()
148+
149+
expect(getValue).toHaveBeenCalledOnce()
150+
expect(applyEdits).toHaveBeenCalledWith([{ range: {}, text: 'server update' }])
151+
act(() => view.root.unmount())
152+
})
153+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type ClipboardEvent as ReactClipboardEvent,
66
useCallback,
77
useEffect,
8+
useMemo,
89
useRef,
910
useState,
1011
} from 'react'
@@ -33,6 +34,44 @@ import { useSelectionCopyBridge } from './use-selection-copy-bridge'
3334
/** File ids observed rendering as Sim pages this session (see the sticky lock). */
3435
const KNOWN_PAGE_FILE_IDS = new Set<string>()
3536

37+
const TEXT_EDITOR_OPTIONS = {
38+
largeFileOptimizations: true,
39+
maxTokenizationLineLength: 20_000,
40+
minimap: { enabled: false },
41+
scrollBeyondLastLine: false,
42+
wordWrap: 'on',
43+
fontSize: 13,
44+
lineNumbers: 'on',
45+
padding: { top: 24, bottom: 24 },
46+
fontFamily:
47+
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
48+
tabSize: 2,
49+
automaticLayout: true,
50+
renderLineHighlight: 'line',
51+
occurrencesHighlight: 'singleFile',
52+
overviewRulerLanes: 0,
53+
hideCursorInOverviewRuler: true,
54+
scrollbar: {
55+
verticalScrollbarSize: 6,
56+
horizontalScrollbarSize: 6,
57+
},
58+
quickSuggestions: false,
59+
suggestOnTriggerCharacters: false,
60+
wordBasedSuggestions: 'currentDocument',
61+
parameterHints: { enabled: false },
62+
codeLens: false,
63+
lightbulb: {
64+
enabled: 'off' as MonacoEditorTypes.ShowLightbulbIconMode,
65+
},
66+
inlayHints: { enabled: 'off' },
67+
contextmenu: false,
68+
fixedOverflowWidgets: true,
69+
glyphMargin: false,
70+
stickyScroll: { enabled: false },
71+
bracketPairColorization: { enabled: false },
72+
unicodeHighlight: { ambiguousCharacters: false },
73+
} satisfies MonacoEditorTypes.IStandaloneEditorConstructionOptions
74+
3675
const SIM_DARK_RULES: MonacoEditorTypes.ITokenThemeRule[] = [
3776
{ token: 'comment', foreground: '606060', fontStyle: 'italic' },
3877
{ token: 'string', foreground: '3ab872' },
@@ -382,6 +421,7 @@ export const TextEditor = memo(function TextEditor({
382421
}: TextEditorProps) {
383422
const containerRef = useRef<HTMLDivElement>(null)
384423
const monacoEditorRef = useRef<Parameters<OnMount>[0] | null>(null)
424+
const lastEditorValueRef = useRef('')
385425
const lastSyncedContentRef = useRef('')
386426
const hasAutoFocusedRef = useRef(false)
387427
const contentRef = useRef('')
@@ -456,11 +496,14 @@ export const TextEditor = memo(function TextEditor({
456496
useSelectionCopyBridge(containerRef, buildSelectionContext, !isContentLoading)
457497

458498
useEffect(() => {
499+
if (lastEditorValueRef.current === content) return
500+
459501
const editor = monacoEditorRef.current
460502
if (!editor) return
461503
const model = editor.getModel()
462504
if (!model) return
463505
const monacoValue = model.getValue()
506+
lastEditorValueRef.current = monacoValue
464507
if (monacoValue === content) return
465508

466509
if (isStreamInteractionLocked || monacoValue === lastSyncedContentRef.current) {
@@ -491,6 +534,7 @@ export const TextEditor = memo(function TextEditor({
491534
model.applyEdits([{ range: model.getFullModelRange(), text: content }])
492535
}
493536
suppressScrollListenerRef.current = false
537+
lastEditorValueRef.current = content
494538
lastSyncedContentRef.current = content
495539
}
496540
}, [content, isStreamInteractionLocked])
@@ -563,9 +607,12 @@ export const TextEditor = memo(function TextEditor({
563607

564608
const model = editor.getModel()
565609
const currentContent = contentRef.current
566-
if (model && currentContent && model.getValue() !== currentContent) {
567-
model.setValue(currentContent)
610+
if (model) {
611+
if (model.getValue() !== currentContent) {
612+
model.setValue(currentContent)
613+
}
568614
lastSyncedContentRef.current = currentContent
615+
lastEditorValueRef.current = currentContent
569616
}
570617

571618
if (autoFocus && !hasAutoFocusedRef.current) {
@@ -588,6 +635,7 @@ export const TextEditor = memo(function TextEditor({
588635
const handleEditorChange = useCallback(
589636
(value: string | undefined) => {
590637
const nextValue = value ?? ''
638+
lastEditorValueRef.current = nextValue
591639
contentRef.current = nextValue
592640
setDraftContent(nextValue)
593641
},
@@ -633,6 +681,10 @@ export const TextEditor = memo(function TextEditor({
633681

634682
const isStreaming = isStreamInteractionLocked
635683
const isEditorReadOnly = isStreamInteractionLocked || !canEdit
684+
const editorOptions = useMemo(
685+
() => ({ ...TEXT_EDITOR_OPTIONS, readOnly: isEditorReadOnly }),
686+
[isEditorReadOnly]
687+
)
636688

637689
const previewType = resolvePreviewType(file.type, file.name)
638690
const isIframeRendered = previewType === 'html' || previewType === 'svg'
@@ -697,44 +749,7 @@ export const TextEditor = memo(function TextEditor({
697749
defaultValue={content}
698750
language={monacoLanguage}
699751
theme={monacoTheme}
700-
options={{
701-
readOnly: isEditorReadOnly,
702-
largeFileOptimizations: true,
703-
maxTokenizationLineLength: 20_000,
704-
minimap: { enabled: false },
705-
scrollBeyondLastLine: false,
706-
wordWrap: 'on',
707-
fontSize: 13,
708-
lineNumbers: 'on',
709-
padding: { top: 24, bottom: 24 },
710-
fontFamily:
711-
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
712-
tabSize: 2,
713-
automaticLayout: true,
714-
renderLineHighlight: 'line',
715-
occurrencesHighlight: 'singleFile',
716-
overviewRulerLanes: 0,
717-
hideCursorInOverviewRuler: true,
718-
scrollbar: {
719-
verticalScrollbarSize: 6,
720-
horizontalScrollbarSize: 6,
721-
},
722-
quickSuggestions: false,
723-
suggestOnTriggerCharacters: false,
724-
wordBasedSuggestions: 'currentDocument',
725-
parameterHints: { enabled: false },
726-
codeLens: false,
727-
lightbulb: {
728-
enabled: 'off' as MonacoEditorTypes.ShowLightbulbIconMode,
729-
},
730-
inlayHints: { enabled: 'off' },
731-
contextmenu: false,
732-
fixedOverflowWidgets: true,
733-
glyphMargin: false,
734-
stickyScroll: { enabled: false },
735-
bracketPairColorization: { enabled: false },
736-
unicodeHighlight: { ambiguousCharacters: false },
737-
}}
752+
options={editorOptions}
738753
onChange={handleEditorChange}
739754
onMount={handleEditorMount}
740755
className='h-full'

0 commit comments

Comments
 (0)