From 7bdb71183f87316ddba25ac5e9b2745ab201ea4c Mon Sep 17 00:00:00 2001 From: lobbystack <236289573+lobbystack@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:14:37 -0400 Subject: [PATCH 01/26] Stop saves from undoing spaces and line breaks typed in notes Saving a note trimmed trailing whitespace, and the editor then treated the saved text as an outside edit: it replaced what was on screen, deleting the space just typed, and showed "Merged changes from another app". The serializer now keeps trailing spaces (two are a Markdown line break), and the note, Markdown, task and project editors change their text only when a save actually merged another app's edit. --- .../app/src/lib/components/note-editor.svelte | 1 + .../lib/components/raw-markdown-editor.svelte | 1 + .../src/lib/editor/document-session.test.ts | 40 ++++++++++++++++--- apps/app/src/lib/editor/document-session.ts | 17 +++++++- .../src/lib/editor/managed-draft-session.ts | 8 ++-- apps/app/src/lib/editor/note-save.ts | 6 ++- crates/workspace-format/src/lib.rs | 38 +++++++++++++++++- 7 files changed, 96 insertions(+), 15 deletions(-) diff --git a/apps/app/src/lib/components/note-editor.svelte b/apps/app/src/lib/components/note-editor.svelte index 241e3b1..6501758 100644 --- a/apps/app/src/lib/components/note-editor.svelte +++ b/apps/app/src/lib/components/note-editor.svelte @@ -110,6 +110,7 @@ status: 'saved', base: baseOf(result.value), canonical: result.value, + merged: result.merged, }; }, read: async () => { diff --git a/apps/app/src/lib/components/raw-markdown-editor.svelte b/apps/app/src/lib/components/raw-markdown-editor.svelte index dbe36a6..4a1893e 100644 --- a/apps/app/src/lib/components/raw-markdown-editor.svelte +++ b/apps/app/src/lib/components/raw-markdown-editor.svelte @@ -112,6 +112,7 @@ return { status: 'saved', base: baseOf(result.current), + merged: result.status === 'merged', canonical: { read: result.current, managedObject: result.managedObject, diff --git a/apps/app/src/lib/editor/document-session.test.ts b/apps/app/src/lib/editor/document-session.test.ts index 7b337f2..699ba11 100644 --- a/apps/app/src/lib/editor/document-session.test.ts +++ b/apps/app/src/lib/editor/document-session.test.ts @@ -36,13 +36,18 @@ class FakeFile { this.body = body; this.revision += 1; } + /** How the file stores text; the note format drops trailing blank lines. */ + normalize = (text: string) => text; save(base: TextBase, local: string): SessionSaveResult { - const merged = - base.revision === String(this.revision) - ? local - : rebaseText(base.body, local, this.body); - this.writeExternally(merged); - return { status: 'saved', base: this.base, canonical: this.body }; + const external = base.revision !== String(this.revision); + const merged = external ? rebaseText(base.body, local, this.body) : local; + this.writeExternally(this.normalize(merged)); + return { + status: 'saved', + base: this.base, + canonical: this.body, + merged: external, + }; } } @@ -58,6 +63,7 @@ function setup(initial: string) { let gate: Promise | null = null; let readGate: Promise | null = null; const saved: string[] = []; + const merges = { count: 0 }; const session = new DocumentSession({ base: file.base, // Timers never fire on their own; tests flush explicitly. @@ -76,6 +82,9 @@ function setup(initial: string) { onConflict: () => { throw new Error('unexpected conflict'); }, + onMerged: () => { + merges.count += 1; + }, }); session.attach(editor); return { @@ -83,6 +92,7 @@ function setup(initial: string) { editor, session, saved, + merges, holdSaves() { const hold = deferred(); gate = hold.promise; @@ -135,6 +145,24 @@ describe('DocumentSession', () => { expect(file.body).toBe('outside\nline\none two'); }); + test('a save that normalizes the text leaves what was typed on screen', async () => { + const { editor, session, file, merges } = setup('Hello'); + // Like the note format, which ends the file with one newline. + file.normalize = (text) => text.replace(/\s+$/, ''); + editor.type(' ', session); + await session.flush(); + expect(file.body).toBe('Hello'); + expect(editor.value).toBe('Hello '); + editor.type('\n', session); + await session.flush(); + expect(editor.value).toBe('Hello \n'); + editor.type('world', session); + await session.flush(); + expect(editor.value).toBe('Hello \nworld'); + expect(file.body).toBe('Hello \nworld'); + expect(merges.count).toBe(0); + }); + test('a reload keeps text typed while the file was being read', async () => { const { editor, session, file, holdReads } = setup('a\n'); file.writeExternally('a\nfrom outside\n'); diff --git a/apps/app/src/lib/editor/document-session.ts b/apps/app/src/lib/editor/document-session.ts index 5167c36..d6887d4 100644 --- a/apps/app/src/lib/editor/document-session.ts +++ b/apps/app/src/lib/editor/document-session.ts @@ -25,7 +25,17 @@ export interface TextBase { } export type SessionSaveResult = - | { status: 'saved'; base: TextBase; canonical: Canonical } + | { + status: 'saved'; + base: TextBase; + canonical: Canonical; + /** + * The save folded in an edit made outside the editor. Without one, the + * file holds this editor's text, possibly normalized, and the text on + * screen stays as typed. + */ + merged?: boolean; + } | { status: 'conflict'; current: Canonical }; export interface DocumentSessionOptions { @@ -215,7 +225,10 @@ export class DocumentSession { return 'paused'; } this.#base = result.base; - if (result.base.body !== body) { + // Only a real merge changes the text on screen. Saving can normalize + // what was typed, such as a trailing space or a final blank line; + // replacing the editor's text with that would undo the keystroke. + if (result.merged && result.base.body !== body) { this.#editor?.rebase(body, result.base.body); options.onMerged?.(); } diff --git a/apps/app/src/lib/editor/managed-draft-session.ts b/apps/app/src/lib/editor/managed-draft-session.ts index 0f65d02..c7da13a 100644 --- a/apps/app/src/lib/editor/managed-draft-session.ts +++ b/apps/app/src/lib/editor/managed-draft-session.ts @@ -252,10 +252,12 @@ export class ManagedDraftSession { } : draftOf(result.current); this.#base = result.current; - if (!sameDraft(local, saved)) { + // Only a merged save changes what is on screen. A plain save can + // normalize the draft (a trailing space, a final blank line), and + // replacing the fields with that would undo what was just typed. + if (result.status === 'merged' && !sameDraft(local, saved)) { this.#port?.rebase(local, saved); - if (result.status === 'merged' && result.body !== local.body) - this.#options.onMerged?.(); + if (result.body !== local.body) this.#options.onMerged?.(); } this.#options.onCanonical?.(result.current); } diff --git a/apps/app/src/lib/editor/note-save.ts b/apps/app/src/lib/editor/note-save.ts index 16ca0bf..bc8fffc 100644 --- a/apps/app/src/lib/editor/note-save.ts +++ b/apps/app/src/lib/editor/note-save.ts @@ -4,7 +4,7 @@ import type { Note } from '@noura/workspace'; import { reconcileNoteTitle } from './title-reconciliation'; export type NoteSaveResult = - | { status: 'saved'; value: Note } + | { status: 'saved'; value: Note; merged: boolean } | { status: 'conflict'; draft: NoteDraft; current: Note }; export interface NoteDraft { @@ -26,6 +26,7 @@ export async function saveNoteWithReconciliation( let currentBaseBody = base.body; let currentBaseTitle = base.title; let currentBaseRevision = base.revision; + let merged = false; for (let attempt = 0; attempt < 3; attempt += 1) { const reconciliation = await getNouraClient().notes.reconcileDraft({ id: note.id, @@ -40,6 +41,7 @@ export async function saveNoteWithReconciliation( current: reconciliation.current as Note, }; } + if (reconciliation.status === 'merged') merged = true; const canonical = reconciliation.current as Note; const title = reconcileNoteTitle( currentBaseTitle, @@ -59,7 +61,7 @@ export async function saveNoteWithReconciliation( title: currentDraft.title, body: currentDraft.body, }); - return { status: 'saved', value: result.value as Note }; + return { status: 'saved', value: result.value as Note, merged }; } catch (error) { if ( !isCoreError(error) || diff --git a/crates/workspace-format/src/lib.rs b/crates/workspace-format/src/lib.rs index 7f22a43..e7324c4 100644 --- a/crates/workspace-format/src/lib.rs +++ b/crates/workspace-format/src/lib.rs @@ -593,9 +593,12 @@ pub fn serialize_object(object: &WorkspaceObject) -> Result, FormatError } let yaml = serde_yaml_ng::to_string(&ordered).map_err(|_| FormatError::ObjectSerialization)?; let mut result = format!("---\n{}---\n\n# {}\n", yaml, object.title.trim()); - if !object.body.trim().is_empty() { + // Only line breaks at the end are normalized. Trailing spaces stay: two + // of them are a Markdown line break, and the editor may be mid-typing. + let body = object.body.trim_end_matches(['\n', '\r']); + if !body.trim().is_empty() { result.push('\n'); - result.push_str(object.body.trim_end()); + result.push_str(body); result.push('\n'); } Ok(result.into_bytes()) @@ -727,6 +730,37 @@ mod tests { assert_eq!(first, second); } + #[test] + fn managed_bodies_keep_trailing_spaces_and_drop_trailing_line_breaks() { + let object = |body: &str| WorkspaceObject { + id: "note_01j00000000000000000000000".into(), + object_type: "note".into(), + title: "Example".into(), + body: body.into(), + relative_path: "notes/example.md".into(), + revision: String::new(), + created: Some("2026-08-27T12:00:00Z".into()), + updated: Some("2026-08-27T12:00:00Z".into()), + properties: BTreeMap::new(), + }; + let body_after_round_trip = |body: &str| { + let bytes = serialize_object(&object(body)).unwrap(); + let ParsedMarkdown::Managed(parsed) = parse_markdown("notes/example.md", &bytes) else { + panic!("managed object expected") + }; + parsed.body + }; + // A trailing space, or two for a Markdown line break, survives a save. + assert_eq!(body_after_round_trip("Typing "), "Typing "); + assert_eq!(body_after_round_trip("Line \nnext"), "Line \nnext"); + // Line breaks at the end are normalized; the file ends with one. + assert_eq!(body_after_round_trip("Text\n\n"), "Text"); + let bytes = serialize_object(&object("Text\n\n")).unwrap(); + assert!(bytes.ends_with(b"Text\n")); + // A body of only whitespace is written as no body. + assert_eq!(body_after_round_trip(" \n"), ""); + } + #[test] fn malformed_frontmatter_is_not_managed() { let ParsedMarkdown::Malformed { error, .. } = From 35f6882cbbb4d75ee0feef4ea762a8b6e81d3374 Mon Sep 17 00:00:00 2001 From: lobbystack <236289573+lobbystack@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:26:25 -0400 Subject: [PATCH 02/26] Add Playwright end-to-end tests for the browser build The tests drive the built app in Chromium on a fresh browser workspace each time and type with real key events. They check what the editor shows and what the note file holds, read through a second workspace worker the way another tab would. The first suite covers typing spaces and line breaks, typing across autosave, undo after saves, reloads and an outside edit. A new CI job installs Chromium and runs the suite on Ubuntu. --- .github/workflows/ci.yml | 32 +++ .gitignore | 3 + apps/app/package.json | 4 +- apps/app/playwright.config.ts | 43 ++++ apps/app/tests/e2e/editor-typing.e2e.ts | 160 ++++++++++++ apps/app/tests/e2e/fixtures.ts | 312 ++++++++++++++++++++++++ bun.lock | 9 + 7 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 apps/app/playwright.config.ts create mode 100644 apps/app/tests/e2e/editor-typing.e2e.ts create mode 100644 apps/app/tests/e2e/fixtures.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91a60a1..6b342ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,38 @@ jobs: - name: Generate the third-party license file run: bun scripts/generate-third-party-licenses.ts --out "$RUNNER_TEMP/THIRD_PARTY_LICENSES.txt" --target x86_64-unknown-linux-gnu + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: { bun-version: 1.3.14 } + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: { toolchain: '1.91', targets: wasm32-unknown-unknown } + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: taiki-e/install-action@76c2e6406e52637deed7160d77bded76bd83e06e # v2.87.14 + with: { tool: wasm-bindgen-cli@0.2.127 } + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + - run: bun install --frozen-lockfile + - name: Install Chromium for Playwright + run: bun x playwright install --with-deps chromium + working-directory: apps/app + - name: Run end-to-end tests against the browser build + run: bun run --cwd apps/app test:e2e + - name: Keep traces of failed tests + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-traces + path: apps/app/test-results/ + retention-days: 7 + rust: strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index 2fededf..9cb792c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ crates/workspace-format/bindings/ # Agent worktrees .claude/worktrees/ +# Playwright output +test-results/ +playwright-report/ diff --git a/apps/app/package.json b/apps/app/package.json index 663fe97..da2570f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -8,7 +8,8 @@ "build": "bun run wasm:build && vite build", "build:hosted": "bun run wasm:build && NOURA_HOSTED_BUILD=1 vite build", "check": "bun run wasm:build && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "lint": "prettier --check src", + "lint": "prettier --check src tests", + "test:e2e": "playwright test", "tauri": "tauri" }, "dependencies": { @@ -37,6 +38,7 @@ "jsdom": "30.0.1", "@fontsource-variable/public-sans": "^5.3.0", "@internationalized/date": "^3.12.0", + "@playwright/test": "1.62.1", "@sveltejs/adapter-static": "latest", "@sveltejs/kit": "latest", "@sveltejs/vite-plugin-svelte": "latest", diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts new file mode 100644 index 0000000..f174c4b --- /dev/null +++ b/apps/app/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = Number(process.env.NOURA_E2E_PORT ?? 4317); +const baseURL = `http://localhost:${port}`; + +/** + * End-to-end tests drive the browser build of the app: the same shell, + * routes and editor as the desktop app, backed by the browser workspace + * worker and its origin-private file system. Every test gets a fresh browser + * context, so every test starts from an empty workspace list. + */ +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.e2e.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + workers: process.env.CI ? 2 : undefined, + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: process.env.CI ? [['list'], ['github']] : 'list', + use: { + baseURL, + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1400, height: 900 }, + }, + }, + ], + webServer: { + command: `bun run build && bun x vite preview --port ${port} --strictPort`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + stdout: 'ignore', + stderr: 'pipe', + }, +}); diff --git a/apps/app/tests/e2e/editor-typing.e2e.ts b/apps/app/tests/e2e/editor-typing.e2e.ts new file mode 100644 index 0000000..d88021c --- /dev/null +++ b/apps/app/tests/e2e/editor-typing.e2e.ts @@ -0,0 +1,160 @@ +import { + expect, + MERGED_MESSAGE, + noteFileBody, + test, + type App, +} from './fixtures'; + +/** Wait until the note stored at `path` holds the text typed so far. */ +async function expectStoredBody(app: App, path: string, typed: string) { + await expect + .poll(() => app.storedBody(path), { timeout: 10_000 }) + .toBe(noteFileBody(typed)); +} + +test.describe('typing in a note', () => { + test('keeps spaces at the end of a line and of the note across saves', async ({ + app, + page, + }) => { + await app.newNote('Spaces'); + const start = await app.doc(); + await page.keyboard.type('Hello '); + // Let the debounced save run with the trailing space in place. + await expectStoredBody(app, 'Spaces.md', `${start}Hello `); + await page.keyboard.type(' world '); + await expectStoredBody(app, 'Spaces.md', `${start}Hello world `); + expect(await app.doc()).toBe(`${start}Hello world `); + expect(await app.caret()).toBe((await app.doc()).length); + await page.keyboard.type('!'); + expect(await app.doc()).toBe(`${start}Hello world !`); + await expectStoredBody(app, 'Spaces.md', `${start}Hello world !`); + expect(await app.messages()).toEqual([]); + }); + + test('keeps Enter at the end, at the start of a line, and blank lines', async ({ + app, + page, + }) => { + await app.newNote('Lines'); + const start = await app.doc(); + await page.keyboard.type('first'); + await page.keyboard.press('Enter'); + await expectStoredBody(app, 'Lines.md', `${start}first\n`); + await page.keyboard.press('Enter'); + await expectStoredBody(app, 'Lines.md', `${start}first\n\n`); + await page.keyboard.type('second'); + await expectStoredBody(app, 'Lines.md', `${start}first\n\nsecond`); + // Enter at the start of a line pushes it down. + await page.keyboard.press('Home'); + await page.keyboard.press('Enter'); + await expectStoredBody(app, 'Lines.md', `${start}first\n\n\nsecond`); + expect(await app.doc()).toBe(`${start}first\n\n\nsecond`); + await page.keyboard.type('x'); + expect(await app.doc()).toBe(`${start}first\n\n\nxsecond`); + await expectStoredBody(app, 'Lines.md', `${start}first\n\n\nxsecond`); + expect(await app.messages()).toEqual([]); + }); + + test('loses nothing when typing fast across the autosave delays', async ({ + app, + page, + }) => { + await app.newNote('Fast'); + const start = await app.doc(); + let expected = start; + // Type steadily for longer than the forced save (2 s), with short + // pauses around the 300 ms debounce so saves start mid-typing. + for (let round = 0; round < 12; round += 1) { + const chunk = `word${round} `; + await page.keyboard.type(chunk, { delay: 15 }); + expected += chunk; + if (round % 3 === 2) { + await page.keyboard.press('Enter'); + expected += '\n'; + } + await page.waitForTimeout(round % 2 === 0 ? 320 : 90); + expect(await app.doc()).toBe(expected); + expect(await app.caret()).toBe(expected.length); + } + await expectStoredBody(app, 'Fast.md', expected); + expect(await app.doc()).toBe(expected); + expect(await app.messages()).toEqual([]); + }); + + test('undo and redo work after saves', async ({ app, page }) => { + await app.newNote('Undo'); + const start = await app.doc(); + await page.keyboard.type('alpha '); + await expectStoredBody(app, 'Undo.md', `${start}alpha `); + await page.waitForTimeout(600); + await page.keyboard.type('beta'); + await expectStoredBody(app, 'Undo.md', `${start}alpha beta`); + await page.keyboard.press('ControlOrMeta+z'); + expect(await app.doc()).toBe(`${start}alpha `); + await expectStoredBody(app, 'Undo.md', `${start}alpha `); + await page.keyboard.press('ControlOrMeta+Shift+z'); + expect(await app.doc()).toBe(`${start}alpha beta`); + await expectStoredBody(app, 'Undo.md', `${start}alpha beta`); + expect(await app.messages()).toEqual([]); + }); + + test('reloading shows exactly what was typed', async ({ app, page }) => { + await app.newNote('Reload'); + await page.keyboard.type('one two '); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.type(' indented'); + await page.keyboard.press('Enter'); + await page.keyboard.type('last '); + const typed = await app.doc(); + expect(typed).toBe('one two \n\n indented\n last '); + await expectStoredBody(app, 'Reload.md', typed); + await page.reload(); + await app.treeItem('Reload').click(); + await expect(app.editor()).toBeVisible(); + expect(await app.doc()).toBe(typed); + expect(await app.messages()).toEqual([]); + }); + + test('an outside edit merges with local typing and says so once', async ({ + app, + page, + }) => { + await app.newNote('Outside'); + const start = await app.doc(); + await page.keyboard.type('local line'); + await expectStoredBody(app, 'Outside.md', `${start}local line`); + const file = (await app.stored('Outside.md'))!; + await app.writeOutside( + 'Outside.md', + file.replace('local line\n', 'local line\n\nfrom outside\n'), + ); + await expect.poll(() => app.doc()).toContain('from outside'); + // Keep typing: the text on screen and the file must keep both edits. + await app.caretToEnd(); + await page.keyboard.type(' more'); + await expectStoredBody( + app, + 'Outside.md', + `${start}local line\n\nfrom outside more`, + ); + expect(await app.doc()).toBe(`${start}local line\n\nfrom outside more`); + + // Typing before the outside edit is saved merges on the next save. + await page.keyboard.press('ControlOrMeta+Home'); + const before = (await app.stored('Outside.md'))!; + await page.keyboard.type('X'); + await app.writeOutside('Outside.md', `${before}tail\n`); + await expectStoredBody( + app, + 'Outside.md', + `X${start}local line\n\nfrom outside more\ntail`, + ); + await expect + .poll(() => app.doc()) + .toBe(`X${start}local line\n\nfrom outside more\ntail`); + expect(await app.messages()).toEqual([MERGED_MESSAGE]); + }); +}); diff --git a/apps/app/tests/e2e/fixtures.ts b/apps/app/tests/e2e/fixtures.ts new file mode 100644 index 0000000..8a5350a --- /dev/null +++ b/apps/app/tests/e2e/fixtures.ts @@ -0,0 +1,312 @@ +import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + test as base, + expect, + type Locator, + type Page, +} from '@playwright/test'; + +export { expect }; + +export const MERGED_MESSAGE = 'Merged changes from another app'; + +/** + * How a note stores the text typed into it: line breaks around the text are + * normalized and the file ends with one; everything else is kept as typed. + */ +export function noteFileBody(typed: string): string { + const body = typed.replace(/^\n+/, '').replace(/[\r\n]+$/, ''); + return body.trim() ? `${body}\n` : ''; +} + +/** The built workspace worker, so tests can open a second one. */ +const workerUrl = (() => { + const directory = fileURLToPath( + new URL('../../build/_app/immutable/workers/', import.meta.url), + ); + let name: string | undefined; + try { + name = readdirSync(directory).find( + (file) => + file.startsWith('browser-workspace.worker') && file.endsWith('.js'), + ); + } catch { + name = undefined; + } + return name ? `/_app/immutable/workers/${name}` : null; +})(); + +declare global { + interface Window { + __e2e?: { + messages: string[]; + outside?: (command: string, payload?: object) => Promise; + }; + } +} + +/** + * Records every time a watched message appears, so a test can prove a + * message that shows for two seconds never showed at all. + */ +function recordMessages(watched: string[]) { + const state = { messages: [] as string[] }; + window.__e2e = state; + const visible = new Set(); + const check = () => { + const text = document.body?.innerText ?? ''; + for (const message of watched) { + const shown = text.includes(message); + if (shown && !visible.has(message)) state.messages.push(message); + if (shown) visible.add(message); + else visible.delete(message); + } + }; + new MutationObserver(check).observe(document, { + subtree: true, + childList: true, + characterData: true, + }); +} + +export class App { + constructor(readonly page: Page) {} + + /** The note or Markdown text editor. */ + editor(name = 'Note text'): Locator { + return this.page.getByRole('textbox', { name, exact: true }); + } + + get fileName(): Locator { + return this.page.getByRole('textbox', { name: 'File name' }); + } + + get tree(): Locator { + return this.page.getByRole('tree', { name: 'Files' }); + } + + treeItem(name: string | RegExp): Locator { + return this.tree.getByRole('treeitem', { name, exact: true }); + } + + async createWorkspace(name = 'E2E') { + await this.page.goto('/'); + await this.page.getByLabel('New workspace').fill(name); + await this.page.getByRole('button', { name: 'Create' }).click(); + await expect( + this.page.getByRole('navigation', { name: 'Sections' }), + ).toBeVisible(); + } + + async openFiles() { + await this.page + .getByRole('navigation', { name: 'Sections' }) + .getByRole('link', { name: 'Files' }) + .click(); + await expect( + this.page.getByRole('button', { name: 'New note' }), + ).toBeVisible(); + } + + /** Create a note with New note, name it, and put the caret in its text. */ + async newNote(name: string) { + await this.openFiles(); + await this.page.getByRole('button', { name: 'New note' }).click(); + await expect(this.fileName).toBeFocused(); + await this.page.keyboard.press('ControlOrMeta+a'); + await this.page.keyboard.type(name); + await this.page.keyboard.press('Enter'); + await expect(this.editor()).toBeFocused(); + await expect(this.treeItem(name)).toBeVisible(); + } + + /** The text the editor holds, which may differ from what it renders. */ + async doc(name = 'Note text'): Promise { + return this.editor(name).evaluate((element) => { + const tile = (element as unknown as { cmTile?: EditorTile }).cmTile; + const view = tile?.root?.view; + if (!view) throw new Error('No editor view'); + return view.state.doc.toString(); + }); + } + + /** Caret offset in the editor's text. */ + async caret(name = 'Note text'): Promise { + return this.editor(name).evaluate((element) => { + const tile = (element as unknown as { cmTile?: EditorTile }).cmTile; + const view = tile?.root?.view; + if (!view) throw new Error('No editor view'); + return view.state.selection.main.head; + }); + } + + /** Put the caret at `offset` (or the end) with the keyboard. */ + async caretToEnd(name = 'Note text') { + await this.editor(name).focus(); + await this.page.keyboard.press('ControlOrMeta+End'); + } + + /** Messages from `recordMessages` seen so far. */ + async messages(): Promise { + return this.page.evaluate(() => [...(window.__e2e?.messages ?? [])]); + } + + async #outside(command: string, payload: object = {}): Promise { + if (!workerUrl) + throw new Error('Build the app first: the workspace worker is missing'); + return (await this.page.evaluate( + async ({ url, command, payload }) => { + const state = window.__e2e!; + if (!state.outside) { + const worker = new Worker(url, { type: 'module' }); + const pending = new Map< + string, + { resolve(value: unknown): void; reject(error: Error): void } + >(); + let next = 0; + await new Promise((resolve, reject) => { + worker.addEventListener('message', (event) => { + const data = event.data; + if (data?.type === 'ready') resolve(); + else if (data?.type === 'startup-error') + reject(new Error('The second worker did not start')); + else if (data?.type === 'response') { + const request = pending.get(data.id); + pending.delete(data.id); + if (data.ok) request?.resolve(data.value); + else request?.reject(new Error(JSON.stringify(data.error))); + } + }); + }); + const request = (command: string, payload: object = {}) => + new Promise((resolve, reject) => { + const id = `outside-${next++}`; + pending.set(id, { resolve, reject }); + worker.postMessage({ type: 'request', id, command, payload }); + }); + const id = localStorage.getItem('noura.browser.last-workspace'); + await request('workspace_open', { + input: { path: `browser://${id}` }, + }); + state.outside = request; + } + return state.outside(command, payload); + }, + { url: workerUrl, command, payload }, + )) as T; + } + + /** + * The bytes stored for `path`, read through a second workspace worker, + * as another tab would. Null when the file does not exist. + */ + async stored(path: string): Promise { + const file = await this.#outside<{ bytes: string } | null>( + 'storage_files_read', + { path }, + ); + if (!file) return null; + return Buffer.from(file.bytes, 'base64').toString('utf8'); + } + + /** + * The body of a stored note: the bytes after its noura header and its + * `# Title` line, exactly as stored. + */ + async storedBody(path: string): Promise { + const text = await this.stored(path); + if (text === null) return null; + const match = /^---\n[\s\S]*?\n---\n\n?# [^\n]*\n\n?/.exec(text); + if (!match) throw new Error(`${path} is not a note:\n${text}`); + return text.slice(match[0].length); + } + + /** Write `text` to `path` from outside this tab, like another app would. */ + async writeOutside(path: string, text: string | Uint8Array) { + const current = await this.#outside<{ revision: string } | null>( + 'storage_files_read', + { path }, + ); + const bytes = + typeof text === 'string' ? Buffer.from(text, 'utf8') : Buffer.from(text); + await this.#outside('storage_files_write', { + path, + bytes: bytes.toString('base64'), + expectedRevision: current?.revision ?? null, + }); + } + + /** + * Hold the workspace storage lock, so saves stay in flight until + * `release` runs, like a slow disk. + */ + async holdStorage(): Promise<() => Promise> { + await this.page.evaluate( + (name) => + new Promise((held) => { + void navigator.locks.request( + name, + { mode: 'exclusive' }, + () => + new Promise((release) => { + (window as unknown as { __release?: () => void }).__release = + release; + held(); + }), + ); + }), + await this.#storageLock(), + ); + return async () => { + await this.page.evaluate(() => + (window as unknown as { __release?: () => void }).__release?.(), + ); + }; + } + + /** True while a request is waiting for the storage lock. */ + async storageWaiting(): Promise { + return this.page.evaluate( + async (name) => { + const state = await navigator.locks.query(); + return (state.pending ?? []).some((lock) => lock.name === name); + }, + await this.#storageLock(), + ); + } + + /** The Web Lock the open workspace's storage operations take. */ + #storageLock(): Promise { + return this.page.evaluate( + () => + `noura:browser-workspace:${localStorage.getItem('noura.browser.last-workspace')}`, + ); + } + + async storedPaths(): Promise { + const entries = + await this.#outside>('files_list'); + return entries.map((entry) => entry.relativePath).sort(); + } +} + +type EditorTile = { + root?: { + view?: { + state: { + doc: { toString(): string }; + selection: { main: { head: number } }; + }; + }; + }; +}; + +export const test = base.extend<{ app: App }>({ + app: async ({ page }, use) => { + await page.addInitScript(recordMessages, [MERGED_MESSAGE]); + const app = new App(page); + await app.createWorkspace(); + await use(app); + }, +}); diff --git a/bun.lock b/bun.lock index d236d4d..d2e3710 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "devDependencies": { "@fontsource-variable/public-sans": "^5.3.0", "@internationalized/date": "^3.12.0", + "@playwright/test": "1.62.1", "@sveltejs/adapter-static": "latest", "@sveltejs/kit": "latest", "@sveltejs/vite-plugin-svelte": "latest", @@ -788,6 +789,8 @@ "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@poppinss/macroable": ["@poppinss/macroable@1.1.2", "", {}, "sha512-FAVBRzzWhYP5mA3lCwLH1A0fKBqq5anyjGet90Z81aRK5c/+LTGUE1zJhZrErjaenBSOOI9BVUs3WVmotneFQA=="], @@ -1398,6 +1401,10 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="], @@ -1728,6 +1735,8 @@ "parse5/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "spdx-compare/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], "spdx-correct/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], From 9143894bb39f7aa5eceb00d26307ac00c79b7013 Mon Sep 17 00:00:00 2001 From: lobbystack <236289573+lobbystack@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:28:42 -0400 Subject: [PATCH 03/26] Keep Cmd-K in the editor from also opening the command palette Cmd-K in a note inserts a link, but the window handlers for the command palette opened it on the same key press. The palette took focus, so the link target typed next went into the palette instead of the note. Both handlers now skip key presses that the focused control already handled. The new editing tests also cover typing while a save waits for storage, list indentation with Tab, input method composition, formatting shortcuts, checklists and find and replace. --- .../src/lib/components/command-palette.svelte | 2 + .../src/lib/components/workspace-shell.svelte | 3 + apps/app/tests/e2e/editor-editing.e2e.ts | 181 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 apps/app/tests/e2e/editor-editing.e2e.ts diff --git a/apps/app/src/lib/components/command-palette.svelte b/apps/app/src/lib/components/command-palette.svelte index 85dec90..f89370a 100644 --- a/apps/app/src/lib/components/command-palette.svelte +++ b/apps/app/src/lib/components/command-palette.svelte @@ -160,6 +160,8 @@ } function handleKeydown(event: KeyboardEvent) { + // The editor takes Cmd-K for links; a handled key is not a shortcut. + if (event.defaultPrevented) return; if ( (event.metaKey || event.ctrlKey) && !event.shiftKey && diff --git a/apps/app/src/lib/components/workspace-shell.svelte b/apps/app/src/lib/components/workspace-shell.svelte index 5ed733c..eeccaaa 100644 --- a/apps/app/src/lib/components/workspace-shell.svelte +++ b/apps/app/src/lib/components/workspace-shell.svelte @@ -82,7 +82,10 @@ // Until a lazy component is mounted it cannot hear its own shortcut. // Once it is, it owns the key and this handler stays out of the way. + // A key the focused control already handled (Cmd-K adds a link in the + // editor) is not a shortcut. function handleKeydown(event: KeyboardEvent) { + if (event.defaultPrevented) return; if (!(event.metaKey || event.ctrlKey) || event.repeat) return; const key = event.key.toLowerCase(); if (key === 'k' && !Palette) { diff --git a/apps/app/tests/e2e/editor-editing.e2e.ts b/apps/app/tests/e2e/editor-editing.e2e.ts new file mode 100644 index 0000000..eca11d7 --- /dev/null +++ b/apps/app/tests/e2e/editor-editing.e2e.ts @@ -0,0 +1,181 @@ +import { expect, noteFileBody, test, type App } from './fixtures'; + +async function expectStoredBody(app: App, path: string, typed: string) { + await expect + .poll(() => app.storedBody(path), { timeout: 10_000 }) + .toBe(noteFileBody(typed)); +} + +test.describe('editing a note', () => { + test('keeps typing while a save waits for the disk', async ({ + app, + page, + }) => { + await app.newNote('Slow'); + await page.keyboard.type('before '); + await expectStoredBody(app, 'Slow.md', 'before '); + const release = await app.holdStorage(); + await page.keyboard.type('during'); + // The debounced save starts and waits for the disk. + await expect.poll(() => app.storageWaiting()).toBe(true); + await page.keyboard.type(' more '); + await page.keyboard.press('Enter'); + await page.keyboard.type('next'); + expect(await app.doc()).toBe('before during more \nnext'); + await release(); + await expectStoredBody(app, 'Slow.md', 'before during more \nnext'); + expect(await app.doc()).toBe('before during more \nnext'); + expect(await app.caret()).toBe('before during more \nnext'.length); + expect(await app.messages()).toEqual([]); + }); + + test('Tab and Shift-Tab nest and lift list items', async ({ app, page }) => { + await app.newNote('List'); + await page.keyboard.type('- one'); + await page.keyboard.press('Enter'); + await page.keyboard.type('two'); + await page.keyboard.press('Tab'); + expect(await app.doc()).toBe('- one\n - two'); + await page.keyboard.press('Enter'); + await page.keyboard.type('three'); + await page.keyboard.press('Shift+Tab'); + expect(await app.doc()).toBe('- one\n - two\n- three'); + await expectStoredBody(app, 'List.md', '- one\n - two\n- three'); + // Tab outside a list keeps focus in the editor. + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await expect(app.editor()).toBeFocused(); + expect(await app.messages()).toEqual([]); + }); + + test('double spaces and a Markdown line break survive', async ({ + app, + page, + }) => { + await app.newNote('Break'); + await page.keyboard.type('line '); + await page.keyboard.press('Enter'); + await page.keyboard.type('next word'); + await expectStoredBody(app, 'Break.md', 'line \nnext word'); + expect(await app.doc()).toBe('line \nnext word'); + }); + + test('text composed with an input method is kept', async ({ app, page }) => { + await app.newNote('Compose'); + await page.keyboard.type('start '); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.imeSetComposition', { + text: 'に', + selectionStart: 1, + selectionEnd: 1, + }); + await cdp.send('Input.imeSetComposition', { + text: 'にほ', + selectionStart: 2, + selectionEnd: 2, + }); + // Wait past the debounce while the composition is open. + await page.waitForTimeout(700); + await cdp.send('Input.insertText', { text: '日本' }); + await page.keyboard.type(' end'); + await expectStoredBody(app, 'Compose.md', 'start 日本 end'); + expect(await app.doc()).toBe('start 日本 end'); + expect(await app.messages()).toEqual([]); + }); + + test('formatting shortcuts wrap the selection', async ({ app, page }) => { + await app.newNote('Format'); + const selectWord = async (word: string) => { + await page.keyboard.type(word); + for (let index = 0; index < word.length; index += 1) + await page.keyboard.press('Shift+ArrowLeft'); + }; + await selectWord('bold'); + await page.keyboard.press('ControlOrMeta+b'); + await page.keyboard.press('End'); + await page.keyboard.type(' '); + await selectWord('it'); + await page.keyboard.press('ControlOrMeta+i'); + await page.keyboard.press('End'); + await page.keyboard.type(' '); + await selectWord('under'); + await page.keyboard.press('ControlOrMeta+u'); + await page.keyboard.press('End'); + await page.keyboard.type(' '); + await selectWord('gone'); + await page.keyboard.press('ControlOrMeta+Shift+x'); + await page.keyboard.press('End'); + await page.keyboard.type(' '); + await selectWord('site'); + // Cmd-K adds a link here; it must not also open the command palette. + await page.keyboard.press('ControlOrMeta+k'); + await page.keyboard.type('https://example.com'); + await expect(app.editor()).toBeFocused(); + await expect( + page.getByRole('dialog', { name: 'Search and commands' }), + ).toBeHidden(); + const expected = + '**bold** *it* under ~~gone~~ [site](https://example.com)'; + expect(await app.doc()).toBe(expected); + await expectStoredBody(app, 'Format.md', expected); + // The same shortcut removes the formatting again. + await app.editor().getByText('bold', { exact: true }).dblclick(); + await page.keyboard.press('ControlOrMeta+b'); + expect(await app.doc()).toBe(expected.replace('**bold**', 'bold')); + }); + + test('Cmd-K adds a link after the command palette has loaded', async ({ + app, + page, + }) => { + const palette = page.getByRole('dialog', { name: 'Search and commands' }); + await page.getByRole('button', { name: 'Search' }).click(); + await expect(palette).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(palette).toBeHidden(); + await app.newNote('Link'); + await page.keyboard.press('ControlOrMeta+k'); + await page.keyboard.type('label'); + expect(await app.doc()).toBe('[label]()'); + await expect(palette).toBeHidden(); + // Outside the editor, Cmd-K still opens the palette. + await page.getByRole('button', { name: 'New folder' }).focus(); + await page.keyboard.press('ControlOrMeta+k'); + await expect(palette).toBeVisible(); + }); + + test('toolbar buttons and checklists', async ({ app, page }) => { + await app.newNote('Toolbar'); + await page.keyboard.type('buy milk'); + await page.getByRole('button', { name: 'Checklist' }).click(); + expect(await app.doc()).toBe('- [ ] buy milk'); + await app.caretToEnd(); + await page.keyboard.press('Enter'); + await page.keyboard.type('eggs'); + expect(await app.doc()).toBe('- [ ] buy milk\n- [ ] eggs'); + await page.keyboard.press('ControlOrMeta+Enter'); + expect(await app.doc()).toBe('- [ ] buy milk\n- [x] eggs'); + // Clicking the rendered checkbox checks the first item. + await app.editor().locator('.cm-md-checkbox').first().click(); + expect(await app.doc()).toBe('- [x] buy milk\n- [x] eggs'); + await expectStoredBody(app, 'Toolbar.md', '- [x] buy milk\n- [x] eggs'); + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + expect(await app.doc()).toBe('- [ ] buy milk\n- [x] eggs'); + await expectStoredBody(app, 'Toolbar.md', '- [ ] buy milk\n- [x] eggs'); + expect(await app.messages()).toEqual([]); + }); + + test('find and replace', async ({ app, page }) => { + await app.newNote('Find'); + await page.keyboard.type('cat and cat and dog'); + await page.keyboard.press('ControlOrMeta+f'); + const find = page.getByRole('textbox', { name: /find/i }).first(); + await expect(find).toBeFocused(); + await page.keyboard.type('cat'); + const replace = page.getByRole('textbox', { name: /replace/i }).first(); + await replace.fill('bird'); + await page.getByRole('button', { name: /replace all/i }).click(); + expect(await app.doc()).toBe('bird and bird and dog'); + await expectStoredBody(app, 'Find.md', 'bird and bird and dog'); + }); +}); From 1d302744c66bfceff12744caaa175d93ec391845 Mon Sep 17 00:00:00 2001 From: lobbystack <236289573+lobbystack@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:31:38 -0400 Subject: [PATCH 04/26] Report raw merges, track body merges, and share body fixtures Raw Markdown saves returned saved even after folding in another program's edit, so the editor never showed that edit and could save over it. Native and browser saves now return merged when the saved text differs from what the editor sent. Note saves count a merge only when the body changed, so a title or property edit elsewhere no longer rewrites text just typed. Shared conformance fixtures now cover how note bodies round-trip, and both Rust and the WebAssembly build run them. --- apps/app/src/lib/editor/note-save.ts | 8 +++- crates/local-core/src/engine/raw_markdown.rs | 29 +++++++++----- crates/local-core/tests/domain.rs | 36 +++++++++++++++++- crates/workspace-format/src/lib.rs | 38 +++++++++++++++++++ .../fixtures/conformance-v1.json | 32 ++++++++++++++++ packages/browser-workspace/src/worker.ts | 11 +++++- .../browser-workspace/src/worker.wasm.test.ts | 3 +- .../scripts/test-wasm-runtime.ts | 26 +++++++++++++ 8 files changed, 169 insertions(+), 14 deletions(-) diff --git a/apps/app/src/lib/editor/note-save.ts b/apps/app/src/lib/editor/note-save.ts index bc8fffc..ba1d233 100644 --- a/apps/app/src/lib/editor/note-save.ts +++ b/apps/app/src/lib/editor/note-save.ts @@ -41,7 +41,13 @@ export async function saveNoteWithReconciliation( current: reconciliation.current as Note, }; } - if (reconciliation.status === 'merged') merged = true; + // A changed revision can be a title or property edit; only an outside + // change to the body means the text on screen must follow the file. + if ( + reconciliation.status === 'merged' && + reconciliation.body !== currentDraft.body + ) + merged = true; const canonical = reconciliation.current as Note; const title = reconcileNoteTitle( currentBaseTitle, diff --git a/crates/local-core/src/engine/raw_markdown.rs b/crates/local-core/src/engine/raw_markdown.rs index 0d1b063..b8cf7ca 100644 --- a/crates/local-core/src/engine/raw_markdown.rs +++ b/crates/local-core/src/engine/raw_markdown.rs @@ -309,6 +309,9 @@ impl WorkspaceEngine { } } }; + // The editor only replaces its text when another program's edit was + // folded in; a changed revision alone doesn't mean the body differs. + let external_merged = merged_body != input.local_body; let next_bytes = compose_raw_bytes(&merged_body, &layout); let next_relative = PathBuf::from(&relative); atomic_write_checked( @@ -336,15 +339,23 @@ impl WorkspaceEngine { ParsedMarkdown::Managed(object) => Some(object), _ => None, }; - Ok(RawSaveResult::Saved { - current: RawMarkdownRead { - relative_path: relative, - body: merged_body, - revision: markdown::revision(&next_bytes), - uses_crlf, - has_bom, - }, - managed_object, + let current = RawMarkdownRead { + relative_path: relative, + body: merged_body, + revision: markdown::revision(&next_bytes), + uses_crlf, + has_bom, + }; + Ok(if external_merged { + RawSaveResult::Merged { + current, + managed_object, + } + } else { + RawSaveResult::Saved { + current, + managed_object, + } }) } diff --git a/crates/local-core/tests/domain.rs b/crates/local-core/tests/domain.rs index 52e7401..24c0532 100644 --- a/crates/local-core/tests/domain.rs +++ b/crates/local-core/tests/domain.rs @@ -1449,7 +1449,7 @@ fn raw_markdown_save_keeps_mixed_line_endings() { local_body: current.body.replace("# Mixed\n", "# Mixed today\n"), }) .unwrap(); - assert!(matches!(saved, RawSaveResult::Saved { .. }), "{saved:?}"); + assert!(matches!(saved, RawSaveResult::Merged { .. }), "{saved:?}"); assert_eq!( std::fs::read(&path).unwrap(), b"# Mixed today\r\n\r\nfrom windows\r\nfrom unix\nexternal\nlast\r\nadded\r\n" @@ -2356,3 +2356,37 @@ fn moving_a_folder_allows_a_case_only_rename() { "path_exists" ); } + +#[test] +fn raw_saves_report_a_merge_only_when_another_edit_changed_the_text() { + let (workspace, _app_data, engine) = engine(); + std::fs::write(workspace.path().join("doc.md"), "one\n\ntwo\n").unwrap(); + engine.reconcile().unwrap(); + let base = engine.read_raw_markdown("doc.md").unwrap(); + + // Another program edits the other end of the file before the save. + std::fs::write(workspace.path().join("doc.md"), "one\n\ntwo edited\n").unwrap(); + let merged = engine + .save_raw_markdown(RawSaveInput { + relative_path: "doc.md".into(), + base_revision: base.revision.clone(), + base_body: base.body.clone(), + local_body: "one local\n\ntwo\n".into(), + }) + .unwrap(); + let RawSaveResult::Merged { current, .. } = merged else { + panic!("a merge with another edit must say so: {merged:?}") + }; + assert_eq!(current.body, "one local\n\ntwo edited\n"); + + // A plain save of the current revision is not a merge. + let saved = engine + .save_raw_markdown(RawSaveInput { + relative_path: "doc.md".into(), + base_revision: current.revision.clone(), + base_body: current.body.clone(), + local_body: "one local \n\ntwo edited\n".into(), + }) + .unwrap(); + assert!(matches!(saved, RawSaveResult::Saved { .. }), "{saved:?}"); +} diff --git a/crates/workspace-format/src/lib.rs b/crates/workspace-format/src/lib.rs index e7324c4..685c76f 100644 --- a/crates/workspace-format/src/lib.rs +++ b/crates/workspace-format/src/lib.rs @@ -786,6 +786,15 @@ mod tests { object_id: Vec, task_properties: Vec, project_properties: Vec, + object_body: Vec, + } + + #[derive(Deserialize)] + struct ObjectBodyFixture { + name: String, + body: String, + expected_body: String, + file_ends_with: String, } #[derive(Deserialize)] @@ -838,6 +847,35 @@ mod tests { ); } + #[test] + fn object_bodies_round_trip_as_the_shared_fixtures_say() { + let fixtures: Fixtures = serde_json::from_str(include_str!( + "../../../docs/workspace-format/fixtures/conformance-v1.json" + )) + .unwrap(); + for fixture in fixtures.object_body { + let object = WorkspaceObject { + id: "note_01j00000000000000000000000".into(), + object_type: "note".into(), + title: "Example".into(), + body: fixture.body, + relative_path: "notes/example.md".into(), + revision: String::new(), + created: Some("2026-08-27T12:00:00Z".into()), + updated: Some("2026-08-27T12:00:00Z".into()), + properties: BTreeMap::new(), + }; + let bytes = serialize_object(&object).unwrap(); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(text.ends_with(&fixture.file_ends_with), "{}", fixture.name); + let ParsedMarkdown::Managed(parsed) = parse_markdown(&object.relative_path, &bytes) + else { + panic!("{}: managed object expected", fixture.name) + }; + assert_eq!(parsed.body, fixture.expected_body, "{}", fixture.name); + } + } + #[test] fn manifest_parser_sorts_and_deduplicates_plugins() { let manifest = parse_workspace_manifest( diff --git a/docs/workspace-format/fixtures/conformance-v1.json b/docs/workspace-format/fixtures/conformance-v1.json index e7e0657..465405d 100644 --- a/docs/workspace-format/fixtures/conformance-v1.json +++ b/docs/workspace-format/fixtures/conformance-v1.json @@ -583,5 +583,37 @@ "updated": "2026-09-02T12:01:00Z" } } + ], + "object_body": [ + { + "name": "trailing space typed at the end stays", + "body": "Typing ", + "expected_body": "Typing ", + "file_ends_with": "Typing \n" + }, + { + "name": "two trailing spaces keep a Markdown line break", + "body": "Line \nnext", + "expected_body": "Line \nnext", + "file_ends_with": "Line \nnext\n" + }, + { + "name": "trailing line breaks normalize to one final newline", + "body": "Text\n\n", + "expected_body": "Text", + "file_ends_with": "Text\n" + }, + { + "name": "CRLF line breaks at the end normalize too", + "body": "Text\r\n", + "expected_body": "Text", + "file_ends_with": "Text\n" + }, + { + "name": "a body of only whitespace is written as no body", + "body": " \n", + "expected_body": "", + "file_ends_with": "# Example\n" + } ] } diff --git a/packages/browser-workspace/src/worker.ts b/packages/browser-workspace/src/worker.ts index 949ba2f..caeff09 100644 --- a/packages/browser-workspace/src/worker.ts +++ b/packages/browser-workspace/src/worker.ts @@ -1426,7 +1426,14 @@ export class BrowserWorkspaceServer { ); body = merged; } - return this.#writeRaw(path, stored, body, operation, 'saved'); + // Report a merge only when another edit changed the text saved. + return this.#writeRaw( + path, + stored, + body, + operation, + body === localBody ? 'saved' : 'merged', + ); } async #writeRaw( @@ -1434,7 +1441,7 @@ export class BrowserWorkspaceServer { stored: { bytes: Uint8Array; revision: string }, body: string, operation: string, - status: 'saved' | null, + status: 'saved' | 'merged' | null, ) { const next = this.#format.composeRawText(stored.bytes, body); const written = await this.#requireCurrent(operation).storage.write({ diff --git a/packages/browser-workspace/src/worker.wasm.test.ts b/packages/browser-workspace/src/worker.wasm.test.ts index 444c470..a31bfd1 100644 --- a/packages/browser-workspace/src/worker.wasm.test.ts +++ b/packages/browser-workspace/src/worker.wasm.test.ts @@ -189,7 +189,8 @@ describe.skipIf(format === null)('browser raw Markdown', () => { localBody: '# Loose\n\nfirst, edited\nmiddle\nsecond\n', }, })) as { status: string; current: RawMarkdownRead }; - expect(saved.status).toBe('saved'); + // The other tab's edit was folded in, so the save reports a merge. + expect(saved.status).toBe('merged'); expect(decoder.decode(files.files.get('Loose.md'))).toBe( '# Loose\r\n\r\nfirst, edited\r\nmiddle\r\nsecond, changed\r\n', ); diff --git a/packages/workspace-format-wasm/scripts/test-wasm-runtime.ts b/packages/workspace-format-wasm/scripts/test-wasm-runtime.ts index 6e38889..fdb0d2c 100644 --- a/packages/workspace-format-wasm/scripts/test-wasm-runtime.ts +++ b/packages/workspace-format-wasm/scripts/test-wasm-runtime.ts @@ -14,6 +14,12 @@ type Fixtures = { valid: boolean; value: Record; }>; + object_body: Array<{ + name: string; + body: string; + expected_body: string; + file_ends_with: string; + }>; }; const wasmModule = new URL('../wasm/workspace_format_wasm.js', import.meta.url); @@ -126,6 +132,26 @@ const parsed = format.parseMarkdown(object.relativePath, bytes); assert.equal(parsed.kind, 'managed'); assert.deepEqual(format.serializeObject(parsed), bytes); +// Browser saves go through the same serializer, so bodies must round-trip +// exactly as the shared fixtures say. +const decoder = new TextDecoder(); +for (const fixture of fixtures.object_body) { + const fixtureBytes = format.serializeObject({ + ...object, + title: 'Example', + properties: {}, + body: fixture.body, + }); + assert.ok( + decoder.decode(fixtureBytes).endsWith(fixture.file_ends_with), + fixture.name, + ); + const fixtureParsed = format.parseMarkdown(object.relativePath, fixtureBytes); + assert.equal(fixtureParsed.kind, 'managed', fixture.name); + if (fixtureParsed.kind === 'managed') + assert.equal(fixtureParsed.body, fixture.expected_body, fixture.name); +} + const created = format.createNote({ title: 'Created in Rust', body: 'The worker persists these canonical bytes.', From 2ad61386d671f26f5499a08a41a2c0d3b21feb9e Mon Sep 17 00:00:00 2001 From: lobbystack <236289573+lobbystack@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:32:20 -0400 Subject: [PATCH 05/26] Show outside edits merged while saving a plain Markdown file The Markdown file editor waited for a "merged" save status that raw saves never return. When another app changed the file while you typed, the save merged both edits on disk, but the editor kept showing only your text, and the next save wrote that text back over the other app's edit. The editor now treats a save whose text differs from what it sent as a merge, updates the text on screen and says so. New tests edit plain Markdown files with frontmatter, tables, code blocks, callouts, wikilinks, checkboxes, CRLF line endings and a byte order mark, and check that the rest of the file keeps its bytes. --- .../lib/components/raw-markdown-editor.svelte | 4 +- apps/app/tests/e2e/fixtures.ts | 20 ++- apps/app/tests/e2e/markdown-files.e2e.ts | 129 ++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 apps/app/tests/e2e/markdown-files.e2e.ts diff --git a/apps/app/src/lib/components/raw-markdown-editor.svelte b/apps/app/src/lib/components/raw-markdown-editor.svelte index 4a1893e..10504b4 100644 --- a/apps/app/src/lib/components/raw-markdown-editor.svelte +++ b/apps/app/src/lib/components/raw-markdown-editor.svelte @@ -112,7 +112,9 @@ return { status: 'saved', base: baseOf(result.current), - merged: result.status === 'merged', + // A raw save reports no separate merge status. It writes the text + // as sent unless it folded in an edit made outside the app. + merged: result.current.body !== body, canonical: { read: result.current, managedObject: result.managedObject, diff --git a/apps/app/tests/e2e/fixtures.ts b/apps/app/tests/e2e/fixtures.ts index 8a5350a..35c6f87 100644 --- a/apps/app/tests/e2e/fixtures.ts +++ b/apps/app/tests/e2e/fixtures.ts @@ -141,12 +141,29 @@ export class App { }); } - /** Put the caret at `offset` (or the end) with the keyboard. */ + /** Put the caret at the end of the text with the keyboard. */ async caretToEnd(name = 'Note text') { await this.editor(name).focus(); await this.page.keyboard.press('ControlOrMeta+End'); } + /** + * Put the caret right after the first `anchor` in the text. Rendered + * tables and code blocks make arrow keys and clicks land unpredictably, + * so this sets the selection directly; typing still uses the keyboard. + */ + async caretAfter(anchor: string, name = 'Note text') { + await this.editor(name).focus(); + await this.editor(name).evaluate((element, anchor) => { + const tile = (element as unknown as { cmTile?: EditorTile }).cmTile; + const view = tile?.root?.view; + if (!view) throw new Error('No editor view'); + const offset = view.state.doc.toString().indexOf(anchor); + if (offset < 0) throw new Error(`No “${anchor}” in the editor`); + view.dispatch({ selection: { anchor: offset + anchor.length } }); + }, anchor); + } + /** Messages from `recordMessages` seen so far. */ async messages(): Promise { return this.page.evaluate(() => [...(window.__e2e?.messages ?? [])]); @@ -298,6 +315,7 @@ type EditorTile = { doc: { toString(): string }; selection: { main: { head: number } }; }; + dispatch(spec: { selection: { anchor: number } }): void; }; }; }; diff --git a/apps/app/tests/e2e/markdown-files.e2e.ts b/apps/app/tests/e2e/markdown-files.e2e.ts new file mode 100644 index 0000000..3ef9ad9 --- /dev/null +++ b/apps/app/tests/e2e/markdown-files.e2e.ts @@ -0,0 +1,129 @@ +import { expect, MERGED_MESSAGE, test, type App } from './fixtures'; + +const RICH = [ + '---', + 'title: Plain file', + 'tags: [one, two]', + '---', + '', + '# Plain file', + '', + 'Intro with trailing spaces ', + '', + '| Name | Value |', + '| ---- | ----: |', + '| a | 1 |', + '', + '```ts', + 'const a = 1;', + '```', + '', + '> [!note] Callout title', + '> Callout body', + '', + '- [ ] open task', + '- [x] done task', + '', + 'See [[Other note]] and [[Other note|alias]].', + '', + 'Edit here', + '', +].join('\n'); + +/** Open a plain Markdown file another app put in the workspace. */ +async function openPlainFile( + app: App, + path: string, + text: string | Uint8Array, +) { + await app.openFiles(); + await app.writeOutside(path, text); + const name = path.replace(/\.md$/, ''); + await app.treeItem(name).click(); + await expect(app.editor('File text')).toBeVisible(); +} + +/** Type `insert` right after `anchor`. */ +async function typeAfter(app: App, anchor: string, insert: string) { + await app.caretAfter(anchor, 'File text'); + await app.page.keyboard.type(insert); +} + +test.describe('plain Markdown files', () => { + test('editing keeps every other byte of the file', async ({ app, page }) => { + await openPlainFile(app, 'Plain.md', RICH); + await typeAfter(app, 'Edit here', ' and there '); + const expected = RICH.replace('Edit here', 'Edit here and there '); + await expect.poll(() => app.stored('Plain.md')).toBe(expected); + await page.reload(); + await app.treeItem('Plain').click(); + await expect(app.editor('File text')).toBeVisible(); + expect(await app.doc('File text')).toBe(expected); + expect(await app.stored('Plain.md')).toBe(expected); + expect(await app.messages()).toEqual([]); + }); + + test('CRLF line endings and a byte order mark survive an edit', async ({ + app, + }) => { + const crlf = RICH.replaceAll('\n', '\r\n'); + const bytes = new Uint8Array([ + 0xef, + 0xbb, + 0xbf, + ...new TextEncoder().encode(crlf), + ]); + await openPlainFile(app, 'Windows.md', bytes); + expect(await app.doc('File text')).toBe(RICH); + await typeAfter(app, 'Edit here', '!'); + const expected = `${crlf.replace('Edit here', 'Edit here!')}`; + await expect.poll(() => app.stored('Windows.md')).toBe(expected); + expect(await app.messages()).toEqual([]); + }); + + test('typing at the end of a file without a final newline', async ({ + app, + page, + }) => { + await openPlainFile(app, 'Bare.md', 'no newline'); + await app.caretToEnd('File text'); + await page.keyboard.type(' '); + await expect.poll(() => app.stored('Bare.md')).toBe('no newline '); + await page.keyboard.press('Enter'); + await expect.poll(() => app.stored('Bare.md')).toBe('no newline \n'); + await page.keyboard.press('Enter'); + await page.keyboard.type('x'); + await expect.poll(() => app.stored('Bare.md')).toBe('no newline \n\nx'); + expect(await app.doc('File text')).toBe('no newline \n\nx'); + expect(await app.messages()).toEqual([]); + }); + + test('an outside edit made during typing is merged, shown, and kept', async ({ + app, + page, + }) => { + await openPlainFile(app, 'Shared.md', 'first\n\nsecond\n\nthird\n'); + await typeAfter(app, 'first', ' local'); + await expect + .poll(() => app.stored('Shared.md')) + .toBe('first local\n\nsecond\n\nthird\n'); + // The outside edit lands before the debounced save of ' more' runs. + await page.keyboard.type(' more'); + await app.writeOutside( + 'Shared.md', + 'first local\n\nsecond\n\nthird outside\n', + ); + await expect + .poll(() => app.stored('Shared.md')) + .toBe('first local more\n\nsecond\n\nthird outside\n'); + await expect + .poll(() => app.doc('File text')) + .toBe('first local more\n\nsecond\n\nthird outside\n'); + // A later save keeps the outside edit. + await page.keyboard.type('!'); + await expect + .poll(() => app.stored('Shared.md')) + .toBe('first local more!\n\nsecond\n\nthird outside\n'); + expect(await app.messages()).toEqual([MERGED_MESSAGE]); + }); +}); From 27b50515ac880cc06b1a7498b5445a4b59883eca Mon Sep 17 00:00:00 2001 From: lobbystack <236289573+lobbystack@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:35:21 -0400 Subject: [PATCH 06/26] Rename the open file with F2 even when the caret is in its text Clicking a file in the tree opens it and moves focus into its text, so the tree's F2 no longer applied. Pressing F2 did nothing, and a user who then pressed Cmd-A and typed a name replaced the note's content. F2 now selects the name in the document header from anywhere outside other text fields and dialogs. Settings lists the shortcut. New tests cover creating notes and folders at the root and in folders, inline and case-only renames, drag and drop, duplicate, keyboard navigation and expanded folders after a reload. --- apps/app/src/lib/app-shortcuts.ts | 1 + .../src/lib/components/document-header.svelte | 39 +++ apps/app/tests/e2e/file-tree.e2e.ts | 233 ++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 apps/app/tests/e2e/file-tree.e2e.ts diff --git a/apps/app/src/lib/app-shortcuts.ts b/apps/app/src/lib/app-shortcuts.ts index ff7d632..ed7f328 100644 --- a/apps/app/src/lib/app-shortcuts.ts +++ b/apps/app/src/lib/app-shortcuts.ts @@ -62,6 +62,7 @@ export const APP_SHORTCUTS: ReadonlyArray<{ { label: 'Open a file by name', keys: ['Mod', 'O'] }, { label: 'Search file contents', keys: ['Mod', 'Shift', 'F'] }, { label: 'Search and run commands', keys: ['Mod', 'K'] }, + { label: 'Rename the open file', keys: ['F2'] }, { label: 'Close tab', keys: ['Mod', 'W'] }, { label: 'Next tab', keys: ['Ctrl', 'Tab'] }, { label: 'Previous tab', keys: ['Ctrl', 'Shift', 'Tab'] }, diff --git a/apps/app/src/lib/components/document-header.svelte b/apps/app/src/lib/components/document-header.svelte index d9df0fa..2882fa4 100644 --- a/apps/app/src/lib/components/document-header.svelte +++ b/apps/app/src/lib/components/document-header.svelte @@ -62,13 +62,52 @@ } } + let nameField: HTMLInputElement | null = null; + function focusName(input: HTMLInputElement) { + nameField = input; if (!autofocus) return; input.focus(); input.select(); } + + /** + * F2 renames the open document from anywhere, including its text: a + * click in the file tree opens the file and puts the caret in the text, + * where the tree's own F2 no longer applies. Other text fields and + * dialogs keep the key. + */ + function renameShortcut(event: KeyboardEvent) { + if ( + event.key !== 'F2' || + event.defaultPrevented || + event.metaKey || + event.ctrlKey || + event.altKey || + event.shiftKey + ) + return; + const field = nameField; + if (!field || field.disabled) return; + const target = event.target instanceof HTMLElement ? event.target : null; + const inText = target?.closest('.cm-editor') !== null; + if ( + target && + !inText && + (target.closest('[role="dialog"], [role="alertdialog"]') || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target.isContentEditable) + ) + return; + event.preventDefault(); + field.focus(); + field.select(); + } + +
{#if crumbs.length > 0}