Skip to content

Commit 0d71045

Browse files
committed
fix(file-editor): preserve drafts and harden editing behavior
1 parent 20cadc8 commit 0d71045

67 files changed

Lines changed: 7305 additions & 1011 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const RECORD = {
4444
folderId: null,
4545
uploadedAt: new Date('2026-08-04T00:00:00.000Z'),
4646
updatedAt: new Date('2026-08-04T00:00:00.000Z'),
47+
contentUpdatedAt: new Date('2026-09-03T20:00:01.000Z'),
4748
}
4849

4950
const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) }
@@ -135,6 +136,43 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => {
135136
})
136137
})
137138

139+
it('passes the content-version precondition to the existing authorized use case', async () => {
140+
const expectedUpdatedAt = '2026-09-03T20:00:00.000Z'
141+
const response = await PUT(
142+
createRequest({ content: 'edited', expectedUpdatedAt }),
143+
routeContext
144+
)
145+
expect(response.status).toBe(200)
146+
await expect(response.json()).resolves.toMatchObject({
147+
file: { contentUpdatedAt: RECORD.contentUpdatedAt.toISOString() },
148+
})
149+
expect(mocks.updateContent).toHaveBeenCalledWith(
150+
expect.objectContaining({
151+
principal: PRINCIPAL,
152+
input: expect.objectContaining({ expectedUpdatedAt: new Date(expectedUpdatedAt) }),
153+
})
154+
)
155+
})
156+
157+
it('rejects invalid content-version tokens before performing a write', async () => {
158+
const response = await PUT(
159+
createRequest({ content: 'edited', expectedUpdatedAt: 'yesterday' }),
160+
routeContext
161+
)
162+
expect(response.status).toBe(400)
163+
expect(mocks.updateContent).not.toHaveBeenCalled()
164+
})
165+
166+
it('preserves a CAS conflict as 409', async () => {
167+
mocks.updateContent.mockRejectedValueOnce(new OrchestrationError('conflict', 'File changed'))
168+
const response = await PUT(
169+
createRequest({ content: 'edited', expectedUpdatedAt: '2026-09-03T20:00:00.000Z' }),
170+
routeContext
171+
)
172+
expect(response.status).toBe(409)
173+
await expect(response.json()).resolves.toMatchObject({ error: 'File changed' })
174+
})
175+
138176
it('allows a base64 JSON body up to what the proxy forwards intact', async () => {
139177
const response = await PUT(
140178
createRequest({ content: 'TQ==', encoding: 'base64' }, 10 * 1024 * 1024),

apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const PUT = defineInternalJsonRoute({
3434
assertedWorkspaceId: params.id,
3535
content: body.content,
3636
encoding: body.encoding === 'base64' ? ('base64' as const) : ('utf-8' as const),
37+
...(body.expectedUpdatedAt ? { expectedUpdatedAt: new Date(body.expectedUpdatedAt) } : {}),
3738
}),
3839
useCase: updateWorkspaceFileContent,
3940
present: internalFilePresenters.successFile,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client'
2+
3+
import { Chip, toast } from '@sim/emcn'
4+
import { getErrorMessage } from '@sim/utils/errors'
5+
6+
interface FileSaveConflictProps {
7+
isReloading: boolean
8+
reloadLatestContent: () => Promise<void>
9+
downloadDraft: () => void
10+
}
11+
12+
/** Keeps both versions recoverable until the user explicitly replaces the local draft. */
13+
export function FileSaveConflict({
14+
isReloading,
15+
reloadLatestContent,
16+
downloadDraft,
17+
}: FileSaveConflictProps) {
18+
return (
19+
<div
20+
role='alert'
21+
className='flex shrink-0 flex-wrap items-center gap-2 border-[var(--border)] border-b px-4 py-2 text-[13px] text-[var(--text-body)]'
22+
>
23+
<p className='min-w-0 flex-1'>
24+
Saving paused: the file changed elsewhere. Your local draft is preserved. Reload replaces it
25+
with the latest version.
26+
</p>
27+
<Chip onClick={downloadDraft}>Download local draft</Chip>
28+
<Chip
29+
disabled={isReloading}
30+
onClick={() => {
31+
void reloadLatestContent().catch((error) =>
32+
toast.error(
33+
getErrorMessage(error, 'Could not reload the file. Your local draft is unchanged.')
34+
)
35+
)
36+
}}
37+
>
38+
{isReloading ? 'Reloading…' : 'Discard draft and reload'}
39+
</Chip>
40+
</div>
41+
)
42+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/** @vitest-environment jsdom */
2+
import { Editor } from '@tiptap/core'
3+
import { GapCursor } from '@tiptap/pm/gapcursor'
4+
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions'
7+
8+
let editor: Editor | undefined
9+
afterEach(() => {
10+
editor?.destroy()
11+
editor = undefined
12+
})
13+
14+
function mount(content: string): Editor {
15+
editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content })
16+
return editor
17+
}
18+
19+
function findText(ed: Editor, text: string): number {
20+
let position = -1
21+
ed.state.doc.descendants((node, pos) => {
22+
if (node.isText && node.text === text) position = pos
23+
})
24+
expect(position).toBeGreaterThan(-1)
25+
return position
26+
}
27+
28+
function move(ed: Editor, direction: 'up' | 'down'): boolean {
29+
return direction === 'up' ? ed.commands.moveBlockUp() : ed.commands.moveBlockDown()
30+
}
31+
32+
describe('block movement across leaf siblings', () => {
33+
it.each(['<hr>', '<img src="https://example.com/picture.png">'])(
34+
'moves text across %s in both directions',
35+
(leaf) => {
36+
const ed = mount(`${leaf}<p>abcdef</p><p>tail</p>`)
37+
ed.commands.setTextSelection(findText(ed, 'abcdef') + 3)
38+
const before = ed.getJSON()
39+
40+
expect(ed.commands.moveBlockUp()).toBe(true)
41+
expect(ed.state.doc.firstChild?.textContent).toBe('abcdef')
42+
expect(ed.state.selection.$from.parentOffset).toBe(3)
43+
expect(ed.commands.moveBlockDown()).toBe(true)
44+
expect(ed.getJSON()).toEqual(before)
45+
expect(ed.state.selection.$from.parentOffset).toBe(3)
46+
}
47+
)
48+
49+
it.each(['<hr>', '<img src="https://example.com/picture.png">'])(
50+
'moves selected %s without replacing node selection with a caret',
51+
(leaf) => {
52+
const ed = mount(`<p>before</p>${leaf}<p>after</p>`)
53+
const leafPosition = ed.state.doc.firstChild?.nodeSize ?? 0
54+
ed.commands.setNodeSelection(leafPosition)
55+
const before = ed.getJSON()
56+
const selectedType = (ed.state.selection as NodeSelection).node.type.name
57+
58+
expect(ed.commands.moveBlockUp()).toBe(true)
59+
expect(ed.state.selection instanceof NodeSelection).toBe(true)
60+
expect(ed.state.selection.from).toBe(0)
61+
expect(ed.state.doc.firstChild?.type.name).toBe(selectedType)
62+
expect(ed.commands.moveBlockDown()).toBe(true)
63+
expect(ed.getJSON()).toEqual(before)
64+
expect(ed.state.selection instanceof NodeSelection).toBe(true)
65+
expect(ed.state.selection.from).toBe(leafPosition)
66+
}
67+
)
68+
69+
it('supports the actual keyboard chord on a selected divider', () => {
70+
const ed = mount('<p>before</p><hr><p>after</p>')
71+
ed.commands.setNodeSelection(8)
72+
ed.view.dom.dispatchEvent(
73+
new KeyboardEvent('keydown', {
74+
key: 'ArrowUp',
75+
ctrlKey: true,
76+
shiftKey: true,
77+
bubbles: true,
78+
cancelable: true,
79+
})
80+
)
81+
82+
expect(ed.state.doc.firstChild?.type.name).toBe('horizontalRule')
83+
expect(ed.state.selection instanceof NodeSelection).toBe(true)
84+
expect(ed.state.selection.from).toBe(0)
85+
})
86+
})
87+
88+
describe('block movement preserves selection intent', () => {
89+
it.each(['up', 'down'] as const)(
90+
'preserves a backwards selected text range when moving %s',
91+
(direction) => {
92+
const ed = mount('<p>before</p><p>abcdef</p><p>after</p>')
93+
const pos = findText(ed, 'abcdef')
94+
ed.commands.setTextSelection({ from: pos + 5, to: pos + 1 })
95+
const before = ed.getJSON()
96+
97+
expect(move(ed, direction)).toBe(true)
98+
expect(ed.state.selection instanceof TextSelection).toBe(true)
99+
expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('bcde')
100+
expect(ed.state.selection.anchor).toBeGreaterThan(ed.state.selection.head)
101+
expect(ed.commands.undo()).toBe(true)
102+
expect(ed.getJSON()).toEqual(before)
103+
expect(ed.state.selection.anchor).toBe(pos + 5)
104+
expect(ed.state.selection.head).toBe(pos + 1)
105+
expect(ed.commands.redo()).toBe(true)
106+
expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('bcde')
107+
}
108+
)
109+
110+
it.each(['up', 'down'] as const)(
111+
'moves all selected blocks together %s, retaining their order',
112+
(direction) => {
113+
const ed = mount('<p>before</p><p>first</p><hr><p>second</p><p>after</p>')
114+
const start = findText(ed, 'first') + 2
115+
const end = findText(ed, 'second') + 4
116+
ed.commands.setTextSelection({ from: end, to: start })
117+
const selected = ed.state.doc.textBetween(start, end, '\n')
118+
119+
expect(move(ed, direction)).toBe(true)
120+
const nodes: string[] = []
121+
ed.state.doc.forEach((node) => nodes.push(node.textContent || node.type.name))
122+
expect(nodes).toEqual(
123+
direction === 'up'
124+
? ['first', 'horizontalRule', 'second', 'before', 'after']
125+
: ['before', 'after', 'first', 'horizontalRule', 'second']
126+
)
127+
expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to, '\n')).toBe(
128+
selected
129+
)
130+
expect(ed.state.selection.anchor).toBeGreaterThan(ed.state.selection.head)
131+
}
132+
)
133+
134+
it('keeps nested selection and descendant marks inside the moved list', () => {
135+
const ed = mount(
136+
'<p>before</p><ul><li><p>parent</p><ul><li><p><strong>child</strong></p></li></ul></li></ul><p>after</p>'
137+
)
138+
const pos = findText(ed, 'child')
139+
ed.commands.setTextSelection({ from: pos, to: pos + 5 })
140+
const listBefore = ed.state.doc.child(1).toJSON()
141+
142+
expect(ed.commands.moveBlockUp()).toBe(true)
143+
expect(ed.state.doc.firstChild?.toJSON()).toEqual(listBefore)
144+
expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('child')
145+
expect(ed.state.selection.$from.depth).toBe(5)
146+
})
147+
148+
it('returns false at the document edge and for a root gap without a selected block', () => {
149+
const ed = mount('<hr><p>last</p>')
150+
ed.commands.setNodeSelection(0)
151+
expect(ed.can().moveBlockUp()).toBe(false)
152+
expect(ed.commands.moveBlockUp()).toBe(false)
153+
ed.view.dispatch(ed.state.tr.setSelection(new GapCursor(ed.state.doc.resolve(0))))
154+
expect(ed.commands.moveBlockDown()).toBe(false)
155+
ed.commands.setTextSelection(findText(ed, 'last'))
156+
expect(ed.commands.moveBlockDown()).toBe(false)
157+
})
158+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover.ts

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,76 @@
11
import { Extension } from '@tiptap/core'
2+
import { Slice } from '@tiptap/pm/model'
23
import type { EditorState, Transaction } from '@tiptap/pm/state'
3-
import { TextSelection } from '@tiptap/pm/state'
4+
import { NodeSelection } from '@tiptap/pm/state'
5+
import { ReplaceAroundStep, StepMap } from '@tiptap/pm/transform'
46

5-
/** The position range of the depth-1 block containing the cursor, or null at the document root. */
6-
function currentTopLevelBlock(state: EditorState): { from: number; to: number } | null {
7-
const { $from } = state.selection
8-
if ($from.depth === 0) return null
9-
return { from: $from.before(1), to: $from.after(1) }
7+
/** The contiguous top-level blocks touched by a text range or node selection. */
8+
function currentTopLevelBlocks(state: EditorState): { from: number; to: number } | null {
9+
const { selection } = state
10+
const { $from, $to } = selection
11+
if ($from.depth === 0 && !(selection instanceof NodeSelection)) return null
12+
return {
13+
from: $from.depth > 0 ? $from.before(1) : $from.pos,
14+
to: $to.depth > 0 ? $to.after(1) : $to.pos,
15+
}
1016
}
1117

1218
/**
13-
* Swaps the current top-level block with its neighbour in `direction`, keeping the caret on the moved
14-
* block. Adjacent top-level blocks share a boundary position (no separator token between them), so the
15-
* move is a single `replaceWith` of the two-block span with the pair reordered. No-ops (returns false)
16-
* at the matching document edge or when the neighbour isn't a top-level block. `newBefore` is the moved
17-
* block's new `before(1)` position; adding the caret's original offset (`selection.from - from`, also
18-
* measured from `before(1)`) re-anchors the caret at the same spot within the block.
19+
* Swaps the selected block range with its immediate sibling, including one-position leaf nodes.
20+
* The replace-around step maps positions inside the moved content. A translated selection bookmark
21+
* preserves selection kind, both endpoints, and direction instead of collapsing a range to a caret.
1922
*/
2023
function moveBlock(
2124
state: EditorState,
2225
dispatch: ((tr: Transaction) => void) | undefined,
2326
direction: 'up' | 'down'
2427
): boolean {
25-
const block = currentTopLevelBlock(state)
28+
const block = currentTopLevelBlocks(state)
2629
if (!block) return false
2730
const { from, to } = block
2831
const up = direction === 'up'
2932

3033
if (up ? from === 0 : to >= state.doc.content.size) return false
31-
const $neighbour = state.doc.resolve(up ? from - 1 : to + 1)
32-
if ($neighbour.depth === 0) return false
34+
const boundary = state.doc.resolve(up ? from : to)
35+
const sibling = up ? boundary.nodeBefore : boundary.nodeAfter
36+
if (!sibling) return false
3337
if (!dispatch) return true
3438

35-
const spanFrom = up ? $neighbour.before(1) : from
36-
const spanTo = up ? to : $neighbour.after(1)
37-
const moving = state.doc.slice(from, to).content
39+
const spanFrom = up ? from - sibling.nodeSize : from
40+
const spanTo = up ? to : to + sibling.nodeSize
3841
const neighbour = up
3942
? state.doc.slice(spanFrom, from).content
4043
: state.doc.slice(to, spanTo).content
41-
const tr = state.tr.replaceWith(
42-
spanFrom,
43-
spanTo,
44-
up ? moving.append(neighbour) : neighbour.append(moving)
45-
)
46-
47-
const newBefore = up ? spanFrom : spanFrom + neighbour.size
48-
const offset = state.selection.from - from
49-
tr.setSelection(
50-
TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size)))
44+
const tr = state.tr.step(
45+
new ReplaceAroundStep(
46+
spanFrom,
47+
spanTo,
48+
from,
49+
to,
50+
new Slice(neighbour, 0, 0),
51+
up ? 0 : neighbour.size
52+
)
5153
)
54+
const offset = up ? -sibling.nodeSize : sibling.nodeSize
55+
tr.setSelection(state.selection.getBookmark().map(StepMap.offset(offset)).resolve(tr.doc))
5256
dispatch(tr.scrollIntoView())
5357
return true
5458
}
5559

5660
declare module '@tiptap/core' {
5761
interface Commands<ReturnType> {
5862
blockMover: {
59-
/** Move the current top-level block up one position, carrying the caret. */
63+
/** Move the selected top-level block range up one position, preserving the selection. */
6064
moveBlockUp: () => ReturnType
61-
/** Move the current top-level block down one position, carrying the caret. */
65+
/** Move the selected top-level block range down one position, preserving the selection. */
6266
moveBlockDown: () => ReturnType
6367
}
6468
}
6569
}
6670

6771
/**
68-
* Reorders the current top-level block with `Mod-Shift-ArrowUp`/`ArrowDown` — the standard
69-
* keyboard block-move affordance (Notion/Obsidian). Pure UI interaction: no schema change, and the
70-
* caret rides along with the block. A no-op (returns false, falling through) at the document edges.
72+
* Reorders the selected top-level blocks with `Mod-Shift-ArrowUp`/`ArrowDown`, keeping their order
73+
* and selection. Returns false at document edges and for root gap cursors with no selected block.
7174
*/
7275
export const BlockMover = Extension.create({
7376
name: 'blockMover',

0 commit comments

Comments
 (0)