Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 })
})
})
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type ClipboardEvent as ReactClipboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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('')
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
},
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
Loading
Loading