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
Expand Up @@ -44,6 +44,7 @@ const RECORD = {
folderId: null,
uploadedAt: new Date('2026-08-04T00:00:00.000Z'),
updatedAt: new Date('2026-08-04T00:00:00.000Z'),
contentUpdatedAt: new Date('2026-09-03T20:00:01.000Z'),
}

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

it('passes the content-version precondition to the existing authorized use case', async () => {
const expectedUpdatedAt = '2026-09-03T20:00:00.000Z'
const response = await PUT(
createRequest({ content: 'edited', expectedUpdatedAt }),
routeContext
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
file: { contentUpdatedAt: RECORD.contentUpdatedAt.toISOString() },
})
expect(mocks.updateContent).toHaveBeenCalledWith(
expect.objectContaining({
principal: PRINCIPAL,
input: expect.objectContaining({ expectedUpdatedAt: new Date(expectedUpdatedAt) }),
})
)
})

it('rejects invalid content-version tokens before performing a write', async () => {
const response = await PUT(
createRequest({ content: 'edited', expectedUpdatedAt: 'yesterday' }),
routeContext
)
expect(response.status).toBe(400)
expect(mocks.updateContent).not.toHaveBeenCalled()
})

it('preserves a CAS conflict as 409', async () => {
mocks.updateContent.mockRejectedValueOnce(new OrchestrationError('conflict', 'File changed'))
const response = await PUT(
createRequest({ content: 'edited', expectedUpdatedAt: '2026-09-03T20:00:00.000Z' }),
routeContext
)
expect(response.status).toBe(409)
await expect(response.json()).resolves.toMatchObject({ error: 'File changed' })
})

it('allows a base64 JSON body up to what the proxy forwards intact', async () => {
const response = await PUT(
createRequest({ content: 'TQ==', encoding: 'base64' }, 10 * 1024 * 1024),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const PUT = defineInternalJsonRoute({
assertedWorkspaceId: params.id,
content: body.content,
encoding: body.encoding === 'base64' ? ('base64' as const) : ('utf-8' as const),
...(body.expectedUpdatedAt ? { expectedUpdatedAt: new Date(body.expectedUpdatedAt) } : {}),
}),
useCase: updateWorkspaceFileContent,
present: internalFilePresenters.successFile,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client'

import { Chip, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'

interface FileSaveConflictProps {
isReloading: boolean
reloadLatestContent: () => Promise<void>
downloadDraft: () => void
}

/** Keeps both versions recoverable until the user explicitly replaces the local draft. */
export function FileSaveConflict({
isReloading,
reloadLatestContent,
downloadDraft,
}: FileSaveConflictProps) {
return (
<div
role='alert'
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)]'
>
<p className='min-w-0 flex-1'>
Saving paused: the file changed elsewhere. Your local draft is preserved. Reload replaces it
with the latest version.
</p>
<Chip onClick={downloadDraft}>Download local draft</Chip>
<Chip
disabled={isReloading}
onClick={() => {
void reloadLatestContent().catch((error) =>
toast.error(
getErrorMessage(error, 'Could not reload the file. Your local draft is unchanged.')
)
)
}}
>
{isReloading ? 'Reloading…' : 'Discard draft and reload'}
</Chip>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/** @vitest-environment jsdom */
import { Editor } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
import { afterEach, describe, expect, it } from 'vitest'
import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions'

let editor: Editor | undefined
afterEach(() => {
editor?.destroy()
editor = undefined
})

function mount(content: string): Editor {
editor = new Editor({ extensions: createMarkdownEditorExtensions({ placeholder: '' }), content })
return editor
}

function findText(ed: Editor, text: string): number {
let position = -1
ed.state.doc.descendants((node, pos) => {
if (node.isText && node.text === text) position = pos
})
expect(position).toBeGreaterThan(-1)
return position
}

function move(ed: Editor, direction: 'up' | 'down'): boolean {
return direction === 'up' ? ed.commands.moveBlockUp() : ed.commands.moveBlockDown()
}

describe('block movement across leaf siblings', () => {
it.each(['<hr>', '<img src="https://example.com/picture.png">'])(
'moves text across %s in both directions',
(leaf) => {
const ed = mount(`${leaf}<p>abcdef</p><p>tail</p>`)
ed.commands.setTextSelection(findText(ed, 'abcdef') + 3)
const before = ed.getJSON()

expect(ed.commands.moveBlockUp()).toBe(true)
expect(ed.state.doc.firstChild?.textContent).toBe('abcdef')
expect(ed.state.selection.$from.parentOffset).toBe(3)
expect(ed.commands.moveBlockDown()).toBe(true)
expect(ed.getJSON()).toEqual(before)
expect(ed.state.selection.$from.parentOffset).toBe(3)
}
)

it.each(['<hr>', '<img src="https://example.com/picture.png">'])(
'moves selected %s without replacing node selection with a caret',
(leaf) => {
const ed = mount(`<p>before</p>${leaf}<p>after</p>`)
const leafPosition = ed.state.doc.firstChild?.nodeSize ?? 0
ed.commands.setNodeSelection(leafPosition)
const before = ed.getJSON()
const selectedType = (ed.state.selection as NodeSelection).node.type.name

expect(ed.commands.moveBlockUp()).toBe(true)
expect(ed.state.selection instanceof NodeSelection).toBe(true)
expect(ed.state.selection.from).toBe(0)
expect(ed.state.doc.firstChild?.type.name).toBe(selectedType)
expect(ed.commands.moveBlockDown()).toBe(true)
expect(ed.getJSON()).toEqual(before)
expect(ed.state.selection instanceof NodeSelection).toBe(true)
expect(ed.state.selection.from).toBe(leafPosition)
}
)

it('supports the actual keyboard chord on a selected divider', () => {
const ed = mount('<p>before</p><hr><p>after</p>')
ed.commands.setNodeSelection(8)
ed.view.dom.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowUp',
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
})
)

expect(ed.state.doc.firstChild?.type.name).toBe('horizontalRule')
expect(ed.state.selection instanceof NodeSelection).toBe(true)
expect(ed.state.selection.from).toBe(0)
})
})

describe('block movement preserves selection intent', () => {
it.each(['up', 'down'] as const)(
'preserves a backwards selected text range when moving %s',
(direction) => {
const ed = mount('<p>before</p><p>abcdef</p><p>after</p>')
const pos = findText(ed, 'abcdef')
ed.commands.setTextSelection({ from: pos + 5, to: pos + 1 })
const before = ed.getJSON()

expect(move(ed, direction)).toBe(true)
expect(ed.state.selection instanceof TextSelection).toBe(true)
expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('bcde')
expect(ed.state.selection.anchor).toBeGreaterThan(ed.state.selection.head)
expect(ed.commands.undo()).toBe(true)
expect(ed.getJSON()).toEqual(before)
expect(ed.state.selection.anchor).toBe(pos + 5)
expect(ed.state.selection.head).toBe(pos + 1)
expect(ed.commands.redo()).toBe(true)
expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('bcde')
}
)

it.each(['up', 'down'] as const)(
'moves all selected blocks together %s, retaining their order',
(direction) => {
const ed = mount('<p>before</p><p>first</p><hr><p>second</p><p>after</p>')
const start = findText(ed, 'first') + 2
const end = findText(ed, 'second') + 4
ed.commands.setTextSelection({ from: end, to: start })
const selected = ed.state.doc.textBetween(start, end, '\n')

expect(move(ed, direction)).toBe(true)
const nodes: string[] = []
ed.state.doc.forEach((node) => nodes.push(node.textContent || node.type.name))
expect(nodes).toEqual(
direction === 'up'
? ['first', 'horizontalRule', 'second', 'before', 'after']
: ['before', 'after', 'first', 'horizontalRule', 'second']
)
expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to, '\n')).toBe(
selected
)
expect(ed.state.selection.anchor).toBeGreaterThan(ed.state.selection.head)
}
)

it('keeps nested selection and descendant marks inside the moved list', () => {
const ed = mount(
'<p>before</p><ul><li><p>parent</p><ul><li><p><strong>child</strong></p></li></ul></li></ul><p>after</p>'
)
const pos = findText(ed, 'child')
ed.commands.setTextSelection({ from: pos, to: pos + 5 })
const listBefore = ed.state.doc.child(1).toJSON()

expect(ed.commands.moveBlockUp()).toBe(true)
expect(ed.state.doc.firstChild?.toJSON()).toEqual(listBefore)
expect(ed.state.doc.textBetween(ed.state.selection.from, ed.state.selection.to)).toBe('child')
expect(ed.state.selection.$from.depth).toBe(5)
})

it('returns false at the document edge and for a root gap without a selected block', () => {
const ed = mount('<hr><p>last</p>')
ed.commands.setNodeSelection(0)
expect(ed.can().moveBlockUp()).toBe(false)
expect(ed.commands.moveBlockUp()).toBe(false)
ed.view.dispatch(ed.state.tr.setSelection(new GapCursor(ed.state.doc.resolve(0))))
expect(ed.commands.moveBlockDown()).toBe(false)
ed.commands.setTextSelection(findText(ed, 'last'))
expect(ed.commands.moveBlockDown()).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -1,73 +1,76 @@
import { Extension } from '@tiptap/core'
import { Slice } from '@tiptap/pm/model'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { TextSelection } from '@tiptap/pm/state'
import { NodeSelection } from '@tiptap/pm/state'
import { ReplaceAroundStep, StepMap } from '@tiptap/pm/transform'

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

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

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

const spanFrom = up ? $neighbour.before(1) : from
const spanTo = up ? to : $neighbour.after(1)
const moving = state.doc.slice(from, to).content
const spanFrom = up ? from - sibling.nodeSize : from
const spanTo = up ? to : to + sibling.nodeSize
const neighbour = up
? state.doc.slice(spanFrom, from).content
: state.doc.slice(to, spanTo).content
const tr = state.tr.replaceWith(
spanFrom,
spanTo,
up ? moving.append(neighbour) : neighbour.append(moving)
)

const newBefore = up ? spanFrom : spanFrom + neighbour.size
const offset = state.selection.from - from
tr.setSelection(
TextSelection.near(tr.doc.resolve(Math.min(newBefore + offset, newBefore + moving.size)))
const tr = state.tr.step(
new ReplaceAroundStep(
spanFrom,
spanTo,
from,
to,
new Slice(neighbour, 0, 0),
up ? 0 : neighbour.size
)
)
const offset = up ? -sibling.nodeSize : sibling.nodeSize
tr.setSelection(state.selection.getBookmark().map(StepMap.offset(offset)).resolve(tr.doc))
dispatch(tr.scrollIntoView())
return true
}

declare module '@tiptap/core' {
interface Commands<ReturnType> {
blockMover: {
/** Move the current top-level block up one position, carrying the caret. */
/** Move the selected top-level block range up one position, preserving the selection. */
moveBlockUp: () => ReturnType
/** Move the current top-level block down one position, carrying the caret. */
/** Move the selected top-level block range down one position, preserving the selection. */
moveBlockDown: () => ReturnType
}
}
}

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