From 2be09b7e8fd88d1bf1753a72375bcb872a9e9776 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 10 Nov 2026 07:46:49 -0800 Subject: [PATCH] [web] simplify editor workflow --- .changeset/editor-publish-actions.md | 5 + apps/web/e2e/editor.e2e.ts | 194 +++++++- apps/web/e2e/support/fixture.ts | 13 + .../editor/EditorStatusContent.svelte | 7 + .../components/editor/MarkdownEditor.svelte | 50 +- .../components/editor/MarkdownEditor.test.ts | 26 - .../lib/components/editor/PieceActions.svelte | 465 +++++++++++++++--- .../components/editor/PieceActions.test.ts | 29 -- .../lib/components/editor/PieceToolbar.svelte | 107 ++-- .../components/editor/PieceToolbar.test.ts | 143 ------ .../editor/PublicationStatus.svelte | 33 ++ .../components/editor/editor-status.test.ts | 52 ++ .../lib/components/editor/editor-status.ts | 91 ++++ .../editor/keybindings-status.test.ts | 173 ------- .../components/editor/keybindings-status.ts | 57 --- .../lib/components/editor/keybindings.test.ts | 148 +----- .../src/lib/components/editor/keybindings.ts | 81 +-- .../lib/components/editor/replace-document.ts | 4 +- .../lib/components/editor/save-state.test.ts | 99 ---- .../src/lib/components/editor/save-state.ts | 30 -- .../components/editor/themes/configured.css | 31 +- .../layout/simple/SearchDialog.svelte | 13 +- .../src/lib/components/ui/dialog-motion.ts | 12 + ...ent.test.ts => publication-client.test.ts} | 132 ++++- ...ublish-client.ts => publication-client.ts} | 91 +++- .../src/lib/pieces/publication.server.test.ts | 30 +- apps/web/src/lib/pieces/publication.server.ts | 11 +- apps/web/src/lib/pieces/types.ts | 13 + apps/web/src/lib/server/markdown.ts | 4 - .../asset/editor/[...asset]/+page.svelte | 15 +- .../piece/[...path]/source/+page.server.ts | 101 ++-- .../admin/piece/[...path]/source/+page.svelte | 366 +++++++++----- .../[...path]/source/page.server.test.ts | 441 ++++++----------- .../(auth)/api/admin/publish/+server.ts | 22 +- .../api/admin/publish/admission.test.ts | 1 + .../(auth)/api/admin/publish/single.test.ts | 1 + .../(auth)/api/admin/publish/status.test.ts | 72 +++ docs/web/README.md | 40 +- docs/web/development.md | 12 +- 39 files changed, 1793 insertions(+), 1422 deletions(-) create mode 100644 .changeset/editor-publish-actions.md create mode 100644 apps/web/src/lib/components/editor/EditorStatusContent.svelte delete mode 100644 apps/web/src/lib/components/editor/MarkdownEditor.test.ts delete mode 100644 apps/web/src/lib/components/editor/PieceActions.test.ts delete mode 100644 apps/web/src/lib/components/editor/PieceToolbar.test.ts create mode 100644 apps/web/src/lib/components/editor/PublicationStatus.svelte create mode 100644 apps/web/src/lib/components/editor/editor-status.test.ts create mode 100644 apps/web/src/lib/components/editor/editor-status.ts delete mode 100644 apps/web/src/lib/components/editor/keybindings-status.test.ts delete mode 100644 apps/web/src/lib/components/editor/keybindings-status.ts delete mode 100644 apps/web/src/lib/components/editor/save-state.test.ts delete mode 100644 apps/web/src/lib/components/editor/save-state.ts create mode 100644 apps/web/src/lib/components/ui/dialog-motion.ts rename apps/web/src/lib/pieces/{unpublish-client.test.ts => publication-client.test.ts} (54%) rename apps/web/src/lib/pieces/{unpublish-client.ts => publication-client.ts} (52%) create mode 100644 apps/web/src/routes/(auth)/api/admin/publish/status.test.ts diff --git a/.changeset/editor-publish-actions.md b/.changeset/editor-publish-actions.md new file mode 100644 index 00000000..048b06f8 --- /dev/null +++ b/.changeset/editor-publish-actions.md @@ -0,0 +1,5 @@ +--- +"@luzzle/web": patch +--- + +Clarify saved and published editor states in the bottom status bar, with a grouped More menu on desktop and an accessible bottom sheet on phones. Share Publish/Unpublish progress and non-destructive status refresh, retain explicit Save and Publish, and toggle Vim through the menu instead of a keyboard shortcut. Save preserves the exact source without reloading the editor; an explicit Format action edits the buffer without saving. diff --git a/apps/web/e2e/editor.e2e.ts b/apps/web/e2e/editor.e2e.ts index b66706ca..ef0313ce 100644 --- a/apps/web/e2e/editor.e2e.ts +++ b/apps/web/e2e/editor.e2e.ts @@ -1,22 +1,202 @@ +import type { Cookie, Page } from '@playwright/test' import { expect, test } from './support/test.js' import { ADMIN, notebook } from './support/fixture.js' -test('sign in, save an edit and reload the saved source', async ({ page, baseURL }) => { - await page.goto(notebook.editorUrl) +let editorSession: Cookie[] | undefined + +async function signIn(page: Page, editorUrl: string) { + if (editorSession) { + await page.context().addCookies(editorSession) + await page.goto(editorUrl) + return + } + await page.goto(editorUrl) + const destination = new URL(editorUrl, page.url()).href await page.locator('#svelte-announcer').waitFor({ state: 'attached' }) await page.getByLabel('Username', { exact: true }).fill(ADMIN.username) await page.getByLabel('Password', { exact: true }).fill(ADMIN.password) await page.getByRole('button', { name: 'Sign in', exact: true }).click() - await expect(page).toHaveURL(`${baseURL}${notebook.editorUrl}`) + await expect(page).toHaveURL(destination) + editorSession = await page.context().cookies() +} +test.beforeEach(async ({ page }) => { + // Allow real read-only metadata, but never start live publication or Preview jobs. + await page.route(/\/api\/admin\/(?:publish|unpublish)(?:\/[^?]*)?(?:\?.*)?$/, (route) => { + const request = route.request() + if (request.method() === 'GET' && new URL(request.url()).pathname === '/api/admin/publish') { + return route.fallback() + } + return route.abort() + }) + await page.route(/\/admin\/piece\/.*\/preview(?:\/[^?]*)?(?:\?.*)?$/, (route) => route.abort()) +}) + +test('Save persists source; Vim menu changes preserve history and :w keeps focus and cursor', async ({ + page, + baseURL +}) => { + await signIn(page, notebook.editorUrl) const editor = page.getByRole('textbox') - const save = page.getByRole('button', { name: 'Save', exact: true }) + const saved = page.getByRole('button', { name: 'Saved', exact: true }) + const more = page.getByRole('button', { name: 'More', exact: true }) + const vim = page.getByRole('menuitemcheckbox', { name: 'Vim mode', exact: true }) + const mode = page.locator('.cm-editor .editor-status-mode') const observation = 'A saved observation from the browser.' - await expect(editor).toContainText('Original observation.') await editor.press('ControlOrMeta+End') await page.keyboard.insertText(`\n\n${observation}`) - await save.click() - await expect(save).toBeDisabled() + await page.getByRole('button', { name: 'Save', exact: true }).click() + await expect(saved).toBeDisabled() await page.reload() await expect(editor).toContainText(observation) + const savedSource = await editor.innerText() + + const draftLine = 'A draft kept while switching keybindings.' + const quotedTitleSource = savedSource.replace(/^title:.*$/m, `title: "${notebook.title}"`) + const draftSource = `${quotedTitleSource.trimEnd()}\n\n${draftLine}\n` + await editor.press('ControlOrMeta+a') + await page.keyboard.insertText(draftSource) + await more.click() + await expect(vim).not.toBeChecked() + await vim.click() + await expect(editor).toBeFocused() + await expect(mode).toHaveText('Normal') + await more.click() + await vim.click() + await expect(editor).toBeFocused() + await editor.press('ControlOrMeta+z') + await expect(editor).toHaveText(savedSource, { useInnerText: true }) + await editor.press('ControlOrMeta+Shift+z') + await expect(editor).toHaveText(draftSource, { useInnerText: true }) + + await more.click() + await vim.click() + await expect(editor).toBeFocused() + const draftLineNumber = draftSource.slice(0, draftSource.indexOf(draftLine)).split('\n').length + await page.keyboard.type(`${draftLineNumber}G$`) + const cursorInLine = () => + editor.evaluate((content) => { + const selection = window.getSelection() + const node = selection?.focusNode + const line = node?.parentElement?.closest('.cm-line') + if (!selection || !node || !line || !content.contains(line)) return null + const range = document.createRange() + range.selectNodeContents(line) + range.setEnd(node, selection.focusOffset) + return { line: line.textContent, offset: range.toString().length } + }) + const expectedCursor = { line: draftLine, offset: draftLine.length - 1 } + await expect.poll(cursorInLine).toEqual(expectedCursor) + await editor.press(':') + const command = page.locator('.cm-editor .editor-vim-status input') + await command.fill('w') + await command.press('Enter') + await expect(saved).toBeDisabled() + await expect(editor).toContainText(`title: "${notebook.title}"`) + await expect(editor).toBeFocused() + await expect.poll(cursorInLine).toEqual(expectedCursor) + await expect(page).toHaveURL(`${baseURL}${notebook.editorUrl}`) + + await page.reload() + await expect(editor).toContainText(draftLine) + await expect(editor).toContainText(`title: "${notebook.title}"`) + await expect(mode).toHaveText('Normal') + await more.click() + await expect(vim).toBeChecked() + const previewUrl = `${baseURL}${notebook.editorUrl.replace('/source', '/preview')}` + await page.route(previewUrl, (route) => + route.fulfill({ status: 400, json: { type: 'failure', status: 400, data: null } }) + ) + const previewRequest = page.waitForRequest(previewUrl) + await page.getByRole('menuitem', { name: 'Preview', exact: true }).click() + expect((await previewRequest).method()).toBe('POST') + + const beforeFormat = await editor.innerText() + let formatSaveRequests = 0 + page.on('request', (request) => { + const url = new URL(request.url()) + if (request.method() === 'POST' && url.searchParams.has('/save')) formatSaveRequests++ + }) + await more.click() + await page.getByRole('menuitem', { name: 'Format', exact: true }).click() + await expect.poll(() => editor.innerText()).not.toBe(beforeFormat) + await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeEnabled() + await editor.press('u') + await expect(editor).toHaveText(beforeFormat, { useInnerText: true }) + await expect(saved).toBeDisabled() + expect(formatSaveRequests).toBe(0) +}) + +test('pending Publish allows saving and typing; failed metadata refresh retains the draft', async ({ + page, + baseURL +}) => { + const file = 'forest.notes.md' + const editorUrl = '/admin/piece/forest.notes.md/source' + const jobId = 'e2e-forest-publication' + const streamUrl = `**/api/admin/publish/${jobId}/stream` + let finishPublication!: () => void + const publicationFinished = new Promise((resolve) => { + finishPublication = resolve + }) + await page.route('**/api/admin/publish', (route) => { + if (route.request().method() !== 'POST') return route.fallback() + return route.fulfill({ status: 202, json: { jobId } }) + }) + await page.route(streamUrl, async (route) => { + await publicationFinished + await route.fulfill({ + contentType: 'text/event-stream', + body: `event: done\ndata: ${JSON.stringify({ + workflowName: 'Publish', + state: 'completed', + result: 'ok', + errors: null + })}\n\n` + }) + }) + try { + await signIn(page, editorUrl) + const editor = page.getByRole('textbox') + const save = page.getByRole('button', { name: 'Save', exact: true }) + const saved = page.getByRole('button', { name: 'Saved', exact: true }) + const publishing = page.getByRole('button', { name: 'Publishing…', exact: true }) + const publication = page.locator('.cm-editor .editor-status .editor-publication-status') + await expect(publication).toHaveText('Up to date') + const acceptedRequest = page.waitForRequest( + (request) => + request.method() === 'POST' && new URL(request.url()).pathname === '/api/admin/publish' + ) + const pendingStream = page.waitForRequest(streamUrl) + await page.getByRole('button', { name: 'More', exact: true }).click() + await page.getByRole('menuitem', { name: 'Publish', exact: true }).click() + expect((await acceptedRequest).postDataJSON()).toEqual({ filePath: file }) + await pendingStream + await expect(publishing).toBeDisabled() + + await editor.press('ControlOrMeta+End') + await page.keyboard.insertText('\n\nSaved while Publish is still running.') + await expect(save).toBeEnabled() + await save.click() + await expect(saved).toBeDisabled() + await expect(publishing).toBeDisabled() + const savedSource = await editor.innerText() + await editor.press('ControlOrMeta+End') + await page.keyboard.insertText('\n\nUnsaved work must survive the metadata failure.') + const draftSource = await editor.innerText() + expect(draftSource).not.toBe(savedSource) + + await page.route( + (url) => url.pathname === '/api/admin/publish' && url.searchParams.get('filePath') === file, + (route) => route.fulfill({ status: 500, json: { message: 'Status temporarily unavailable' } }) + ) + finishPublication() + await expect(page.getByRole('alert')).toContainText('Unable to refresh publication status') + await expect(publication).toHaveAttribute('data-state', 'unknown') + await expect(editor).toHaveText(draftSource, { useInnerText: true }) + await expect(page).toHaveURL(`${baseURL}${editorUrl}`) + await expect(save).toBeEnabled() + } finally { + finishPublication() + } }) diff --git a/apps/web/e2e/support/fixture.ts b/apps/web/e2e/support/fixture.ts index 7f612e71..831103ce 100644 --- a/apps/web/e2e/support/fixture.ts +++ b/apps/web/e2e/support/fixture.ts @@ -91,6 +91,17 @@ export async function createFixture(directory: string, origin: string) { for await (const result of await schemas.sync(asCoreDatabase(db))) { if (result.error) throw new Error(result.message) } + const notes = await schemas.getPiece('notes') + for await (const result of await notes.sync( + asCoreDatabase(db), + pieces.map((piece) => piece.file) + )) { + if (result.error) throw new Error(result.message) + } + const cachedPieces = await db + .selectFrom('pieces_cache') + .select(['file_path', 'content_hash']) + .execute() // Seed the published read model directly; no Publish job is being simulated. await db .insertInto('web_pieces') @@ -101,6 +112,8 @@ export async function createFixture(directory: string, origin: string) { slug: piece.slug, type: piece.type, file_path: piece.file, + content_hash: cachedPieces.find((cached) => cached.file_path === piece.file)! + .content_hash, title: piece.title, note, json_metadata: JSON.stringify({ title: piece.title, date: dateConsumed }), diff --git a/apps/web/src/lib/components/editor/EditorStatusContent.svelte b/apps/web/src/lib/components/editor/EditorStatusContent.svelte new file mode 100644 index 00000000..f91e7de3 --- /dev/null +++ b/apps/web/src/lib/components/editor/EditorStatusContent.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/apps/web/src/lib/components/editor/MarkdownEditor.svelte b/apps/web/src/lib/components/editor/MarkdownEditor.svelte index abea0994..7f4c8c25 100644 --- a/apps/web/src/lib/components/editor/MarkdownEditor.svelte +++ b/apps/web/src/lib/components/editor/MarkdownEditor.svelte @@ -1,5 +1,5 @@ - - - More - - - {#snippet child({ open, props, wrapperProps })} - {#if open} -
- + {/snippet} + {/if} - {/snippet} - - - + {/each} +
+ {/each} +{/snippet} + +{#if phone.current} + + + More + + + + {#snippet child({ props, open })} + {#if open} +
+ {/if} + {/snippet} +
+ restoreFocus(event, true)}> + {#snippet child({ props, open })} + {#if open} +
+
+ + {#snippet child({ props })} +

Piece actions

+ {/snippet} +
+ + {#snippet child({ props })} + + {/snippet} + +
+ + View, edit or manage this piece. + +
+ {@render groupedActions(true)} +
+
+ {/if} + {/snippet} +
+
+
+{:else} + + + More + + restoreFocus(event, false)} + > + {#snippet child({ open, props, wrapperProps })} + {#if open} +
+ +
+ {/if} + {/snippet} +
+
+
+{/if} diff --git a/apps/web/src/lib/components/editor/PieceActions.test.ts b/apps/web/src/lib/components/editor/PieceActions.test.ts deleted file mode 100644 index 913a5930..00000000 --- a/apps/web/src/lib/components/editor/PieceActions.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test, vi } from 'vitest' -import { render } from 'svelte/server' -import PieceActions from './PieceActions.svelte' - -// SSR covers the trigger, not portal actions, client selection or focus. -test.each([false, true])('More is labeled and disabled=%s while saving', (disabled) => { - const { body } = render(PieceActions, { - props: { - canGenerate: true, - disabled, - hasPublishedVersion: true, - publishDisabled: disabled, - unpublishDisabled: disabled, - onPublish: vi.fn(), - onUnpublish: vi.fn(), - onDelete: vi.fn(), - onAttach: vi.fn(), - onGenerate: vi.fn() - } - }) - const trigger = body.match(/]*aria-haspopup="menu"[^>]*>/)?.[0] - expect(trigger).toBeDefined() - expect(trigger).toContain('data-variant="editor"') - expect(trigger).toContain('type="button"') - expect(trigger).toContain('aria-expanded="false"') - expect(body).toContain('More') - if (disabled) expect(trigger).toMatch(/\sdisabled(?:\s|=|>)/) - else expect(trigger).not.toMatch(/\sdisabled(?:\s|=|>)/) -}) diff --git a/apps/web/src/lib/components/editor/PieceToolbar.svelte b/apps/web/src/lib/components/editor/PieceToolbar.svelte index 5f946ee3..535d5305 100644 --- a/apps/web/src/lib/components/editor/PieceToolbar.svelte +++ b/apps/web/src/lib/components/editor/PieceToolbar.svelte @@ -1,112 +1,77 @@
- - {@render children()} -
-
-
- {#if isSaving} - Saving… - {:else if isDirty} - Unsaved - {:else} -
- -
- {/if} -
- - {#if isUnpublishing} - Unpublishing… - {:else if hasPublishedVersion} - Published - {:else} - Unpublished - {/if} + + + {#if publicationStatus !== 'published' && publicationStatus !== 'unpublishing'} + + + + {/if} + {@render children()}
diff --git a/apps/web/src/lib/components/editor/PieceToolbar.test.ts b/apps/web/src/lib/components/editor/PieceToolbar.test.ts deleted file mode 100644 index 19599724..00000000 --- a/apps/web/src/lib/components/editor/PieceToolbar.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { createRawSnippet, type ComponentProps } from 'svelte' -import { render } from 'svelte/server' -import { beforeEach, expect, test, vi } from 'vitest' -import { resolve } from '$app/paths' -import { - encodeArchivePath, - previewHref as piecePreviewHref, - publishedHref as piecePublishedHref -} from '#lib/pieces/navigation.js' -import PieceToolbar from './PieceToolbar.svelte' - -vi.mock('$app/paths', () => ({ - resolve: vi.fn((route: string, params: { path: string }) => - route.replace('/(auth)', '').replace('[...path]', () => params.path) - ) -})) -vi.mock('$app/forms', () => ({ enhance: vi.fn() })) -beforeEach(() => vi.clearAllMocks()) - -const file = 'books/源 #?%.books.md' -const previewHref = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/preview' -const publishedHref = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published' -const children = createRawSnippet(() => ({ render: () => 'More fixture' })) -const disabledAttribute = /(?:^|\s)disabled(?:\s|=|$)/ - -function html(props: Partial> = {}) { - return render(PieceToolbar, { - props: { - file, - saveFormId: 'save-piece', - isDirty: false, - isSaving: false, - hasPublishedVersion: true, - children, - ...props - } - }).body -} - -function buttons(output: string) { - return [...output.matchAll(/]*)>([\s\S]*?)<\/button>/g)].map((match) => ({ - attributes: match[1], - label: match[2].replace(//g, '').trim() - })) -} - -function buttonAttributes(output: string, label: string): string { - const button = buttons(output).find((button) => button.label === label) - if (!button) throw new Error(`Missing button: ${label}`) - return button.attributes -} - -test.each([false, true])( - 'saved files submit a preview action when published=%s', - (hasPublishedVersion) => { - const output = html({ hasPublishedVersion }) - expect(buttons(output).map((button) => button.label)).toEqual(['Save', 'Saved']) - expect(buttonAttributes(output, 'Save')).toMatch(disabledAttribute) - expect(buttonAttributes(output, 'Saved')).toContain('type="submit"') - expect(output).toContain('method="post"') - expect(output).toContain(`action="${previewHref}"`) - expect(output).not.toContain(`href="${previewHref}"`) - expect(output).toContain('aria-label="Preview saved piece"') - expect(output).toContain('More fixture') - expect(output).toContain('aria-live="polite"') - } -) - -test('unsaved documents have a short status instead of a Saved preview action', () => { - const output = html({ isDirty: true }) - expect(buttonAttributes(output, 'Save')).not.toMatch(disabledAttribute) - expect(output).toContain('Unsaved') - expect(output).not.toContain('Unsaved changes') - expect(output).not.toContain(previewHref) - expect(output).toContain(publishedHref) -}) - -test.each([false, true])('saving disables Save when dirty=%s', (isDirty) => { - const output = html({ isDirty, isSaving: true }) - expect(buttonAttributes(output, 'Saving…')).toMatch(disabledAttribute) - expect(output).not.toContain(previewHref) - expect(output).not.toContain('Unsaved') -}) - -test.each([ - { isDirty: false, isSaving: false, saveLabel: 'Save' }, - { isDirty: true, isSaving: false, saveLabel: 'Save' }, - { isDirty: false, isSaving: true, saveLabel: 'Saving…' }, - { isDirty: true, isSaving: true, saveLabel: 'Saving…' } -])('editor commands retain their shared appearance with %o', ({ saveLabel, ...state }) => { - const output = html(state) - expect(buttonAttributes(output, saveLabel)).toContain('data-variant="editor"') -}) - -test('Save retains its source form submission', () => { - const output = html({ isDirty: true }) - const save = buttonAttributes(output, 'Save') - expect(save).toContain('type="submit"') - expect(save).toContain('form="save-piece"') -}) - -test('publication status links only to an existing public version', () => { - expect(html()).toContain(`href="${publishedHref}"`) - const unpublished = html({ hasPublishedVersion: false }) - expect(unpublished).toContain('Unpublished') - expect(unpublished).not.toContain(publishedHref) - expect(unpublished).toContain(previewHref) -}) - -test.each([false, true])( - 'pending Unpublish replaces only the public link when published=%s', - (hasPublishedVersion) => { - const output = html({ hasPublishedVersion, isUnpublishing: true }) - expect(output).toContain('Unpublishing…') - expect(output).not.toContain(publishedHref) - expect(output).toContain(previewHref) - } -) - -test('editor actions do not duplicate archive navigation', () => { - const output = html() - expect(output).toContain('aria-label="Editor actions"') - expect(output).not.toContain('href="/admin"') - expect(output).not.toContain('Back to archive') -}) - -test('encoded file paths enter the app resolver unchanged', () => { - const file = 'books/literal%2F 源 #?%.books.md' - const preview = piecePreviewHref(file) - const published = piecePublishedHref(file) - const output = html({ file }) - - expect(preview).toContain('literal%252F%20%E6%BA%90%20%23%3F%25.books.md') - expect(vi.mocked(resolve).mock.calls).toEqual([ - ['/(auth)/admin/piece/[...path]/preview', { path: encodeArchivePath(file) }], - ['/(auth)/admin/piece/[...path]/published', { path: encodeArchivePath(file) }] - ]) - expect(output).toContain(`action="${preview}"`) - expect(output).toContain(`href="${published}"`) -}) - -// Mocked SSR verifies resolver inputs, labels, links and disabled/form semantics, -// not browser/native resolution, clicks or responsive layout. diff --git a/apps/web/src/lib/components/editor/PublicationStatus.svelte b/apps/web/src/lib/components/editor/PublicationStatus.svelte new file mode 100644 index 00000000..11d4452d --- /dev/null +++ b/apps/web/src/lib/components/editor/PublicationStatus.svelte @@ -0,0 +1,33 @@ + + + + {#if status === 'unknown'}Status unavailable + {:else if status === 'publishing'}Publishing… + {:else if status === 'unpublishing'}Unpublishing… + {:else if isSaving}Saving… + {:else if isDirty}Unsaved + {:else if status === 'changed'}Saved changes + {:else if hasPublishedVersion}Up to date + {:else}Unpublished{/if} + + + diff --git a/apps/web/src/lib/components/editor/editor-status.test.ts b/apps/web/src/lib/components/editor/editor-status.test.ts new file mode 100644 index 00000000..2ed8b2dc --- /dev/null +++ b/apps/web/src/lib/components/editor/editor-status.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, test, vi } from 'vitest' +import type { EditorView, ViewUpdate } from '@codemirror/view' +import { getCM } from '@replit/codemirror-vim' +import { editorStatusPanel } from './editor-status' + +vi.mock('@replit/codemirror-vim', () => ({ getCM: vi.fn() })) + +afterEach(() => vi.resetAllMocks()) + +test('mode events update the panel without document edits and detach on disable or destroy', () => { + const dom = { hidden: false, setAttribute: vi.fn(), append: vi.fn() } + const modeLabel = { textContent: '', setAttribute: vi.fn() } + const prompt = { hidden: true, append: vi.fn() } + const createElement = vi + .fn() + .mockReturnValueOnce(dom) + .mockReturnValueOnce(modeLabel) + .mockReturnValueOnce(prompt) + const view = { dom: { ownerDocument: { createElement } } } as unknown as EditorView + const events = new EventTarget() + const state = { vim: { insertMode: false } } + const off = vi.fn((event: string, handler: () => void) => + events.removeEventListener(event, handler) + ) + const adapter = { + state, + on: (event: string, handler: () => void) => events.addEventListener(event, handler), + off + } as unknown as NonNullable> + vi.mocked(getCM).mockReturnValue(adapter) + const panel = editorStatusPanel(view) + expect(modeLabel.textContent).toBe('Normal') + + state.vim.insertMode = true + events.dispatchEvent(new Event('vim-mode-change')) + expect(modeLabel.textContent).toBe('Insert') + + vi.mocked(getCM).mockReturnValue(null) + panel.update?.({} as ViewUpdate) + expect(off).toHaveBeenCalledWith('vim-mode-change', expect.any(Function)) + state.vim.insertMode = false + events.dispatchEvent(new Event('vim-mode-change')) + expect(modeLabel.textContent).toBe('') + + vi.mocked(getCM).mockReturnValue(adapter) + panel.update?.({} as ViewUpdate) + expect(modeLabel.textContent).toBe('Normal') + panel.destroy?.() + state.vim.insertMode = true + events.dispatchEvent(new Event('vim-mode-change')) + expect(modeLabel.textContent).toBe('Normal') +}) diff --git a/apps/web/src/lib/components/editor/editor-status.ts b/apps/web/src/lib/components/editor/editor-status.ts new file mode 100644 index 00000000..f606178f --- /dev/null +++ b/apps/web/src/lib/components/editor/editor-status.ts @@ -0,0 +1,91 @@ +import type { EditorView, Panel } from '@codemirror/view' +import { getCM } from '@replit/codemirror-vim' + +export type StatusContentRenderer = (target: HTMLElement) => () => void + +export function editorStatusPanel(view: EditorView, renderContent?: StatusContentRenderer): Panel { + const document = view.dom.ownerDocument + const dom = document.createElement('div') + dom.className = 'editor-status' + dom.setAttribute('role', 'group') + dom.setAttribute('aria-label', 'Editor status') + const modeLabel = document.createElement('span') + modeLabel.className = 'editor-status-mode' + modeLabel.setAttribute('role', 'status') + dom.append(modeLabel) + const prompt = document.createElement('span') + prompt.className = 'editor-vim-status cm-vim-panel' + prompt.hidden = true + dom.append(prompt) + let content: HTMLElement | undefined + let destroyContent: (() => void) | undefined + if (renderContent) { + content = document.createElement('span') + content.className = 'editor-status-content' + dom.append(content) + destroyContent = renderContent(content) + } + + let editor: ReturnType = null + let previousStatusbar: Element | null | undefined + + function render() { + const state = editor?.state.vim + let mode = '' + if (state) { + mode = 'Normal' + if (state.visualMode) { + mode = 'Visual' + if (state.visualBlock) mode = 'Visual block' + else if (state.visualLine) mode = 'Visual line' + } else if (state.insertMode) { + mode = editor?.state.overwrite ? 'Replace' : 'Insert' + } + } + // Cursor movements and focus updates must not repeatedly announce unchanged status. + if (modeLabel.textContent !== mode) modeLabel.textContent = mode + const dialog = editor?.state.dialog + const showingPrompt = !!dialog + if (dialog && dialog.parentElement !== prompt) prompt.append(dialog) + prompt.hidden = !showingPrompt + modeLabel.hidden = showingPrompt || !mode + if (content) content.hidden = showingPrompt + dom.setAttribute('data-vim-dialog', String(showingPrompt)) + dom.hidden = !showingPrompt && !mode && !renderContent + } + + function detachEditor() { + editor?.off('vim-mode-change', render) + editor?.off('dialog', render) + if (editor && editor.state.statusbar === prompt) { + editor.state.statusbar = previousStatusbar + } + } + + function update() { + const current = getCM(view) + if (current !== editor) { + detachEditor() + editor = current + previousStatusbar = editor?.state.statusbar + if (editor) { + // Give Vim a host in our panel so prompts do not create a second bottom row. + editor.state.statusbar = prompt + editor.on('vim-mode-change', render) + editor.on('dialog', render) + } + } + render() + } + + update() + return { + dom, + top: false, + update, + destroy() { + detachEditor() + destroyContent?.() + } + } +} diff --git a/apps/web/src/lib/components/editor/keybindings-status.test.ts b/apps/web/src/lib/components/editor/keybindings-status.test.ts deleted file mode 100644 index e10a2d92..00000000 --- a/apps/web/src/lib/components/editor/keybindings-status.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { afterEach, expect, test, vi } from 'vitest' -import type { EditorView, ViewUpdate } from '@codemirror/view' -import { getCM } from '@replit/codemirror-vim' -import { keybindingsStatus } from './keybindings-status' - -vi.mock('@replit/codemirror-vim', () => ({ getCM: vi.fn() })) - -afterEach(() => vi.resetAllMocks()) - -function element() { - let text = '' - const writes = vi.fn() - return { - className: '', - setAttribute: vi.fn(), - append: vi.fn(), - writes, - get textContent() { - return text - }, - set textContent(value: string) { - text = value - writes(value) - } - } -} - -function setup() { - const dom = element() - const label = element() - const modeLabel = element() - const createElement = vi - .fn() - .mockReturnValueOnce(dom) - .mockReturnValueOnce(label) - .mockReturnValueOnce(modeLabel) - const focus = vi.fn() - const view = { - dom: { ownerDocument: { createElement } }, - hasFocus: false, - focus - } as unknown as EditorView - const panel = keybindingsStatus(view) - return { panel, dom, label, modeLabel, createElement, focus } -} - -function vimEditor() { - const events = new EventTarget() - const state = { - vim: { insertMode: false, visualMode: false, visualLine: false, visualBlock: false }, - overwrite: false - } - const adapter = { - state, - on: vi.fn((event: string, handler: () => void) => events.addEventListener(event, handler)), - off: vi.fn((event: string, handler: () => void) => events.removeEventListener(event, handler)) - } - return { - adapter: adapter as unknown as NonNullable>, - state, - on: adapter.on, - off: adapter.off, - modeChanged: () => events.dispatchEvent(new Event('vim-mode-change')) - } -} - -test('renders a bottom informational footer in Standard mode with no focusable controls', () => { - vi.mocked(getCM).mockReturnValue(null) - const { panel, dom, label, modeLabel, createElement, focus } = setup() - expect(panel.top).toBe(false) - expect(panel.dom).toBe(dom) - expect(dom.className).toBe('editor-status') - expect(dom.setAttribute).toHaveBeenCalledWith('aria-label', 'Editor keybindings') - expect(label.setAttribute).toHaveBeenCalledWith('role', 'status') - expect(label.textContent).toBe('Standard (Ctrl+Alt+V)') - expect(modeLabel.textContent).toBe('') - expect(modeLabel.setAttribute).toHaveBeenCalledWith('role', 'status') - expect(createElement.mock.calls).toEqual([['div'], ['span'], ['span']]) - expect(dom.append).toHaveBeenCalledWith(label, modeLabel) - panel.update?.({ focusChanged: true } as ViewUpdate) - panel.destroy?.() - expect(focus).not.toHaveBeenCalled() -}) - -test.each([ - ['Normal', {}], - ['Insert', { insertMode: true }], - ['Visual', { visualMode: true }], - ['Visual line', { visualMode: true, visualLine: true }], - ['Visual block', { visualMode: true, visualBlock: true }] -])('shows the actual initial Vim mode: %s', (mode, flags) => { - const editor = vimEditor() - Object.assign(editor.state.vim, flags) - vi.mocked(getCM).mockReturnValue(editor.adapter) - const { dom, label, modeLabel } = setup() - expect(label.textContent).toBe('Vim (Ctrl+Alt+V)') - expect(modeLabel.textContent).toBe(mode) - expect(dom.append).toHaveBeenCalledWith(label, modeLabel) -}) - -test('updates from mode events even when there is no document transaction', () => { - const editor = vimEditor() - vi.mocked(getCM).mockReturnValue(editor.adapter) - const { panel, modeLabel, focus } = setup() - const listener = editor.on.mock.calls[0][1] - expect(editor.on).toHaveBeenCalledExactlyOnceWith('vim-mode-change', listener) - editor.state.vim.insertMode = true - editor.modeChanged() - expect(modeLabel.textContent).toBe('Insert') - editor.state.overwrite = true - editor.modeChanged() - expect(modeLabel.textContent).toBe('Replace') - editor.state.vim.insertMode = false - editor.state.overwrite = false - editor.modeChanged() - expect(modeLabel.textContent).toBe('Normal') - panel.update?.({ focusChanged: true } as ViewUpdate) - expect(modeLabel.textContent).toBe('Normal') - expect(focus).not.toHaveBeenCalled() -}) - -test('tracks enable/disable and a replacement Vim instance without leaking listeners', () => { - vi.mocked(getCM).mockReturnValue(null) - const { panel, label, modeLabel } = setup() - const first = vimEditor() - vi.mocked(getCM).mockReturnValue(first.adapter) - panel.update?.({} as ViewUpdate) - expect(label.textContent).toBe('Vim (Ctrl+Alt+V)') - expect(modeLabel.textContent).toBe('Normal') - const firstListener = first.on.mock.calls[0][1] - - vi.mocked(getCM).mockReturnValue(null) - panel.update?.({} as ViewUpdate) - expect(first.off).toHaveBeenCalledExactlyOnceWith('vim-mode-change', firstListener) - expect(label.textContent).toBe('Standard (Ctrl+Alt+V)') - expect(modeLabel.textContent).toBe('') - first.state.vim.insertMode = true - first.modeChanged() - expect(label.textContent).toBe('Standard (Ctrl+Alt+V)') - expect(modeLabel.textContent).toBe('') - - const second = vimEditor() - vi.mocked(getCM).mockReturnValue(second.adapter) - panel.update?.({} as ViewUpdate) - const secondListener = second.on.mock.calls[0][1] - panel.destroy?.() - expect(second.off).toHaveBeenCalledExactlyOnceWith('vim-mode-change', secondListener) - second.state.vim.insertMode = true - second.modeChanged() - expect(label.textContent).toBe('Vim (Ctrl+Alt+V)') - expect(modeLabel.textContent).toBe('Normal') -}) - -test('does not rewrite the live status for every cursor or focus update', () => { - const editor = vimEditor() - vi.mocked(getCM).mockReturnValue(editor.adapter) - const { panel, label, modeLabel } = setup() - panel.update?.({ selectionSet: true } as ViewUpdate) - panel.update?.({ focusChanged: true } as ViewUpdate) - editor.modeChanged() - expect(label.writes).toHaveBeenCalledOnce() - expect(modeLabel.writes).toHaveBeenCalledOnce() - expect(editor.on).toHaveBeenCalledOnce() -}) - -test('does not report Vim from a stale adapter without active Vim state', () => { - const editor = vimEditor() - delete (editor.state as { vim?: unknown }).vim - vi.mocked(getCM).mockReturnValue(editor.adapter) - const { label, modeLabel } = setup() - expect(label.textContent).toBe('Standard (Ctrl+Alt+V)') - expect(modeLabel.textContent).toBe('') -}) diff --git a/apps/web/src/lib/components/editor/keybindings-status.ts b/apps/web/src/lib/components/editor/keybindings-status.ts deleted file mode 100644 index 56b98024..00000000 --- a/apps/web/src/lib/components/editor/keybindings-status.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { EditorView, Panel } from '@codemirror/view' -import { getCM } from '@replit/codemirror-vim' - -/** An informational footer; keybinding changes still belong to the shortcut. */ -export function keybindingsStatus(view: EditorView): Panel { - const document = view.dom.ownerDocument - const dom = document.createElement('div') - dom.className = 'editor-status' - dom.setAttribute('role', 'group') - dom.setAttribute('aria-label', 'Editor keybindings') - const label = document.createElement('span') - label.setAttribute('role', 'status') - const modeLabel = document.createElement('span') - modeLabel.setAttribute('role', 'status') - dom.append(label, modeLabel) - - let editor: ReturnType = null - - function render() { - const state = editor?.state.vim - const text = `${state ? 'Vim' : 'Standard'} (Ctrl+Alt+V)` - let mode = '' - if (state) { - mode = 'Normal' - if (state.visualMode) { - mode = 'Visual' - if (state.visualBlock) mode = 'Visual block' - else if (state.visualLine) mode = 'Visual line' - } else if (state.insertMode) { - mode = editor?.state.overwrite ? 'Replace' : 'Insert' - } - } - // Cursor movements and focus updates must not repeatedly announce the same mode. - if (label.textContent !== text) label.textContent = text - if (modeLabel.textContent !== mode) modeLabel.textContent = mode - } - - function update() { - const current = getCM(view) - if (current !== editor) { - editor?.off('vim-mode-change', render) - editor = current - editor?.on('vim-mode-change', render) - } - render() - } - - update() - return { - dom, - top: false, - update, - destroy() { - editor?.off('vim-mode-change', render) - } - } -} diff --git a/apps/web/src/lib/components/editor/keybindings.test.ts b/apps/web/src/lib/components/editor/keybindings.test.ts index 255e2ca2..a3000174 100644 --- a/apps/web/src/lib/components/editor/keybindings.test.ts +++ b/apps/web/src/lib/components/editor/keybindings.test.ts @@ -1,45 +1,35 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { EditorState, StateField, type TransactionSpec } from '@codemirror/state' -import { EditorView, showPanel } from '@codemirror/view' -import { history, undo } from '@codemirror/commands' -import { getCM, Vim, vim } from '@replit/codemirror-vim' -import { editorKeybindings, finishVimInput } from './keybindings' -import { keybindingsStatus } from './keybindings-status' +import type { EditorView } from '@codemirror/view' +import { vim } from '@replit/codemirror-vim' +import { createEditorKeybindings } from './keybindings' vi.mock('@replit/codemirror-vim', () => ({ vim: vi.fn(), getCM: vi.fn(), - Vim: { handleKey: vi.fn() } + Vim: { defineEx: vi.fn() } })) const activeVim = StateField.define({ create: () => true, update: (value) => value }) const storage = { getItem: vi.fn(), setItem: vi.fn() } -let handlers: Parameters[0] beforeEach(() => { vi.stubGlobal('window', { localStorage: storage }) storage.getItem.mockReturnValue(null) vi.mocked(vim).mockReturnValue(activeVim) - vi.mocked(getCM).mockReturnValue(null) - vi.spyOn(EditorView, 'domEventHandlers').mockImplementation((value) => { - handlers = value - return [] - }) }) afterEach(() => { vi.resetAllMocks() - vi.restoreAllMocks() vi.unstubAllGlobals() }) function setup() { - const bindings = editorKeybindings() - const keydown = handlers.keydown! + const controller = createEditorKeybindings() let state = EditorState.create({ doc: 'Notes', selection: { anchor: 1, head: 3 }, - extensions: [bindings, history()] + extensions: controller.extension }) const view = { get state() { @@ -49,129 +39,41 @@ function setup() { state = state.update(spec).state } } as EditorView - return { view, keydown: (event: KeyboardEvent) => keydown.call(view, event, view) } + return { controller, view } } -function shortcut(overrides: Partial = {}): KeyboardEvent { - return { - key: 'v', - ctrlKey: true, - altKey: true, - shiftKey: false, - metaKey: false, - isComposing: false, - repeat: false, - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - ...overrides - } as unknown as KeyboardEvent -} +test('menu changes preserve the buffer and selection, and persist for the next editor', () => { + storage.setItem.mockImplementation((_key, value) => storage.getItem.mockReturnValue(value)) + const { controller, view } = setup() + const selection = view.state.selection + expect(controller.enabled).toBe(false) -test.each([null, 'standard', 'invalid'])('defaults to Standard for preference %j', (preference) => { - storage.getItem.mockReturnValue(preference) - const { view } = setup() - expect(storage.getItem).toHaveBeenCalledWith('editor.keybindings') - expect(view.state.facet(showPanel)).toContain(keybindingsStatus) - expect(view.state.field(activeVim, false)).toBeUndefined() - expect(vim).not.toHaveBeenCalled() -}) - -test('restores Vim without the plugin’s separate status panel', () => { - storage.getItem.mockReturnValue('vim') - const { view } = setup() - expect(view.state.field(activeVim)).toBe(true) - expect(vim).toHaveBeenCalledExactlyOnceWith({ status: false }) -}) - -test('Ctrl+Alt+V toggles and remembers the preference without changing text or selection', () => { - const { view, keydown } = setup() - const before = view.state.selection - const event = shortcut() - expect(keydown(event)).toBe(true) - expect(event.preventDefault).toHaveBeenCalledOnce() - expect(event.stopPropagation).toHaveBeenCalledOnce() + controller.setEnabled(view, true) + expect(controller.enabled).toBe(true) expect(view.state.field(activeVim)).toBe(true) + expect(vim).toHaveBeenCalledWith({ status: false }) expect(storage.setItem).toHaveBeenLastCalledWith('editor.keybindings', 'vim') - expect(view.state.doc.toString()).toBe('Notes') - expect(view.state.selection.eq(before)).toBe(true) + const restored = setup() + expect(restored.controller.enabled).toBe(true) + expect(restored.view.state.field(activeVim)).toBe(true) - expect(keydown(shortcut())).toBe(true) + controller.setEnabled(view, false) + expect(controller.enabled).toBe(false) expect(view.state.field(activeVim, false)).toBeUndefined() expect(storage.setItem).toHaveBeenLastCalledWith('editor.keybindings', 'standard') - expect(view.state.facet(showPanel)).toContain(keybindingsStatus) expect(view.state.doc.toString()).toBe('Notes') - expect(view.state.selection.eq(before)).toBe(true) + expect(view.state.selection.eq(selection)).toBe(true) }) -test('does not add history entries or discard prior editing history when toggled', () => { - const { view, keydown } = setup() - view.dispatch({ changes: { from: 5, insert: ' edited' } }) - keydown(shortcut()) - keydown(shortcut()) - expect(undo(view)).toBe(true) - expect(view.state.doc.toString()).toBe('Notes') -}) - -test('restores the shared preference when opening another editor', () => { - storage.setItem.mockImplementation((_key, value) => storage.getItem.mockReturnValue(value)) - setup().keydown(shortcut()) - expect(setup().view.state.field(activeVim)).toBe(true) -}) - -test.each([ - { ctrlKey: false }, - { altKey: false }, - { shiftKey: true }, - { metaKey: true }, - { key: 'x' }, - { isComposing: true } -])('leaves other key events alone: %j', (overrides) => { - const { view, keydown } = setup() - const event = shortcut(overrides) - expect(keydown(event)).toBe(false) - expect(event.preventDefault).not.toHaveBeenCalled() - expect(view.state.field(activeVim, false)).toBeUndefined() - expect(storage.setItem).not.toHaveBeenCalled() -}) - -test('does not toggle repeatedly while the shortcut is held down', () => { - const { view, keydown } = setup() - keydown(shortcut()) - expect(keydown(shortcut({ repeat: true }))).toBe(true) - expect(view.state.field(activeVim)).toBe(true) - expect(storage.setItem).toHaveBeenCalledTimes(1) -}) - -test('accepts Caps Lock without requiring Shift', () => { - const { view, keydown } = setup() - keydown(shortcut({ key: 'V' })) - expect(view.state.field(activeVim)).toBe(true) -}) - -test('still edits and toggles when browser storage is unavailable', () => { +test('unavailable browser storage does not prevent switching to Vim', () => { storage.getItem.mockImplementation(() => { throw new Error('Storage unavailable') }) storage.setItem.mockImplementation(() => { throw new Error('Storage unavailable') }) - const { view, keydown } = setup() - expect(view.state.field(activeVim, false)).toBeUndefined() - expect(() => keydown(shortcut())).not.toThrow() + const { controller, view } = setup() + expect(controller.enabled).toBe(false) + controller.setEnabled(view, true) expect(view.state.field(activeVim)).toBe(true) }) - -test('finishes the active Vim input operation before an external document replacement', () => { - const { view } = setup() - const editor = {} as NonNullable> - vi.mocked(getCM).mockReturnValue(editor) - finishVimInput(view) - expect(getCM).toHaveBeenCalledExactlyOnceWith(view) - expect(Vim.handleKey).toHaveBeenCalledExactlyOnceWith(editor, '', 'api') -}) - -test('finishing Vim input is a no-op in Standard mode', () => { - const { view } = setup() - finishVimInput(view) - expect(Vim.handleKey).not.toHaveBeenCalled() -}) diff --git a/apps/web/src/lib/components/editor/keybindings.ts b/apps/web/src/lib/components/editor/keybindings.ts index d534e797..c48596a8 100644 --- a/apps/web/src/lib/components/editor/keybindings.ts +++ b/apps/web/src/lib/components/editor/keybindings.ts @@ -1,9 +1,17 @@ -import { Compartment, Prec, type Extension } from '@codemirror/state' -import { EditorView, showPanel } from '@codemirror/view' -import { getCM, Vim, vim } from '@replit/codemirror-vim' -import { keybindingsStatus } from './keybindings-status' +import { Compartment, type Extension } from '@codemirror/state' +import { showPanel, ViewPlugin, type EditorView } from '@codemirror/view' +import { getCM, Vim, vim, type CodeMirrorV } from '@replit/codemirror-vim' +import { editorStatusPanel, type StatusContentRenderer } from './editor-status' const preferenceKey = 'editor.keybindings' +const saveHandlers = new WeakMap void>() + +function saveFromVim(editor: CodeMirrorV) { + saveHandlers.get(editor.cm6)?.() +} + +Vim.defineEx('write', 'w', saveFromVim) +Vim.defineEx('wq', 'wq', saveFromVim) function readPreference(): boolean { try { @@ -21,38 +29,47 @@ function savePreference(enabled: boolean) { } } -/** Create once per editor, before its ordinary keymaps. */ -export function editorKeybindings(): Extension { +export function createEditorKeybindings(renderStatus?: StatusContentRenderer, onSave?: () => void) { const keybindings = new Compartment() let enabled = readPreference() - return [ - Prec.highest( - EditorView.domEventHandlers({ - keydown(event, view) { - if ( - !event.ctrlKey || - !event.altKey || - event.shiftKey || - event.metaKey || - event.isComposing || - event.key.toLowerCase() !== 'v' - ) - return false - event.preventDefault() - event.stopPropagation() - if (event.repeat) return true - enabled = !enabled - view.dispatch({ - effects: keybindings.reconfigure(enabled ? vim({ status: false }) : []) - }) - savePreference(enabled) - return true + + function setEnabled(view: EditorView, next: boolean) { + if (enabled === next) return + enabled = next + view.dispatch({ + effects: keybindings.reconfigure(enabled ? vim({ status: false }) : []) + }) + savePreference(enabled) + } + + const extension: Extension[] = [ + keybindings.of(enabled ? vim({ status: false }) : []), + showPanel.of((view) => editorStatusPanel(view, renderStatus)) + ] + if (onSave) { + extension.push( + ViewPlugin.define((view) => { + saveHandlers.set(view, onSave) + return { + destroy() { + saveHandlers.delete(view) + } } }) - ), - keybindings.of(enabled ? vim({ status: false }) : []), - showPanel.of(keybindingsStatus) - ] + ) + } + + return { + extension, + get enabled() { + return enabled + }, + setEnabled + } +} + +export function editorKeybindings(onSave?: () => void): Extension { + return createEditorKeybindings(undefined, onSave).extension } /** Finish Vim's insertion before applying a generated document, outside dot-repeat. */ diff --git a/apps/web/src/lib/components/editor/replace-document.ts b/apps/web/src/lib/components/editor/replace-document.ts index 2bc4e687..d5faf0fb 100644 --- a/apps/web/src/lib/components/editor/replace-document.ts +++ b/apps/web/src/lib/components/editor/replace-document.ts @@ -3,9 +3,7 @@ import { isolateHistory } from '@codemirror/commands' export function replaceDocument(state: EditorState, content: string, expectedSource: string) { if (state.doc.toString() !== expectedSource) { - throw new Error( - 'The editor changed during generation. Nothing was applied; try again with the current content.' - ) + throw new Error('The editor changed. Nothing was applied; try again with the current content.') } return state.update({ changes: { from: 0, to: state.doc.length, insert: content }, diff --git a/apps/web/src/lib/components/editor/save-state.test.ts b/apps/web/src/lib/components/editor/save-state.test.ts deleted file mode 100644 index c35c1e1e..00000000 --- a/apps/web/src/lib/components/editor/save-state.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect, test } from 'vitest' -import { reconcileSave, reconcileSourceRefresh } from './save-state' - -const file = 'a.note.md' -const submitted = { file, content: 'A' } -const saved = { type: 'success' as const, content: 'A\n' } - -test('an unchanged submitted buffer adopts normalized source and is saved', () => { - const content = reconcileSave(submitted, submitted, saved) - expect(content).toBe('A\n') - expect(content === saved.content).toBe(true) -}) - -test('typing B during Save A preserves B against the refreshed A baseline', () => { - const content = reconcileSave({ file, content: 'B' }, submitted, saved) - expect(content).toBe('B') - expect(content !== saved.content).toBe(true) - - // The retained draft can be submitted next, then adopt its own normalization. - const nextSubmission = { file, content } - const nextSaved = { type: 'success' as const, content: 'B\n' } - const nextContent = reconcileSave(nextSubmission, nextSubmission, nextSaved) - expect(nextContent).toBe('B\n') - expect(nextContent === nextSaved.content).toBe(true) -}) - -test('a failed Save echoing A does not replace newer B or mark it saved', () => { - const baseline = 'original source' - const content = reconcileSave({ file, content: 'B' }, submitted, { - type: 'failure', - content: 'A' - }) - expect(content).toBe('B') - expect(content !== baseline).toBe(true) -}) - -test('failure preserves an unchanged submitted draft too', () => { - expect(reconcileSave(submitted, submitted, { type: 'failure', content: 'error A' })).toBe('A') - expect(reconcileSave(submitted, submitted, { type: 'failure' })).toBe('A') -}) - -test('clearing the buffer while Save is pending remains an unsaved edit', () => { - const content = reconcileSave({ file, content: '' }, submitted, saved) - expect(content).toBe('') - expect(content !== saved.content).toBe(true) -}) - -test('an old file response cannot replace the newly loaded file, even with identical text', () => { - const current = { file: 'other.note.md', content: 'A' } - expect(reconcileSave(current, submitted, saved)).toBe(current.content) - expect(reconcileSave(current, submitted, { type: 'failure', content: 'error A' })).toBe( - current.content - ) -}) - -test('pristine same-file refreshes adopt each new saved baseline', () => { - const refreshed = { file, content: 'B' } - const content = reconcileSourceRefresh('A', submitted, refreshed, false) - expect(content).toBe('B') - expect(content).toBe(refreshed.content) - - const nextRefresh = { file, content: 'C' } - expect(reconcileSourceRefresh(content, refreshed, nextRefresh, false)).toBe('C') -}) - -test('same-file refreshes preserve dirty drafts against the latest loaded baseline', () => { - const refreshed = { file, content: 'B' } - const content = reconcileSourceRefresh('draft', submitted, refreshed, false) - expect(content).toBe('draft') - expect(content).not.toBe(refreshed.content) - - // Returning to historical A is still dirty against the now-loaded B baseline. - const nextRefresh = { file, content: 'C' } - expect(reconcileSourceRefresh('A', refreshed, nextRefresh, false)).toBe('A') -}) - -test('a pending Save defers even a pristine buffer refresh to save reconciliation', () => { - const refreshed = { file, content: saved.content } - const content = reconcileSourceRefresh('A', submitted, refreshed, true) - expect(content).toBe('A') - expect(reconcileSave({ file, content }, submitted, saved)).toBe(refreshed.content) -}) - -test('typing B survives refresh during Save A and either save outcome', () => { - const refreshed = { file, content: saved.content } - const content = reconcileSourceRefresh('B', submitted, refreshed, true) - expect(content).toBe('B') - expect(reconcileSave({ file, content }, submitted, saved)).toBe('B') - expect(reconcileSave({ file, content }, submitted, { type: 'failure', content: 'A' })).toBe('B') - expect(content).not.toBe(refreshed.content) -}) - -test('navigation always loads the new file despite a dirty buffer or pending Save', () => { - const refreshed = { file: 'other.note.md', content: 'other source' } - expect(reconcileSourceRefresh('draft', submitted, refreshed, false)).toBe('other source') - const content = reconcileSourceRefresh('draft', submitted, refreshed, true) - expect(content).toBe('other source') - expect(reconcileSave({ file: refreshed.file, content }, submitted, saved)).toBe(content) -}) diff --git a/apps/web/src/lib/components/editor/save-state.ts b/apps/web/src/lib/components/editor/save-state.ts deleted file mode 100644 index 1e183d6e..00000000 --- a/apps/web/src/lib/components/editor/save-state.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type SaveSnapshot = { file: string; content: string } - -export function reconcileSourceRefresh( - content: string, - previous: SaveSnapshot, - refreshed: SaveSnapshot, - savePending: boolean -): string { - if (refreshed.file !== previous.file || (!savePending && content === previous.content)) { - return refreshed.content - } - return content -} - -type SaveResult = { type: 'success'; content: string } | { type: 'failure'; content?: string } - -export function reconcileSave( - current: SaveSnapshot, - submitted: SaveSnapshot, - result: SaveResult -): string { - if ( - result.type === 'failure' || - current.file !== submitted.file || - current.content !== submitted.content - ) { - return current.content - } - return result.content -} diff --git a/apps/web/src/lib/components/editor/themes/configured.css b/apps/web/src/lib/components/editor/themes/configured.css index 65d06ab7..aa86953e 100644 --- a/apps/web/src/lib/components/editor/themes/configured.css +++ b/apps/web/src/lib/components/editor/themes/configured.css @@ -73,7 +73,6 @@ .cm-editor .editor-status { display: flex; align-items: center; - justify-content: space-between; gap: var(--space-3); box-sizing: border-box; block-size: calc(var(--space-4) + var(--space-2)); @@ -84,3 +83,33 @@ line-height: 1; white-space: nowrap; } + +.cm-editor .editor-status[data-vim-dialog='true'] { + block-size: auto; + min-block-size: calc(var(--space-4) + var(--space-2)); + max-block-size: 30dvh; + overflow: auto; +} + +.cm-editor .editor-status .editor-vim-status { + flex: 1; + min-width: 0; + padding: 0; + font: inherit; + white-space: normal; +} + +.cm-editor .editor-status-mode { + flex-shrink: 0; +} + +.cm-editor .editor-status-content { + margin-inline-start: auto; + min-width: 0; + overflow: hidden; +} + +.cm-editor .editor-status[hidden], +.cm-editor .editor-status > [hidden] { + display: none; +} diff --git a/apps/web/src/lib/components/layout/simple/SearchDialog.svelte b/apps/web/src/lib/components/layout/simple/SearchDialog.svelte index 9f7072c8..b3aa77fc 100644 --- a/apps/web/src/lib/components/layout/simple/SearchDialog.svelte +++ b/apps/web/src/lib/components/layout/simple/SearchDialog.svelte @@ -2,6 +2,7 @@ import SearchIcon from 'virtual:icons/ph/magnifying-glass' import CloseIcon from 'virtual:icons/ph/x' import { fade } from 'svelte/transition' + import { responsiveFly } from '#lib/components/ui/dialog-motion.js' import { Dialog } from 'bits-ui' import SearchForm from './SearchForm.svelte' import { afterNavigate } from '$app/navigation' @@ -19,18 +20,6 @@ isOpen = false }) - - function responsiveFly(node: HTMLElement, { duration = 300 }) { - return { - duration, - css: (t: number) => { - return ` - opacity: ${t}; - transform: translate(var(--fly-x, 0px), calc((1 - ${t}) * var(--fly-y-offset, 0px))); - ` - } - } - } diff --git a/apps/web/src/lib/components/ui/dialog-motion.ts b/apps/web/src/lib/components/ui/dialog-motion.ts new file mode 100644 index 00000000..5d65a590 --- /dev/null +++ b/apps/web/src/lib/components/ui/dialog-motion.ts @@ -0,0 +1,12 @@ +export function responsiveFly(node: HTMLElement, { duration = 300 } = {}) { + if (node.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches) { + duration = 0 + } + return { + duration, + css: (t: number) => ` + opacity: ${t}; + transform: translate(var(--fly-x, 0px), calc((1 - ${t}) * var(--fly-y-offset, 0px))); + ` + } +} diff --git a/apps/web/src/lib/pieces/unpublish-client.test.ts b/apps/web/src/lib/pieces/publication-client.test.ts similarity index 54% rename from apps/web/src/lib/pieces/unpublish-client.test.ts rename to apps/web/src/lib/pieces/publication-client.test.ts index fa7f479a..f137b751 100644 --- a/apps/web/src/lib/pieces/unpublish-client.test.ts +++ b/apps/web/src/lib/pieces/publication-client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { encodeEvent, SSE_HEADERS } from '#lib/server/sse.js' -import { submitUnpublish } from './unpublish-client' +import { getPublicationState, submitPublication } from './publication-client' +import { MarkdownPublicationStatus } from './types.js' const fetchMock = vi.fn() const encoder = new TextEncoder() @@ -49,7 +50,7 @@ test('posts the exact file and waits for its own completion, not just admission' fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response) const observer = new AbortController() let settled = false - const pending = submitUnpublish(filePath, observer.signal).then((result) => { + const pending = submitPublication('unpublish', filePath, observer.signal).then((result) => { settled = true return result }) @@ -73,7 +74,7 @@ test('does not adopt a conflicting job or submit again', async () => { fetchMock.mockResolvedValue( new Response(JSON.stringify({ jobId: 'foreign-job' }), { status: 409 }) ) - const result = await submitUnpublish(filePath, new AbortController().signal) + const result = await submitPublication('unpublish', filePath, new AbortController().signal) expect(result).toMatchObject({ state: 'failed', reason: 'busy' }) expect(result?.jobId).toBeUndefined() expect(fetchMock).toHaveBeenCalledOnce() @@ -90,7 +91,9 @@ test('preserves serialized cleanup diagnostics without retrying the job', async }) ) fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response) - await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({ + await expect( + submitPublication('unpublish', filePath, new AbortController().signal) + ).resolves.toMatchObject({ state: 'failed', reason: 'job', jobId: 'job-1', @@ -108,7 +111,9 @@ test('rejects a different workflow rather than claiming Unpublish succeeded', as }) ) fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response) - await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({ + await expect( + submitPublication('unpublish', filePath, new AbortController().signal) + ).resolves.toMatchObject({ state: 'failed', reason: 'response', jobId: 'job-1' @@ -119,7 +124,9 @@ test('reports an interrupted stream as unknown completion and does not reconnect const source = stream(encodeEvent('state', { workflowName: 'Unpublish', state: 'running' })) source.close() fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response) - await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({ + await expect( + submitPublication('unpublish', filePath, new AbortController().signal) + ).resolves.toMatchObject({ state: 'failed', reason: 'connection', jobId: 'job-1' @@ -129,7 +136,7 @@ test('reports an interrupted stream as unknown completion and does not reconnect test('reports authentication failure without observing a job', async () => { fetchMock.mockResolvedValue(new Response('Unauthorized', { status: 401 })) - const result = await submitUnpublish(filePath, new AbortController().signal) + const result = await submitPublication('unpublish', filePath, new AbortController().signal) expect(result).toMatchObject({ state: 'failed', reason: 'sign-in' }) expect(result?.jobId).toBeUndefined() expect(fetchMock).toHaveBeenCalledOnce() @@ -139,7 +146,9 @@ test('distinguishes authentication loss after admission from a worker failure', fetchMock .mockResolvedValueOnce(accepted()) .mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })) - await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({ + await expect( + submitPublication('unpublish', filePath, new AbortController().signal) + ).resolves.toMatchObject({ state: 'failed', reason: 'sign-in', jobId: 'job-1' @@ -148,7 +157,9 @@ test('distinguishes authentication loss after admission from a worker failure', test('reports request diagnostics without treating them as success', async () => { fetchMock.mockResolvedValue(new Response('queue unavailable', { status: 500 })) - await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({ + await expect( + submitPublication('unpublish', filePath, new AbortController().signal) + ).resolves.toMatchObject({ state: 'failed', reason: 'request', detail: 'queue unavailable' @@ -158,7 +169,9 @@ test('reports request diagnostics without treating them as success', async () => test('requires a job ID before opening a status stream', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ result: 'ok' }))) - await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({ + await expect( + submitPublication('unpublish', filePath, new AbortController().signal) + ).resolves.toMatchObject({ state: 'failed', reason: 'response' }) @@ -169,10 +182,107 @@ test('detaching stops observation, not the admitted server job', async () => { const source = stream() fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response) const observer = new AbortController() - const pending = submitUnpublish(filePath, observer.signal) + const pending = submitPublication('unpublish', filePath, observer.signal) await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) observer.abort() await expect(pending).resolves.toBeUndefined() expect(fetchMock).toHaveBeenCalledTimes(2) expect(source.cancel).toHaveBeenCalledOnce() }) + +test('publishes through the shared transport and waits for Publish completion', async () => { + const source = stream( + encodeEvent('done', { + workflowName: 'Publish', + state: 'completed', + result: 'ok', + errors: null + }) + ) + fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response) + const observer = new AbortController() + await expect(submitPublication('publish', filePath, observer.signal)).resolves.toEqual({ + state: 'completed', + jobId: 'job-1' + }) + expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/admin/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filePath }), + signal: observer.signal + }) +}) + +test('reports partial publication as a job failure with the failed piece diagnostics', async () => { + const detail = 'cover upload refused' + const source = stream( + encodeEvent('done', { + workflowName: 'Publish', + state: 'completed', + result: 'partial', + errors: null, + failedPieces: [{ filePath, message: detail }] + }) + ) + fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response) + await expect( + submitPublication('publish', filePath, new AbortController().signal) + ).resolves.toEqual({ + state: 'failed', + reason: 'job', + jobId: 'job-1', + detail: `${filePath}: ${detail}` + }) +}) + +test('reads publication state for the exact filename with a cancellable GET', async () => { + const state = { + hasPublishedVersion: true, + markdownPublicationStatus: MarkdownPublicationStatus.MATCHING + } + fetchMock.mockResolvedValue(Response.json(state)) + const observer = new AbortController() + + await expect(getPublicationState(filePath, observer.signal)).resolves.toEqual(state) + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + `/api/admin/publish?${new URLSearchParams({ filePath })}`, + { signal: observer.signal } + ) +}) + +test.each([ + { name: 'HTTP failure', response: () => new Response('unavailable', { status: 500 }) }, + { name: 'sign-in HTML', response: () => new Response('Sign in') }, + { name: 'invalid JSON', response: () => new Response('{') }, + { name: 'missing state', response: () => Response.json({}) }, + { + name: 'invalid boolean', + response: () => + Response.json({ hasPublishedVersion: 'yes', markdownPublicationStatus: 'matching' }) + }, + { + name: 'unknown status', + response: () => + Response.json({ hasPublishedVersion: true, markdownPublicationStatus: 'pending' }) + } +])( + 'a state read throws on $name without retrying or submitting an action', + async ({ response }) => { + fetchMock.mockResolvedValue(response()) + + await expect(getPublicationState(filePath, new AbortController().signal)).rejects.toThrow() + + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls[0][1]?.method).toBeUndefined() + } +) + +test('a failed or aborted state fetch propagates without retrying an action', async () => { + const error = new DOMException('Aborted', 'AbortError') + fetchMock.mockRejectedValue(error) + + await expect(getPublicationState(filePath, new AbortController().signal)).rejects.toBe(error) + + expect(fetchMock).toHaveBeenCalledOnce() +}) diff --git a/apps/web/src/lib/pieces/unpublish-client.ts b/apps/web/src/lib/pieces/publication-client.ts similarity index 52% rename from apps/web/src/lib/pieces/unpublish-client.ts rename to apps/web/src/lib/pieces/publication-client.ts index fe4628a9..c0d6ed5c 100644 --- a/apps/web/src/lib/pieces/unpublish-client.ts +++ b/apps/web/src/lib/pieces/publication-client.ts @@ -1,15 +1,17 @@ import { readJsonEvents } from '#lib/sse.js' +import { MarkdownPublicationStatus, type PiecePublicationState } from './types.js' -export type UnpublishFailureReason = +export type PublicationAction = 'publish' | 'unpublish' +export type PublicationFailureReason = | 'busy' | 'sign-in' | 'request' | 'connection' | 'response' | 'job' -export type UnpublishOutcome = +export type PublicationOutcome = | { state: 'completed'; jobId: string } - | { state: 'failed'; reason: UnpublishFailureReason; detail: string; jobId?: string } + | { state: 'failed'; reason: PublicationFailureReason; detail: string; jobId?: string } function isObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) @@ -30,7 +32,53 @@ function messageDetail(value: unknown): string { return '' } -function failure(reason: UnpublishFailureReason, detail = '', jobId?: string): UnpublishOutcome { +function publicationFailureDetail(errors: unknown, failedPieces: unknown[]): string { + const details: string[] = [] + if (Array.isArray(errors)) { + for (const error of errors) { + const detail = messageDetail(error) + if (detail) details.push(detail) + } + } + for (const piece of failedPieces) { + const detail = messageDetail(piece) + if (!detail) continue + if (isObject(piece) && typeof piece.filePath === 'string') { + details.push(`${piece.filePath}: ${detail}`) + } else { + details.push(detail) + } + } + return details.join('\n') +} + +export async function getPublicationState( + filePath: string, + signal: AbortSignal +): Promise { + const query = new URLSearchParams({ filePath }) + const response = await fetch(`/api/admin/publish?${query}`, { signal }) + if (!response.ok || response.redirected) { + throw new Error('Failed to read publication state') + } + const state: unknown = await response.json() + if (!isObject(state) || typeof state.hasPublishedVersion !== 'boolean') { + throw new Error('Invalid publication state response') + } + const markdownPublicationStatus = state.markdownPublicationStatus + const validPublicationStatus = + markdownPublicationStatus === MarkdownPublicationStatus.MATCHING || + markdownPublicationStatus === MarkdownPublicationStatus.CHANGED || + markdownPublicationStatus === MarkdownPublicationStatus.UNKNOWN + if (!validPublicationStatus) throw new Error('Invalid publication state response') + return { hasPublishedVersion: state.hasPublishedVersion, markdownPublicationStatus } +} + +function failure( + reason: PublicationFailureReason, + detail = '', + jobId?: string +): PublicationOutcome { return { state: 'failed', reason, detail, jobId } } @@ -38,10 +86,11 @@ function requiresSignIn(response: Response): boolean { return response.redirected || response.status === 401 || response.status === 403 } -async function observeUnpublishJob( +async function observePublicationJob( + action: PublicationAction, jobId: string, signal: AbortSignal -): Promise { +): Promise { try { const stream = await fetch(`/api/admin/publish/${encodeURIComponent(jobId)}/stream`, { headers: { Accept: 'text/event-stream' }, @@ -64,21 +113,22 @@ async function observeUnpublishJob( if (signal.aborted) return if (event.type === 'error') return failure('connection', messageDetail(event.data), jobId) if (event.type !== 'state' && event.type !== 'done') continue - if (!isObject(event.data) || event.data.workflowName !== 'Unpublish') { + if ( + !isObject(event.data) || + event.data.workflowName !== (action === 'publish' ? 'Publish' : 'Unpublish') + ) { return failure('response', '', jobId) } if (event.type !== 'done') continue - if (event.data.state === 'completed' && event.data.result === 'ok') { + const failedPieces = Array.isArray(event.data.failedPieces) ? event.data.failedPieces : [] + if ( + event.data.state === 'completed' && + event.data.result === 'ok' && + failedPieces.length === 0 + ) { return { state: 'completed', jobId } } - const details: string[] = [] - if (Array.isArray(event.data.errors)) { - for (const error of event.data.errors) { - const detail = messageDetail(error) - if (detail) details.push(detail) - } - } - return failure('job', details.join('\n'), jobId) + return failure('job', publicationFailureDetail(event.data.errors, failedPieces), jobId) } if (signal.aborted) return return failure('connection', '', jobId) @@ -87,13 +137,14 @@ async function observeUnpublishJob( } } -export async function submitUnpublish( +export async function submitPublication( + action: PublicationAction, filePath: string, signal: AbortSignal -): Promise { +): Promise { if (signal.aborted) return try { - const response = await fetch('/api/admin/unpublish', { + const response = await fetch(`/api/admin/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filePath }), @@ -118,7 +169,7 @@ export async function submitUnpublish( return failure('response') } if (signal.aborted) return - return observeUnpublishJob(accepted.jobId, signal) + return observePublicationJob(action, accepted.jobId, signal) } catch (error) { if (!signal.aborted) return failure('request', messageDetail(error)) } diff --git a/apps/web/src/lib/pieces/publication.server.test.ts b/apps/web/src/lib/pieces/publication.server.test.ts index a9d7ba1e..419e10fb 100644 --- a/apps/web/src/lib/pieces/publication.server.test.ts +++ b/apps/web/src/lib/pieces/publication.server.test.ts @@ -12,9 +12,10 @@ import { import { db } from '#lib/server/database/index.js' import type { AppDatabase } from '@luzzle/web.db' import { getPieces } from '#lib/server/pieces.js' +import type * as ServerPieces from '#lib/server/pieces.js' import { getStorage } from '#lib/server/storage.js' import { actions } from '../../routes/(auth)/admin/piece/[...path]/source/+page.server' -import { getMarkdownPublicationStatus } from './publication.server.js' +import { getMarkdownPublicationStatus, getPiecePublicationState } from './publication.server.js' import { MarkdownPublicationStatus } from './types.js' vi.mock('#lib/server/database/index.js', async () => { @@ -28,7 +29,10 @@ vi.mock('#lib/server/database/index.js', async () => { if (web.error) throw new Error(`Test web migration failed: ${web.error}`) return { db } }) -vi.mock('#lib/server/pieces.js', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() })) +vi.mock('#lib/server/pieces.js', async (importOriginal) => ({ + ...(await importOriginal()), + getPieces: vi.fn() +})) vi.mock('#lib/server/storage.js', () => ({ getStorage: vi.fn() })) vi.mock('#lib/server/config.js', async () => { const { makeConfig } = await import('#lib/server/config.fixture.js') @@ -163,13 +167,17 @@ test('Save creates the archive item/cache before first publication, not a servin title: 'Saved title', cover: '.assets/books/cover.png' }) - expect(item.note_markdown).toBe('A paragraph.\n') + expect(item.note_markdown).toBe('A paragraph.') expect(JSON.parse(item.assets_json_array!)).toEqual(['.assets/books/cover.png']) expect(cache.content_hash).toBe(digest(savedBytes)) - expect(cache.content_hash).not.toBe(digest(raw)) + expect(savedBytes).toEqual(Buffer.from(raw)) expect(storage.writeFile).toHaveBeenCalledOnce() expect(storage.createReadStream).toHaveBeenCalledExactlyOnceWith(file) expect(await db.selectFrom('web_pieces').selectAll().execute()).toEqual([]) + expect(await getPiecePublicationState(file)).toEqual({ + hasPublishedVersion: false, + markdownPublicationStatus: MarkdownPublicationStatus.UNKNOWN + }) }) test('Save updates existing item/cache while retaining serving content, hash and timestamp', async () => { @@ -180,9 +188,10 @@ test('Save updates existing item/cache while retaining serving content, hash and .where('file_path', '=', file) .executeTakeFirstOrThrow() const serving = await published(originalCache.content_hash) - expect(await getMarkdownPublicationStatus(file, serving.content_hash)).toBe( - MarkdownPublicationStatus.MATCHING - ) + expect(await getPiecePublicationState(file)).toEqual({ + hasPublishedVersion: true, + markdownPublicationStatus: MarkdownPublicationStatus.MATCHING + }) expect( await save('---\ntitle: Updated\ncover: .assets/books/new.png\n---\nUpdated body') @@ -201,9 +210,10 @@ test('Save updates existing item/cache while retaining serving content, hash and expect(cache.content_hash).toBe(digest(savedBytes)) expect(JSON.parse(item.frontmatter_json).title).toBe('Updated') expect(JSON.parse(item.assets_json_array!)).toEqual(['.assets/books/new.png']) - expect(await getMarkdownPublicationStatus(file, serving.content_hash)).toBe( - MarkdownPublicationStatus.CHANGED - ) + expect(await getPiecePublicationState(file)).toEqual({ + hasPublishedVersion: true, + markdownPublicationStatus: MarkdownPublicationStatus.CHANGED + }) expect( await db .selectFrom('web_pieces') diff --git a/apps/web/src/lib/pieces/publication.server.ts b/apps/web/src/lib/pieces/publication.server.ts index 9b1705c0..990d61ea 100644 --- a/apps/web/src/lib/pieces/publication.server.ts +++ b/apps/web/src/lib/pieces/publication.server.ts @@ -1,5 +1,14 @@ import { db } from '#lib/server/database/index.js' -import { MarkdownPublicationStatus } from './types.js' +import { getWebPiece } from '#lib/server/pieces.js' +import { MarkdownPublicationStatus, type PiecePublicationState } from './types.js' + +export async function getPiecePublicationState(file: string): Promise { + const webPiece = await getWebPiece(db, file) + return { + hasPublishedVersion: Boolean(webPiece), + markdownPublicationStatus: await getMarkdownPublicationStatus(file, webPiece?.content_hash) + } +} export async function getMarkdownPublicationStatus( file: string, diff --git a/apps/web/src/lib/pieces/types.ts b/apps/web/src/lib/pieces/types.ts index 122ba14e..762bb51b 100644 --- a/apps/web/src/lib/pieces/types.ts +++ b/apps/web/src/lib/pieces/types.ts @@ -3,3 +3,16 @@ export enum MarkdownPublicationStatus { CHANGED = 'changed', UNKNOWN = 'unknown' } + +export type EditorPublicationStatus = + | 'unpublished' + | 'published' + | 'changed' + | 'unknown' + | 'publishing' + | 'unpublishing' + +export interface PiecePublicationState { + hasPublishedVersion: boolean + markdownPublicationStatus: MarkdownPublicationStatus +} diff --git a/apps/web/src/lib/server/markdown.ts b/apps/web/src/lib/server/markdown.ts index 6014a58f..ce7c1ccb 100644 --- a/apps/web/src/lib/server/markdown.ts +++ b/apps/web/src/lib/server/markdown.ts @@ -7,7 +7,3 @@ export async function normalizeMarkdown(content: string): Promise { printWidth: 80 }) } - -export function normalizeLineEndings(content: string): string { - return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n') -} diff --git a/apps/web/src/routes/(auth)/admin/asset/editor/[...asset]/+page.svelte b/apps/web/src/routes/(auth)/admin/asset/editor/[...asset]/+page.svelte index e2222163..33f594a0 100644 --- a/apps/web/src/routes/(auth)/admin/asset/editor/[...asset]/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/asset/editor/[...asset]/+page.svelte @@ -29,6 +29,7 @@ // Save refreshes preserve the draft; the asset-path key resets it on navigation. let editorContent = $state(untrack(() => data.content ?? '')) let isSaving = $state(false) + let saveForm = $state() let saveError = $state(null) let saveErrorReason = $state< 'navigation' | 'delete' | 'delete-refresh' | 'sign-in' | 'save' | 'refresh' | null @@ -78,6 +79,10 @@ } } + function saveFromVim() { + if (isDirty && !isSaving) saveForm?.requestSubmit() + } + const enhanceSave: SubmitFunction = ({ cancel }) => { if (isSaving) { cancel() @@ -93,8 +98,12 @@ try { if (data.path !== file) return if (result.type === 'success') { + const restoreEditorFocus = view?.hasFocus ?? false await update({ reset: false, navigate: false }) - if (!disposed && data.path === file) saveSuccess = true + if (!disposed && data.path === file) { + if (restoreEditorFocus) view?.focus() + saveSuccess = true + } } else if (result.type === 'failure') { const failure = result.data as { error?: { message?: string } } | undefined saveError = failure?.error?.message || 'Failed to save' @@ -222,7 +231,7 @@ const initialTheme = configuredTheme(isDark ? 'dark' : 'light') const extensions = [ - editorKeybindings(), + editorKeybindings(saveFromVim), highlightSpecialChars(), history(), drawSelection(), @@ -376,7 +385,7 @@ {/if} {#if data.exists && !data.isBinary} -
+