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
This commit is contained in:
Waleed
2026-08-27 12:49:59 -07:00
committed by GitHub
parent 9efbe36c4a
commit 5682c6cda3
7 changed files with 381 additions and 52 deletions
@@ -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 })
})
})
@@ -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,
@@ -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 <div data-testid='monaco-editor' />
},
}))
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<typeof TextEditor> = {
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(<TextEditor {...props} />))
return {
rerender: () => act(() => root.render(<TextEditor {...props} file={{ ...file }} />)),
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())
})
})
@@ -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<string>()
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<HTMLDivElement>(null)
const monacoEditorRef = useRef<Parameters<OnMount>[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'
@@ -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)
})
})
@@ -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<typeof import('xlsx').utils, 'decode_range' | 'sheet_to_json'>
}
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<string[]>(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,
}
}
@@ -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<string[]>(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({
</div>
<div ref={scrollRef} className='flex-1 overflow-auto p-6'>
<DataTable headers={currentSheet.headers} rows={currentSheet.rows} />
{currentSheet.truncated && (
{(currentSheet.rowTruncated || currentSheet.columnTruncated) && (
<p className='mt-3 text-center text-[12px] text-[var(--text-muted)]'>
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.
</p>
)}
</div>