diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c827c049 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +**/node_modules +.turbo +**/.turbo +dist +apps/desktop/out +apps/web/dist +apps/share-viewer/dist +apps/server/web/dist +apps/server/bin diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e90eaf1c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Contract fixtures are hashed byte for byte by the TypeScript, Go server, and +# TUI consumers, so they must check out with LF on every platform, including +# Windows runners whose Git defaults to CRLF conversion. +packages/bridge-contract/fixtures/** text eol=lf +apps/server/internal/vault/testdata/** text eol=lf +apps/server/internal/httpserver/testdata/** text eol=lf diff --git a/.github/workflows/boundary-artifact-release.yml b/.github/workflows/boundary-artifact-release.yml new file mode 100644 index 00000000..e2bb891d --- /dev/null +++ b/.github/workflows/boundary-artifact-release.yml @@ -0,0 +1,70 @@ +name: Prepare boundary artifact release +on: + workflow_dispatch: + inputs: + artifact: + type: choice + options: [core, web, viewer] + required: true + source_commit: + description: Reviewed full source commit SHA + type: string + required: true +permissions: + contents: read +concurrency: + group: boundary-release-${{ inputs.artifact }} + cancel-in-progress: false +jobs: + build: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.prepare.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_commit }} + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - uses: actions/setup-go@v6 + with: + go-version: stable + - run: npm ci + - run: npm run typecheck + - run: npm run test:run + - run: npm run test:web-artifact + - if: inputs.artifact == 'core' + run: npm run test:app-core-package && npm run test:app-core-browser + - if: inputs.artifact == 'core' + env: + ZEN_CORE_VITE_VERSION: 8.2.2 + run: npm run test:app-core-package && npm run test:app-core-browser + - id: prepare + env: + ARTIFACT: ${{ inputs.artifact }} + APPROVED_SOURCE: ${{ inputs.source_commit }} + run: node tooling/scripts/prepare-boundary-release.mjs "$ARTIFACT" + - uses: actions/upload-artifact@v4 + with: + name: boundary-release + path: ${{ steps.prepare.outputs.directory }} + if-no-files-found: error + draft: + needs: build + runs-on: ubuntu-latest + environment: boundary-artifacts + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: boundary-release + path: release + - env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.build.outputs.tag }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: gh release create "$TAG" release/* --target "$SOURCE_COMMIT" --title "$TAG" --draft --prerelease --notes "Immutable boundary artifacts. Validate consumer pins before publishing this draft." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc614a7a..98f43b4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,21 @@ concurrency: cancel-in-progress: true jobs: + server: + name: Go server without frontend dependencies + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/server + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: stable + cache-dependency-path: apps/server/go.sum + - run: go vet ./... + - run: go test ./... + production-dependency-audit: name: Production dependency audit runs-on: ubuntu-latest @@ -55,17 +70,53 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - # Pin a modern Go instead of go.mod's `go 1.22`: the 1.22 macOS - # linker omits the LC_UUID load command, which the updated - # macos-latest dyld now rejects ("missing LC_UUID", abort trap) when - # launching `go test` binaries. go.mod stays at 1.22 (its real - # minimum), so Nix/release builds are unaffected. + # Keep macOS on the current linker; older Go toolchains produced + # binaries without the LC_UUID load command required by dyld. go-version: stable cache-dependency-path: apps/server/go.sum - name: Install dependencies run: npm ci + - name: Verify standalone shared packages + run: npm run test:shared-packages + + - name: Verify isolated editor package and assets + if: matrix.os == 'ubuntu-latest' + run: npm run test:app-core-package + + - name: Exercise the installed editor in Chrome + if: matrix.os == 'ubuntu-latest' + run: npm run test:app-core-browser + + - name: Verify the editor with the mobile Vite version + if: matrix.os == 'ubuntu-latest' + env: + ZEN_CORE_VITE_VERSION: 8.2.2 + run: npm run test:app-core-package + + - name: Exercise the Vite 8 editor in Chrome + if: matrix.os == 'ubuntu-latest' + run: npm run test:app-core-browser + + - name: Collect browser evidence + if: always() && matrix.os == 'ubuntu-latest' + run: node tooling/scripts/collect-app-core-evidence.mjs "$RUNNER_TEMP/app-core-browser-evidence" + + - name: Retain browser evidence + if: always() && matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: app-core-browser-evidence + path: ${{ runner.temp }}/app-core-browser-evidence + if-no-files-found: ignore + + - name: Verify contract fixture copies + run: npm run check:contract-fixtures + + - name: Verify browser asset build lock + run: npm run test:web-dist-lock + - name: Typecheck and build app env: GOCACHE: ${{ runner.temp }}/go-build-cache diff --git a/.github/workflows/share-viewer-artifact.yml b/.github/workflows/share-viewer-artifact.yml new file mode 100644 index 00000000..d46b0bc9 --- /dev/null +++ b/.github/workflows/share-viewer-artifact.yml @@ -0,0 +1,34 @@ +name: Public share viewer artifact + +on: + workflow_dispatch: + pull_request: + paths: + - 'apps/share-viewer/**' + - 'packages/**' + - 'tooling/scripts/**' + - 'package*.json' + - 'tsconfig.base.json' + - 'LICENSE' + - '.github/workflows/share-viewer-artifact.yml' + +permissions: + contents: read + +jobs: + candidate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: node --test tooling/scripts/pack-web-artifact.test.mjs + - run: npm run pack:share-viewer + - uses: actions/upload-artifact@v4 + with: + name: share-viewer-candidate + path: dist/viewer-artifacts/* + if-no-files-found: error diff --git a/.github/workflows/web-artifact.yml b/.github/workflows/web-artifact.yml new file mode 100644 index 00000000..5a596fa7 --- /dev/null +++ b/.github/workflows/web-artifact.yml @@ -0,0 +1,67 @@ +name: Self-hosted web artifact boundary + +on: + workflow_dispatch: + pull_request: + paths: + - 'apps/web/**' + - 'apps/server/**' + - 'packages/**' + - 'tooling/scripts/**' + - 'package*.json' + - 'tsconfig.base.json' + - 'LICENSE' + - '.github/workflows/web-artifact.yml' + +permissions: + contents: read + +jobs: + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:web-artifact + - run: npm run check:contract-fixtures + - run: npm run artifact:web + - uses: actions/upload-artifact@v4 + with: + name: self-hosted-web-candidate + path: dist/web-artifacts/* + if-no-files-found: error + + server: + needs: browser + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: apps/server + - uses: actions/setup-go@v6 + with: + go-version: stable + cache-dependency-path: apps/server/go.sum + - uses: actions/download-artifact@v4 + with: + name: self-hosted-web-candidate + path: ${{ runner.temp }}/web-artifact + - name: Test and build using the pinned archive with Go alone + shell: bash + working-directory: apps/server + run: | + go vet ./... + go test ./... + manifests=("$RUNNER_TEMP"/web-artifact/*.tgz.json) + test "${#manifests[@]}" -eq 1 + go run ./cmd/prepare-web -manifest "${manifests[0]}" -output web/dist + go test -tags=embed_web ./web + go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server diff --git a/Dockerfile b/Dockerfile index 945ccb5b..f7d59d7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ COPY packages/shared-domain/package.json packages/shared-domain/package.json COPY packages/shared-ui/package.json packages/shared-ui/package.json COPY apps/desktop/package.json apps/desktop/package.json COPY apps/server/package.json apps/server/package.json +COPY apps/share-viewer/package.json apps/share-viewer/package.json RUN npm ci --no-audit --no-fund --loglevel=error @@ -55,7 +56,7 @@ ENV CGO_ENABLED=0 \ GOFLAGS=-trimpath WORKDIR /app/apps/server -RUN go build -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server +RUN go build -tags=embed_web -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server FROM scratch LABEL org.opencontainers.image.title="ZenNotes" \ diff --git a/apps/desktop/src/main/databases.test.ts b/apps/desktop/src/main/databases.test.ts index 205db6cf..4a40089b 100644 --- a/apps/desktop/src/main/databases.test.ts +++ b/apps/desktop/src/main/databases.test.ts @@ -10,6 +10,8 @@ import { writeDatabaseRows } from './databases' +import { getVaultSettings, setVaultSettings, invalidateVaultSettingsCache, writeNoteComments, readNoteComments } from './vault' + const tmpDirs: string[] = [] async function makeVault(): Promise { const dir = await mkdtemp(path.join(os.tmpdir(), 'zennotes-db-')) @@ -107,6 +109,26 @@ describe('renameDatabase', () => { }) }) + +describe('database rename comments', () => { + it.each(['inbox', 'root'] as const)('moves record comments with custom folder settings in %s mode', async (location) => { + const root = await makeVault() + await setVaultSettings(root, { ...await getVaultSettings(root), primaryNotesLocation: location, systemFolderPaths: { inbox: 'My Notes' }, folderIcons: { 'inbox:Work/People.base': 'book' }, folderColors: { 'inbox:Work/People.base': 'blue' } }) + const doc = await createDatabase(root, 'inbox', 'Work', 'People') + const page = await createRecordPage(root, doc.path, 'Record', 'Record body.') + await writeNoteComments(root, page, [{ notePath: page, anchorStart: 0, anchorEnd: 6, anchorText: 'Record', body: 'Keep comment' }]) + const renamed = await renameDatabase(root, doc.path, 'Customers') + const nextPage = renamed.replace('data.csv', 'Record.md') + expect(await readNoteComments(root, nextPage)).toMatchObject([{ notePath: nextPage, body: 'Keep comment' }]) + expect(await readNoteComments(root, page)).toEqual([]) + invalidateVaultSettingsCache(root) + const settings = await getVaultSettings(root) + expect(settings.folderIcons['inbox:Work/Customers.base']).toBe('book') + expect(settings.folderColors['inbox:Work/Customers.base']).toBe('blue') + expect(settings.folderIcons['inbox:Work/People.base']).toBeUndefined() + }) +}) + describe('adopting a plain CSV (no sidecar)', () => { it('infers schema, materializes the sidecar + stable ids, and is stable on re-read', async () => { const root = await makeVault() diff --git a/apps/desktop/src/main/databases.ts b/apps/desktop/src/main/databases.ts index 06fea523..4817fdad 100644 --- a/apps/desktop/src/main/databases.ts +++ b/apps/desktop/src/main/databases.ts @@ -34,6 +34,10 @@ import { databaseDataPath, databaseSidecarPath, folderRoot, + folderForRelativePath, + getVaultSettings, + renameFolder, + renameFolderTrees, sanitizeNoteTitle, uniqueTitle, writeFileAtomic @@ -330,7 +334,7 @@ export async function deleteDatabase(root: string, csvRel: string): Promise.base` folder to * `.base` (non-colliding). Returns the new `data.csv` path. Because the - * data, schema, and pages all live inside, nothing else needs rewriting. + * data, schema, and pages move together; the parallel comment tree follows too. */ export async function renameDatabase( root: string, @@ -354,7 +358,17 @@ export async function renameDatabase( break } } - await fs.rename(databaseDataPath(root, formDir), databaseDataPath(root, targetRel)) + const settings = await getVaultSettings(root) + const folder = folderForRelativePath(formDir, settings) + const top = folder ? await folderRoot(root, folder) : null + const oldSub = top ? toPosix(path.relative(top, databaseDataPath(root, formDir))) : null + if (folder && top && oldSub && oldSub !== '..' && !oldSub.startsWith('../')) { + const newSub = toPosix(path.relative(top, databaseDataPath(root, targetRel))) + await renameFolder(root, folder, oldSub, newSub) + } else { + // Existing root-level databases remain accessible even in inbox mode. + await renameFolderTrees(root, formDir, targetRel) + } return csvPathForFormDir(targetRel) } diff --git a/apps/desktop/src/main/demo-tour-data.ts b/apps/desktop/src/main/demo-tour-data.ts index 039aad83..fe09723b 100644 --- a/apps/desktop/src/main/demo-tour-data.ts +++ b/apps/desktop/src/main/demo-tour-data.ts @@ -1,82 +1,2 @@ -export interface DemoTourTemplateFile { - path: string - body: string -} - -export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ - { - path: "inbox/demo/00 — Start Here.md", - body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n" - }, - { - path: "inbox/demo/01 — Markdown Basics.md", - body: "# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://lumarylabs.com)\n- Autolink: \n- Wikilink: [[07 — Wiki Links and Tags]]\n- Custom label: [[11 — Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 — Code Blocks]] for syntax highlighting, [[06 — Callouts and Footnotes]] for richer block styles, or back to [[00 — Start Here]].\n\n#demo #markdown\n" - }, - { - path: "inbox/demo/02 — Code Blocks.md", - body: "# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and renders them with syntax highlighting in preview and split view.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## What's next\n\nSee [[05 — Mermaid Diagrams]] for Mermaid fences, [[05b — Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 — Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n" - }, - { - path: "inbox/demo/03 — Tables and Task Lists.md", - body: "# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Mermaid | ✅ | Rendered inside preview and split view. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | File-based | Use any sync tool that watches folders. |\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 — Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday — design review\n - [ ] Tuesday — code-freeze prep\n - [x] Saturday — offline\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row to scan every live note across **Inbox**, **Quick Notes**, and **Archive**. From there you can:\n\n- filter by task content\n- filter by note title\n- filter by inline `#tags`\n- filter by priority markers like `!high`\n- press `Enter` or `o` to open the source note\n- press `Space` or `x` to toggle the selected task without leaving the list\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 — Callouts and Footnotes]])\n- [x] Math blocks (see [[04 — Math with KaTeX]])\n- [x] Mermaid (see [[05 — Mermaid Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b — Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Screenshots in the tour due:2026-04-25 !med #docs\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives.\n\n#demo #tasks #tables\n" - }, - { - path: "inbox/demo/04 — Math with KaTeX.md", - body: "# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown while preview and split mode give you readable math output.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode keeps the raw LaTeX visible.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## Prefer Typst? An alternative math engine\n\nZenNotes can also typeset math with **Typst** instead of KaTeX. Open **Settings ▸ Editor ▸ Math renderer** and pick **Typst**; it applies in both the live editor and the reading view.\n\nTypst reads the same `$…$` and `$$…$$` blocks as **Typst markup**, not LaTeX, so each note's math is written for whichever engine you pick. The formulas here are Typst syntax: with the Math renderer set to **Typst** they render; with **KaTeX** (the default) they show as errors until you switch.\n\nInline: $x^2 + y^2 = z^2$ and $sqrt(a^2 + b^2)$.\n\n$$\nintegral_0^1 x^2 dif x = 1/3\n$$\n\n$$\nsum_(n=1)^oo 1/n^2 = pi^2/6\n$$\n\n$$\nmat(1, 2; 3, 4) quad vec(a, b, c)\n$$\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b — Math Diagrams]].\n\n#demo #math #reference\n" - }, - { - path: "inbox/demo/05 — Mermaid Diagrams.md", - body: "# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b — Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- **Split** mode is usually the sweet spot: raw source on one side, rendered diagram on the other.\n- Diagrams are still searchable because the source fence lives in the note body.\n- If Mermaid syntax breaks, ZenNotes falls back to showing the source block, which makes failures debuggable instead of mysterious.\n\n## What's next\n\nStay in diagram mode with [[05b — Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n" - }, - { - path: "inbox/demo/05b — Math Diagrams.md", - body: "# Math diagrams — TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 — Mermaid Diagrams]]) and KaTeX (see [[04 — Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ — figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly — no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph — interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object — no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"` — useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser — so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot — quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math — `Math.PI`, `Math.sin(x)`, etc. — plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve — the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 — Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 — Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n" - }, - { - path: "inbox/demo/06 — Callouts and Footnotes.md", - body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" - }, - { - path: "inbox/demo/07 — Wiki Links and Tags.md", - body: "# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively.\n\n- Shortest form: [[01 — Markdown Basics]]\n- Custom display text: [[11 — Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] — opening it offers to create the note\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- PDFs can open directly into the reference pane\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 — Start Here]]\n A --> B[[01 — Markdown Basics]]\n A --> C[[02 — Code Blocks]]\n A --> D[[03 — Tables and Task Lists]]\n A --> E[[04 — Math with KaTeX]]\n A --> F[[05 — Mermaid Diagrams]]\n A --> G[[05b — Math Diagrams]]\n A --> H[[06 — Callouts and Footnotes]]\n A --> I[[07 — Wiki Links and Tags]]\n A --> J[[08 — Daily Notes]]\n A --> K[[09 — Vim Cheat Sheet]]\n A --> L[[10 — Ideas and Tasks]]\n A --> M[[11 — Workspace, Search, and Views]]\n A --> N[[12 — Settings and Keymaps]]\n A --> O[[13 — Commands, Help, and Demo Tour]]\n A --> P[[14 — Reference Pane and Floating Windows]]\n A --> Q[[15 — Search Backends and Fuzzy Workflows]]\n```\n\n#demo #reference #search #links\n" - }, - { - path: "inbox/demo/08 — Daily Notes.md", - body: "---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Agenda\n\n- [ ] Morning: triage [[10 — Ideas and Tasks]]\n- [ ] 10:00 — design review\n- [ ] 12:00 — lunch\n- [x] 14:00 — code-freeze prep\n- [ ] Evening: reading — Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Add a sample PDF so the reference-pane flow is demonstrated with a real file.\n- [ ] Add more screenshots for the search palette.\n- [ ] Refine the help text for view-specific ex prompts.\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 — Tables and Task Lists]]\n- [ ] Review [[12 — Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n" - }, - { - path: "inbox/demo/09 — Vim Cheat Sheet.md", - body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" - }, - { - path: "inbox/demo/10 — Ideas and Tasks.md", - body: "# Ideas and tasks — a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should attachment previews appear inline for PDFs by default?\n- [ ] Is the built-in text-search backend fast enough on large vaults when neither `fzf` nor `ripgrep` is available?\n- [ ] Do we expose tag renaming from the UI, or keep it intentionally file-grep first?\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which now opens as a dedicated main-pane list view.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n## Now\n\n- [ ] Add a sample PDF + image to the tour so [[06 — Callouts and Footnotes]] can illustrate attachments and reference-pane workflows.\n- [x] Document the Tasks tab behavior in [[03 — Tables and Task Lists]].\n- [ ] Collect feedback on [[09 — Vim Cheat Sheet]] now that keymaps are configurable.\n- [ ] Confirm the search backend badge is visible enough in the vault text search palette.\n\n## Shipped\n\n- [x] Vault text search can use **Auto**, **Built-in**, **ripgrep**, or **fzf**.\n- [x] Custom binary paths can be configured when `rg` or `fzf` live outside `PATH`.\n- [x] Settings now show the resolved runtime backend instead of only the requested one.\n- [x] Archive and Trash both behave as list-style built-in tabs instead of sidebar dump zones.\n\n## Cross-references\n\n- Tour index: [[00 — Start Here]]\n- Search and links: [[07 — Wiki Links and Tags]]\n- Workspace guide: [[11 — Workspace, Search, and Views]]\n- Settings and keymaps: [[12 — Settings and Keymaps]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n\n#demo #tasks #planning #workspace\n" - }, - { - path: "inbox/demo/11 — Workspace, Search, and Views.md", - body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n" - }, - { - path: "inbox/demo/12 — Settings and Keymaps.md", - body: "# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid anymore. Settings now cover both presentation and behavior.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location\n- inspect the app version\n- see the About section\n- find the Lumary Labs link\n- remember that Settings save automatically on this device\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 — Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n" - }, - { - path: "inbox/demo/13 — Commands, Help, and Demo Tour.md", - body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n" - }, - { - path: "inbox/demo/14 — Reference Pane and Floating Windows.md", - body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n" - }, - { - path: "inbox/demo/15 — Search Backends and Fuzzy Workflows.md", - body: "# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 — Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 — Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 — Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n" - }, -] - -export const DEMO_TOUR_ASSETS: DemoTourTemplateFile[] = [ - { - path: "zennotes-demo-card.svg", - body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n DEMO\n ZenNotes Demo\n Local files, keyboard-first flows, and markdown-friendly structure.\n \n \n \n \n \n \n \n SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES\n\n" - }, -] \ No newline at end of file +export { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@zennotes/shared-domain/demo-tour-data' +export type { DemoTourTemplateFile } from '@zennotes/shared-domain/demo-tour-data' diff --git a/apps/desktop/src/main/vault-trash-system.test.ts b/apps/desktop/src/main/vault-trash-system.test.ts index 0d0250c8..0177e959 100644 --- a/apps/desktop/src/main/vault-trash-system.test.ts +++ b/apps/desktop/src/main/vault-trash-system.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -13,7 +13,7 @@ vi.mock('electron', () => ({ shell: { trashItem: (abs: string) => trashItem(abs) } })) -const { ensureVaultLayout, listNotes, trashNoteToSystem } = await import('./vault') +const { ensureVaultLayout, listNotes, trashNoteToSystem, writeNoteComments, readNoteComments } = await import('./vault') const roots: string[] = [] afterEach(async () => { @@ -49,3 +49,20 @@ describe('trashNoteToSystem (temporary folder sessions, #650)', () => { expect(trashItem).not.toHaveBeenCalled() }) }) + + +it('restores comment paths if system Trash fails and detaches them on success',async()=>{ + const root=await mkdtemp(path.join(os.tmpdir(),'zen-system-trash-comments-')) + roots.push(root) + await ensureVaultLayout(root) + await writeFile(path.join(root,'inbox/One.md'),'Keep café. \n') + await writeNoteComments(root,'inbox/One.md',[{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + trashItem.mockRejectedValueOnce(new Error('OS refused')) + await expect(trashNoteToSystem(root,'inbox/One.md')).rejects.toThrow('OS refused') + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Keep café. \n') + expect(await readNoteComments(root,'inbox/One.md')).toHaveLength(1) + await trashNoteToSystem(root,'inbox/One.md') + await writeFile(path.join(root,'inbox/One.md'),'New note') + expect(await readNoteComments(root,'inbox/One.md')).toEqual([]) + expect(await readFile(path.join(root,'inbox/One.md.in-system-trash'),'utf8')).toBe('Keep café. \n') +}) diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index a93c78c7..84ca5e2b 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -9,8 +9,10 @@ import { appendToNote, archiveNote, deleteAsset, + deleteNote, duplicateAsset, emptyDeletedAssets, + emptyTrash, ensureVaultLayout, folderForRelativePath, forgetLocalVault, @@ -25,11 +27,16 @@ import { listFolders, migrateLooseAssets, moveAsset, + moveNote, + renameNote, moveToTrash, rememberLocalVault, purgeDeletedAsset, renameAsset, renameFolder, + deleteFolder, + readNoteComments, + writeNoteComments, restoreDeletedAsset, restoreFromTrash, rootContentHiddenByInboxMode, @@ -43,6 +50,8 @@ import { writeNote } from './vault' +import { registerEphemeralRoot, unregisterEphemeralRoot } from './ephemeral-vaults' + const tempDirs: string[] = [] async function makeTempDir(prefix: string): Promise { @@ -1391,3 +1400,268 @@ describe('writeNote atomic-save fidelity (#585)', () => { expect(isAtomicWriteTempPath('inbox/report.2024.01.tmp')).toBe(false) }) }) + + +describe('folder comment storage', () => { + it.each(['inbox', 'root'] as const)('moves and deletes nested comments in %s mode', async (location) => { + const root = await makeTempDir('zennotes-folder-comments-') + await ensureVaultLayout(root) + const settings = await getVaultSettings(root) + await setVaultSettings(root, { ...settings, primaryNotesLocation: location, systemFolderPaths: { inbox: 'My Notes' } }) + const prefix = location === 'root' ? '' : 'My Notes/' + const original = `${prefix}Work/Nested/Note.md` + await writeNote(root, original, 'Body.\n') + await writeNoteComments(root, original, [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Keep this comment', createdAt: 1, updatedAt: 1 }]) + await renameFolder(root, 'inbox', 'Work', 'Renamed') + const renamed = `${prefix}Renamed/Nested/Note.md` + expect(await readNoteComments(root, renamed)).toMatchObject([{ id: 'comment', body: 'Keep this comment', notePath: renamed }]) + expect(await readNoteComments(root, original)).toEqual([]) + await deleteFolder(root, 'inbox', 'Renamed') + await writeNote(root, renamed, 'New note.\n') + expect(await readNoteComments(root, renamed)).toEqual([]) + }) + it('rejects missing sources and comment collisions without moving notes', async () => { + const root = await makeTempDir('zennotes-folder-comments-collision-') + await ensureVaultLayout(root) + await expect(renameFolder(root, 'inbox', 'Missing', 'New')).rejects.toThrow() + await writeNote(root, 'inbox/Work/Note.md', 'Original') + await writeNoteComments(root, 'inbox/Renamed/Note.md', [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'orphan', body: 'Retain orphan', createdAt: 1, updatedAt: 1 }]) + await expect(renameFolder(root, 'inbox', 'Work', 'Renamed')).rejects.toThrow('comments already exist') + expect(await readFile(path.join(root, 'inbox/Work/Note.md'), 'utf8')).toBe('Original') + expect(await readNoteComments(root, 'inbox/Renamed/Note.md')).toHaveLength(1) + }) + + it.each(['rename', 'delete'] as const)('rolls back content if the %s comment move fails', async (operation) => { + const root = await makeTempDir('zennotes-folder-comments-rollback-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/Work/Note.md', 'Original') + await writeNoteComments(root, 'inbox/Work/Note.md', [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Keep', createdAt: 1, updatedAt: 1 }]) + const originalRename = fsPromises.rename + const spy = vi.spyOn(fsPromises, 'rename').mockImplementation(async (from, to) => { + if (String(from) === path.join(root, '.zennotes/comments/inbox/Work')) throw new Error('Comment move failed') + return originalRename(from, to) + }) + try { + await expect(operation === 'rename' ? renameFolder(root, 'inbox', 'Work', 'Renamed') : deleteFolder(root, 'inbox', 'Work')).rejects.toThrow('Comment move failed') + } finally { spy.mockRestore() } + expect(await readFile(path.join(root, 'inbox/Work/Note.md'), 'utf8')).toBe('Original') + expect(await readNoteComments(root, 'inbox/Work/Note.md')).toHaveLength(1) + }) + + it('retains comments through a case-only folder rename', async () => { + const root = await makeTempDir('zennotes-folder-comments-case-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/Work/Note.md', 'Original') + await writeNoteComments(root, 'inbox/Work/Note.md', [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Keep', createdAt: 1, updatedAt: 1 }]) + await renameFolder(root, 'inbox', 'Work', 'work') + expect(await readNoteComments(root, 'inbox/work/Note.md')).toMatchObject([{ notePath: 'inbox/work/Note.md' }]) + }) + + it.each([false, true])('deletes temporary-session folders without creating state (existing comments: %s)', async (withComments) => { + const root = await makeTempDir('zennotes-folder-ephemeral-') + if (withComments) { + await ensureVaultLayout(root) + await writeNote(root, 'inbox/Work/Note.md', 'Original') + await writeNoteComments(root, 'inbox/Work/Note.md', [{ notePath: 'inbox/Work/Note.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Remove', createdAt: 1, updatedAt: 1 }]) + } else { + await mkdir(path.join(root, 'inbox/Work'), { recursive: true }) + await writeFile(path.join(root, 'inbox/Work/Note.md'), 'Original') + } + registerEphemeralRoot(root) + try { + await deleteFolder(root, 'inbox', 'Work') + await expect(stat(path.join(root, 'inbox/Work'))).rejects.toMatchObject({ code: 'ENOENT' }) + if (withComments) expect(await readNoteComments(root, 'inbox/Work/Note.md')).toEqual([]) + else await expect(stat(path.join(root, '.zennotes'))).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { unregisterEphemeralRoot(root) } + }) + + it('deletes a folder from a fresh vault without private metadata', async () => { + const root = await makeTempDir('zennotes-folder-fresh-') + await mkdir(path.join(root, 'inbox/Work'), { recursive: true }) + await writeFile(path.join(root, 'inbox/Work/Note.md'), 'Original') + await deleteFolder(root, 'inbox', 'Work') + await expect(stat(path.join(root, 'inbox/Work'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + +}) + + +describe('note move transaction', () => { + it.each([false, true])('retains the source when destination comments already exist (source comments: %s)', async (withComments) => { + const root = await makeTempDir('zennotes-note-move-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/One.md', 'Original café. \n') + if (withComments) await writeNoteComments(root, 'inbox/One.md', [{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'source', body:'Source discussion', createdAt:1, updatedAt:1}]) + await writeNoteComments(root, 'inbox/Work/One.md', [{notePath:'inbox/Work/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'destination', body:'Keep destination', createdAt:1, updatedAt:1}]) + await expect(moveNote(root,'inbox/One.md','inbox','Work')).rejects.toThrow() + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Original café. \n') + await expect(stat(path.join(root,'inbox/Work/One.md'))).rejects.toMatchObject({code:'ENOENT'}) + expect((await readNoteComments(root,'inbox/Work/One.md'))[0].body).toBe('Keep destination') + if (withComments) expect((await readNoteComments(root,'inbox/One.md'))[0].body).toBe('Source discussion') + }) +}) + +it('numbers a moved drawing without replacing the existing drawing', async () => { + const root=await makeTempDir('zennotes-drawing-move-') + await ensureVaultLayout(root) + await mkdir(path.join(root,'inbox/Work'),{recursive:true}) + await writeFile(path.join(root,'inbox/Sketch.excalidraw'),'source drawing','utf8') + await writeFile(path.join(root,'inbox/Work/Sketch.excalidraw'),'existing drawing','utf8') + const moved=await moveNote(root,'inbox/Sketch.excalidraw','inbox','Work') + expect(moved.path).toBe('inbox/Work/Sketch 2.excalidraw') + expect(await readFile(path.join(root,moved.path),'utf8')).toBe('source drawing') + expect(await readFile(path.join(root,'inbox/Work/Sketch.excalidraw'),'utf8')).toBe('existing drawing') +}) + +it('rolls a moved note back when moving its comment file fails', async () => { + const root=await makeTempDir('zennotes-note-rollback-') + await ensureVaultLayout(root) + await writeNote(root,'inbox/One.md','Keep source.\n') + await writeNoteComments(root,'inbox/One.md',[{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + if (String(from).endsWith('One.md.comments.json')) throw new Error('Comment move failed') + return rename(from,to) + }) + try { await expect(moveNote(root,'inbox/One.md','inbox','Work')).rejects.toThrow('Comment move failed') } + finally { spy.mockRestore() } + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Keep source.\n') + expect((await readNoteComments(root,'inbox/One.md'))[0].body).toBe('Keep discussion') + await expect(stat(path.join(root,'inbox/Work/One.md'))).rejects.toMatchObject({code:'ENOENT'}) +}) + +describe('note rename transaction', () => { + it.each([false, true])('retains the source when destination comments already exist (source comments: %s)', async (withComments) => { + const root = await makeTempDir('zennotes-note-rename-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/One.md', 'Original café. \n') + if (withComments) await writeNoteComments(root, 'inbox/One.md', [{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'source', body:'Source discussion', createdAt:1, updatedAt:1}]) + await writeNoteComments(root, 'inbox/Renamed.md', [{notePath:'inbox/Renamed.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'destination', body:'Keep destination', createdAt:1, updatedAt:1}]) + await expect(renameNote(root,'inbox/One.md','Renamed')).rejects.toThrow() + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Original café. \n') + await expect(stat(path.join(root,'inbox/Renamed.md'))).rejects.toMatchObject({code:'ENOENT'}) + expect((await readNoteComments(root,'inbox/Renamed.md'))[0].body).toBe('Keep destination') + if (withComments) expect((await readNoteComments(root,'inbox/One.md'))[0].body).toBe('Source discussion') + }) +}) + + +it('rolls a renamed note back when moving its comment file fails', async () => { + const root=await makeTempDir('zennotes-note-rollback-') + await ensureVaultLayout(root) + await writeNote(root,'inbox/One.md','Keep source.\n') + await writeNoteComments(root,'inbox/One.md',[{notePath:'inbox/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + if (String(from).endsWith('One.md.comments.json')) throw new Error('Comment move failed') + return rename(from,to) + }) + try { await expect(renameNote(root,'inbox/One.md','Renamed')).rejects.toThrow('Comment move failed') } + finally { spy.mockRestore() } + expect(await readFile(path.join(root,'inbox/One.md'),'utf8')).toBe('Keep source.\n') + expect((await readNoteComments(root,'inbox/One.md'))[0].body).toBe('Keep discussion') + await expect(stat(path.join(root,'inbox/Renamed.md'))).rejects.toMatchObject({code:'ENOENT'}) +}) +it('renames note comments and inbound links, including a case-only rename', async () => { + const root = await makeTempDir('zennotes-note-rename-links-') + await ensureVaultLayout(root) + await writeNote(root, 'inbox/One.md', 'Original café. \n') + await writeNote(root, 'inbox/Links.md', 'See [[One#Heading|alias]] and `[[One]]`.\n') + await writeNoteComments(root, 'inbox/One.md', [{ notePath: 'inbox/One.md', anchorStart: 0, anchorEnd: 0, anchorText: '', id: 'comment', body: 'Keep discussion', createdAt: 1, updatedAt: 1 }]) + const renamed = await renameNote(root, 'inbox/One.md', 'one') + expect(renamed.path).toBe('inbox/one.md') + expect(await readFile(path.join(root, renamed.path), 'utf8')).toBe('Original café. \n') + expect(await readFile(path.join(root, 'inbox/Links.md'), 'utf8')).toBe('See [[one#Heading|alias]] and `[[One]]`.\n') + expect((await readNoteComments(root, renamed.path))[0]).toMatchObject({ notePath: renamed.path, body: 'Keep discussion' }) +}) + + +describe('note lifecycle transactions', () => { + it.each([ + ['archive', archiveNote, 'inbox/One.md', 'archive/One.md'], + ['trash', moveToTrash, 'inbox/One.md', 'trash/One.md'], + ['unarchive', unarchiveNote, 'archive/One.md', 'inbox/One.md'], + ['restore', restoreFromTrash, 'trash/One.md', 'inbox/One.md'] + ] as const)('rolls back %s when the comment move fails', async (_name, action, source, target) => { + const root=await makeTempDir('zennotes-lifecycle-') + await ensureVaultLayout(root) + await writeNote(root,source,'Keep café. \n') + await writeNoteComments(root,source,[{notePath:source,anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + if(String(from).endsWith('One.md.comments.json')) throw new Error('Comment move failed') + return rename(from,to) + }) + try {await expect(action(root,source)).rejects.toThrow('Comment move failed')} + finally {spy.mockRestore()} + expect(await readFile(path.join(root,source),'utf8')).toBe('Keep café. \n') + expect(await readNoteComments(root,source)).toHaveLength(1) + await expect(stat(path.join(root,target))).rejects.toMatchObject({code:'ENOENT'}) + }) + + it('retains a permanently deleted note if detaching its comments fails', async () => { + const root=await makeTempDir('zennotes-lifecycle-delete-') + await ensureVaultLayout(root) + await writeNote(root,'trash/One.md','Keep source.\n') + await writeNoteComments(root,'trash/One.md',[{notePath:'trash/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + if(String(from).endsWith('One.md.comments.json')) throw new Error('Comment move failed') + return rename(from,to) + }) + try {await expect(deleteNote(root,'trash/One.md')).rejects.toThrow('Comment move failed')} + finally {spy.mockRestore()} + expect(await readFile(path.join(root,'trash/One.md'),'utf8')).toBe('Keep source.\n') + expect(await readNoteComments(root,'trash/One.md')).toHaveLength(1) + await deleteNote(root,'trash/One.md') + await writeNote(root,'trash/One.md','New note.\n') + expect(await readNoteComments(root,'trash/One.md')).toEqual([]) + }) +}) + + +it.each([['archive', archiveNote], ['trash', moveToTrash]] as const)('preserves drawings with colliding %s filenames',async(folder,action)=>{ + const root=await makeTempDir('zennotes-lifecycle-drawing-') + await ensureVaultLayout(root) + await writeFile(path.join(root,'inbox/Sketch.excalidraw'),'source drawing') + await writeFile(path.join(root,folder,'Sketch.excalidraw'),'existing drawing') + const meta=await action(root,'inbox/Sketch.excalidraw') + expect(meta.path).toBe(`${folder}/Sketch 2.excalidraw`) + expect(await readFile(path.join(root,meta.path),'utf8')).toBe('source drawing') + expect(await readFile(path.join(root,folder,'Sketch.excalidraw'),'utf8')).toBe('existing drawing') +}) + + +describe('Empty Trash transaction',()=>{ + it.each(['root','inbox'] as const)('clears the remapped Trash and nested comments in %s mode',async location=>{ + const root=await makeTempDir('zennotes-empty-trash-') + await ensureVaultLayout(root) + const settings=await getVaultSettings(root) + await setVaultSettings(root,{...settings,primaryNotesLocation:location,systemFolderPaths:{trash:'Deleted files'}}) + await writeNote(root,'Deleted files/Nested/One.md','Delete me') + await writeNoteComments(root,'Deleted files/Nested/One.md',[{notePath:'Deleted files/Nested/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Remove discussion',createdAt:1,updatedAt:1}]) + await writeNote(root,'trash/Unrelated.md','Keep literal trash folder') + await emptyTrash(root) + await expect(stat(path.join(root,'Deleted files/Nested/One.md'))).rejects.toMatchObject({code:'ENOENT'}) + expect(await readNoteComments(root,'Deleted files/Nested/One.md')).toEqual([]) + expect(await readFile(path.join(root,'trash/Unrelated.md'),'utf8')).toBe('Keep literal trash folder') + await emptyTrash(root) + }) + it('rolls back every trashed file if moving the comment tree fails',async()=>{ + const root=await makeTempDir('zennotes-empty-trash-rollback-') + await ensureVaultLayout(root) + await writeNote(root,'trash/One.md','Keep me') + await writeNoteComments(root,'trash/One.md',[{notePath:'trash/One.md',anchorStart:0,anchorEnd:0,anchorText:'',id:'comment',body:'Keep discussion',createdAt:1,updatedAt:1}]) + const rename=fsPromises.rename + const spy=vi.spyOn(fsPromises,'rename').mockImplementation(async(from,to)=>{ + // Windows joins with backslashes, so compare the normalized path. + if(String(from).replace(/\\/g,'/').endsWith('.zennotes/comments/trash'))throw new Error('Comment move refused') + return rename(from,to) + }) + try {await expect(emptyTrash(root)).rejects.toThrow('Comment move refused')} + finally{spy.mockRestore()} + expect(await readFile(path.join(root,'trash/One.md'),'utf8')).toBe('Keep me') + expect(await readNoteComments(root,'trash/One.md')).toHaveLength(1) + }) +}) diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 15e84e0d..ce3fd92f 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -3526,7 +3526,7 @@ export async function renameNote( if (!folder) throw new Error(`Note not in a known folder: ${rel}`) const dir = path.dirname(abs) const trimmed = sanitizeNoteTitle(nextTitle) - // Preserve the file's type on rename — a `.excalidraw` drawing must stay a + // Preserve the file's type on rename: a `.excalidraw` drawing must stay a // drawing, not get turned into a `.md` note (which would render its JSON). const ext = isExcalidrawPath(abs) ? '.excalidraw' : '.md' const target = path.join(dir, `${trimmed}${ext}`) @@ -3534,28 +3534,12 @@ export async function renameNote( // Snapshot the vault before the rename so inbound [[wikilinks]] still // resolve to this note under its current name; we rewrite them afterwards. const notesBefore = willRename ? await listNotes(root) : [] - if (willRename) { - // Check for conflicts, but allow case-only renames on case-insensitive FS - try { - await fs.access(target) - const [srcStat, dstStat] = await Promise.all([fs.stat(abs), fs.stat(target)]) - if (srcStat.ino !== dstStat.ino) { - throw new Error(`A note named "${trimmed}" already exists in ${folder}`) - } - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e - } - // Two-step rename for case-only changes on case-insensitive filesystems - if (abs.toLowerCase() === target.toLowerCase() && abs !== target) { - const tmp = abs + '_rename_tmp_' + Date.now() - await fs.rename(abs, tmp) - await fs.rename(tmp, target) - } else { - await fs.rename(abs, target) - } - } - const meta = await readMeta(root, target, folder) - await moveNoteComments(root, rel, meta.path) + const nextRel = toPosix(path.relative(root, target)) + let meta!: NoteMeta + await relocateFolderTrees( + [[abs, target], [noteCommentsPath(root, rel), noteCommentsPath(root, nextRel)]], + async () => { meta = await readMeta(root, target, folder) } + ) invalidateNoteMetaCache(root, rel) invalidateNoteMetaCache(root, meta.path) invalidateVaultTextSearchCache(root) @@ -3586,7 +3570,7 @@ async function updateInboundWikilinks( (n) => n.path !== oldPath && n.folder !== 'trash' && - (n.wikilinks ?? []).some((t) => resolveWikilinkTarget(refs, t)?.path === oldPath) + (n.wikilinks ?? []).some((t) => resolveWikilinkTarget(refs, t.split(/[|#^]/, 1)[0])?.path === oldPath) ) for (const candidate of candidates) { try { @@ -3627,14 +3611,13 @@ async function moveBetweenFolders( const targetRoot = await folderRoot(root, target) const destDir = subpath ? resolveSafe(targetRoot, subpath) : targetRoot await fs.mkdir(destDir, { recursive: true }) - const baseTitle = path.basename(filename, path.extname(filename)) - const finalTitle = await uniqueTitle(destDir, baseTitle) - // Preserve the file type when moving (a `.excalidraw` drawing stays a drawing). - const ext = isExcalidrawPath(filename) ? '.excalidraw' : '.md' - const destAbs = path.join(destDir, `${finalTitle}${ext}`) - await fs.rename(abs, destAbs) - const meta = await readMeta(root, destAbs, target) - await moveNoteComments(root, rel, meta.path) + const destAbs = path.join(destDir, await uniqueFilename(destDir, filename)) + const nextRel = toPosix(path.relative(root, destAbs)) + let meta!: NoteMeta + await relocateFolderTrees( + [[abs, destAbs], [noteCommentsPath(root, rel), noteCommentsPath(root, nextRel)]], + async () => { meta = await readMeta(root, destAbs, target) } + ) invalidateNoteMetaCache(root, rel) invalidateNoteMetaCache(root, meta.path) invalidateVaultTextSearchCache(root) @@ -3656,7 +3639,18 @@ export async function trashNoteToSystem(root: string, rel: string): Promise true, (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false + throw error + }) + if (hasComments) { + const temporary = await fs.mkdtemp(path.join(root, INTERNAL_VAULT_DIR, 'note-delete-')) + await relocateFolderTrees([[comments, path.join(temporary, 'comments')]], () => shell.trashItem(abs)) + await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Note cleanup pending', error)) + } else { + await shell.trashItem(abs) + } invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) return meta @@ -3676,25 +3670,43 @@ export function unarchiveNote(root: string, rel: string): Promise { export async function emptyTrash(root: string): Promise { const trashDir = await folderRoot(root, 'trash') - const settings = await getVaultSettings(root) - const trashRelPrefix = resolveFolderPath('trash', settings.systemFolderPaths) - try { - const entries = await fs.readdir(trashDir) - await Promise.all(entries.map((e) => removeNoteComments(root, `${trashRelPrefix}/${e}`))) - await Promise.all( - entries.map((e) => fs.rm(path.join(trashDir, e), { recursive: true, force: true })) - ) - invalidateNoteMetaCache(root) - invalidateVaultTextSearchCache(root) - } catch { - /* no trash dir yet */ - } + const trashRel = toPosix(path.relative(root, trashDir)) + const comments = resolveSafe(noteCommentsRoot(root), trashRel) + await fs.mkdir(path.join(root, INTERNAL_VAULT_DIR), { recursive: true }) + const temporary = await fs.mkdtemp(path.join(root, INTERNAL_VAULT_DIR, 'trash-delete-')) + await relocateFolderTrees([ + [trashDir, path.join(temporary, 'content')], + [comments, path.join(temporary, 'comments')] + ], async () => {}) + await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Trash cleanup pending', error)) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) } export async function deleteNote(root: string, rel: string): Promise { const abs = resolveSafe(root, rel) - await fs.rm(abs, { force: true }) - await removeNoteComments(root, rel) + const source = await fs.lstat(abs).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error + return null + }) + if (source?.isDirectory()) throw new Error('Use the folder action to delete a directory.') + const comments = noteCommentsPath(root, rel) + const hasComments = await fs.stat(comments).then(() => true, (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false + throw error + }) + if (isEphemeralRoot(root) && !hasComments) { + await fs.rm(abs, { force: true }) + } else { + await fs.mkdir(path.join(root, INTERNAL_VAULT_DIR), { recursive: true }) + const temporary = await fs.mkdtemp(path.join(root, INTERNAL_VAULT_DIR, 'note-delete-')) + await relocateFolderTrees([ + [abs, path.join(temporary, 'content')], + [comments, path.join(temporary, 'comments')] + ], async () => {}) + // Once detached, cleanup cannot attach the old discussion to a new note. + await fs.rm(temporary, { recursive: true, force: true }).catch(error => console.warn('Note cleanup pending', error)) + } invalidateNoteMetaCache(root, rel) invalidateVaultTextSearchCache(root) } @@ -3938,9 +3950,95 @@ export async function createFolder( await fs.mkdir(abs, { recursive: true }) } +async function renameDirectory(from: string, to: string): Promise { + if (from === to) return + if (from.toLowerCase() !== to.toLowerCase()) return fs.rename(from, to) + const temporary = `${from}_rename_tmp_${randomUUID()}` + await fs.rename(from, temporary) + try { + await fs.rename(temporary, to) + } catch (error) { + try { + await fs.rename(temporary, from) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'FOLDER_STATE_UNCERTAIN: Folder change could not be rolled back; reload the vault before editing' + ) + } + throw error + } +} + +/** Move content and its parallel comments together, retaining the originals on failure. */ +async function relocateFolderTrees( + moves: Array<[string, string]>, + persistSettings: () => Promise +): Promise { + const present: Array<[string, string]> = [] + for (const [from, to] of moves) { + let source + try { + source = await fs.stat(from) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + try { + const target = await fs.stat(to) + if (!source || source.ino !== target.ino || source.dev !== target.dev) + throw new Error('The destination folder or its comments already exist') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (source) present.push([from, to]) + } + const moved: Array<[string, string]> = [] + try { + for (const [from, to] of present) { + await fs.mkdir(path.dirname(to), { recursive: true }) + await renameDirectory(from, to) + moved.push([from, to]) + } + await persistSettings() + } catch (error) { + const failures: unknown[] = [error] + for (const [from, to] of moved.reverse()) { + try { + await renameDirectory(to, from) + } catch (rollbackError) { + failures.push(rollbackError) + } + } + if (failures.length > 1) + throw new AggregateError( + failures, + 'FOLDER_STATE_UNCERTAIN: Folder change could not be rolled back; reload the vault before editing' + ) + throw error + } +} + +/** Shared local folder move for ordinary folders and database containers. */ +export async function renameFolderTrees( + root: string, oldRelative: string, newRelative: string, + persistSettings: () => Promise = async () => {} +): Promise { + const oldAbs = resolveSafe(root, oldRelative) + const newAbs = resolveSafe(root, newRelative) + if (oldAbs === root || newAbs === root) throw new Error('Cannot rename the vault root') + await fs.stat(oldAbs) + if (oldAbs === newAbs) return + if ((newAbs + path.sep).startsWith(oldAbs + path.sep)) throw new Error('Cannot move a folder into itself') + const oldComments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, oldAbs))) + const newComments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, newAbs))) + await relocateFolderTrees([[oldAbs, newAbs], [oldComments, newComments]], persistSettings) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) +} + /** * Rename or move a subfolder. `newSubpath` is the full target path - * relative to `{topFolder}` — e.g. rename `Work/Research` → `Projects/Research` + * relative to `{topFolder}`, for example rename `Work/Research` → `Projects/Research` * also moves it into `Projects`. Refuses to move into itself or a * descendant, and refuses to touch the top-level folder. */ @@ -3958,6 +4056,7 @@ export async function renameFolder( const topRoot = await folderRoot(root, topFolder) const oldAbs = resolveSafe(topRoot, oldClean) const newAbs = resolveSafe(topRoot, newClean) + await fs.stat(oldAbs) if (newAbs === oldAbs) return newClean const sep = path.sep @@ -3979,35 +4078,13 @@ export async function renameFolder( if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e } - await fs.mkdir(path.dirname(newAbs), { recursive: true }) - // On case-insensitive filesystems, a direct rename('AI','ai') may - // not change the case. Use a two-step rename via a temp name. - if (oldAbs.toLowerCase() === newAbs.toLowerCase() && oldAbs !== newAbs) { - const tmpAbs = oldAbs + '_rename_tmp_' + Date.now() - await fs.rename(oldAbs, tmpAbs) - await fs.rename(tmpAbs, newAbs) - } else { - await fs.rename(oldAbs, newAbs) - } const settings = await getVaultSettings(root) const nextSettings: VaultSettings = { ...settings, - folderIcons: rewriteFolderIconsForRename( - settings.folderIcons, - topFolder, - oldClean, - newClean - ), - folderColors: rewriteFolderColorsForRename( - settings.folderColors, - topFolder, - oldClean, - newClean - ) + folderIcons: rewriteFolderIconsForRename(settings.folderIcons, topFolder, oldClean, newClean), + folderColors: rewriteFolderColorsForRename(settings.folderColors, topFolder, oldClean, newClean) } - await setVaultSettings(root, nextSettings) - invalidateNoteMetaCache(root) - invalidateVaultTextSearchCache(root) + await renameFolderTrees(root, toPosix(path.relative(root, oldAbs)), toPosix(path.relative(root, newAbs)), () => setVaultSettings(root, nextSettings)) return newClean } @@ -4023,14 +4100,36 @@ export async function deleteFolder( const clean = subpath.replace(/^\/+|\/+$/g, '') if (!clean) throw new Error('Cannot delete the top-level folder') const abs = resolveSafe(await folderRoot(root, topFolder), clean) - await fs.rm(abs, { recursive: true, force: true }) + const comments = resolveSafe(noteCommentsRoot(root), toPosix(path.relative(root, abs))) + const hasComments = await fs.stat(comments).then(() => true, (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false + throw error + }) + if (isEphemeralRoot(root) && !hasComments) { + await fs.rm(abs, { recursive: true, force: true }) + invalidateNoteMetaCache(root) + invalidateVaultTextSearchCache(root) + return + } const settings = await getVaultSettings(root) const nextSettings: VaultSettings = { ...settings, folderIcons: removeFolderIcons(settings.folderIcons, topFolder, clean), folderColors: removeFolderColors(settings.folderColors, topFolder, clean) } - await setVaultSettings(root, nextSettings) + await fs.mkdir(path.join(root, INTERNAL_VAULT_DIR), { recursive: true }) + const temporary = await fs.mkdtemp(path.join(root, INTERNAL_VAULT_DIR, 'folder-delete-')) + await relocateFolderTrees( + [ + [abs, path.join(temporary, 'content')], + [comments, path.join(temporary, 'comments')] + ], + () => setVaultSettings(root, nextSettings) + ) + // Cleanup can be retried safely: neither tree remains at a live note path. + await fs + .rm(temporary, { recursive: true, force: true }) + .catch((error) => console.warn('Folder cleanup pending', error)) invalidateNoteMetaCache(root) invalidateVaultTextSearchCache(root) } @@ -4246,13 +4345,14 @@ export async function moveNote( } await fs.mkdir(destDir, { recursive: true }) - const ext = path.extname(filename) - const baseTitle = path.basename(filename, ext) - const finalTitle = await uniqueTitle(destDir, baseTitle) - const destAbs = path.join(destDir, `${finalTitle}${ext}`) - await fs.rename(oldAbs, destAbs) - const meta = await readMeta(root, destAbs, targetFolder) - await moveNoteComments(root, oldRel, meta.path) + const finalName = await uniqueFilename(destDir, filename) + const destAbs = path.join(destDir, finalName) + const nextRel = toPosix(path.relative(root, destAbs)) + let meta!: NoteMeta + await relocateFolderTrees( + [[oldAbs, destAbs], [noteCommentsPath(root, oldRel), noteCommentsPath(root, nextRel)]], + async () => { meta = await readMeta(root, destAbs, targetFolder) } + ) invalidateNoteMetaCache(root, oldRel) invalidateNoteMetaCache(root, meta.path) invalidateVaultTextSearchCache(root) diff --git a/apps/desktop/src/main/wikilink-rename.ts b/apps/desktop/src/main/wikilink-rename.ts index 6f8fa127..44fcd306 100644 --- a/apps/desktop/src/main/wikilink-rename.ts +++ b/apps/desktop/src/main/wikilink-rename.ts @@ -1,145 +1,6 @@ -/** - * Rewriting inbound `[[wikilinks]]` when a note is renamed. - * - * The wikilink *resolution* here mirrors - * `packages/app-core/src/lib/wikilinks.ts` (the renderer's source of truth): - * a target resolves by note title (case-insensitive) unless it looks like a - * path, in which case it resolves by explicit/suffix path match. We keep a - * backend copy because the main process cannot import the renderer bundle. - * The Go server carries an equivalent port in `internal/vault`. - */ - -export interface RenameNoteRef { - path: string - title: string - folder: string -} - -const TOP_FOLDERS = ['inbox', 'quick', 'archive', 'trash'] - -function normalizeSlashes(value: string): string { - return value.replace(/\\/g, '/').replace(/\/+/g, '/') -} - -function stripMdExtension(value: string): string { - return value.replace(/\.md$/i, '') -} - -function normalizeForCompare(value: string): string { - return value.trim().toLowerCase() -} - -export function isPathLikeWikilinkTarget(target: string): boolean { - const trimmed = target.trim() - return trimmed.startsWith('/') || trimmed.includes('/') || /\.md$/i.test(trimmed) -} - -function resolveExplicitPath(notes: RenameNoteRef[], target: string): RenameNoteRef | null { - const normalized = normalizeSlashes(target.trim()) - if (!normalized) return null - const trimmed = stripMdExtension(normalized).replace(/^\/+/, '').replace(/\/+$/, '') - if (!trimmed) return null - - let relPath: string | null = null - if (normalized.startsWith('/')) { - relPath = `inbox/${trimmed}.md` - } else if (TOP_FOLDERS.some((folder) => trimmed.toLowerCase().startsWith(`${folder}/`))) { - relPath = `${trimmed}.md` - } - if (!relPath) return null - - const needle = normalizeForCompare(relPath) - return notes.find((note) => normalizeForCompare(note.path) === needle) ?? null -} - -function resolvePathSuffix(notes: RenameNoteRef[], target: string): RenameNoteRef | null { - const trimmed = stripMdExtension(normalizeSlashes(target.trim())) - .replace(/^\/+/, '') - .replace(/\/+$/, '') - if (!trimmed) return null - - const suffix = normalizeForCompare(`/${trimmed}.md`) - const exact = normalizeForCompare(`${trimmed}.md`) - const matches = notes.filter((note) => { - const path = normalizeForCompare(note.path) - return path === exact || path.endsWith(suffix) - }) - return matches.length === 1 ? matches[0] : null -} - -export function resolveWikilinkTarget( - notes: RenameNoteRef[], - target: string -): RenameNoteRef | null { - const visible = notes.filter((note) => note.folder !== 'trash') - if (isPathLikeWikilinkTarget(target)) { - return resolveExplicitPath(visible, target) ?? resolvePathSuffix(visible, target) - } - const needle = normalizeForCompare(stripMdExtension(target)) - return visible.find((note) => normalizeForCompare(note.title) === needle) ?? null -} - -/** Split `[[ ... ]]` inner text into target, `#heading`/`^block` anchor, and - * `|alias` — the anchor/alias keep their leading delimiter so the link can be - * reassembled verbatim. */ -function splitWikilinkContent(content: string): { - target: string - anchor: string - alias: string -} { - let rest = content - let alias = '' - const pipe = rest.indexOf('|') - if (pipe >= 0) { - alias = rest.slice(pipe) - rest = rest.slice(0, pipe) - } - let anchor = '' - const anchorIdx = rest.search(/[#^]/) - if (anchorIdx >= 0) { - anchor = rest.slice(anchorIdx) - rest = rest.slice(0, anchorIdx) - } - return { target: rest, anchor, alias } -} - -/** Replace a wikilink target's final segment (the renamed file's name) with the - * new title, preserving any directory prefix, leading slash, and `.md`. */ -function swapBasename(target: string, newTitle: string): string { - const slash = target.lastIndexOf('/') - const dir = slash >= 0 ? target.slice(0, slash + 1) : '' - const base = slash >= 0 ? target.slice(slash + 1) : target - const md = base.match(/\.md$/i) - return `${dir}${newTitle}${md ? md[0] : ''}` -} - -// Matches a fenced code block, inline code, or a (possibly embedded) wikilink. -// Code is matched first so links inside code spans/blocks are left untouched. -const TOKEN_RE = /(```[\s\S]*?```|`[^`\n]*`)|(!?)\[\[([^\]\n]+?)\]\]/g - -/** - * Rewrite every inbound `[[target]]` / `![[target]]` in `body` whose target - * resolves to the note at `oldPath`, pointing it at `newTitle` instead. Aliases, - * `#heading` / `^block` anchors, and embeds are preserved; code is skipped. - * - * `notes` must reflect the pre-rename vault (the renamed note still under its - * old title/path) so resolution matches what the links currently point to. - */ -export function rewriteWikilinksForRename( - body: string, - notes: RenameNoteRef[], - oldPath: string, - newTitle: string -): { body: string; changed: number } { - let changed = 0 - const next = body.replace(TOKEN_RE, (full, code, embed, content) => { - if (code !== undefined) return full - const { target, anchor, alias } = splitWikilinkContent(content as string) - if (resolveWikilinkTarget(notes, target)?.path !== oldPath) return full - const newTarget = swapBasename(target, newTitle) - if (newTarget === target) return full - changed++ - return `${embed}[[${newTarget}${anchor}${alias}]]` - }) - return { body: next, changed } -} +export { + isPathLikeWikilinkTarget, + resolveWikilinkTarget, + rewriteWikilinksForRename +} from '@zennotes/shared-domain/wikilink-rename' +export type { RenameNoteRef } from '@zennotes/shared-domain/wikilink-rename' diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 396be024..d91ac389 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -123,7 +123,8 @@ const DESKTOP_APP_INFO: ZenAppInfo = { version: appPackage.version, description: appPackage.description, homepage: appPackage.homepage, - runtime: 'desktop' + runtime: 'desktop', + hostKind: 'desktop' } let remoteWorkspaceInfo: RemoteWorkspaceInfo | null = null diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index 1b32a4aa..16bc1b50 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -1,86 +1,5 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ['./src/renderer/index.html', '../../packages/app-core/src/**/*.{ts,tsx}'], - theme: { - extend: { - colors: { - paper: { - 50: 'rgb(var(--z-bg-softer) / )', - 100: 'rgb(var(--z-bg) / )', - 200: 'rgb(var(--z-bg-1) / )', - 300: 'rgb(var(--z-bg-2) / )', - 400: 'rgb(var(--z-bg-3) / )', - 500: 'rgb(var(--z-bg-4) / )' - }, - ink: { - 900: 'rgb(var(--z-fg) / )', - 800: 'rgb(var(--z-fg-1) / )', - 700: 'rgb(var(--z-fg-2) / )', - 600: 'rgb(var(--z-grey-2) / )', - 500: 'rgb(var(--z-grey-1) / )', - 400: 'rgb(var(--z-grey-0) / )', - 300: 'rgb(var(--z-grey-dim) / )' - }, - accent: { - DEFAULT: 'rgb(var(--z-accent) / )', - soft: 'rgb(var(--z-accent-soft) / )', - muted: 'rgb(var(--z-accent-muted) / )' - }, - danger: 'rgb(var(--z-red) / )', - success: 'rgb(var(--z-green) / )', - warning: 'rgb(var(--z-yellow) / )' - }, - borderRadius: { - // Scale every rounded-* by --z-radius-scale (default 1) so one var can - // square all corners (Quick tweaks → Square corners sets it to 0). - // rounded-none / rounded-full keep Tailwind defaults, so pills and - // circles stay round. - DEFAULT: 'calc(0.25rem * var(--z-radius-scale, 1))', - sm: 'calc(0.125rem * var(--z-radius-scale, 1))', - md: 'calc(0.375rem * var(--z-radius-scale, 1))', - lg: 'calc(0.5rem * var(--z-radius-scale, 1))', - xl: 'calc(0.75rem * var(--z-radius-scale, 1))', - '2xl': 'calc(1rem * var(--z-radius-scale, 1))', - '3xl': 'calc(1.5rem * var(--z-radius-scale, 1))' - }, - fontFamily: { - sans: [ - '-apple-system', - 'BlinkMacSystemFont', - '"SF Pro Text"', - '"Inter"', - 'system-ui', - 'sans-serif' - ], - serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], - mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] - }, - boxShadow: { - panel: - '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', - float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' - }, - fontSize: { - '2xs': ['0.6875rem', { lineHeight: '1rem' }] - }, - zIndex: { - dropdown: '40', - palette: '50', - modal: '70', - nested: '75', - popover: '80', - toast: '90' - }, - maxWidth: { - 'dialog-xs': '420px', - 'dialog-sm': '440px', - 'dialog-md': '560px', - 'dialog-lg': '720px', - 'dialog-xl': '900px', - 'dialog-2xl': '1120px', - 'dialog-3xl': '1360px' - } - } - }, - plugins: [] + presets: [require('../../packages/app-core/build/tailwind-preset.cjs')], + content: ['./src/renderer/index.html', '../../packages/app-core/src/**/*.{ts,tsx}'] } diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 00000000..97627704 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,88 @@ +# ZenNotes self-hosted server + +This Go service owns filesystem access, the self-hosted HTTP API, authentication, +configuration, and vault watching. Laravel owns ZenNotes Cloud accounts, billing, +sync, and publishing in the separate private website repository. + +The planned server repository is [ZenNotes/znserver](https://github.com/ZenNotes/znserver). +Source and distribution channels still live in the main repository during the migration. +The Go module path stays unchanged until the extraction checkpoint is ready. + +## Develop and test with Go alone + +Requires Go 1.25 or later. From this directory: + +```sh +go vet ./... +go test ./... +go run ./cmd/zennotes-server +``` + +These commands do not require Node, npm, a sibling checkout, or `web/dist`. +The default binary serves the API. It logs that the browser bundle is absent. +For browser development, run the Vite client separately with `npm run dev:web` +from the main repository and use a dedicated test vault and auth token. + +## Build a binary with the browser app + +Production builds use Go's `embed_web` build tag. They require the web distribution +under `web/dist`, including `index.html`. Missing assets fail compilation or the +bundle check. Go's [build constraints](https://pkg.go.dev/cmd/go#hdr-Build_constraints) +keep the [embedded assets](https://pkg.go.dev/embed) out of ordinary Go tests. + +From the main repository: + +```sh +npm run build --workspace @zennotes/web +npm run build --workspace @zennotes/server +``` + +The server build stages the web distribution and holds the existing asset lock +through the bundle check and Go compilation. Its output is `bin/zennotes-server` +(`bin/zennotes-server.exe` on Windows). + +With a prebuilt web distribution already staged, the standalone Go commands are: + +```sh +go test -tags=embed_web ./web +go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server +``` + +Docker and the server Nix package also select `embed_web`. Existing binary names, +configuration variables, API routes, authentication, and vault formats are unchanged. +The source-based distribution channels stay in place during the migration. + +## Build from a pinned browser archive + +The main repository can produce a candidate with `npm run artifact:web`. Its output +under `dist/web-artifacts` contains an immutable `.tgz` and a JSON manifest naming +the protocol, source commit, toolchain, archive checksum, and every asset checksum. +This is separate from desktop releases and from the public share viewer. + +In a clean server build directory, place the reviewed manifest next to its archive: + +```sh +go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist +go test -tags=embed_web ./web +go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server +``` + +Only the artifact producer needs Node. The Go consumer accepts an adjacent archive, +an explicit `-archive` path, or the manifest's HTTPS URL. The manifest is the trust +anchor and must be reviewed and pinned with the server source. Checksums detect +changed downloads; they do not authenticate an independently replaced manifest. + +Uncommitted source candidates require `-allow-dirty` for local testing and have no +release URL. No artifacts from this migration have been published. The candidate +CI workflow only transfers build artifacts between jobs; it does not make a release. + +Use a privately owned build directory. Concurrent cooperating installs are rejected +using an exclusive `.install-lock` beside the output. Other processes must not +modify the output or its parents during the build. If an installer crashes, confirm +it has exited before removing its stale lock. Existing matching assets are verified +and reused; a different build requires a new clean output directory. + +The importer rejects unsupported protocols, ambiguous manifests, traversal, links, +unexpected or duplicate files, size violations, and checksum mismatches before +exposing assets. The protocol marker `self-hosted-http-v1` identifies the tested +HTTP behavior; it is independent of the legacy `/api/version` response. diff --git a/apps/server/cmd/prepare-web/main.go b/apps/server/cmd/prepare-web/main.go new file mode 100644 index 00000000..92c38727 --- /dev/null +++ b/apps/server/cmd/prepare-web/main.go @@ -0,0 +1,28 @@ +// prepare-web installs the browser archive pinned by a reviewed local manifest. +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/ZenNotes/zennotes/apps/server/internal/webartifact" +) + +func main() { + manifest := flag.String("manifest", "", "path to the pinned browser artifact manifest") + archive := flag.String("archive", "", "optional local archive (otherwise use the adjacent file or manifest HTTPS URL)") + output := flag.String("output", "", "new output directory, typically web/dist in a clean build") + allowDirty := flag.Bool("allow-dirty", false, "allow uncommitted source candidates for local testing") + flag.Parse() + if *manifest == "" || *output == "" || flag.NArg() != 0 { + flag.Usage() + os.Exit(2) + } + if err := webartifact.Install(context.Background(), *manifest, *archive, *output, *allowDirty); err != nil { + fmt.Fprintln(os.Stderr, "prepare-web:", err) + os.Exit(1) + } + fmt.Println("Verified browser artifact installed:", *output) +} diff --git a/apps/server/internal/httpserver/contract_test.go b/apps/server/internal/httpserver/contract_test.go new file mode 100644 index 00000000..058495aa --- /dev/null +++ b/apps/server/internal/httpserver/contract_test.go @@ -0,0 +1,179 @@ +package httpserver + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "reflect" + "testing" + + "github.com/ZenNotes/zennotes/apps/server/internal/config" +) + +func TestSelfHostedHTTPContract(t *testing.T) { + data, err := os.ReadFile("testdata/self-hosted-http.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + SchemaVersion int + Protocol string + MountPaths []string + RoutePrefixes []string + Note struct { + Path, Body, UpdatedBody string + AssetEmbeds, UpdatedAssetEmbeds []string + } + RequiredNoteFields, RequiredCapabilities []string + Errors struct { + Unauthenticated, MissingNote, DirectoryAsNote int + Challenge string + } + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if fixture.SchemaVersion != 1 || fixture.Protocol != "self-hosted-http-v1" { + t.Fatal("unsupported fixture") + } + for _, base := range fixture.MountPaths { + for _, prefix := range fixture.RoutePrefixes { + t.Run("mount="+base+", api="+prefix, func(t *testing.T) { + root := t.TempDir() + const token = "test-only-contract-token" + server, v := newTestServer(t, config.Config{VaultPath: root, DefaultVaultPath: root, BasePath: base, AuthToken: token, BrowseRoots: []string{root}}) + if _, err := v.WriteNote(fixture.Note.Path, fixture.Note.Body); err != nil { + t.Fatal(err) + } + endpoint := server.URL + base + prefix + request := func(client *http.Client, method, path string, body any, bearer bool) *http.Response { + t.Helper() + var input io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + input = bytes.NewReader(encoded) + } + req, err := http.NewRequest(method, endpoint+path, input) + if err != nil { + t.Fatal(err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if bearer { + req.Header.Set("Authorization", "Bearer "+token) + } + response, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { response.Body.Close() }) + return response + } + decode := func(response *http.Response, status int) map[string]json.RawMessage { + t.Helper() + if response.StatusCode != status { + body, _ := io.ReadAll(response.Body) + t.Fatalf("status %d, want %d: %s", response.StatusCode, status, body) + } + var value map[string]json.RawMessage + if err := json.NewDecoder(response.Body).Decode(&value); err != nil { + t.Fatal(err) + } + return value + } + assertNote := func(value map[string]json.RawMessage, embeds []string, body *string) { + t.Helper() + for _, field := range fixture.RequiredNoteFields { + if _, ok := value[field]; !ok { + t.Errorf("missing required note field: %s", field) + } + } + var assets []string + if err := json.Unmarshal(value["assetEmbeds"], &assets); err != nil { + t.Error(err) + } else if !reflect.DeepEqual(assets, embeds) { + t.Errorf("assetEmbeds = %#v, want %#v", assets, embeds) + } + if body != nil { + var actual string + if err := json.Unmarshal(value["body"], &actual); err != nil { + t.Fatal(err) + } + if actual != *body { + t.Fatal("HTTP response changed Markdown bytes") + } + } + } + caps := decode(request(http.DefaultClient, "GET", "/capabilities", nil, false), 200) + for _, field := range fixture.RequiredCapabilities { + if _, ok := caps[field]; !ok { + t.Errorf("missing capability: %s", field) + } + } + readPath := "/notes/read?path=" + url.QueryEscape(fixture.Note.Path) + anonymous := request(http.DefaultClient, "GET", readPath, nil, false) + if anonymous.StatusCode != fixture.Errors.Unauthenticated || anonymous.Header.Get("WWW-Authenticate") != fixture.Errors.Challenge { + t.Fatalf("anonymous response: status=%d, challenge=%q", anonymous.StatusCode, anonymous.Header.Get("WWW-Authenticate")) + } + assertNote(decode(request(http.DefaultClient, "GET", readPath, nil, true), 200), fixture.Note.AssetEmbeds, &fixture.Note.Body) + update := map[string]string{"path": fixture.Note.Path, "body": fixture.Note.UpdatedBody} + assertNote(decode(request(http.DefaultClient, "POST", "/notes/write", update, true), 200), fixture.Note.UpdatedAssetEmbeds, nil) + assertNote(decode(request(http.DefaultClient, "GET", readPath, nil, true), 200), fixture.Note.UpdatedAssetEmbeds, &fixture.Note.UpdatedBody) + stored, err := v.ReadNote(fixture.Note.Path) + if err != nil || stored.Body != fixture.Note.UpdatedBody { + t.Fatalf("stored bytes differ: %v", err) + } + for path, status := range map[string]int{"missing.md": fixture.Errors.MissingNote, "inbox": fixture.Errors.DirectoryAsNote} { + if response := request(http.DefaultClient, "GET", "/notes/read?path="+url.QueryEscape(path), nil, true); response.StatusCode != status { + t.Errorf("read %s: got %d, want %d", path, response.StatusCode, status) + } + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + apiURL, _ := url.Parse(server.URL + base + "/api/session") + jar.SetCookies(apiURL, []*http.Cookie{{Name: sessionCookieName, Value: "old-narrow-cookie", Path: base + "/api"}}) + login := request(client, "POST", "/session/login", map[string]string{"token": token}, false) + decode(login, 200) + validCookie := false + for _, cookie := range login.Cookies() { + if cookie.Name == sessionCookieName && cookie.Value != "" && cookie.Path == base+"/" { + validCookie = true + } + } + if !validCookie { + t.Errorf("session cookie does not cover mount %q", base) + } + if cookies := jar.Cookies(apiURL); len(cookies) != 1 || cookies[0].Value == "old-narrow-cookie" { + t.Fatal("login did not replace the old API cookie") + } + + assertNote(decode(request(client, "GET", readPath, nil, false), 200), fixture.Note.UpdatedAssetEmbeds, &fixture.Note.UpdatedBody) + // A cached client can change route families after a browser upgrade. + if prefix == "/api" { + endpoint = server.URL + base + } else { + endpoint = server.URL + base + "/api" + } + assertNote(decode(request(client, "GET", readPath, nil, false), 200), fixture.Note.UpdatedAssetEmbeds, &fixture.Note.UpdatedBody) + decode(request(client, "POST", "/session/logout", nil, false), 200) + if len(jar.Cookies(apiURL)) != 0 { + t.Fatal("logout left a session cookie") + } + if response := request(client, "GET", readPath, nil, false); response.StatusCode != 401 { + t.Fatal("logout left an authenticated session") + } + }) + } + } +} diff --git a/apps/server/internal/httpserver/security.go b/apps/server/internal/httpserver/security.go index 97e084fc..70bdf6b4 100644 --- a/apps/server/internal/httpserver/security.go +++ b/apps/server/internal/httpserver/security.go @@ -469,9 +469,10 @@ func sessionStatusPayload(authenticated bool, cfg config.Config) map[string]any func (s *Server) sessionCookie(r *http.Request, token string, expiresAt time.Time) *http.Cookie { cookie := &http.Cookie{ - Name: sessionCookieName, - Value: token, - Path: "/api", + Name: sessionCookieName, + Value: token, + // One path supports canonical and legacy routes across browser upgrades. + Path: config.NormalizeBasePath(s.currentConfig().BasePath) + "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: expiresAt, @@ -488,6 +489,12 @@ func (s *Server) clearSessionCookie(r *http.Request) *http.Cookie { return cookie } +func (s *Server) expireOldAPICookie(w http.ResponseWriter, r *http.Request) { + cookie := s.clearSessionCookie(r) + cookie.Path = config.NormalizeBasePath(s.currentConfig().BasePath) + "/api" + http.SetCookie(w, cookie) +} + func (s *Server) requestAuthenticatedViaSession(r *http.Request) bool { cookie, err := r.Cookie(sessionCookieName) if err != nil { @@ -548,6 +555,7 @@ func (s *Server) sessionLogin(w http.ResponseWriter, r *http.Request) { return } http.SetCookie(w, s.sessionCookie(r, token, expiresAt)) + s.expireOldAPICookie(w, r) writeJSON(w, http.StatusOK, sessionStatusPayload(true, cfg)) return } @@ -556,10 +564,13 @@ func (s *Server) sessionLogin(w http.ResponseWriter, r *http.Request) { } func (s *Server) sessionLogout(w http.ResponseWriter, r *http.Request) { - if cookie, err := r.Cookie(sessionCookieName); err == nil { - s.sessions.delete(cookie.Value) + for _, cookie := range r.Cookies() { + if cookie.Name == sessionCookieName { + s.sessions.delete(cookie.Value) + } } http.SetCookie(w, s.clearSessionCookie(r)) + s.expireOldAPICookie(w, r) writeJSON(w, http.StatusOK, sessionStatusPayload(false, s.currentConfig())) } @@ -617,6 +628,7 @@ func (s *Server) sessionRotateToken(w http.ResponseWriter, r *http.Request) { } s.sessions.deleteAll() http.SetCookie(w, s.clearSessionCookie(r)) + s.expireOldAPICookie(w, r) writeJSON(w, http.StatusOK, map[string]any{"rotated": true}) } diff --git a/apps/server/internal/httpserver/testdata/self-hosted-http.json b/apps/server/internal/httpserver/testdata/self-hosted-http.json new file mode 100644 index 00000000..d799b7b7 --- /dev/null +++ b/apps/server/internal/httpserver/testdata/self-hosted-http.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "protocol": "self-hosted-http-v1", + "mountPaths": [ + "", + "/notes" + ], + "note": { + "path": "inbox/Contract.md", + "body": "# Contract\n\nUnicode café 日本語. \n\n![[photo.png]]\n![](assets/document.pdf)\n", + "updatedBody": "# Contract\n\nUpdated café 日本語. \n\n![Photo]()\n\n", + "assetEmbeds": [ + "photo.png", + "assets/document.pdf" + ], + "updatedAssetEmbeds": [ + "assets/photo two.png" + ] + }, + "requiredNoteFields": [ + "path", + "title", + "folder", + "siblingOrder", + "createdAt", + "updatedAt", + "size", + "tags", + "wikilinks", + "assetEmbeds", + "hasAttachments", + "excerpt" + ], + "requiredCapabilities": [ + "version", + "platform", + "authRequired", + "supportsSessionLogin", + "browseRootsEnforced", + "supportsVaultSelection", + "supportsDirectoryBrowsing", + "supportsWatch", + "reportsMissingAsNotFound", + "supportsAssetOps", + "supportsWorkflows", + "supportsCustomTemplates" + ], + "errors": { + "unauthenticated": 401, + "challenge": "Bearer realm=\"ZenNotes\"", + "missingNote": 404, + "directoryAsNote": 400 + }, + "routePrefixes": [ + "/api", + "" + ] +} diff --git a/apps/server/internal/httpserver/testdata/self-hosted-http.json.source.json b/apps/server/internal/httpserver/testdata/self-hosted-http.json.source.json new file mode 100644 index 00000000..096d2fe5 --- /dev/null +++ b/apps/server/internal/httpserver/testdata/self-hosted-http.json.source.json @@ -0,0 +1,5 @@ +{ + "sourceRepository": "https://github.com/ZenNotes/zennotes", + "sourcePath": "packages/bridge-contract/fixtures/self-hosted-http.json", + "sha256": "a52743639aa8641ac206a8a887874e7d6034828118725fb89502a661c5890437" +} diff --git a/apps/server/internal/vault/main_test.go b/apps/server/internal/vault/main_test.go new file mode 100644 index 00000000..7d8a51f2 --- /dev/null +++ b/apps/server/internal/vault/main_test.go @@ -0,0 +1,17 @@ +package vault + +import ( + "os" + "testing" +) + +// ListNotes schedules a note-meta cache write one second later. Tests remove +// their temporary vaults as soon as they finish, and on Windows that write +// raced the removal ("The directory is not empty"). Keep the writer off for +// this package's tests; a test that opts back in must drain it with Close. +func TestMain(m *testing.M) { + if os.Getenv("ZEN_PERF_DISABLE_PERSISTED_META_CACHE") == "" { + _ = os.Setenv("ZEN_PERF_DISABLE_PERSISTED_META_CACHE", "1") + } + os.Exit(m.Run()) +} diff --git a/apps/server/internal/vault/parse.go b/apps/server/internal/vault/parse.go index 4b9a6d00..8251c320 100644 --- a/apps/server/internal/vault/parse.go +++ b/apps/server/internal/vault/parse.go @@ -1,11 +1,54 @@ package vault import ( + "net/url" "regexp" "strings" "unicode" + "unicode/utf8" ) +// RE2's \s is ASCII-only; JavaScript also treats these Unicode characters as whitespace. +const assetEmbedSpaceClass = `\t\n\v\f\r \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}` +const assetEmbedTrimSpace = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff" + +var assetEmbedMarkdownRe = regexp.MustCompile(`!\[[^\]]*\]\([` + assetEmbedSpaceClass + `]*` + assetEmbedSpaceClass + `]+)>?[^)]*\)`) +var assetEmbedSchemeRe = regexp.MustCompile(`^[a-zA-Z][\w+.\-]*:`) + +// ExtractAssetEmbeds mirrors extractAssetEmbeds in apps/desktop/src/main/vault.ts. +// Keep target order and URI decoding equivalent so both hosts report the same usage. +func ExtractAssetEmbeds(body string) []string { + stripped := stripCodeContent(body) + out := []string{} + seen := map[string]bool{} + add := func(target string) { + if !seen[target] { + seen[target] = true + out = append(out, target) + } + } + for _, match := range embedRe.FindAllStringSubmatch(stripped, -1) { + target := strings.Trim(match[1], assetEmbedTrimSpace) + // Desktop also accepts generic file extensions, beyond previewable media. + clean := strings.SplitN(strings.SplitN(target, "#", 2)[0], "?", 2)[0] + if strings.Contains(clean, ".") { + add(target) + } + } + for _, match := range assetEmbedMarkdownRe.FindAllStringSubmatch(stripped, -1) { + raw := strings.Trim(match[1], assetEmbedTrimSpace) + if raw == "" || strings.HasPrefix(raw, "#") || assetEmbedSchemeRe.MatchString(raw) { + continue + } + if decoded, err := url.PathUnescape(raw); err == nil && utf8.ValidString(decoded) { + add(decoded) + } else { + add(raw) + } + } + return out +} + // Regexes below mirror the TS extractors in src/main/vault.ts. They are // intentionally the same shape so the extracted metadata matches the // desktop build byte-for-byte for the common cases. diff --git a/apps/server/internal/vault/parse_test.go b/apps/server/internal/vault/parse_test.go index 50ac204b..cce8b694 100644 --- a/apps/server/internal/vault/parse_test.go +++ b/apps/server/internal/vault/parse_test.go @@ -1,6 +1,9 @@ package vault -import "testing" +import ( + "reflect" + "testing" +) func TestBodyHasLocalAssetDetectsOnlyLocalAssets(t *testing.T) { cases := []struct { @@ -315,3 +318,27 @@ func TestParseTasksWithIncludeExcluded(t *testing.T) { t.Errorf("inline ids %q, %q, want #0 and #1", tasks[1].ID, tasks[2].ID) } } + +func TestExtractAssetEmbedsMatchesDesktopTargets(t *testing.T) { + cases := []struct { + name, body string + want []string + }{ + {"empty", "plain text", []string{}}, + {"JavaScript whitespace", "![](\u00a0) ![](first\u00a0part.png) ![[\ufeffdrawing.psd\ufeff]]", []string{"drawing.psd", "photo.png", "first"}}, + {"generic files", "![[design.psd|Source]] ![[file.zip#section]] ![[file.custom?download]] ![[Note#dot.png]]", []string{"design.psd", "file.zip#section", "file.custom?download"}}, + {"wiki assets only", "![[photo.png|Preview]] ![[brief.pdf]] ![[Other Note]] [[plain.png]]", []string{"photo.png", "brief.pdf"}}, + {"wiki before markdown with deduplication", "![](a.png) ![[b.png]] ![[a.png]] ![](b.png)", []string{"b.png", "a.png"}}, + {"decoded targets", "![Photo]( \"title\") ![](plus+sign.png) ![](%E6%97%A5.png)", []string{"assets/photo two.png", "plus+sign.png", "日.png"}}, + {"malformed escapes", "![](bad%GG.png) ![](%FF.png)", []string{"bad%GG.png", "%FF.png"}}, + {"remote and anchors", "![](https://example.com/x.png) ![](data:image/png;base64,abc) ![](custom_app:x) ![](#heading)", []string{}}, + {"code", "`![[inline.png]]`\n ~~~md\n![](fenced.png)\n ~~~\n![[visible.png]]", []string{"visible.png"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ExtractAssetEmbeds(tc.body); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %#v, want %#v", got, tc.want) + } + }) + } +} diff --git a/apps/server/internal/vault/task_roundtrip_contract_test.go b/apps/server/internal/vault/task_roundtrip_contract_test.go new file mode 100644 index 00000000..bc5411f7 --- /dev/null +++ b/apps/server/internal/vault/task_roundtrip_contract_test.go @@ -0,0 +1,87 @@ +package vault + +import ( + "encoding/json" + "os" + "reflect" + "testing" +) + +func TestSharedTaskRoundtripContract(t *testing.T) { + data, err := os.ReadFile("testdata/task-roundtrip.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + SchemaVersion int `json:"schemaVersion"` + Cases []struct { + ID string `json:"id"` + Note struct { + Path string `json:"path"` + Title string `json:"title"` + Folder NoteFolder `json:"folder"` + } `json:"note"` + Body string `json:"body"` + ExpectedBody string `json:"expectedBody"` + TaskIndex int `json:"taskIndex"` + ExpectedBefore map[string]any `json:"expectedBefore"` + ExpectedAfter map[string]any `json:"expectedAfter"` + ExpectedTaskCount int `json:"expectedTaskCount"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if fixture.SchemaVersion != 1 || len(fixture.Cases) == 0 { + t.Fatal("unsupported or empty task contract fixture") + } + for _, tc := range fixture.Cases { + t.Run(tc.ID, func(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + var originalID string + // The client transforms Markdown; Go stores those exact bytes and + // parses the resulting task state for the next client read. + for index, phase := range []struct { + body string + want map[string]any + }{{tc.Body, tc.ExpectedBefore}, {tc.ExpectedBody, tc.ExpectedAfter}} { + if _, err := v.WriteNote(tc.Note.Path, phase.body); err != nil { + t.Fatal(err) + } + note, err := v.ReadNote(tc.Note.Path) + if err != nil { + t.Fatal(err) + } + if note.Body != phase.body { + t.Fatal("storage changed Markdown bytes") + } + tasks := ParseTasks(tc.Note.Path, tc.Note.Title, tc.Note.Folder, note.Body) + if len(tasks) != tc.ExpectedTaskCount || tc.TaskIndex < 0 || tc.TaskIndex >= len(tasks) { + t.Fatalf("got %d tasks, want %d with index %d", len(tasks), tc.ExpectedTaskCount, tc.TaskIndex) + } + task := tasks[tc.TaskIndex] + if index == 0 { + originalID = task.ID + } else if task.ID != originalID { + t.Fatal("task identity changed after editing") + } + encoded, err := json.Marshal(task) + if err != nil { + t.Fatal(err) + } + var actual map[string]any + if err := json.Unmarshal(encoded, &actual); err != nil { + t.Fatal(err) + } + for field, want := range phase.want { + if !reflect.DeepEqual(actual[field], want) { + t.Errorf("phase %d field %s: got %#v, want %#v", index, field, actual[field], want) + } + } + } + }) + } +} diff --git a/apps/server/internal/vault/testdata/task-roundtrip.json b/apps/server/internal/vault/testdata/task-roundtrip.json new file mode 100644 index 00000000..b3988cac --- /dev/null +++ b/apps/server/internal/vault/testdata/task-roundtrip.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "reschedule-in-progress-task-without-changing-other-content", + "note": { "path": "inbox/Release.md", "title": "Release", "folder": "inbox" }, + "body": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release due:2026-09-15 !high #release\n- [ ] Next item\n", + "taskIndex": 0, + "due": "2026-09-16", + "expectedBefore": { "due": "2026-09-15", "inProgress": true }, + "expectedBody": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release !high #release due:2026-09-16\n- [ ] Next item\n", + "expectedAfter": { "due": "2026-09-16", "inProgress": true, "checked": false, "priority": "high", "tags": ["release"] }, + "expectedTaskCount": 2 + }, + { + "id": "assign-local-today-near-midnight", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [ ] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 0, 15], + "expectedBefore": { "checked": false }, + "expectedBody": "# Today\n\n- [ ] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false }, + "expectedTaskCount": 1 + }, + { + "id": "assign-local-today-late-at-night", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [/] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 23, 45], + "expectedBefore": { "inProgress": true }, + "expectedBody": "# Today\n\n- [/] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false, "inProgress": true }, + "expectedTaskCount": 1 + } + ] +} diff --git a/apps/server/internal/vault/testdata/task-roundtrip.json.source.json b/apps/server/internal/vault/testdata/task-roundtrip.json.source.json new file mode 100644 index 00000000..c9f327de --- /dev/null +++ b/apps/server/internal/vault/testdata/task-roundtrip.json.source.json @@ -0,0 +1,5 @@ +{ + "sourceRepository": "https://github.com/ZenNotes/zennotes", + "sourcePath": "packages/bridge-contract/fixtures/task-roundtrip.json", + "sha256": "59705ba724a1a96822ec47a2cbcc7fd09e140036972ad1769869eac9b4cac37f" +} diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go index 79bfde3c..8911b067 100644 --- a/apps/server/internal/vault/types.go +++ b/apps/server/internal/vault/types.go @@ -331,6 +331,7 @@ type NoteMeta struct { Size int64 `json:"size"` Tags []string `json:"tags"` Wikilinks []string `json:"wikilinks"` + AssetEmbeds []string `json:"assetEmbeds"` HasAttachments bool `json:"hasAttachments"` Excerpt string `json:"excerpt"` } diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index a52bfcff..1add6ec7 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -9,6 +9,7 @@ import ( "hash/fnv" "io" "io/fs" + "log" "math" "os" "path/filepath" @@ -26,7 +27,7 @@ const ( internalVaultDir = ".zennotes" vaultSettingsFile = "vault.json" noteMetaCacheFile = "note-meta-cache-v1.json" - noteMetaCacheVersion = 1 + noteMetaCacheVersion = 2 noteCommentsDir = "comments" noteCommentsSuffix = ".comments.json" noteMetaReadLimit = 64 @@ -196,6 +197,17 @@ type Vault struct { // first, settingsMu second, and never the reverse. settingsMu sync.Mutex settingsCache *cachedVaultSettings + // pending counts delayed background writers, currently the note-meta + // cache snapshot, so Close can drain them before a caller removes the + // vault directory. Windows refuses to delete a directory a writer is + // still creating files in. + pending sync.WaitGroup +} + +// Close waits for background writers such as the delayed note-meta cache +// snapshot. The vault remains usable afterwards; Close only drains. +func (v *Vault) Close() { + v.pending.Wait() } // cachedVaultSettings is a parsed vault.json plus the identity of the bytes it @@ -1025,7 +1037,7 @@ func validCachedNoteMeta(meta NoteMeta, path string) bool { if meta.Path != path || meta.Title == "" || !IsValidFolder(meta.Folder) { return false } - if meta.Tags == nil || meta.Wikilinks == nil { + if meta.Tags == nil || meta.Wikilinks == nil || meta.AssetEmbeds == nil { return false } return true @@ -1087,7 +1099,9 @@ func (v *Vault) persistNoteMetaCacheSnapshot(metas []NoteMeta) { } metas = append([]NoteMeta(nil), metas...) + v.pending.Add(1) go func(metas []NoteMeta, generation uint64) { + defer v.pending.Done() time.Sleep(time.Second) entries := make([]persistedNoteMetaEntry, 0, len(metas)) @@ -1456,19 +1470,21 @@ func kindForExt(ext string) string { // color like "#1971c2" in the scene must not register as a #tag. func buildNoteMeta(relPosix, title string, folder NoteFolder, info os.FileInfo, bodyStr, preambleFolder string) NoteMeta { meta := NoteMeta{ - Path: relPosix, - Title: title, - Folder: folder, - CreatedAt: info.ModTime().UnixMilli(), - UpdatedAt: info.ModTime().UnixMilli(), - Size: info.Size(), - Tags: []string{}, - Wikilinks: []string{}, + Path: relPosix, + Title: title, + Folder: folder, + CreatedAt: info.ModTime().UnixMilli(), + UpdatedAt: info.ModTime().UnixMilli(), + Size: info.Size(), + Tags: []string{}, + Wikilinks: []string{}, + AssetEmbeds: []string{}, } if isExcalidrawName(relPosix) { return meta } meta.Wikilinks = ExtractWikilinks(bodyStr) + meta.AssetEmbeds = ExtractAssetEmbeds(bodyStr) meta.HasAttachments = BodyHasLocalAsset(bodyStr) meta.Excerpt = BuildExcerpt(bodyStr) // A Typst preamble holds Typst source, not prose: `#let vec(x) = bold(x)` @@ -1923,7 +1939,10 @@ func (v *Vault) CreateExcalidraw(folder NoteFolder, title, subpath string) (Note func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { // Snapshot the vault before the rename (ListNotes takes its own read lock) // so inbound [[wikilinks]] still resolve to this note under its current name. - notesBefore, _ := v.ListNotes() + notesBefore, err := v.ListNotes() + if err != nil { + return NoteMeta{}, err + } meta, err := v.renameNoteFile(rel, nextTitle) if err != nil { return NoteMeta{}, err @@ -1948,19 +1967,42 @@ func (v *Vault) renameNoteFile(rel, nextTitle string) (NoteMeta, error) { return NoteMeta{}, errors.New("empty title") } dir := filepath.Dir(abs) - newAbs := uniquePath(dir, nextTitle, noteExt(abs)) - if err := os.Rename(abs, newAbs); err != nil { + desired := filepath.Join(dir, nextTitle+noteExt(abs)) + newAbs := desired + source, err := os.Stat(abs) + if err != nil { return NoteMeta{}, err } - v.invalidateTextSearchCache() - folder, _ := v.folderOf(newAbs) - meta, err := v.readMeta(folder, newAbs) + if target, statErr := os.Stat(desired); statErr == nil { + if !os.SameFile(source, target) { + newAbs = uniquePath(dir, nextTitle, noteExt(abs)) + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return NoteMeta{}, statErr + } + nextRel, err := filepath.Rel(v.root, newAbs) if err != nil { return NoteMeta{}, err } - if err := v.moveNoteCommentsLocked(rel, meta.Path); err != nil { + oldComments, err := v.commentsPath(rel) + if err != nil { + return NoteMeta{}, err + } + nextComments, err := v.commentsPath(filepath.ToSlash(nextRel)) + if err != nil { return NoteMeta{}, err } + folder, _ := v.folderOf(newAbs) + var meta NoteMeta + err = v.relocateFolderTrees([][2]string{{abs, newAbs}, {oldComments, nextComments}}, func() error { + var readErr error + meta, readErr = v.readMeta(folder, newAbs) + return readErr + }) + if err != nil { + return NoteMeta{}, err + } + v.invalidateTextSearchCache() return meta, nil } @@ -1974,7 +2016,8 @@ func (v *Vault) rewriteInboundWikilinks(notesBefore []NoteMeta, oldPath, newTitl } linksToIt := false for _, t := range n.Wikilinks { - if r, ok := wikiResolveTarget(notesBefore, t); ok && r.Path == oldPath { + target, _, _ := wikiSplitContent(t) + if r, ok := wikiResolveTarget(notesBefore, target); ok && r.Path == oldPath { linksToIt = true break } @@ -2000,11 +2043,33 @@ func (v *Vault) DeleteNote(rel string) error { if err != nil { return err } - if err := os.Remove(abs); err != nil { + info, err := os.Lstat(abs) + if err != nil { + return err + } + if info.IsDir() { + return errors.New("use the folder action to delete a directory") + } + comments, err := v.commentsPath(rel) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(v.root, internalVaultDir), v.dirMode); err != nil { + return err + } + temporary, err := os.MkdirTemp(filepath.Join(v.root, internalVaultDir), "note-delete-") + if err != nil { + return err + } + if err := v.relocateFolderTrees([][2]string{{abs, filepath.Join(temporary, "content")}, {comments, filepath.Join(temporary, "comments")}}, func() error { return nil }); err != nil { return err } v.invalidateTextSearchCache() - return v.removeNoteCommentsLocked(rel) + // Cleanup cannot resurrect comments at a live note path. + if err := os.RemoveAll(temporary); err != nil { + log.Printf("note cleanup pending: %v", err) + } + return nil } // --- Trash / Restore / Archive / Unarchive / Duplicate / Move --- @@ -2069,33 +2134,60 @@ func (v *Vault) moveBetweenFolders(rel string, target NoteFolder) (NoteMeta, err return NoteMeta{}, err } newAbs := uniquePath(destDir, title, noteExt(abs)) - if err := os.Rename(abs, newAbs); err != nil { + nextRel, err := filepath.Rel(v.root, newAbs) + if err != nil { return NoteMeta{}, err } - v.invalidateTextSearchCache() - meta, err := v.readMeta(target, newAbs) + oldComments, err := v.commentsPath(rel) if err != nil { return NoteMeta{}, err } - if err := v.moveNoteCommentsLocked(rel, meta.Path); err != nil { + nextComments, err := v.commentsPath(filepath.ToSlash(nextRel)) + if err != nil { + return NoteMeta{}, err + } + var meta NoteMeta + err = v.relocateFolderTrees([][2]string{{abs, newAbs}, {oldComments, nextComments}}, func() error { + var readErr error + meta, readErr = v.readMeta(target, newAbs) + return readErr + }) + if err != nil { return NoteMeta{}, err } + v.invalidateTextSearchCache() return meta, nil } func (v *Vault) EmptyTrash() error { v.mu.Lock() defer v.mu.Unlock() - trashDir := filepath.Join(v.root, string(FolderTrash)) - entries, err := os.ReadDir(trashDir) + trashDir, err := v.folderRoot(FolderTrash) if err != nil { - return nil + return err } - for _, e := range entries { - _ = v.removeNoteCommentsLocked(filepath.ToSlash(filepath.Join(string(FolderTrash), e.Name()))) - _ = os.RemoveAll(filepath.Join(trashDir, e.Name())) + rel, err := filepath.Rel(v.root, trashDir) + if err != nil { + return err + } + comments, err := SafeJoin(v.commentsRoot(), rel) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(v.root, internalVaultDir), v.dirMode); err != nil { + return err + } + temporary, err := os.MkdirTemp(filepath.Join(v.root, internalVaultDir), "trash-delete-") + if err != nil { + return err + } + if err := v.relocateFolderTrees([][2]string{{trashDir, filepath.Join(temporary, "content")}, {comments, filepath.Join(temporary, "comments")}}, func() error { return nil }); err != nil { + return err } v.invalidateTextSearchCache() + if err := os.RemoveAll(temporary); err != nil { + log.Printf("trash cleanup pending: %v", err) + } return nil } @@ -2149,17 +2241,28 @@ func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (N } title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) newAbs := uniquePath(destDir, title, noteExt(abs)) - if err := os.Rename(abs, newAbs); err != nil { + nextRel, err := filepath.Rel(v.root, newAbs) + if err != nil { return NoteMeta{}, err } - v.invalidateTextSearchCache() - meta, err := v.readMeta(target, newAbs) + oldComments, err := v.commentsPath(rel) if err != nil { return NoteMeta{}, err } - if err := v.moveNoteCommentsLocked(rel, meta.Path); err != nil { + nextComments, err := v.commentsPath(filepath.ToSlash(nextRel)) + if err != nil { + return NoteMeta{}, err + } + var meta NoteMeta + err = v.relocateFolderTrees([][2]string{{abs, newAbs}, {oldComments, nextComments}}, func() error { + var readErr error + meta, readErr = v.readMeta(target, newAbs) + return readErr + }) + if err != nil { return NoteMeta{}, err } + v.invalidateTextSearchCache() return meta, nil } @@ -2182,6 +2285,53 @@ func (v *Vault) CreateFolder(folder NoteFolder, subpath string) error { return os.MkdirAll(abs, v.dirMode) } +// relocateFolderTrees keeps content and its parallel comment tree together. +func (v *Vault) relocateFolderTrees(moves [][2]string, persistSettings func() error) error { + present := make([][2]string, 0, len(moves)) + for _, move := range moves { + source, err := os.Stat(move[0]) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if target, err := os.Stat(move[1]); err == nil { + if source == nil || !os.SameFile(source, target) { + return errors.New("destination folder or its comments already exist") + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if source != nil { + present = append(present, move) + } + } + moved := make([][2]string, 0, len(present)) + rollback := func(cause error) error { + failures := []error{cause} + for i := len(moved) - 1; i >= 0; i-- { + if err := os.Rename(moved[i][1], moved[i][0]); err != nil { + failures = append(failures, err) + } + } + if len(failures) > 1 { + return fmt.Errorf("FOLDER_STATE_UNCERTAIN: Folder change could not be rolled back; reload the vault before editing: %w", errors.Join(failures...)) + } + return cause + } + for _, move := range present { + if err := os.MkdirAll(filepath.Dir(move[1]), v.dirMode); err != nil { + return rollback(err) + } + if err := os.Rename(move[0], move[1]); err != nil { + return rollback(err) + } + moved = append(moved, move) + } + if err := persistSettings(); err != nil { + return rollback(err) + } + return nil +} + func (v *Vault) RenameFolder(folder NoteFolder, oldSub, newSub string) (string, error) { v.mu.Lock() defer v.mu.Unlock() @@ -2197,31 +2347,40 @@ func (v *Vault) RenameFolder(folder NoteFolder, oldSub, newSub string) (string, if err != nil { return "", err } - if err := os.MkdirAll(filepath.Dir(newAbs), v.dirMode); err != nil { + if oldAbs == base { + return "", errors.New("refusing to rename top-level folder") + } + if _, err := os.Stat(oldAbs); err != nil { return "", err } - if err := os.Rename(oldAbs, newAbs); err != nil { + if strings.HasPrefix(newAbs+string(filepath.Separator), oldAbs+string(filepath.Separator)) && newAbs != oldAbs { + return "", errors.New("cannot move a folder into itself") + } + oldRel, _ := filepath.Rel(v.root, oldAbs) + newRel, _ := filepath.Rel(v.root, newAbs) + oldComments, err := SafeJoin(v.commentsRoot(), oldRel) + if err != nil { + return "", err + } + newComments, err := SafeJoin(v.commentsRoot(), newRel) + if err != nil { return "", err } - v.invalidateTextSearchCache() settings, err := v.GetSettings() if err != nil { return "", err } - _, err = v.SetSettings(VaultSettings{ - PrimaryNotesLocation: settings.PrimaryNotesLocation, - DailyNotes: settings.DailyNotes, - WeeklyNotes: settings.WeeklyNotes, - MonthlyNotes: settings.MonthlyNotes, - FolderIcons: rewriteFolderIconsForRename(settings.FolderIcons, folder, oldSub, newSub), - FolderColors: rewriteFolderColorsForRename(settings.FolderColors, folder, oldSub, newSub), - // Favorites are carried through verbatim; the client rewrites stale - // favorite keys after the rename and re-persists them. - Favorites: settings.Favorites, + err = v.relocateFolderTrees([][2]string{{oldAbs, newAbs}, {oldComments, newComments}}, func() error { + next := settings + next.FolderIcons = rewriteFolderIconsForRename(settings.FolderIcons, folder, oldSub, newSub) + next.FolderColors = rewriteFolderColorsForRename(settings.FolderColors, folder, oldSub, newSub) + _, err := v.SetSettings(next) + return err }) if err != nil { return "", err } + v.invalidateTextSearchCache() rel, _ := filepath.Rel(base, newAbs) return filepath.ToSlash(rel), nil } @@ -2240,26 +2399,38 @@ func (v *Vault) DeleteFolder(folder NoteFolder, subpath string) error { if abs == base { return errors.New("refusing to delete top-level folder") } - if err := os.RemoveAll(abs); err != nil { + rel, _ := filepath.Rel(v.root, abs) + comments, err := SafeJoin(v.commentsRoot(), rel) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(v.root, internalVaultDir), v.dirMode); err != nil { + return err + } + temporary, err := os.MkdirTemp(filepath.Join(v.root, internalVaultDir), "folder-delete-") + if err != nil { return err } - v.invalidateTextSearchCache() settings, err := v.GetSettings() if err != nil { return err } - _, err = v.SetSettings(VaultSettings{ - PrimaryNotesLocation: settings.PrimaryNotesLocation, - DailyNotes: settings.DailyNotes, - WeeklyNotes: settings.WeeklyNotes, - MonthlyNotes: settings.MonthlyNotes, - FolderIcons: removeFolderIcons(settings.FolderIcons, folder, subpath), - FolderColors: removeFolderColors(settings.FolderColors, folder, subpath), - // Favorites are carried through verbatim; the client prunes the deleted - // folder's favorites and re-persists them. - Favorites: settings.Favorites, + err = v.relocateFolderTrees([][2]string{{abs, filepath.Join(temporary, "content")}, {comments, filepath.Join(temporary, "comments")}}, func() error { + next := settings + next.FolderIcons = removeFolderIcons(settings.FolderIcons, folder, subpath) + next.FolderColors = removeFolderColors(settings.FolderColors, folder, subpath) + _, err := v.SetSettings(next) + return err }) - return err + if err != nil { + return err + } + v.invalidateTextSearchCache() + // Cleanup cannot resurrect comments at a live note path. + if err := os.RemoveAll(temporary); err != nil { + log.Printf("folder cleanup pending: %v", err) + } + return nil } func (v *Vault) DuplicateFolder(folder NoteFolder, subpath string) (string, error) { diff --git a/apps/server/internal/vault/vault_test.go b/apps/server/internal/vault/vault_test.go index 0dc1e7cf..3616ca94 100644 --- a/apps/server/internal/vault/vault_test.go +++ b/apps/server/internal/vault/vault_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" @@ -448,6 +449,7 @@ func TestListNotesUsesMatchingPersistedMetadata(t *testing.T) { Size: info.Size(), Tags: []string{"cached"}, Wikilinks: []string{"Cached Target"}, + AssetEmbeds: []string{}, HasAttachments: false, Excerpt: "cached excerpt", }, @@ -506,6 +508,7 @@ func TestListNotesIgnoresStalePersistedMetadata(t *testing.T) { Size: 1, Tags: []string{"stale"}, Wikilinks: []string{}, + AssetEmbeds: []string{}, HasAttachments: false, Excerpt: "stale excerpt", }, @@ -1325,3 +1328,451 @@ func TestNoteCommentsKeepAuthorAndThreadReplies(t *testing.T) { t.Fatalf("orphan reply kept a missing parent: %#v", read[2]) } } + +func TestListNotesRebuildsCacheWithoutAssetEmbeds(t *testing.T) { + for _, version := range []int{1, noteMetaCacheVersion} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + meta, err := v.WriteNote("inbox/asset-cache.md", "![[photo.png]]\n") + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(v.Root(), filepath.FromSlash(meta.Path))) + if err != nil { + t.Fatal(err) + } + meta.AssetEmbeds = nil + cache := persistedNoteMetaCache{Version: version, Entries: []persistedNoteMetaEntry{{Path: meta.Path, MtimeMs: mtimeMs(info), Size: info.Size(), Meta: meta}}} + raw, err := json.Marshal(cache) + if err != nil { + t.Fatal(err) + } + cachePath := v.noteMetaCachePath() + if err := os.MkdirAll(filepath.Dir(cachePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cachePath, raw, 0o600); err != nil { + t.Fatal(err) + } + v.invalidateNoteMetaCache() + notes, err := v.ListNotes() + if err != nil { + t.Fatal(err) + } + got, ok := findNoteMeta(notes, meta.Path) + if !ok || len(got.AssetEmbeds) != 1 || got.AssetEmbeds[0] != "photo.png" { + t.Fatalf("cache did not rebuild asset metadata: %#v", got) + } + }) + } +} + +func TestFolderMutationsPreserveCommentStorage(t *testing.T) { + for _, location := range []PrimaryNotesLocation{PrimaryNotesInbox, PrimaryNotesRoot} { + t.Run(string(location), func(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + settings, err := v.GetSettings() + if err != nil { + t.Fatal(err) + } + settings.PrimaryNotesLocation = PrimaryNotesLocation(location) + settings.SystemFolderPaths = map[string]string{string(FolderInbox): "My Notes"} + if _, err := v.SetSettings(settings); err != nil { + t.Fatal(err) + } + prefix := "My Notes/" + if location == PrimaryNotesRoot { + prefix = "" + } + original := prefix + "Work/Nested/Note.md" + if _, err := v.WriteNote(original, "Body.\n"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNoteComments(original, []NoteComment{{ID: "comment", Body: "Keep this comment", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + if _, err := v.RenameFolder(FolderInbox, "Work", "Renamed"); err != nil { + t.Fatal(err) + } + renamed := prefix + "Renamed/Nested/Note.md" + comments, err := v.ReadNoteComments(renamed) + if err != nil { + t.Fatal(err) + } + if len(comments) != 1 || comments[0].Body != "Keep this comment" || comments[0].NotePath != renamed { + t.Fatalf("comments lost: %#v", comments) + } + old, err := v.ReadNoteComments(original) + if err != nil || len(old) != 0 { + t.Fatalf("old comments remain: %#v %v", old, err) + } + if err := v.DeleteFolder(FolderInbox, "Renamed"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote(renamed, "New note.\n"); err != nil { + t.Fatal(err) + } + comments, err = v.ReadNoteComments(renamed) + if err != nil || len(comments) != 0 { + t.Fatalf("deleted comments returned: %#v %v", comments, err) + } + }) + } +} + +func TestFolderTreesRollbackOnSettingsFailure(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + source := filepath.Join(root, "content") + comments := filepath.Join(root, "comments") + for _, dir := range []string{source, comments} { + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + } + err = v.relocateFolderTrees([][2]string{{source, source + "-new"}, {comments, comments + "-new"}}, func() error { return errors.New("settings failed") }) + if err == nil { + t.Fatal("expected settings failure") + } + for _, dir := range []string{source, comments} { + if _, err := os.Stat(dir); err != nil { + t.Fatal(err) + } + } + for _, dir := range []string{source + "-new", comments + "-new"} { + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("target remains: %s", dir) + } + } +} + +func TestFolderRenameRejectsMissingSourceAndCommentCollision(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + if _, err := v.RenameFolder(FolderInbox, "Missing", "New"); err == nil { + t.Fatal("missing source accepted") + } + if _, err := v.WriteNote("inbox/Work/Note.md", "Original"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNoteComments("inbox/Renamed/Note.md", []NoteComment{{ID: "orphan", Body: "Retain", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + if _, err := v.RenameFolder(FolderInbox, "Work", "Renamed"); err == nil { + t.Fatal("comment collision accepted") + } + if _, err := v.ReadNote("inbox/Work/Note.md"); err != nil { + t.Fatal(err) + } + comments, err := v.ReadNoteComments("inbox/Renamed/Note.md") + if err != nil || len(comments) != 1 { + t.Fatalf("orphan lost: %v %v", comments, err) + } +} + +func TestDeleteFolderInFreshVault(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote("inbox/Work/Note.md", "Original"); err != nil { + t.Fatal(err) + } + if err := v.DeleteFolder(FolderInbox, "Work"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(v.root, "inbox/Work")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("folder remains: %v", err) + } +} + +func TestNoteMoveRetainsSourceOnCommentCollision(t *testing.T) { + for _, withComments := range []bool{false, true} { + t.Run(fmt.Sprint(withComments), func(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + original := "Original café. \n" + if _, err := v.WriteNote("inbox/One.md", original); err != nil { + t.Fatal(err) + } + if withComments { + if _, err := v.WriteNoteComments("inbox/One.md", []NoteComment{{ID: "source", Body: "Source discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + } + if _, err := v.WriteNoteComments("inbox/Work/One.md", []NoteComment{{ID: "destination", Body: "Keep destination", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + if _, err := v.MoveNote("inbox/One.md", FolderInbox, "Work"); err == nil { + t.Fatal("expected collision") + } + content, err := v.ReadNote("inbox/One.md") + if err != nil || content.Body != original { + t.Fatalf("source lost: %#v %v", content, err) + } + if _, err := os.Stat(filepath.Join(v.Root(), "inbox/Work/One.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("target exists: %v", err) + } + comments, err := v.ReadNoteComments("inbox/Work/One.md") + if err != nil || len(comments) != 1 || comments[0].Body != "Keep destination" { + t.Fatalf("target comments changed: %#v %v", comments, err) + } + }) + } +} + +func TestNoteRenameRetainsSourceOnCommentCollision(t *testing.T) { + for _, withComments := range []bool{false, true} { + t.Run(fmt.Sprint(withComments), func(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + original := "Original café. \n" + if _, err := v.WriteNote("inbox/One.md", original); err != nil { + t.Fatal(err) + } + if withComments { + if _, err := v.WriteNoteComments("inbox/One.md", []NoteComment{{ID: "source", Body: "Source discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + } + if _, err := v.WriteNoteComments("inbox/Renamed.md", []NoteComment{{ID: "destination", Body: "Keep destination", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + if _, err := v.RenameNote("inbox/One.md", "Renamed"); err == nil { + t.Fatal("expected collision") + } + content, err := v.ReadNote("inbox/One.md") + if err != nil || content.Body != original { + t.Fatalf("source lost: %#v %v", content, err) + } + if _, err := os.Stat(filepath.Join(v.Root(), "inbox/Renamed.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("target exists: %v", err) + } + comments, err := v.ReadNoteComments("inbox/Renamed.md") + if err != nil || len(comments) != 1 || comments[0].Body != "Keep destination" { + t.Fatalf("target comments changed: %#v %v", comments, err) + } + }) + } +} + +func TestRenameNoteCaseOnlyKeepsFilenameAndComments(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote("inbox/One.md", "Keep café. \n"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNoteComments("inbox/One.md", []NoteComment{{ID: "one", Body: "Keep discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + meta, err := v.RenameNote("inbox/One.md", "one") + if err != nil { + t.Fatal(err) + } + if meta.Path != "inbox/one.md" { + t.Fatalf("unexpected path: %s", meta.Path) + } + entries, err := os.ReadDir(filepath.Join(v.Root(), "inbox")) + if err != nil { + t.Fatal(err) + } + found := false + for _, entry := range entries { + if entry.Name() == "One.md" { + t.Fatal("old spelling remains on disk") + } + if entry.Name() == "one.md" { + found = true + } + } + if !found { + t.Fatal("new spelling missing on disk") + } + comments, err := v.ReadNoteComments(meta.Path) + if err != nil || len(comments) != 1 || comments[0].Body != "Keep discussion" { + t.Fatalf("lost comments: %#v %v", comments, err) + } +} + +func TestNoteLifecycleCommentCollisions(t *testing.T) { + for _, action := range []string{"archive", "trash", "unarchive", "restore"} { + t.Run(action, func(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + source, target := "inbox/One.md", "archive/One.md" + mutate := v.ArchiveNote + switch action { + case "trash": + target = "trash/One.md" + mutate = v.MoveToTrash + case "unarchive": + source = "archive/One.md" + target = "inbox/One.md" + mutate = v.UnarchiveNote + case "restore": + source = "trash/One.md" + target = "inbox/One.md" + mutate = v.RestoreFromTrash + } + if _, err := v.WriteNote(source, "Keep café. \n"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNoteComments(target, []NoteComment{{ID: "orphan", Body: "Keep target discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + if _, err := mutate(source); err == nil { + t.Fatal("expected comment collision") + } + note, err := v.ReadNote(source) + if err != nil || note.Body != "Keep café. \n" { + t.Fatalf("lost source: %#v %v", note, err) + } + if _, err := os.Stat(filepath.Join(v.Root(), target)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("target remains: %v", err) + } + comments, err := v.ReadNoteComments(target) + if err != nil || len(comments) != 1 || comments[0].Body != "Keep target discussion" { + t.Fatalf("lost discussion: %#v %v", comments, err) + } + }) + } +} + +func TestNoteLifecycleRoundTripAndDeletion(t *testing.T) { + for _, location := range []string{"inbox", "root"} { + t.Run(location, func(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + settings, err := v.GetSettings() + if err != nil { + t.Fatal(err) + } + settings.PrimaryNotesLocation = PrimaryNotesLocation(location) + settings.SystemFolderPaths = map[string]string{"inbox": "My Notes", "archive": "Filed", "trash": "Bin"} + if _, err := v.SetSettings(settings); err != nil { + t.Fatal(err) + } + original := "My Notes/One.md" + if location == "root" { + original = "One.md" + } + body := "Keep café 日本語. \n" + if _, err := v.WriteNote(original, body); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNoteComments(original, []NoteComment{{ID: "source", Body: "Discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + meta, err := v.ArchiveNote(original) + if err != nil { + t.Fatal(err) + } + if meta.Path != "Filed/One.md" { + t.Fatal(meta.Path) + } + meta, err = v.UnarchiveNote(meta.Path) + if err != nil { + t.Fatal(err) + } + if meta.Path != original { + t.Fatal(meta.Path) + } + meta, err = v.MoveToTrash(meta.Path) + if err != nil { + t.Fatal(err) + } + if meta.Path != "Bin/One.md" { + t.Fatal(meta.Path) + } + meta, err = v.RestoreFromTrash(meta.Path) + if err != nil { + t.Fatal(err) + } + note, err := v.ReadNote(meta.Path) + if err != nil || note.Body != body { + t.Fatalf("lost bytes: %#v %v", note, err) + } + comments, err := v.ReadNoteComments(meta.Path) + if err != nil || len(comments) != 1 || comments[0].Body != "Discussion" { + t.Fatalf("lost comments: %#v %v", comments, err) + } + if err := v.DeleteNote(meta.Path); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote(meta.Path, "New note"); err != nil { + t.Fatal(err) + } + comments, err = v.ReadNoteComments(meta.Path) + if err != nil || len(comments) != 0 { + t.Fatalf("resurrected comments: %#v %v", comments, err) + } + }) + } +} + +func TestEmptyTrashRespectsRemappedPath(t *testing.T) { + for _, location := range []PrimaryNotesLocation{PrimaryNotesRoot, PrimaryNotesInbox} { + t.Run(string(location), func(t *testing.T) { + v, err := New(t.TempDir(), Options{}) + if err != nil { + t.Fatal(err) + } + settings, err := v.GetSettings() + if err != nil { + t.Fatal(err) + } + settings.PrimaryNotesLocation = location + settings.SystemFolderPaths = map[string]string{"trash": "Deleted files"} + if _, err := v.SetSettings(settings); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote("Deleted files/Nested/One.md", "Delete me"); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNoteComments("Deleted files/Nested/One.md", []NoteComment{{ID: "comment", Body: "Remove discussion", CreatedAt: 1, UpdatedAt: 1}}); err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote("trash/Unrelated.md", "Keep literal trash folder"); err != nil { + t.Fatal(err) + } + if err := v.EmptyTrash(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(v.Root(), "Deleted files/Nested/One.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("note remains: %v", err) + } + comments, err := v.ReadNoteComments("Deleted files/Nested/One.md") + if err != nil || len(comments) != 0 { + t.Fatalf("comments remain: %#v %v", comments, err) + } + body, err := os.ReadFile(filepath.Join(v.Root(), "trash/Unrelated.md")) + if err != nil || string(body) != "Keep literal trash folder" { + t.Fatalf("unrelated note changed: %s %v", body, err) + } + if err := v.EmptyTrash(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/apps/server/internal/vault/wikilink_rename_test.go b/apps/server/internal/vault/wikilink_rename_test.go index 3a297655..efef269c 100644 --- a/apps/server/internal/vault/wikilink_rename_test.go +++ b/apps/server/internal/vault/wikilink_rename_test.go @@ -93,3 +93,35 @@ func TestRenameNoteRewritesInboundWikilinks(t *testing.T) { t.Fatalf("source after rename =\n%q\nwant\n%q", got.Body, want) } } + +func TestRenameNoteRewritesAnchoredOnlyInboundWikilinks(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + if _, err := v.WriteNote("inbox/Target.md", "# Target\n"); err != nil { + t.Fatal(err) + } + src := "See [[Target#Heading|alias]].\n\nCode stays: `[[Target]]`\n" + if _, err := v.WriteNote("inbox/Source.md", src); err != nil { + t.Fatal(err) + } + + meta, err := v.RenameNote("inbox/Target.md", "Renamed") + if err != nil { + t.Fatal(err) + } + if meta.Title != "Renamed" { + t.Fatalf("renamed title = %q, want Renamed", meta.Title) + } + + got, err := v.ReadNote("inbox/Source.md") + if err != nil { + t.Fatal(err) + } + want := "See [[Renamed#Heading|alias]].\n\nCode stays: `[[Target]]`\n" + if got.Body != want { + t.Fatalf("source after rename =\n%q\nwant\n%q", got.Body, want) + } +} diff --git a/apps/server/internal/webartifact/artifact.go b/apps/server/internal/webartifact/artifact.go new file mode 100644 index 00000000..e2669d3b --- /dev/null +++ b/apps/server/internal/webartifact/artifact.go @@ -0,0 +1,473 @@ +// Package webartifact verifies pinned browser distributions for Go-only builds. +package webartifact + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +const ( + maxManifest = 4 << 20 + maxArchive = 256 << 20 + maxExpanded = 512 << 20 + maxFiles = 4096 +) + +type File struct { + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type Archive struct { + File string `json:"file"` + URL string `json:"url,omitempty"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type Source struct { + Repository string `json:"repository"` + Commit string `json:"commit"` + Dirty *bool `json:"dirty"` + LockfileSHA256 string `json:"lockfileSha256,omitempty"` +} + +type Manifest struct { + SchemaVersion int `json:"schemaVersion"` + Artifact string `json:"artifact"` + Version string `json:"version"` + Protocol string `json:"protocol"` + Source Source `json:"source"` + Toolchain map[string]string `json:"toolchain,omitempty"` + Archive Archive `json:"archive"` + Entrypoints []string `json:"entrypoints"` + Files []File `json:"files"` +} + +var checksum = regexp.MustCompile(`^[a-f0-9]{64}$`) +var commit = regexp.MustCompile(`^[a-f0-9]{40}$`) + +func portablePath(name string) bool { + if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, `\:*?"<>|`) { + return false + } + for _, part := range strings.Split(name, "/") { + if strings.TrimRight(part, ". ") != part { + return false + } + base := strings.ToUpper(strings.SplitN(part, ".", 2)[0]) + if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || + (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '0' && base[3] <= '9') { + return false + } + for _, char := range part { + if char < 32 || char == 127 { + return false + } + } + } + return true +} + +// Reject ambiguous keys before decoding into structs, where JSON otherwise uses +// the last duplicate. Limit nesting independently of the manifest's byte limit. +func checkJSONValue(decoder *json.Decoder, depth int) error { + if depth > 32 { + return errors.New("manifest nesting exceeds limit") + } + token, err := decoder.Token() + if err != nil { + return err + } + switch token { + case json.Delim('{'): + seen := map[string]bool{} + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return err + } + name, ok := key.(string) + if !ok || seen[strings.ToLower(name)] { + return fmt.Errorf("duplicate or invalid manifest key: %v", key) + } + seen[strings.ToLower(name)] = true + if err := checkJSONValue(decoder, depth+1); err != nil { + return err + } + } + _, err = decoder.Token() + case json.Delim('['): + for decoder.More() { + if err := checkJSONValue(decoder, depth+1); err != nil { + return err + } + } + _, err = decoder.Token() + } + return err +} + +func ReadManifest(path string, allowDirty bool) (Manifest, error) { + var manifest Manifest + file, err := os.Open(path) + if err != nil { + return manifest, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, maxManifest+1)) + if err != nil { + return manifest, err + } + if len(data) > maxManifest { + return manifest, errors.New("artifact manifest is too large") + } + if err := checkJSONValue(json.NewDecoder(bytes.NewReader(data)), 0); err != nil { + return manifest, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return manifest, err + } + if _, err := decoder.Token(); err != io.EOF { + return manifest, errors.New("unexpected data after manifest") + } + if manifest.SchemaVersion != 1 || manifest.Artifact != "zennotes-self-hosted-web" || manifest.Protocol != "self-hosted-http-v1" { + return manifest, errors.New("unsupported browser artifact or protocol") + } + if manifest.Version == "" || !commit.MatchString(manifest.Source.Commit) || manifest.Source.Repository != "https://github.com/ZenNotes/zennotes" { + return manifest, errors.New("invalid artifact provenance") + } + if manifest.Source.Dirty == nil { + return manifest, errors.New("source.dirty must be an explicit boolean") + } + if manifest.Source.LockfileSHA256 != "" && !checksum.MatchString(manifest.Source.LockfileSHA256) { + return manifest, errors.New("invalid lockfile checksum") + } + if *manifest.Source.Dirty && !allowDirty { + return manifest, errors.New("uncommitted source requires -allow-dirty for local testing") + } + if !portablePath(manifest.Archive.File) || strings.Contains(manifest.Archive.File, "/") || + !strings.HasSuffix(manifest.Archive.File, ".tgz") || !checksum.MatchString(manifest.Archive.SHA256) || + manifest.Archive.Size <= 0 || manifest.Archive.Size > maxArchive { + return manifest, errors.New("invalid archive pin") + } + if len(manifest.Files) == 0 || len(manifest.Files) > maxFiles { + return manifest, errors.New("invalid artifact file count") + } + paths := make(map[string]bool) + var total int64 + for _, item := range manifest.Files { + key := strings.ToLower(item.Path) + if !portablePath(item.Path) || paths[key] || !checksum.MatchString(item.SHA256) || item.Size < 0 || item.Size > 128<<20 { + return manifest, fmt.Errorf("invalid or duplicate asset %q", item.Path) + } + paths[key] = true + total += item.Size + } + if total > maxExpanded { + return manifest, errors.New("expanded artifact is too large") + } + if len(manifest.Entrypoints) == 0 { + return manifest, errors.New("missing artifact entrypoints") + } + hasIndex := false + for _, name := range manifest.Entrypoints { + found := false + for _, item := range manifest.Files { + if item.Path == name && item.Size > 0 { + found = true + } + } + if !found { + return manifest, fmt.Errorf("entrypoint %q is missing from inventory", name) + } + if name == "index.html" { + hasIndex = true + } + } + if !hasIndex { + return manifest, errors.New("missing index.html entrypoint") + } + return manifest, nil +} + +func validateHTTPS(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" { + return errors.New("artifact download requires an HTTPS URL without credentials or a fragment") + } + return nil +} + +func openArchive(ctx context.Context, manifestPath, override string, archive Archive) (io.ReadCloser, error) { + if override != "" { + return os.Open(override) + } + local, err := os.Open(filepath.Join(filepath.Dir(manifestPath), archive.File)) + if err == nil { + return local, nil + } + if !os.IsNotExist(err) { + return nil, err + } + if err := validateHTTPS(archive.URL); err != nil { + return nil, err + } + client := &http.Client{Timeout: 2 * time.Minute, CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many artifact redirects") + } + return validateHTTPS(req.URL.String()) + }} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, archive.URL, nil) + if err != nil { + return nil, err + } + response, err := client.Do(req) + if err != nil { + return nil, err + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + return nil, fmt.Errorf("artifact download: HTTP %d", response.StatusCode) + } + return response.Body, nil +} + +func verifyFile(path string, expected File) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + hash := sha256.New() + n, err := io.Copy(hash, io.LimitReader(f, expected.Size+1)) + if err != nil { + return err + } + if n != expected.Size || hex.EncodeToString(hash.Sum(nil)) != expected.SHA256 { + return fmt.Errorf("asset checksum or size mismatch: %s", expected.Path) + } + return nil +} + +func verifyTree(root string, manifest Manifest) error { + want := make(map[string]File, len(manifest.Files)) + for _, item := range manifest.Files { + want[item.Path] = item + } + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return errors.New("artifact tree contains a symbolic link") + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + name := filepath.ToSlash(rel) + item, ok := want[name] + if !ok || !entry.Type().IsRegular() { + return fmt.Errorf("unexpected artifact file: %s", name) + } + if err := verifyFile(path, item); err != nil { + return err + } + delete(want, name) + return nil + }) + if err != nil { + return err + } + if len(want) != 0 { + return errors.New("artifact tree is incomplete") + } + return nil +} + +func extract(archive *os.File, stage string, manifest Manifest) error { + gz, err := gzip.NewReader(archive) + if err != nil { + return err + } + defer gz.Close() + limited := &io.LimitedReader{R: gz, N: maxExpanded + 16<<20} + reader := tar.NewReader(limited) + want := make(map[string]File, len(manifest.Files)) + for _, item := range manifest.Files { + want[item.Path] = item + } + seen := make(map[string]bool) + for count := 0; ; count++ { + if count > maxFiles*2 { + return errors.New("too many archive entries") + } + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + name := strings.TrimSuffix(header.Name, "/") + if !portablePath(name) || seen[strings.ToLower(name)] { + return fmt.Errorf("unsafe or duplicate archive path %q", header.Name) + } + seen[strings.ToLower(name)] = true + if header.Typeflag == tar.TypeDir { + if name != "package" && name != "package/dist" && !strings.HasPrefix(name, "package/dist/") { + return errors.New("unexpected archive directory") + } + continue + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { + return fmt.Errorf("unsupported archive entry %q", name) + } + if name == "package/package.json" || name == "package/LICENSE" { + if header.Size > 64<<10 { + return errors.New("oversized package metadata") + } + continue + } + rel := strings.TrimPrefix(name, "package/dist/") + item, ok := want[rel] + if !strings.HasPrefix(name, "package/dist/") || !ok || header.Size != item.Size { + return fmt.Errorf("unexpected asset or size: %q", name) + } + destination := filepath.Join(stage, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + file, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + hash := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(file, hash), reader) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if n != item.Size || hex.EncodeToString(hash.Sum(nil)) != item.SHA256 { + return fmt.Errorf("asset checksum mismatch: %s", rel) + } + delete(want, rel) + } + if len(want) != 0 { + return errors.New("archive is missing declared assets") + } + // Read through gzip's checksum, accepting only tar's trailing zero padding. + buffer := make([]byte, 32<<10) + for { + n, err := limited.Read(buffer) + for _, b := range buffer[:n] { + if b != 0 { + return errors.New("unexpected data after tar archive") + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + if limited.N <= 0 { + return errors.New("expanded archive exceeds limit") + } + return nil +} + +// Install verifies an archive before exposing its files and serializes cooperating +// installers. Use a privately owned build directory: other processes must not +// mutate the destination or its parents during verification and publication. +// An existing distribution is reused only if all its files match the manifest. +func Install(ctx context.Context, manifestPath, archiveOverride, destination string, allowDirty bool) error { + manifest, err := ReadManifest(manifestPath, allowDirty) + if err != nil { + return err + } + if destination == "" { + return errors.New("an output directory is required") + } + destination = filepath.Clean(destination) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + lockPath := destination + ".install-lock" + lock, err := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("acquire artifact install lock: %w", err) + } + lock.Close() + defer os.Remove(lockPath) + if _, err := os.Lstat(destination); err == nil { + if err := verifyTree(destination, manifest); err != nil { + return fmt.Errorf("destination already exists; use a clean build directory: %w", err) + } + return nil + } else if !os.IsNotExist(err) { + return err + } + work, err := os.MkdirTemp(filepath.Dir(destination), ".web-artifact-") + if err != nil { + return err + } + defer os.RemoveAll(work) + input, err := openArchive(ctx, manifestPath, archiveOverride, manifest.Archive) + if err != nil { + return err + } + defer input.Close() + archive, err := os.CreateTemp(work, "archive-") + if err != nil { + return err + } + defer archive.Close() + hash := sha256.New() + n, err := io.Copy(io.MultiWriter(archive, hash), io.LimitReader(input, manifest.Archive.Size+1)) + if err != nil { + return err + } + if n != manifest.Archive.Size || hex.EncodeToString(hash.Sum(nil)) != manifest.Archive.SHA256 { + return errors.New("archive checksum or size mismatch") + } + if _, err := archive.Seek(0, io.SeekStart); err != nil { + return err + } + stage := filepath.Join(work, "dist") + if err := os.Mkdir(stage, 0o755); err != nil { + return err + } + if err := extract(archive, stage, manifest); err != nil { + return err + } + return os.Rename(stage, destination) +} diff --git a/apps/server/internal/webartifact/artifact_test.go b/apps/server/internal/webartifact/artifact_test.go new file mode 100644 index 00000000..11486ec7 --- /dev/null +++ b/apps/server/internal/webartifact/artifact_test.go @@ -0,0 +1,321 @@ +package webartifact + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func dirty(value bool) *bool { return &value } + +func digest(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +type entry struct { + name, body string + typeflag byte +} + +func candidate(t *testing.T, entries []entry, change func(*Manifest)) (string, string) { + t.Helper() + root := t.TempDir() + var buffer bytes.Buffer + gz := gzip.NewWriter(&buffer) + tw := tar.NewWriter(gz) + for _, item := range entries { + kind := item.typeflag + if kind == 0 { + kind = tar.TypeReg + } + header := &tar.Header{Name: item.name, Mode: 0o644, Typeflag: kind, Size: int64(len(item.body))} + if kind == tar.TypeSymlink || kind == tar.TypeLink { + header.Linkname = "../../outside" + header.Size = 0 + } + if err := tw.WriteHeader(header); err != nil { + t.Fatal(err) + } + if header.Size > 0 { + if _, err := tw.Write([]byte(item.body)); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + manifest := Manifest{ + SchemaVersion: 1, Artifact: "zennotes-self-hosted-web", Version: "1.0.0-test", + Protocol: "self-hosted-http-v1", + Source: Source{Repository: "https://github.com/ZenNotes/zennotes", Commit: strings.Repeat("a", 40), Dirty: dirty(false)}, + Archive: Archive{File: "web.tgz", Size: int64(buffer.Len()), SHA256: digest(buffer.Bytes())}, + Entrypoints: []string{"index.html"}, + Files: []File{{Path: "index.html", Size: 5, SHA256: digest([]byte("hello"))}}, + } + if change != nil { + change(&manifest) + } + data, err := json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(root, "web.json") + if err := os.WriteFile(manifestPath, data, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "web.tgz"), buffer.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + return manifestPath, filepath.Join(root, "build", "dist") +} + +func TestInstallVerifiedArtifactAndReuse(t *testing.T) { + manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) + for range 2 { + if err := Install(context.Background(), manifest, "", target, false); err != nil { + t.Fatal(err) + } + } + data, err := os.ReadFile(filepath.Join(target, "index.html")) + if err != nil || string(data) != "hello" { + t.Fatalf("installed entrypoint: %q, %v", data, err) + } +} + +func TestInvalidArtifactsLeaveDestinationAbsent(t *testing.T) { + valid := []entry{{name: "package/dist/index.html", body: "hello"}} + tests := []struct { + name string + entries []entry + change func(*Manifest) + }{ + {"archive checksum", valid, func(m *Manifest) { m.Archive.SHA256 = strings.Repeat("0", 64) }}, + {"file checksum", valid, func(m *Manifest) { m.Files[0].SHA256 = strings.Repeat("0", 64) }}, + {"wrong size", valid, func(m *Manifest) { m.Files[0].Size++ }}, + {"missing entrypoint", nil, nil}, + {"unexpected file", append(append([]entry{}, valid...), entry{name: "package/dist/extra.js", body: "extra"}), nil}, + {"duplicate file", append(append([]entry{}, valid...), valid...), nil}, + {"traversal", []entry{{name: "package/dist/../../outside", body: "bad"}}, nil}, + {"absolute path", []entry{{name: "/outside", body: "bad"}}, nil}, + {"backslash", []entry{{name: `package/dist/..\outside`, body: "bad"}}, nil}, + {"symlink", []entry{{name: "package/dist/index.html", typeflag: tar.TypeSymlink}}, nil}, + {"hard link", []entry{{name: "package/dist/index.html", typeflag: tar.TypeLink}}, nil}, + {"dirty source", valid, func(m *Manifest) { m.Source.Dirty = dirty(true) }}, + {"unknown protocol", valid, func(m *Manifest) { m.Protocol = "cloud-v1" }}, + {"unsafe manifest path", valid, func(m *Manifest) { m.Files[0].Path = "../outside" }}, + {"case collision", valid, func(m *Manifest) { f := m.Files[0]; f.Path = "INDEX.html"; m.Files = append(m.Files, f) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + manifest, target := candidate(t, tc.entries, tc.change) + if err := Install(context.Background(), manifest, "", target, false); err == nil { + t.Fatal("accepted invalid artifact") + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("failed install left destination behind: %v", err) + } + }) + } +} + +func TestExistingBuildIsNeverReplaced(t *testing.T) { + manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "index.html"), []byte("previous build"), 0o644); err != nil { + t.Fatal(err) + } + if err := Install(context.Background(), manifest, "", target, false); err == nil { + t.Fatal("replaced an existing distribution") + } + data, _ := os.ReadFile(filepath.Join(target, "index.html")) + if string(data) != "previous build" { + t.Fatal("changed previous distribution bytes") + } +} + +func TestDirtyCandidateRequiresExplicitOptIn(t *testing.T) { + manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, func(m *Manifest) { m.Source.Dirty = dirty(true) }) + if err := Install(context.Background(), manifest, "", target, true); err != nil { + t.Fatal(err) + } +} + +func TestManifestRequiresUnambiguousSchema(t *testing.T) { + for _, tc := range []struct{ name, from, to string }{ + {"missing dirty", `"dirty":false`, `"lockfileSha256":""`}, + {"null dirty", `"dirty":false`, `"dirty":null`}, + {"unknown nested field", `"dirty":false`, `"dirty":false,"reviewed":true`}, + {"unknown top field", `"schemaVersion":1`, `"schemaVersion":1,"verified":true`}, + {"duplicate dirty", `"dirty":false`, `"dirty":true,"dirty":false`}, + {"case duplicate dirty", `"dirty":false`, `"Dirty":true,"dirty":false`}, + } { + t.Run(tc.name, func(t *testing.T) { + manifest, _ := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) + data, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + changed := strings.Replace(string(data), tc.from, tc.to, 1) + if changed == string(data) { + t.Fatal("test did not change manifest") + } + if err := os.WriteFile(manifest, []byte(changed), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadManifest(manifest, false); err == nil { + t.Fatal("accepted ambiguous manifest") + } + }) + } +} + +func TestExistingTreeRejectsExtraFilesAndLinks(t *testing.T) { + for _, link := range []bool{false, true} { + t.Run(fmt.Sprint(link), func(t *testing.T) { + manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) + if err := Install(context.Background(), manifest, "", target, false); err != nil { + t.Fatal(err) + } + extra := filepath.Join(target, "extra") + var err error + if link { + err = os.Symlink("index.html", extra) + } else { + err = os.WriteFile(extra, []byte("extra"), 0o600) + } + if err != nil { + if link { + t.Skipf("symlink unavailable: %v", err) + } + t.Fatal(err) + } + if err := Install(context.Background(), manifest, "", target, false); err == nil { + t.Fatal("reused unexpected file") + } + if _, err := os.Lstat(extra); err != nil { + t.Fatal("changed existing tree") + } + }) + } +} + +func TestInstallLockAndArchiveOverride(t *testing.T) { + manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + lock := target + ".install-lock" + if err := os.WriteFile(lock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := Install(context.Background(), manifest, "", target, false); err == nil { + t.Fatal("ignored another installer") + } + if err := os.Remove(lock); err != nil { + t.Fatal(err) + } + override := filepath.Join(filepath.Dir(manifest), "candidate with spaces & symbols.tgz") + if err := os.Rename(filepath.Join(filepath.Dir(manifest), "web.tgz"), override); err != nil { + t.Fatal(err) + } + if err := Install(context.Background(), manifest, override, target, false); err != nil { + t.Fatal(err) + } +} + +func TestHTTPSDownloads(t *testing.T) { + for _, mode := range []string{"success", "http", "downgrade", "redirect loop", "credentials", "oversized", "wrong checksum"} { + t.Run(mode, func(t *testing.T) { + var payload []byte + var serverURL string + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + t.Error("download sent credentials") + } + switch mode { + case "downgrade": + http.Redirect(w, r, "http://127.0.0.1:1/forbidden", http.StatusFound) + case "redirect loop": + http.Redirect(w, r, serverURL+"/again", http.StatusFound) + case "oversized": + w.Write(append(payload, 'x')) + case "wrong checksum": + w.Write(bytes.Repeat([]byte{'x'}, len(payload))) + default: + w.Write(payload) + } + })) + defer server.Close() + serverURL = server.URL + // Trust only this test server's certificate without changing production TLS. + previous := http.DefaultTransport + http.DefaultTransport = server.Client().Transport + defer func() { http.DefaultTransport = previous }() + download := server.URL + "/web.tgz" + if mode == "http" { + download = strings.Replace(download, "https:", "http:", 1) + } + if mode == "credentials" { + download = strings.Replace(download, "https://", "https://user:secret@", 1) + } + manifest, target := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, func(m *Manifest) { m.Archive.URL = download }) + archive := filepath.Join(filepath.Dir(manifest), "web.tgz") + var err error + payload, err = os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(archive); err != nil { + t.Fatal(err) + } + err = Install(context.Background(), manifest, "", target, false) + if mode == "success" { + if err != nil { + t.Fatal(err) + } + } else { + if err == nil { + t.Fatal("accepted invalid download") + } + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatal("failed download exposed files") + } + } + }) + } +} + +func TestManifestRejectsTrailingData(t *testing.T) { + manifest, _ := candidate(t, []entry{{name: "package/dist/index.html", body: "hello"}}, nil) + f, err := os.OpenFile(manifest, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + _, err = io.WriteString(f, "{}") + f.Close() + if err != nil { + t.Fatal(err) + } + if _, err := ReadManifest(manifest, false); err == nil { + t.Fatal("accepted trailing JSON") + } +} diff --git a/apps/server/web/api_only.go b/apps/server/web/api_only.go new file mode 100644 index 00000000..b0eafabd --- /dev/null +++ b/apps/server/web/api_only.go @@ -0,0 +1,14 @@ +//go:build !embed_web + +package web + +import ( + "errors" + "io/fs" +) + +// Dist allows Go development and tests without a frontend toolchain or assets. +// Distribution builds select embed_web and include the pinned browser bundle. +func Dist() (fs.FS, error) { + return nil, errors.New("web bundle not embedded: use Vite in development or build with -tags embed_web") +} diff --git a/apps/server/web/embed.go b/apps/server/web/embed.go index 0fedd582..4aa0a4cc 100644 --- a/apps/server/web/embed.go +++ b/apps/server/web/embed.go @@ -1,3 +1,5 @@ +//go:build embed_web + package web import ( @@ -5,12 +7,11 @@ import ( "io/fs" ) -//go:embed all:dist +//go:embed all:dist dist/index.html var dist embed.FS -// Dist returns the embedded PWA bundle rooted at `dist/`. When the -// client bundle has not been built yet, the subtree is empty and the -// caller should fall back to proxying to Vite dev in development. +// Dist returns the embedded PWA bundle. Production builds require index.html +// at compile time so an incomplete asset bundle cannot produce a release binary. func Dist() (fs.FS, error) { return fs.Sub(dist, "dist") } diff --git a/apps/server/web/embed_test.go b/apps/server/web/embed_test.go new file mode 100644 index 00000000..5450b176 --- /dev/null +++ b/apps/server/web/embed_test.go @@ -0,0 +1,36 @@ +//go:build embed_web + +package web + +import ( + "io/fs" + "regexp" + "strings" + "testing" +) + +func TestEmbeddedBrowserEntrypoint(t *testing.T) { + bundle, err := Dist() + if err != nil { + t.Fatal(err) + } + index, err := fs.ReadFile(bundle, "index.html") + if err != nil { + t.Fatal(err) + } + // Vite emits local module scripts and stylesheets in the entry document. + // Check the actual distribution so an incomplete staging step fails CI. + references := regexp.MustCompile(`(?:src|href)="((?:\./|/)?assets/[^"?#]+)(?:[?#][^"]*)?"`).FindAllSubmatch(index, -1) + if len(references) == 0 { + t.Fatal("index.html does not reference any bundled browser assets") + } + for _, reference := range references { + path := strings.TrimPrefix(strings.TrimPrefix(string(reference[1]), "./"), "/") + info, err := fs.Stat(bundle, path) + if err != nil { + t.Errorf("entrypoint asset %s: %v", path, err) + } else if info.IsDir() || info.Size() == 0 { + t.Errorf("entrypoint asset %s is not a nonempty file", path) + } + } +} diff --git a/apps/share-viewer/README.md b/apps/share-viewer/README.md new file mode 100644 index 00000000..cfd02504 --- /dev/null +++ b/apps/share-viewer/README.md @@ -0,0 +1,48 @@ +# Public share viewer + +This is the maintained read-only renderer embedded by Laravel's public share +pages. Its source was recovered from `c534a1d0`, then updated for current core APIs, +shared styles, published themes, and graceful fallback. The recovered historical +build was not byte-identical to the previously copied website bundle; see the +ecosystem plan's provenance record. This is a new, independently verified build. + +## Build and package + +```sh +npm ci +npm run build --workspace @zennotes/share-viewer +npm run pack:share-viewer +``` + +The producer emits an immutable archive and JSON manifest in +`dist/viewer-artifacts`. The manifest records the `share-page-payload-v1` protocol, +source commit/dirty state, lockfile hash, toolchain, entrypoints, and every asset's +size and checksum. Changing any payload bytes creates a new version; reusing a +version with different bytes fails. This viewer is separate from the self-hosted +web application's login/editor artifact. + +`share-viewer.js` and `share-viewer.css` have stable names inside a versioned asset +directory. Chunks/fonts are relative to that directory. Laravel pins the manifest, +verifies and imports the archive without a frontend source checkout, and retains +previous versions for rollback. Candidate CI builds artifacts; it does not publish +or deploy them. Clean-source release publication and Laravel's production build +configuration remain approval gates. + +## Payload and fallback + +Laravel owns `#zen-share-data`: title, exact Markdown, public asset URL mapping, +pre-rendered TikZ SVG mapping, appearance, and timestamps. Assets are resolved only +from that mapping; TikZ SVGs are sanitized. Private links and task mutations stay +inert. Copy, external links, Mermaid, sanitized TikZ, and Markdown formatting remain. +JSXGraph and function-plot renderers are excluded from this public bundle because +their configuration can introduce executable expressions or unsanitized HTML. +Those fences retain source access and explain that rendering is unavailable. + +The page supplies escaped Markdown in `.zen-share-fallback` under +`#zen-share-root`. It remains visible until the renderer completes. Invalid JSON, +missing JavaScript, or module load errors leave the fallback available. The viewer +preserves publication/note themes and follows the OS when appearance is `system`. + +The Laravel repository owns browser integration tests using actual generated +share/publication HTML and attachment responses. Run those tests before updating +its deployment pin; a successful standalone Vite build is insufficient. diff --git a/apps/share-viewer/index.html b/apps/share-viewer/index.html new file mode 100644 index 00000000..c6e3f53b --- /dev/null +++ b/apps/share-viewer/index.html @@ -0,0 +1,26 @@ + + + + + + ZenNotes Share Viewer; dev harness + + + + +
Loading…
+ + + diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json new file mode 100644 index 00000000..10f080d2 --- /dev/null +++ b/apps/share-viewer/package.json @@ -0,0 +1,65 @@ +{ + "name": "@zennotes/share-viewer", + "private": true, + "version": "2.50.4", + "type": "module", + "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", + "homepage": "https://zennotes.org", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "build:nocheck": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.4.3" + } +} diff --git a/apps/share-viewer/postcss.config.js b/apps/share-viewer/postcss.config.js new file mode 100644 index 00000000..2b75bd8a --- /dev/null +++ b/apps/share-viewer/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/apps/share-viewer/src/disabled-diagrams.ts b/apps/share-viewer/src/disabled-diagrams.ts new file mode 100644 index 00000000..04440f15 --- /dev/null +++ b/apps/share-viewer/src/disabled-diagrams.ts @@ -0,0 +1,8 @@ +/** Public publishers are untrusted. These libraries accept executable expressions + * or unsanitized HTML after the Markdown sanitizer has already run. Keep them out + * of the public bundle until a separate validated rendering contract exists. */ +function unavailable(): never { + throw new Error('Interactive plot rendering is unavailable on public shares. Use the source button to read the diagram code.') +} +export const JSXGraph = { initBoard: unavailable } +export default Object.assign(unavailable, { JSXGraph }) diff --git a/apps/share-viewer/src/env.d.ts b/apps/share-viewer/src/env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/apps/share-viewer/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/share-viewer/src/main.tsx b/apps/share-viewer/src/main.tsx new file mode 100644 index 00000000..6206c3c0 --- /dev/null +++ b/apps/share-viewer/src/main.tsx @@ -0,0 +1,176 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import type { AssetMeta, ImportedAssetKind, NoteContent, VaultInfo } from '@shared/ipc' +import { readSharePagePayload, type SharePagePayload } from './payload' +import { THEMES } from '@renderer/lib/themes' +import { installShareViewerBridge } from './shim' +// The full app stylesheet (prose, themes, KaTeX, highlight, diagram +// chrome); the same file the PDF export window ships wholesale. +import '@renderer/styles/index.css' + +const payload = readSharePagePayload() +if (payload) { + // The bridge must exist before any app-core module runs. + installShareViewerBridge(payload) + void boot(payload).catch(error => console.error('Share viewer could not load; keeping the Markdown fallback.', error)) +} else { + console.error('zen-share-data payload missing or malformed; leaving fallback markup in place.') +} + +async function boot(data: SharePagePayload): Promise { + applyTheme(data) + + // Imported lazily so the shim is installed before app-core touches + // window.zen, and so the store never boots on malformed pages. + const [{ useStore }, { LazyPreview }] = await Promise.all([ + import('@renderer/store'), + import('@renderer/components/LazyPreview') + ]) + + const notePath = 'shared-note.md' + const note: NoteContent = { + path: notePath, + title: data.title, + folder: 'inbox', + siblingOrder: 0, + createdAt: data.published_at ? Date.parse(data.published_at) : Date.now(), + updatedAt: data.updated_at ? Date.parse(data.updated_at) : Date.now(), + size: data.markdown.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: Object.keys(data.assets).length > 0, + excerpt: '', + body: data.markdown + } + + // Asset refs double as vault-relative paths: the publisher uploaded + // each asset under the literal markdown ref, so an identity mapping + // makes app-core's resolver land on exactly those keys. + const assetFiles: AssetMeta[] = Object.keys(data.assets).map((ref, index) => ({ + path: ref, + name: ref.split('/').pop() ?? ref, + kind: assetKindOf(ref), + siblingOrder: index, + size: 0, + updatedAt: 0 + })) + + useStore.setState({ + vault: { root: '/shared', name: 'Shared note' } satisfies VaultInfo, + notes: [], + assetFiles, + selectedPath: notePath, + activeNote: note + }) + + const root = document.getElementById('zen-share-root') + if (!root) return + const fallback = root.querySelector('.zen-share-fallback') + const rendered = document.createElement('div') + rendered.className = 'zen-share-rendering' + root.appendChild(rendered) + // The public page allows ordinary links, selection, copy and diagram tools. + // Stop native app navigation/mutations before Preview's event handlers run. + root.addEventListener('click', event => { + const target = event.target instanceof Element ? event.target : null + if (target?.closest('input[type="checkbox"], .zen-task-state-in-progress[data-task-index]')) { + event.preventDefault(); event.stopPropagation(); return + } + const anchor = target?.closest('a') + if (!anchor) return + event.stopPropagation() + const href = anchor.getAttribute('href') ?? '' + if (anchor.matches('.wikilink, .hashtag') || !/^(https?:|mailto:|#)/i.test(href)) event.preventDefault() + else if (!href.startsWith('#')) { anchor.target = '_blank'; anchor.rel = 'noopener noreferrer' } + }, true) + root.addEventListener('change', event => event.stopPropagation(), true) + root.addEventListener('contextmenu', event => event.stopPropagation(), true) + const onRendered = (): void => { + neutralizeAppOnlyInteractions() + rendered.classList.remove('zen-share-rendering') + if (fallback) fallback.hidden = true + root.dataset.viewerReady = 'true' + } + + ReactDOM.createRoot(rendered).render( + +
+ +
+
+ ) +} + +/** Respect the published theme; system follows the viewer's OS preference. */ +function applyTheme(data: SharePagePayload): void { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const apply = (): void => { + const html = document.documentElement + const theme = THEMES.find(theme => theme.id === data.appearance.theme) + ?? THEMES.find(theme => theme.id === (media.matches ? 'github-dark' : 'github-light'))! + html.dataset.theme = theme.id + html.dataset.themeMode = theme.mode + html.setAttribute('data-opaque', '') + html.style.colorScheme = theme.mode + } + apply() + media.addEventListener('change', apply) + + const style = document.createElement('style') + style.textContent = ` + /* The app stylesheet treats the document as a fixed-viewport app + shell (height: 100%, overflow: hidden, user-select: none). A + public page is a normal scrolling document; undo all three, + same as the PDF export window does. */ + html, body { + height: auto !important; + min-height: 100vh; + margin: 0; + overflow: visible !important; + user-select: text !important; + background: rgb(var(--z-bg)); + } + #zen-share-root { position: relative; } + .zen-share-rendering { position: absolute; visibility: hidden; pointer-events: none; width: 100%; } + .zen-share-note { padding: 8px 0 48px; } + .zen-share-note .prose-zen a.wikilink, + .zen-share-note .prose-zen a.wikilink.broken, + .zen-share-note .prose-zen a.hashtag { + color: rgb(var(--z-grey-1)); + border-bottom: 1px dashed rgb(var(--z-grey-dim)); + text-decoration: none; + pointer-events: none; + cursor: default; + } + .zen-share-note .prose-zen input[type="checkbox"] { + pointer-events: none; + } + ` + document.head.appendChild(style) +} + +/** + * Wikilinks, hashtags, and task checkboxes act on the vault in-app; on + * a public page they are inert text. CSS removes the affordances; this + * pass drops the zen:// hrefs and locks checkboxes for good measure. + */ +function neutralizeAppOnlyInteractions(): void { + const root = document.getElementById('zen-share-root') + if (!root) return + for (const anchor of root.querySelectorAll('a.wikilink, a.hashtag')) { + anchor.removeAttribute('href') + } + for (const checkbox of root.querySelectorAll('input[type="checkbox"]')) { + checkbox.disabled = true + } +} + +function assetKindOf(ref: string): ImportedAssetKind { + const ext = ref.toLowerCase().split('.').pop() ?? '' + if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'apng'].includes(ext)) return 'image' + if (ext === 'pdf') return 'pdf' + if (['mp3', 'm4a', 'aac', 'flac', 'ogg', 'wav'].includes(ext)) return 'audio' + if (['mp4', 'm4v', 'mov', 'ogv', 'webm'].includes(ext)) return 'video' + return 'file' +} diff --git a/apps/share-viewer/src/payload.ts b/apps/share-viewer/src/payload.ts new file mode 100644 index 00000000..0f6eba35 --- /dev/null +++ b/apps/share-viewer/src/payload.ts @@ -0,0 +1,38 @@ +/** The JSON document the Laravel share page embeds in #zen-share-data. */ +export interface SharePagePayload { + title: string + markdown: string + /** Markdown ref (decoded) → absolute public URL. */ + assets: Record + /** sha1(raw tikz fence body) → pre-rendered SVG. */ + tikz: Record + appearance: { theme: string; logo_url: string | null } + published_at: string | null + updated_at: string | null +} + +export function readSharePagePayload(): SharePagePayload | null { + const el = document.getElementById('zen-share-data') + if (!el?.textContent) return null + try { + const parsed = JSON.parse(el.textContent) as Partial + if (typeof parsed.markdown !== 'string') return null + return { + title: typeof parsed.title === 'string' ? parsed.title : 'Untitled', + markdown: parsed.markdown, + assets: isStringRecord(parsed.assets) ? parsed.assets : {}, + tikz: isStringRecord(parsed.tikz) ? parsed.tikz : {}, + appearance: { theme: typeof parsed.appearance?.theme === 'string' ? parsed.appearance.theme : 'system', + logo_url: typeof parsed.appearance?.logo_url === 'string' ? parsed.appearance.logo_url : null }, + published_at: typeof parsed.published_at === 'string' ? parsed.published_at : null, + updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : null + } + } catch { + return null + } +} + +function isStringRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + return Object.values(value).every((entry) => typeof entry === 'string') +} diff --git a/apps/share-viewer/src/shim.ts b/apps/share-viewer/src/shim.ts new file mode 100644 index 00000000..855539a8 --- /dev/null +++ b/apps/share-viewer/src/shim.ts @@ -0,0 +1,168 @@ +import DOMPurify from 'dompurify' +import type { ZenAppInfo, ZenBridge, ZenCapabilities } from '@bridge-contract/bridge' +import type { TikzRenderResponse } from '@shared/ipc' +import appPackage from '../package.json' +import type { SharePagePayload } from './payload' + +const VIEWER_CAPABILITIES: ZenCapabilities = { + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: false, + supportsRemoteWorkspace: false, + supportsCliInstall: false, + supportsCustomTemplates: false, + supportsCloudSync: false, + supportsCustomCodeLanguages: false +} + +const VIEWER_APP_INFO: ZenAppInfo = { + name: 'zennotes-share-viewer', + productName: 'ZenNotes', + version: appPackage.version, + description: 'Read-only viewer for shared ZenNotes', + homepage: 'https://zennotes.org', + runtime: 'web' +} + +/** + * sha1 hex matching Node's createHash('sha1') output. WebCrypto when + * available; plain-JS fallback because crypto.subtle only exists in + * secure contexts and local dev serves over plain http (zennotes.test). + */ +async function sha1Hex(input: string): Promise { + if (typeof crypto !== 'undefined' && crypto.subtle) { + const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(input)) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') + } + return sha1HexSync(input) +} + +function sha1HexSync(input: string): string { + const bytes = new TextEncoder().encode(input) + const byteLength = bytes.length + const totalLength = Math.ceil((byteLength + 9) / 64) * 64 + const padded = new Uint8Array(totalLength) + padded.set(bytes) + padded[byteLength] = 0x80 + const view = new DataView(padded.buffer) + view.setUint32(totalLength - 8, Math.floor((byteLength * 8) / 0x100000000)) + view.setUint32(totalLength - 4, (byteLength * 8) >>> 0) + + let h0 = 0x67452301 + let h1 = 0xefcdab89 + let h2 = 0x98badcfe + let h3 = 0x10325476 + let h4 = 0xc3d2e1f0 + const words = new Uint32Array(80) + const rotl = (x: number, n: number): number => ((x << n) | (x >>> (32 - n))) >>> 0 + + for (let offset = 0; offset < totalLength; offset += 64) { + for (let i = 0; i < 16; i += 1) words[i] = view.getUint32(offset + i * 4) + for (let i = 16; i < 80; i += 1) { + words[i] = rotl(words[i - 3]! ^ words[i - 8]! ^ words[i - 14]! ^ words[i - 16]!, 1) + } + let a = h0 + let b = h1 + let c = h2 + let d = h3 + let e = h4 + for (let i = 0; i < 80; i += 1) { + let f: number + let k: number + if (i < 20) { + f = (b & c) | (~b & d) + k = 0x5a827999 + } else if (i < 40) { + f = b ^ c ^ d + k = 0x6ed9eba1 + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d) + k = 0x8f1bbcdc + } else { + f = b ^ c ^ d + k = 0xca62c1d6 + } + const next = (rotl(a, 5) + (f >>> 0) + e + k + words[i]!) >>> 0 + e = d + d = c + c = rotl(b, 30) + b = a + a = next + } + h0 = (h0 + a) >>> 0 + h1 = (h1 + b) >>> 0 + h2 = (h2 + c) >>> 0 + h3 = (h3 + d) >>> 0 + h4 = (h4 + e) >>> 0 + } + + return [h0, h1, h2, h3, h4].map((part) => part.toString(16).padStart(8, '0')).join('') +} + +function decodeRef(href: string): string { + const cleaned = href.split('#')[0]?.split('?')[0] ?? href + try { + return decodeURIComponent(cleaned) + } catch { + return cleaned + } +} + +function lookupAsset(payload: SharePagePayload, href: string): string | null { + const direct = Object.hasOwn(payload.assets, href) ? payload.assets[href] : null + if (direct && /^https?:\/\//i.test(direct)) return direct + const ref = decodeRef(href) + const decoded = Object.hasOwn(payload.assets, ref) ? payload.assets[ref] : null + const value = decoded ?? null + return value && /^https?:\/\//i.test(value) ? value : null +} + +/** + * Install a minimal `window.zen` so app-core's Preview pipeline renders + * a shared note exactly like the app does: + * + * - `renderTikz` substitutes the pre-rendered (and sanitized) SVG the + * publisher uploaded, keyed by sha1 of the fence body. + * - asset URL resolution maps markdown refs onto the share's public + * asset URLs. + * - everything else is inert; this is a read-only page. + */ +export function installShareViewerBridge(payload: SharePagePayload): void { + const overrides: Partial = { + getCapabilities: () => VIEWER_CAPABILITIES, + getConfigSync: () => null, + getAppInfo: () => VIEWER_APP_INFO, + platformSync: () => 'linux' as const, + platform: async () => 'linux' as const, + listCustomCodeLanguages: async () => [], + readWorkspaceState: async () => null, + + renderTikz: async (source: string): Promise => { + const svg = payload.tikz[await sha1Hex(source)] + if (!svg) { + return { ok: false, error: 'This TikZ diagram is not available on the shared page.' } + } + const sanitized = DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true } + }) + return { ok: true, svg: sanitized } + }, + + resolveVaultAssetUrl: (_vaultRoot: string, assetPath: string): string | null => + lookupAsset(payload, assetPath), + resolveLocalAssetUrl: (_vaultRoot: string, _notePath: string, href: string): string | null => + lookupAsset(payload, href), + getPathForFile: () => null, + + clipboardWriteText: (text: string): void => { + void navigator.clipboard?.writeText(text) + }, + clipboardReadText: (): string => '' + } + + // Only the Preview read/copy surface exists. An unsupported mutation must + // fail instead of claiming that a write to a public share succeeded. + window.zen = Object.freeze(overrides) as ZenBridge + +} diff --git a/apps/share-viewer/tailwind.config.js b/apps/share-viewer/tailwind.config.js new file mode 100644 index 00000000..3fc05146 --- /dev/null +++ b/apps/share-viewer/tailwind.config.js @@ -0,0 +1,2 @@ +import preset from '../../packages/app-core/build/tailwind-preset.cjs' +export default { ...preset, content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'] } diff --git a/apps/share-viewer/tsconfig.json b/apps/share-viewer/tsconfig.json new file mode 100644 index 00000000..eb0fa1d9 --- /dev/null +++ b/apps/share-viewer/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "useDefineForClassFields": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@renderer/*": ["../../packages/app-core/src/*"], + "@shared/*": ["../../packages/shared-domain/src/*"], + "@bridge-contract/*": ["../../packages/bridge-contract/src/*"], + "@zennotes/app-core/*": ["../../packages/app-core/src/*"], + "@zennotes/bridge-contract/*": ["../../packages/bridge-contract/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/share-viewer/vite.config.ts b/apps/share-viewer/vite.config.ts new file mode 100644 index 00000000..d590d805 --- /dev/null +++ b/apps/share-viewer/vite.config.ts @@ -0,0 +1,45 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { zenNotesAssets } from '../../packages/app-core/build/vite.mjs' + +// The Laravel share page references exactly two stable filenames - +// share-viewer.js and share-viewer.css (cache-busted by ?v=). Lazy +// chunks keep content hashes and load relative to the entry module. +export default defineConfig({ + root: __dirname, + base: './', + resolve: { + dedupe: ['react', 'react-dom'], + alias: [ + { find: /^(jsxgraph|function-plot)$/, replacement: resolve(__dirname, 'src/disabled-diagrams.ts') }, + { find: '@renderer', replacement: resolve(__dirname, '../../packages/app-core/src') }, + { find: '@shared', replacement: resolve(__dirname, '../../packages/shared-domain/src') }, + { + find: '@bridge-contract', + replacement: resolve(__dirname, '../../packages/bridge-contract/src') + } + ] + }, + server: { + port: 5179 + }, + plugins: [react(), zenNotesAssets({ harper: false })], + build: { + outDir: 'dist', + emptyOutDir: true, + chunkSizeWarningLimit: 3500, + sourcemap: false, + // One stylesheet for the whole viewer (lazy chunks included) so the + // Blade page only ever links share-viewer.css. + cssCodeSplit: false, + rollupOptions: { + output: { + entryFileNames: 'share-viewer.js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: (info) => + info.name?.endsWith('.css') ? 'share-viewer.css' : 'assets/[name]-[hash][extname]' + } + } + } +}) diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index eec10d46..3381fc18 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -106,7 +106,8 @@ const WEB_APP_INFO: ZenAppInfo = { version: appPackage.version, description: appPackage.description, homepage: appPackage.homepage, - runtime: 'web' + runtime: 'web', + hostKind: 'browser' } // Base path under which the server is mounted (e.g. "/zennotes" when diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 31b20915..365de1ee 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -1,86 +1,7 @@ +import preset from '../../packages/app-core/build/tailwind-preset.cjs' + /** @type {import('tailwindcss').Config} */ export default { - content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'], - theme: { - extend: { - colors: { - paper: { - 50: 'rgb(var(--z-bg-softer) / )', - 100: 'rgb(var(--z-bg) / )', - 200: 'rgb(var(--z-bg-1) / )', - 300: 'rgb(var(--z-bg-2) / )', - 400: 'rgb(var(--z-bg-3) / )', - 500: 'rgb(var(--z-bg-4) / )' - }, - ink: { - 900: 'rgb(var(--z-fg) / )', - 800: 'rgb(var(--z-fg-1) / )', - 700: 'rgb(var(--z-fg-2) / )', - 600: 'rgb(var(--z-grey-2) / )', - 500: 'rgb(var(--z-grey-1) / )', - 400: 'rgb(var(--z-grey-0) / )', - 300: 'rgb(var(--z-grey-dim) / )' - }, - accent: { - DEFAULT: 'rgb(var(--z-accent) / )', - soft: 'rgb(var(--z-accent-soft) / )', - muted: 'rgb(var(--z-accent-muted) / )' - }, - danger: 'rgb(var(--z-red) / )', - success: 'rgb(var(--z-green) / )', - warning: 'rgb(var(--z-yellow) / )' - }, - borderRadius: { - // Scale every rounded-* by --z-radius-scale (default 1) so one var can - // square all corners (Quick tweaks → Square corners sets it to 0). - // rounded-none / rounded-full keep Tailwind defaults, so pills and - // circles stay round. - DEFAULT: 'calc(0.25rem * var(--z-radius-scale, 1))', - sm: 'calc(0.125rem * var(--z-radius-scale, 1))', - md: 'calc(0.375rem * var(--z-radius-scale, 1))', - lg: 'calc(0.5rem * var(--z-radius-scale, 1))', - xl: 'calc(0.75rem * var(--z-radius-scale, 1))', - '2xl': 'calc(1rem * var(--z-radius-scale, 1))', - '3xl': 'calc(1.5rem * var(--z-radius-scale, 1))' - }, - fontFamily: { - sans: [ - '-apple-system', - 'BlinkMacSystemFont', - '"SF Pro Text"', - '"Inter"', - 'system-ui', - 'sans-serif' - ], - serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], - mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] - }, - boxShadow: { - panel: - '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', - float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' - }, - fontSize: { - '2xs': ['0.6875rem', { lineHeight: '1rem' }] - }, - zIndex: { - dropdown: '40', - palette: '50', - modal: '70', - nested: '75', - popover: '80', - toast: '90' - }, - maxWidth: { - 'dialog-xs': '420px', - 'dialog-sm': '440px', - 'dialog-md': '560px', - 'dialog-lg': '720px', - 'dialog-xl': '900px', - 'dialog-2xl': '1120px', - 'dialog-3xl': '1360px' - } - } - }, - plugins: [] + presets: [preset], + content: ['./index.html', './src/**/*.{ts,tsx}', '../../packages/app-core/src/**/*.{ts,tsx}'] } diff --git a/docs/boundary-release-cutover.md b/docs/boundary-release-cutover.md new file mode 100644 index 00000000..4da0e9a6 --- /dev/null +++ b/docs/boundary-release-cutover.md @@ -0,0 +1,87 @@ +# Boundary release and repository cutover + +Local preparation is verified. No workflow here has been dispatched, and no +release owner or production build setting has changed. + +## Reviewable local changes + +| Repository | Review groups | +| --- | --- | +| Main `zennotes` | Contract ownership/fixtures; public core API and mutation lifecycle; package producer/consumer checks; web/Go artifact boundary; maintained public viewer; extraction/distribution templates; architecture evidence | +| Android | Vendored package pins and public imports; native workspace rollback and SAF error contracts; boundary/runtime/provider fixtures | +| iOS | Same package/public API migration and native lifecycle checks; existing unrelated Xcode project changes must remain separate | +| TUI | Exact-byte task and HTTP contract fixtures, opt-in real-server verification, documentation | +| Laravel | Verified viewer importer and retained pins; current payload integration; document/fallback behavior; read-only viewer browser gate | + +The user's pre-existing staged changes in main are preserved. Do not stage the +entire ecosystem as one change. Review the existing index first, then make focused +checkpoints per group after explicit approval. The core package and its consumers +must move together; they cannot mix the package store with private source imports. + +## Publication order + +1. The released v2.50.4 source has been reconciled locally and affected checks + pass; see [the integration record](v2.50.4-boundary-integration.md). Review and + approve source commits and branch-history alignment before any push. The + existing HEAD and index are unchanged. +2. Configure the `boundary-artifacts` GitHub environment with required review. + `.github/workflows/boundary-artifact-release.yml` accepts an approved full source + SHA, validates/builds the selected artifact, and creates a draft release only. + It does not publish to npm. The machine has no npm identity; scope ownership + must be settled before choosing registry publication instead of archives. +3. Verify the draft's consumer behavior, then approve publication. Web and viewer + manifests already name their immutable final release URL. Dirty manifests have + no URL and ordinary consumers reject them. Never hand-edit dirty provenance to + make a candidate appear released. +4. Update both mobile consumers from the published package set. Retain the exact + archives in their repository `vendor` directories and update lockfiles and + checksum manifests together; fresh CI must need no main-repository checkout. + Run native/account-backed staging gates before native releases. +5. Update Laravel's viewer pin, placing the prior released pin in + `resources/share-viewer/retained/`. `viewer:install` rebuilds that complete + supported asset set on every deployment. Run Pest and the actual-payload Chrome + harness. Add `npm run viewer:install` to the production build only in the reviewed + deployment change. The retained legacy root bundle permits rollback during the + migration. Do not assume Laravel Cloud preserves an old build directory. +6. Pin a clean published web manifest in the extracted Go source. Its API-only + tests, embedded build, Docker build, and Nix build must pass without Node. + +## Go repository and channel order + +- Verify `ZenNotes/znserver` is still empty before import. Preserve old repository + history and tags. The documented dry-run is complete; actual filtering must run + only in a disposable clone of the approved checkpoint. +- Copy `tooling/server-repository` into the extracted tree, rewrite module imports, + and retain fixture provenance. Add the reviewed web pin and server release + metadata. The source-copy rehearsal automates these transformations. +- Configure required CI checks, review/branch protections, security reporting, + protected release environments, and minimum necessary publisher credentials. + Do not copy website/account credentials into a public repository. +- Run fresh destination CI. The manual `release.yml` creates draft binaries and + SHA-256 sums for Linux/macOS amd64/arm64 and Windows amd64. Complete candidate + installation and rollback checks before publishing that draft. +- Disable the main repository's Docker publisher before enabling the destination's + manual publisher. Preserve `adibhanna/zennotes`, amd64/arm64, tags, non-root UID, + port, volume paths, config variables, and binary name. Configure the protected + `server-docker-publisher` environment. Move Nix server source/artifact pins in a + separate reviewed channel update; desktop Nix/AUR/Homebrew stay in main. +- After one verified destination release and channel rollback rehearsal, remove + `apps/server`, its npm workspace and old publisher. Keep `dev:web-stack` using the + configured external checkout/binary. Until then, the old source stays available. + +## Acceptance that needs an external environment + +- Real Cloud/iCloud entitlements, account switching/revocation, and sync with + isolated staging accounts; local native/provider tests do not claim this proof. +- Clean remote CI and macOS Nix; local Nix proof is aarch64 Linux. +- Open-tab behavior through the chosen server rollout: unlike Laravel's retained + manifest set, a single embedded Go binary contains one browser bundle. Preserve + old hashed assets at the deployment layer during overlap, or define and test a + recoverable reload path before promising seamless tab survival. +- Installed-client support policy. Public release inventory is recorded, but App + Store/TestFlight availability and older installed versions need owner input. + Keep existing HTTP aliases and compatibility exports in the meantime. + +Cloud browser login/editing is a separate feature with its own auth, cache, +revision/conflict, encrypted-vault, and draft-recovery gates. This migration does +not expose private notes through browser login. diff --git a/docs/monorepo-architecture.md b/docs/monorepo-architecture.md index 8031ac76..116712c9 100644 --- a/docs/monorepo-architecture.md +++ b/docs/monorepo-architecture.md @@ -1,6 +1,9 @@ # ZenNotes Monorepo Architecture -ZenNotes now uses a single monorepo so the desktop app, self-hosted web app, and future hosted deployment can share one product core instead of drifting across separate repositories. +Desktop and web share one product core in this repository. iOS, Android, the TUI, +and Laravel Cloud have separate repositories. The Go self-hosted server currently +lives here and will move to `ZenNotes/znserver` after its build and release inputs +are independent. See [the ecosystem migration plan](specs/ecosystem-boundaries-and-repository-plan.md). ## Layout @@ -8,12 +11,13 @@ ZenNotes now uses a single monorepo so the desktop app, self-hosted web app, and apps/ desktop/ Electron shell, preload, updater, packaging web/ Vite/PWA shell and HTTP bridge - server/ Go server for self-hosted and hosted deployments + server/ Go server for self-hosted deployments + share-viewer/ Public read-only renderer packaged for Laravel packages/ app-core/ Shared React application and renderer logic bridge-contract/ Typed runtime contract between UI and host - shared-domain/ Shared types and note/task/view models - shared-ui/ Reusable UI primitives (small today, can grow later) + shared-domain/ Portable domain functions and compatibility type exports + shared-ui/ Reserved workspace, currently an empty export tooling/ scripts/ Shared tooling hooks and migration scripts ``` @@ -28,6 +32,11 @@ Platform-specific code should stay in the app shells: - `apps/web` for browser/PWA bootstrapping - `apps/server` for HTTP/WebSocket serving, vault access, and deployment/runtime config +Go development and tests now run without frontend assets. Distribution builds use +`-tags=embed_web` and require a complete browser bundle. The root server build, +Docker, Nix, and browser runtime harness select that tag. See +[the server build guide](../apps/server/README.md) for both modes. + ## Bridge Contract The shared UI depends on the typed bridge in `packages/bridge-contract`. @@ -45,13 +54,59 @@ Each runtime installs its own implementation: - Electron preload installs the desktop bridge - The web client installs the HTTP bridge backed by the Go server +- The mobile repositories install their native adapters + +### Dependency direction + +`app-core` depends on `shared-domain` and `bridge-contract`. Domain functions +depend on contracts. Contracts depend only on their own source files and standard +browser types, never on domain implementations or Node globals. + +The contract compiler uses `noResolve` and an empty `types` list to enforce this +closed source set. Existing domain type imports remain available through +compatibility re-exports. The portable preference key list has one definition in +the contract package and remains available from `shared-domain/app-config`. + +See TypeScript's [noResolve](https://www.typescriptlang.org/tsconfig/noResolve.html) +and [types](https://www.typescriptlang.org/tsconfig/types.html) documentation. ## Deployment Modes -ZenNotes should ship as: +Runtime ownership is: - desktop: `apps/desktop` - self-hosted: `apps/web` + `apps/server` -- hosted: the same `apps/web` + `apps/server` stack, with auth/storage additions - -Hosted mode is a deployment mode of the same web stack, not a separate frontend. +- Cloud: the separate private `ZenNotes/website` Laravel application owns + accounts, billing, vault revisions, storage authorization, and publishing + +Cloud browser editing is planned. It will share the editor source through a +browser adapter for Laravel; the existing Go HTTP bridge does not already provide +that integration. + +The self-hosted browser can now be packed as a pinned archive with +`npm run artifact:web`. The Go-only `cmd/prepare-web` verifies its manifest and +assets before an `embed_web` build. A local extraction rehearsal builds with the +destination module name and no frontend source. Production publishing remains in +this repository until the remaining migration gates pass. See +[the rehearsal guide](server-extraction-rehearsal.md). + +## Other repositories and release boundaries + +| Repository | Owns | Current cross-repository dependency | +| --- | --- | --- | +| `ZenNotes/zennotesios` | iOS shell, iCloud/filesystem, native lifecycle and integrations | Exact vendored core/contract/domain archives, public exports, native lifecycle fixtures | +| `ZenNotes/zennotesandroid` | Android shell, storage access framework, native lifecycle and integrations | Exact vendored core/contract/domain archives, public exports, native lifecycle fixtures | +| `ZenNotes/tui` | TUI, standalone `zn` CLI and MCP, local/remote backends | Self-hosted HTTP API and independently implemented vault rules | +| `ZenNotes/website` (private) | Laravel Cloud, website, accounts, billing, public shares and publications | Versioned viewer manifest/importer with actual-payload browser tests; local pin awaits clean publication | +| `ZenNotes/znserver` | Selected destination for Go self-hosting | Empty repository at the start of this migration | + +Desktop installers/updater and AUR/Homebrew/Nix desktop packages remain owned by +this repository. The Go binary, Docker publishing, and server-specific Nix inputs +move only after a verified server release and rollback rehearsal. Native releases +and the TUI's GoReleaser/Homebrew distribution remain independent. + +The public share viewer source is restored under `apps/share-viewer`. Its new +artifact is verified against current Laravel payloads; it does not claim byte +identity with the older copied bundle. Laravel retains that legacy bundle and +installs current/retained versioned pins independently. The local candidate has +not been deployed. See [the viewer guide](../apps/share-viewer/README.md). diff --git a/docs/server-extraction-rehearsal.md b/docs/server-extraction-rehearsal.md new file mode 100644 index 00000000..18a57254 --- /dev/null +++ b/docs/server-extraction-rehearsal.md @@ -0,0 +1,134 @@ +# Local Go server extraction rehearsal + +Status: local verification on September 15, 2026. Nothing has been committed, +published, or pushed as part of this migration. The working repository still owns +the server; `ZenNotes/znserver` remains the selected destination. + +## Reproduce the source and artifact boundary + +From the main repository: + +```sh +npm run artifact:web +node tooling/scripts/rehearse-server-extraction.mjs +``` + +The second command creates a new temporary directory and prints its location. It +copies Go source, fixtures, license, and the explicit browser artifact inputs. It +rewrites the module and Go imports to `github.com/ZenNotes/znserver` in that copy +only. It does not initialize a repository, change the original module, rewrite Git +history, remove source, or contact the destination repository. + +The retained tree contains `source/web-artifact/manifest.json` and its adjacent +archive, so subsequent builds need neither the main checkout nor its artifact +directory. `provenance.json` records original/extracted file hashes and artifact +hashes. Source symlinks and npm workspace machinery are not copied. +Destination-specific Go/Docker/Nix/CI/release files come from +`tooling/server-repository`; they are inert templates until copied into a +reviewed destination checkpoint. `release.json` preserves the current server +version and Go vendor hash. + +The rehearsal runs `go vet ./...`, `go test ./...`, and an API-only build before +installing browser assets. It then runs the Go artifact importer, tagged bundle +tests, and an embedded production build. `GOWORK=off` prevents an ambient Go +workspace from satisfying missing source dependencies. + +For local dirty candidates, the command explicitly enables `-allow-dirty`. A +release build must use a reviewed clean-source manifest without that option. +See [the server build guide](../apps/server/README.md) for importer constraints. + +To run the existing browser benchmark against the exact resulting binary: + +```sh +ZEN_PERF_WEB_SERVER_BINARY= \ + ZEN_PERF_WEB_NOTES=300 npm run perf:web-runtime +``` + +It seeds a temporary vault and browser profile. With a prebuilt binary selected, +the benchmark does not rebuild or restage the browser bundle. + +## Evidence and remaining gates + +Local verification covers: + +- Full workspace typecheck and test suites; standalone contract/domain tarballs. +- Go tests and race checks for the importer, HTTP API, and vault operations. +- Strict manifests, checksums, unsafe archive paths/links, HTTPS redirects, + immutable archive identity, and existing-output protection. +- A fresh source tree using the destination module name, built with Go alone. +- The compiled browser app: login, note read/edit, exact Unicode save bytes, + asset metadata, reload with the session preserved, and logout at `/` and `/notes`. +- A 300-note browser benchmark against the extracted binary. +- A Go-only Docker candidate built from the copied source and archive, with root + and prefixed HTTP checks for assets, authentication, exact writes, generic-file + metadata, and logout across both route families. The candidate uses a temporary + Dockerfile and local image tag; the published Dockerfile/publisher has not moved. + +The HTTP fixture also covers legacy root-level API routes. Both route families use one session +cookie scoped to `/`, allowing cached clients to change API paths across +upgrades. Login, logout, and rotation expire the old `/api` cookie to avoid +duplicate cookies. Session flags and bearer authentication remain in place. Generic embedded file targets follow desktop metadata behavior. + +Both native package consumers, the maintained viewer/Laravel boundary, and TUI +fixtures now pass local integration gates. Go-only Docker images for arm64/amd64 +pass root and `/notes` runtime checks. A separate disposable Nix container builds +the Go-only candidate on aarch64-linux and verifies embedded HTML and authenticated +exact Unicode read/write. Its image is +`nixos/nix@sha256:7a007c766426c1877758ddc5cb87a965ac131fc78c582ce0083d922d51ae945c`, +with nixpkgs `26.05.3494.714a5f8c4ead` and Go 1.26.4. No host Nix installation was +required. The Nix importer runs in `postConfigure`, after vendoring; `preBuild` +also runs during dependency collection and must not invoke the importer there. + +A local released-v2.50.4 -> candidate -> released-v2.50.4 sequence at both URL +mounts preserves exact fixture bytes and permits continued writes after rollback. +The candidate includes the released v2.50.4 source, including asset-reference +rewrites. Its web artifact and Go source were rebuilt after reconciliation; +[the integration record](v2.50.4-boundary-integration.md) records the exact commit +and checks. Rehearsal release metadata reads the server package version while +retaining the established Go dependency hash. Remote CI and macOS Nix have not run. Live account-backed Cloud/iCloud acceptance and Cloud browser access have +separate gates in the [ecosystem plan](specs/ecosystem-boundaries-and-repository-plan.md). + +Full reports are in [the local validation bundle](../dist/ecosystem-boundary-validation/VALIDATION.md). + +## External server during browser development + +The existing `dev:web-stack` command can run an extracted checkout or binary: + +```sh +ZENNOTES_SERVER_DIR=/path/to/znserver npm run dev:web-stack +# Or choose the compiled binary, without a Go checkout: +ZENNOTES_SERVER_BINARY=/path/to/zennotes-server npm run dev:web-stack +``` + +Set only one option. The Vite proxy still targets localhost:7878. Keep dedicated +test config/vault/auth variables for runtime verification. With neither option, +the main repository's existing Go source remains the compatibility fallback. +The external-binary entrypoint has a local startup/health check. + +## History and release ownership, after local validation and approval + +The current rehearsal deliberately copies uncommitted working source. Actual history +extraction must wait until the intended source checkpoint is approved and committed. +A non-mutating dry run is complete: git-filter-repo 2.47.0 processed 979 commits, +including 160 touching `apps/server`, with original and scratch refs unchanged. +The dry run writes filtered fast-export text without running fast-import, so it +creates no replacement commits. The scratch clone then fetched v2.50.4 read-only +for the upstream comparison; its refs are no longer the initial dry-run snapshot. +Then use a disposable clone and filter `apps/server/` to the new repository root, +following the [GitHub extraction guide](https://docs.github.com/en/get-started/using-git/splitting-a-subfolder-out-into-a-new-repository). +Keep the original repository and release tags untouched. Module renaming and +destination-specific build files should be explicit follow-up changes in that copy. + +Review distribution ownership before enabling any new publisher: + +| Existing owner | Destination responsibility | Compatibility to preserve | +| --- | --- | --- | +| Main `.github/workflows/docker-publish.yml` and `Dockerfile` | Server Docker build and publishing | `adibhanna/zennotes`, amd64/arm64, tags, UID 65532, `/workspace`, `/data`, port 7878, binary entrypoint, configuration variables | +| Main `packaging/nix/package-server.nix` | Server source and pinned browser inputs | Package/binary name, Go vendor hash, supported Linux/macOS builds | +| Main server workspace and development scripts | Documented server checkout or installed binary | Convenient web-stack development, existing CLI/configuration behavior | +| Main web build | Immutable browser artifacts and protocol fixtures | Complete assets, reviewed manifest, retained previous artifact for rollback | + +Do not move desktop installers, updater, desktop Nix, AUR, Homebrew, mobile, or TUI +publishing into the server repository. Switch one server channel only after its +candidate installation, upgrade, and rollback checks pass. Remove the original +server source only after a verified destination release and channel cutover. diff --git a/docs/specs/ecosystem-boundaries-and-repository-plan.md b/docs/specs/ecosystem-boundaries-and-repository-plan.md new file mode 100644 index 00000000..9b620bfb --- /dev/null +++ b/docs/specs/ecosystem-boundaries-and-repository-plan.md @@ -0,0 +1,521 @@ +# ZenNotes ecosystem boundaries and repository plan + +**Status:** Local boundary implementation and cross-client validation are complete on `refactor/ecosystem-boundaries`. Publication, account-backed staging validation, destination import, and channel cutover remain gated. Nothing has been staged, committed, pushed, or deployed by this work. +**Date:** September 15, 2026 + +## 1. Recommendation + +Keep desktop, web, and their shared editor in `ZenNotes/zennotes`. Keep the existing iOS, Android, TUI, and private Laravel repositories. Make the Go self-hosted server independently testable and releasable inside the current repository, then extract it into one new public repository, `ZenNotes/znserver`, the destination selected by the maintainer. + +The intended result is **six repositories, with explicit package, artifact, and API boundaries**. Five contain the existing product source; the maintainer has also created the empty `ZenNotes/znserver` destination. A separate repository for every shared library would add release coordination before delivering useful isolation. + +### Execution record + +| Slice | Local status and evidence | +| --- | --- | +| P0 ownership and provenance | Six repository owners documented. Historical viewer `c534a1d0` reproduced 67 of 73 old payload files; six differences prevent claiming exact old-byte provenance. The maintained replacement has separate browser proof; the old Laravel bundle remains available. | +| P0/P3 shared behavior | The same exact-byte task fixtures pass in TypeScript, Go server, and TUI, including near-midnight dates in Los Angeles and Auckland. Go/TUI consume versioned fixture copies with SHA-256 provenance. | +| P1 dependency graph | Contracts own portable types and `ZenPlatform`; domain depends on contracts, never the reverse. Compatibility exports remain. CI rejects Node globals and reverse imports. Pure rename/demo helpers belong to shared-domain. | +| P1 public host APIs | Navigation, notes/batches, folder/database actions, tasks, settings, commands, dialogs, immutable shell/workspace observations, editor commands/attachments/geometry, and host capabilities are implemented. Public exports do not expose store or CodeMirror internals. | +| P1 lifecycle guarantees | Mutations drain pending writers and protect late edits. Vault transitions invalidate captured host contexts monotonically, including cancelled/failed transitions. Native vault moves reserve save/move/reopen/rollback as one operation; failed rollback enters recovery state. | +| P2 portable packages | Core `2.50.4-core.h8a09555824b619b5` and its exact companion packages pass standalone nested installs, typechecks, Vite 6 and Vite 8 builds, and 50 real-browser checks per version. Assets, WASM, fonts, and React/CodeMirror/Lezer identity are verified. | +| P2 Android | Source cloning/private imports removed. A clean source-only consumer installs vendored immutable packages and passes 138 tests, types, web/native builds, four instrumentation tests, 20 emulator runtime checks, and three cold-start checks. SAF absence, directories, revoked access, provider failure, and exact bytes have native provider coverage. | +| P2 iOS | Same package boundary, 102 tests, clean web/native simulator builds, 20 runtime and three cold-start checks. Native preferences initialize before the editor; whole-vault rename/reopen and stale attachment contexts are covered. Existing unrelated Xcode project edits remain unchanged. | +| P3 TUI | Task and HTTP fixtures adopted without changing runtime ownership. Root/prefixed HTTP contracts pass against the actual Go binary; all TUI tests, vet, and build pass. | +| P3 Go/web artifact | Go tests need Go alone. Strict Go importer verifies protocol, source, archive/file hashes, tar paths/types, and immutable output. Web `2.50.4-web.hffc8c055ae2667f2` builds into the extracted module without Node or frontend source. | +| P3 Laravel/viewer | Viewer `2.50.4-viewer.h475ea70770234498` renders actual Laravel share/publication payloads. Seventeen focused Pest tests (141 assertions), eleven importer tests, and six Chrome cases pass after the v2.50.4 integration. Static license, normal document scrolling, safe fallback, published themes, and lazy diagrams are verified. Unsafe interactive plot libraries are excluded from the public bundle. | +| P3 artifact retention | Laravel installs current and explicitly retained manifests on every fresh deployment. Old lazy assets do not depend on a persistent build filesystem. Invalid/dirty pins fail normal release installation; the current candidate requires explicit local flags. | +| P4 source/history rehearsal | Fresh extracted source uses `github.com/ZenNotes/znserver`. A git-filter-repo 2.47.0 dry run processed 979 commits (160 touching server paths) without changing original or scratch refs or creating rewritten commits. | +| P4 distribution rehearsal | Go-only Docker arm64/amd64 builds pass root and prefixed auth/assets/exact writes/logout. Released v2.50.4 -> candidate -> released v2.50.4 preserves exact fixture bytes on both mounts. Go-only Nix arm64 Linux build and authenticated runtime read/write pass. | +| Release preparation | Candidate CI, manual draft artifact releases, and destination Go/Docker/Nix/binary release templates are prepared and actionlint-checked. Dirty-source release rejection and local release assembly are verified. No publisher was enabled or dispatched. | + +[Consolidated local evidence](../../dist/ecosystem-boundary-validation/VALIDATION.md) +contains manifests, native/browser reports, screenshots, and logs. The final +workspace check passes all eight typecheck tasks. The full source suites pass +4,806 tests with five existing skips; the desktop build and CLI-without-node_modules +check pass. The production dependency audit reports no advisories. The full Laravel suite +passes 793 tests (6,272 assertions); its deployment safeguard still requires all +quality gates unconditionally while retaining failure evidence. + +The native runtime fixture verifies exact Unicode/trailing-space persistence, +Find, tasks, attachments, note rename, Trash/Restore with comments, whole-vault +rename/reopen, stale context rejection, and cold start. Real account-backed +Cloud sync and iCloud entitlements were not exercised; they require an isolated +staging account/device. No personal or production vault was used. + +The npm registry has no authenticated publishing identity on this machine; +`@zennotes` scope ownership remains unverified. Immutable archives vendored in the +mobile repositories are the interim transport, so clean mobile checkouts do not +need a sibling checkout or unpublished registry packages. Configure registry +publication only after ownership is verified. GitHub draft artifact publication +is prepared separately and remains approval-gated. + +The current branch began at v2.50.3 and now includes the released v2.50.4 source +from `850cf5f8a7f7e10d3f47df1c5732209d8e0c2dcc` through a local three-way +reconciliation. Git HEAD and the pre-existing index remain unchanged. See the +[integration record](../v2.50.4-boundary-integration.md). Read-only release inventory +shows desktop v2.50.4, Android v1.1.20, and TUI v0.1.0; iOS has no GitHub releases. Checkout iOS +version 1.9.10/build 21 is not proof of App Store availability. Preserve all existing +protocol routes and compatibility exports until an installed-client support policy +is explicitly approved. A newer release is not permission to deprecate older apps. + +See [the publication/cutover runbook](../boundary-release-cutover.md) for review +groups, release ordering, retained assets, and rollback. + +### Remaining work in order + +1. **Approve a source checkpoint.** The exact released v2.50.4 changes have been + reconciled locally, and affected source/package/native/browser/distribution + checks pass. Review the existing index and focused migration groups before + committing or aligning branch history with the release. Do not publish the + current dirty candidates as release artifacts. +2. **Run account-backed staging acceptance.** Use dedicated iCloud/Cloud fixtures + to verify real entitlement, account-switch, revocation, and sync behavior. The + local adapter/native storage checks do not replace these account-specific gates. +3. **Publish reviewed clean artifacts and update consumer pins.** Approve commits + and publication; configure protected release environments. Rebuild at approved + source SHAs, validate the clean candidates, publish the reviewed drafts, and + replace local mobile/Laravel/server pins. Laravel's normal CI intentionally + rejects the current dirty pin with no release URL. Run its actual viewer gate + before enabling the install command in production's build configuration. +4. **Import Go history and run destination CI.** The empty `ZenNotes/znserver` + destination is verified, and local extraction/build templates are ready. Actual + rewritten history/import, repository security settings, publisher credentials, + and fresh remote runner checks require approval. Linux Nix runtime is proven; + macOS Nix remains part of the remote/platform matrix. +5. **Switch distribution ownership once.** Publish a verified destination release, + disable the old Docker publisher before enabling the new one, move the server + Nix source pin, then remove the old server source/workspace wrappers. Preserve + desktop packaging. Keep rollback binaries/manifests and test open browser tabs + through the chosen deployment rollout; a single Go binary does not retain all + prior lazy assets automatically. + +These are explicit release/cutover gates, not completed work. The global user rule +requires approval before committing or pushing; the local-only instruction also +precludes publication/deployment now. The migration cannot honestly be called +fully rolled out until those gates pass. + +Cloud browser login and online editing (Phase 5) remain a separate product stream, +as agreed earlier. They are enabled by these boundaries but are not a prerequisite +for the Go split, and are not implemented by this migration. + +### Outcomes + +- Editor changes have one source and can reach desktop, browser, and mobile through deliberate dependency updates. +- Each product can build, test, release, and roll back without checking out another repository's private source tree. +- Native behavior stays with the native application that owns it. +- Existing Markdown vaults, Cloud revisions, published links, command names, and distribution channels keep working. +- Shared behavior is verified across TypeScript, Go, and PHP without forcing them to use the same implementation language. + +This plan does not require a framework rewrite, a vault format migration, new microservices, merging the two backends, or adding Cloud support to the TUI. Keep the current Capacitor versions during the boundary work. + +## 2. Current ecosystem and evidence + +The inspected ecosystem has five clients: desktop, browser, iOS, Android, and TUI. It has two backends: the Go self-hosted server and Laravel Cloud. The website and account portal are also in Laravel. + +| Repository | Current responsibility | Inspected commit | +| --- | --- | --- | +| `ZenNotes/zennotes` | Desktop, web, shared TypeScript, Go server | `a8fc4fc9a954c107b2fe4d6a4433c53702b01b13` | +| `ZenNotes/website` (private) | Laravel website, accounts, billing, Cloud, publishing | `652dbb19b4891f05d421401fb3b108cb4582b2da` | +| `ZenNotes/zennotesios` | Capacitor iOS shell and native integrations | `9971018286cd371f90d7afb882e46d8be3afde50` | +| `ZenNotes/zennotesandroid` | Capacitor Android shell and native integrations | `50c31dcb6ad7799f146cbe45ec6627181cda562f` | +| `ZenNotes/tui` | Go TUI, standalone `zn` CLI, MCP, local/remote adapters | `3ccdc81547780eeee325eb1e4b8b09dc85d17f28` | + +These are source snapshots, not necessarily every deployed version. iOS also has an existing local change to `ios/App/App.xcodeproj/project.pbxproj`; preserve it during future work. + +### Initial coupling inventory (before implementation) + +1. **Mobile builds depend on another repository's layout.** At the initial inventory, both `tooling/prepare-zennotes.sh` scripts cloned the main repository at `.zennotes-commit`. Vite and TypeScript aliases reach into its source. Both mobile vault adapters import `demo-tour-data` and `wikilink-rename` from desktop main-process source. +2. **Shared packages do not yet form a one-way dependency graph.** `shared-domain` depends on `bridge-contract`, while `bridge-contract/src/bridge.ts` imports domain types. The public bridge also exposes `NodeJS.Platform`. +3. **Mobile shells use app-core internals.** They import the store through undeclared package subpaths and directly access state, actions, and editor references. `app-core` currently declares only the `./main` export. +4. **Package builds are source checks, not independently consumable releases.** The core packages are private and build with `tsc --noEmit`. `shared-ui` currently has an empty export, so it is not an existing component library to reorganize around. +5. **Go testing is coupled to frontend preparation.** `run-go-server-test.mjs` prepares the web distribution before running Go tests. Production embeds `web/dist`; Docker builds both stacks from the workspace. Root CI runs a combined production build, although desktop distribution scripts are already scoped to desktop. +6. **The TUI has a good adapter boundary but duplicated rules.** Its `Backend` interface already separates local and remote operations. Its vault types explicitly reference copies of behavior from desktop, shared-domain, and the Go server. +7. **The public share viewer lacks a current reproducible source path.** Laravel tracks a built viewer under `public/vendor/share-viewer`. The main repository has historical viewer source at `c534a1d0`, but no currently tracked `apps/share-viewer` source. Historical source must be verified against the deployed payload and bundle before being treated as the replacement. +8. **Cloud and self-hosted web are different integrations.** Today's web bridge talks to Go. Laravel's sync endpoints expect personal access tokens and active devices. Its browser login is a separate session flow. The existing web service worker also caches same-origin successful GET responses outside its `api/` exclusion, which is unsuitable as a default policy for authenticated Cloud content. + +## 3. Target repository ownership + +| Repository | Owns | Consumes | Release responsibility | +| --- | --- | --- | --- | +| `ZenNotes/zennotes` | Desktop shell; browser shell; shared editor, domain rules, contracts; restored public viewer source | Platform libraries and public dependencies | Desktop installers/updater; core packages; web and viewer artifacts; fixture releases | +| `ZenNotes/znserver` (selected destination) | Go self-hosted API, auth, filesystem access, watcher, server configuration | Pinned self-hosted web artifact; protocol fixtures | Go binary, self-hosted Docker image, server packaging | +| `ZenNotes/website` (existing, private) | Laravel Cloud, account portal, billing, marketing, docs, public shares/publications | Pinned Cloud web and public viewer artifacts | Laravel deployment and Cloud API compatibility | +| `ZenNotes/zennotesios` | iOS app lifecycle, filesystem/iCloud, keychain, native UI, widgets, mobile integration | Versioned core packages and contract fixtures | iOS release | +| `ZenNotes/zennotesandroid` | Android lifecycle, storage access framework, secure storage, native UI, widgets, mobile integration | Versioned core packages and contract fixtures | Android release | +| `ZenNotes/tui` | Terminal UI, standalone CLI/MCP, local and self-hosted adapters | Versioned format/protocol fixtures; optional later pure Go library | TUI/CLI binaries and existing package channels | + +Keep the Laravel repository private. Public contracts and browser assets can be released from the public application repository without exposing Laravel implementation or deployment configuration. Keep marketing and Cloud in the same Laravel application unless a concrete ownership or deployment constraint later justifies separating them. + +### Dependency map + +Solid arrows describe dependencies or communication. Dotted arrows are build artifacts delivered to another repository. + +```mermaid +flowchart TB + subgraph main["zennotes repository"] + contracts["Contracts and behavior fixtures"] + domain["Pure domain functions"] + core["Shared editor and application UI"] + desktop["Desktop shell and native adapters"] + web["Browser shell and adapters"] + viewer["Public share viewer"] + domain --> contracts + core --> domain + core --> contracts + desktop --> core + web --> core + viewer --> domain + viewer -->|"Read-only rendering exports"| core + end + ios["iOS repository"] --> core + android["Android repository"] --> core + ios --> contracts + android --> contracts + web -. "Pinned self-hosted build" .-> server["Go server repository"] + web -. "Pinned Cloud build" .-> cloud["Private Laravel repository"] + viewer -. "Pinned viewer build" .-> cloud + desktop -->|"Cloud sync API"| cloud + ios -->|"Cloud sync API"| cloud + android -->|"Cloud sync API"| cloud + tui["TUI repository"] -->|"Self-hosted API"| server + server --> contracts + tui --> contracts + cloud --> contracts +``` + +Cross-repository source dependencies in this diagram are versioned packages or fixture archives. They are never imports into a sibling checkout. Browser builds execute in the browser and call their chosen backend over HTTP; Laravel and Go serve the assets, not the editor runtime. + +## 4. Code boundaries inside the main repository + +Keep existing package names during migration. Renaming folders is not a prerequisite. + +| Layer | Allowed responsibilities | Boundary rule | +| --- | --- | --- | +| `bridge-contract` | Passive shared types, capabilities, host operation interfaces, separately named wire DTOs | No imports from app-core, domain implementations, Electron, Node, Capacitor, or Laravel source | +| `shared-domain` | Markdown/task/path/rename rules, portable configuration logic, pure transformations | Can import contracts; platform I/O enters through an explicit interface | +| `app-core` | Editor, note navigation, feature UI and orchestration | Calls host interfaces; never directly owns OS paths, native credentials, or server storage | +| `apps/desktop` | Electron IPC, windows, local filesystem, shortcuts, native integrations | Implements host interfaces and validates renderer input at the privileged boundary | +| `apps/web` | Browser bootstrap, HTTP adapters, browser session state, service worker | Chooses self-hosted or Cloud adapter explicitly; reports actual capabilities | +| Mobile repositories | Native adapters and mobile interaction shell | Import declared package exports; no desktop source aliases or arbitrary store mutation | + +### Migration rules + +- Move passive type definitions out of the current type cycle one type family at a time. Leave temporary re-exports in their previous locations to avoid changing every consumer together. +- Replace public Node-specific types with platform-neutral values. Represent operating system and host kind separately where needed. Audit mobile capability reporting instead of assuming that all non-desktop hosts are equivalent. +- Divide the large bridge into coherent interfaces such as vault operations, platform services, and Cloud sync. Preserve the existing `window.zen` facade while migrating implementations; a namespace rewrite is unnecessary. +- Add small, intentional app-core exports for navigation actions, state selectors, editor commands, and host extension hooks. Do not solve deep imports by exporting every store field. +- Give the public viewer a read-only rendering entrypoint with no editor bootstrap, authenticated session, or privileged host dependency. Confirm its required exports during source recovery before creating another package. +- Move pure wikilink rename logic to shared-domain and reusable demo data to a documented package subpath. Keep filesystem traversal and native writes in each host. +- Keep iOS iCloud and Android storage access implementations separate. Preserve Android's asynchronous native preference restoration before importing app-core. +- Preserve lazy loading for heavy editor features and one compatible instance of React, Zustand, and CodeMirror per app. Validate peer dependencies and asset inclusion in real consumer builds. +- Enforce dependency directions and forbidden imports in CI after each family has migrated. Temporary compatibility exceptions need an owner and removal task. + +### Mobile integration findings for the next slices + +The September 15 local review identified these requirements before P2.3/P2.4: + +- Install the Home guard before React or other store subscribers. Preserve native + preference restoration before importing any app-core runtime entrypoint. +- Adopt the verified `@zennotes/app-core/editor` attachment API during native + package migration. Both current native helpers capture a note path before a + picker but reacquire the active editor after asynchronous imports; they also + resolve the active vault separately for each file. Capture the insertion target + and bind the host importer to one vault before the picker. Keep module-owned + picker lifetime and native keyboard behavior, cancel on disposal, and show + recovery guidance for `saved-only` or partially failed imports. +- Adopt the verified `runEditorCommand` and `hasEditorSelection` exports. Keep + CodeMirror views, mutable store state, and pane-layout serialization private. + Retain DOM selection checks for Preview, the toolbar's native keyboard lifetime, + and Android's ordered back-button handling when closing Find. +- Adopt `installEditorHost` for native typing attributes and keyboard/toolbar + insets, and `revealEditorCaret` for focused-note scrolling. Both native shells + currently append CodeMirror configuration by watching `editorViewRef`; exposing + that reference would recreate the private boundary. Preserve their rAF plus + 150/400/800 ms retry cadence for staged keyboard geometry, cancelling retries on + teardown. Host overlay mount, removal, size, position, and viewport changes must + refresh the cached measurements and request a reveal together. In Android, + remove the old selection-clearance installer and its competing padding CSS in + the same migration. Keep its physical selection space distinct from additional + keyboard-toolbar scroll clearance. +- Adopt `getBrowseNotes` and `getAdjacentNotePath` from the public shell export for + both drawer note rows and swipe navigation. Preserve host-owned pins, natural + title sorting, stable ties, and the mobile recent-first fallback for none/manual. + Exclude database records at every depth beneath `.base`, including `pages/`; + Android currently includes them and iOS only checks the immediate directory. +- Adopt `getShellSnapshot`, `subscribeShell`, and `useShellSnapshot` for note/vault + metadata, selection, restoration, and history observations. These frozen copies + do not expose bodies, credentials, settings objects, pane layouts, or store + operations. Native note-index readiness remains separate from workspace + restoration. Keep native pins keyed by the host's stable vault token; the + exposed root can be an iOS friendly label rather than a durable identity. + Note mutations, task snapshots/actions, workspace commands, settings mutations, + and the command palette now use explicit public boundaries. Do not introduce a + generic selector over the store when adding future host operations. +- Adopt `getBrowseSnapshot`, `subscribeBrowse`, `useBrowseSnapshot`, and + `getBrowseDirectory` for the drawer's folder/database rows and enabled date + directory settings. Keep note and folder pins separate; database rows preserve + their title ordering. Pass database navigation targets to `openNote` without + interpreting their serialized paths. Adopt the three `request*Browse*` folder + actions together with host identity and vault-switch draining. Adopt + `createBrowseDatabase` and `requestRenameBrowseDatabase`: omitted creation target + uses configured placement, while an explicit target is primary-relative. Keep + legacy configured placement behavior even inside an active record directory; + explicit Browse actions reject database internals. Adopt `requestMoveNote` and + `requestRenameNote`, `requestArchiveNote`, `requestTrashNote`, `restoreNote`, and + `requestDeleteNotePermanently` with host identity and save draining. Finish batch + lifecycle coordination and native comment storage parity. +- In the task API slice, coordinate already-dispatched task mutation queues with + folder mutations and vault switches. This Browse slice invalidates task scans + and remaps the cached task index; it does not migrate the existing task-write + lifecycle or claim native host adoption. + +These remain local migration tasks. The package candidate deliberately does not +add wildcard exports to make the existing private imports compile. + +## 5. Contracts to share + +Use three distinct contracts. Combining them into a universal backend would obscure differences that matter. + +### A. Vault behavior and format + +Language-neutral fixtures describe inputs and expected results: Markdown bytes, frontmatter, task dates/status, tags, links, relative paths, system-folder mappings, database sidecars, rename results, and portable settings. + +Start with one useful slice: parse a note containing a dated task, edit that task, save, and read it back. Include timezone/day-boundary cases and exact content preservation. Expand fixtures in small behavior families. Record known intentional differences instead of making every client mimic every desktop feature. + +TypeScript and Go run the same cases where they implement the behavior. PHP runs the relevant persistence, payload, revision, and publication cases; it need not acquire a full editor parser. Preserve legacy `attachements` handling and custom system-folder mappings. + +For TypeScript clients, share the actual pure functions once fixtures prove equivalent behavior. For Go server and TUI, first share fixtures. Extract a small Go format module only if proven common functions justify its maintenance. Keep OS filesystem, HTTP, and terminal UI out of it. A module can initially live in a main-repository subdirectory and use proper path-prefixed Go release tags. [Go module source management](https://go.dev/doc/modules/managing-source) + +### B. Host bridge + +The editor asks for operations such as read note, save note, watch vault, open external URL, or show a native dialog. Each host implements what it supports and advertises capabilities for the rest. + +Specify error categories, cancellation, content encoding, and save preconditions alongside method signatures. Unsupported behavior must not look like a successful no-op. Keep runtime validation at IPC and HTTP boundaries; TypeScript types alone do not validate outside input. + +### C. Network protocols + +- **Self-hosted API:** Go filesystem service and its authentication/session model; consumed by the web adapter and TUI remote adapter. +- **Cloud API:** Laravel ownership, revisions, quotas, devices, idempotency, sharing, and billing entitlements; consumed by sync clients and a future browser adapter. + +Give each protocol a documented capability/version contract and request/response fixtures. Share identifiers, error concepts, and applicable semantics, but preserve backend-specific auth and persistence rules. If a machine-readable schema is introduced, pilot it on one endpoint before committing to broad code generation. + +The standalone TUI CLI/MCP and desktop-bundled CLI/MCP also need an explicit compatibility inventory. Keep existing `zn` installation and command resolution working. Replacing one CLI with the other is a separate decision. + +## 6. Packages, artifacts, CI, and release ownership + +### Shared TypeScript packages + +Produce installable ESM packages with declarations, explicit exports, required styles/assets, and preserved dynamic imports. Validate them with `npm pack` in an isolated consumer that cannot see the workspace. Compiled packages are a suitable boundary for external consumers; merely pointing an export at workspace source does not prove portable packaging. [Turborepo package guidance](https://turborepo.dev/docs/core-concepts/internal-packages) + +Use one shared-core release version for the participating TypeScript packages initially. Keep desktop, mobile, server, and Cloud product releases independent. Consumers pin exact versions in lockfiles and update through reviewed dependency changes. A new desktop release does not require releasing both mobile apps. + +Prefer public scoped npm packages if the organization controls the scope. Check ownership and publishing credentials before choosing it. Immutable package archives with checksums are a workable interim transport. CI must never resolve a mutable branch or silently fetch the newest source revision. + +Use immutable candidate archives to prove the two mobile migrations before enabling the permanent publishing workflow. Keep their references accessible to clean-checkout CI; a local `file:` link into the main workspace does not satisfy the package milestone. + +### Web and public viewer artifacts + +Release separate artifacts for the self-hosted web app, Cloud web app when implemented, and public share viewer. The browser builds share source but select their adapter/bootstrap explicitly. Keep deployment base paths configurable and test non-root paths. + +Each artifact manifest records its version, source commit, SHA-256, entrypoints, asset list, and supported protocol/payload range. Go and Laravel pin an artifact manifest in their own release change. Fetching verifies the digest and archive paths, fails closed, and is covered by the consuming repository's CI. Do not fetch `latest` during production deployment. + +Retain the previous artifact and hashed assets during rollout so already-open pages can load lazy chunks. Laravel's deployed commit must serve the same pinned artifacts that passed CI. The public viewer and authenticated editor can roll back independently. Include copied viewer dependencies in the source build's vulnerability and license checks. + +### Compatibility policy + +- Keep external API changes additive during the migration. Old clients must not break merely because repositories moved. +- Capture the currently shipped desktop, mobile, web, and TUI versions as the initial compatibility baseline. Current plus previous integration tests are useful minimum coverage, not automatic permission to drop older installed mobile clients. +- Define support windows from actual shipped clients before any breaking API removal. Native app review and user upgrade delays make coordinated mandatory releases unreliable. +- Deprecations require usage evidence where available, a replacement, a documented removal version, and an explicit decision. No vault format changes belong in this cleanup. + +### Build graph + +Split CI into core, desktop, web, server, and artifact-consumer checks. A shared contract change fans out to its consumers. A desktop-window-only change does not require Go unit tests. A full release still runs the relevant integration matrix. + +Remove unconditional desktop preparation from web-only/package-only setup once its replacement is proven. Go unit/API tests use an explicit static fixture or injected asset filesystem and run with Go alone. Server release tests separately exercise the complete pinned web artifact. + +Keep a convenient local `dev:web-stack` command. It can use a configured server checkout or installed binary, while CI uses declared dependencies. Local convenience must not become an implicit release dependency. + +## 7. Ordered implementation plan + +Each row is a focused reviewable slice. Rows marked **repeat per family/consumer/channel** are templates for separate PRs, not permission to combine all instances. Keep each slice near two to five implementation files; split further if investigation expands its scope. Existing public entrypoints remain available until their replacements pass consumer checks. + +### Phase 0: Establish the baseline and resolve uncertainty + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P0.1 | Record runtime/release ownership and supported client baseline. Main architecture docs and release manifests. | None | Every binary, browser artifact, API consumer, CLI, and distribution channel has an owner and current reference. Mark superseded self-hosted-only assumptions in architecture docs. | +| P0.2 | Recover public viewer provenance in an isolated worktree. Historical `apps/share-viewer`, Laravel `ShareViewer.php`, share payload fixture. | None | Identify whether historical source reproduces the deployed contract. Build and render a representative share fixture. If not equivalent, document the gap and preserve the deployed bundle until resolved. | +| P0.3 | Define the first behavior fixture and format. Proposed fixture directory plus one existing domain test. | P0.1 | A dated-task read/edit/write case passes in the current TypeScript implementation; fixture captures bytes, dates, expected result, and any known divergence. | + +**Gate 0:** Agreed ownership, reproducible baseline checks, and a clear viewer recovery path. No repository creation is needed to reach this point. + +### Phase 1: Make shared boundaries one-way + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P1.1 | Remove one contracts/domain type cycle at a time. `bridge-contract/src/bridge.ts`, relevant domain type module, contract module. **Repeat per type family.** | P0.1 | Types have one owner; old imports still work through re-exports; affected workspace typechecks pass. | +| P1.2 | Remove host-specific public type assumptions. Bridge types and platform capability producers. **Repeat per host.** | P1.1 families complete | Contracts build without Node/Electron type dependencies; host identity and capability behavior remain accurate. | +| P1.3 | Move pure desktop helper imports. Wikilink rename first, then demo data in a separate slice. | P0.3, relevant P1.1 | Shared exports reproduce existing results; mobile imports no longer reach desktop main for that helper. Existing rename cases pass. | +| P1.4 | Add a stable mobile navigation/editor action surface. app-core exports, one mobile navigation call site. **Repeat per operation family.** | P1.1 | One complete user flow uses public actions/selectors with no private store mutation. Verify note open/edit/back navigation, then repeat for remaining flows. | +| P1.5 | Add import-boundary enforcement for completed migrations. Package exports and lint/check tooling. | Relevant P1 slices | CI rejects reintroduced reverse dependencies, desktop source imports from mobile, and undeclared migrated subpaths. Remaining temporary exceptions are listed. | + +**Gate 1:** Contracts no longer depend on domain implementations; pure helper imports have a valid home; host APIs exist for mobile integration. Run affected unit checks and actual desktop/web/mobile note flows before removing compatibility re-exports. + +### Phase 2: Ship packages and remove mobile source clones + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P2.1 | Package contracts, then domain. Package manifest/build config and isolated consumer fixture. **Separate slice per package.** | P1.1, P1.2 | `npm pack` output installs and typechecks outside the workspace; declared imports resolve; no sibling checkout is needed. | +| P2.2 | Package app-core and its assets. Export manifest, build config, isolated browser harness. | P2.1, public hooks from P1.4 | Packed editor loads, edits, and saves through a test host. CSS/fonts/wasm/lazy features load; React/CodeMirror state is not duplicated. | +| P2.3 | Migrate Android's dependency transport. `package.json`, lockfile, Vite/TS config, preparation script. | P1.4 complete for Android, P2.2 | Clean checkout installs pinned packages with `.zennotes-source` absent; no private aliases remain. Device/emulator test covers preference bootstrap, SAF note read/write, background/restore, and existing sync flow. | +| P2.4 | Migrate iOS's dependency transport using the proven package. Equivalent iOS build files. | P1.4 complete for iOS, P2.3 | Clean checkout works without source clone; native test covers note read/write, iCloud integration, lifecycle, and existing sync flow. Preserve unrelated Xcode changes. | +| P2.5 | Automate immutable shared-package releases and one consumer update. Main release workflow and consumer manifest. | P2.3, P2.4 | A release is reproducible from its commit; consumer upgrade and downgrade both pass. Mobile product version remains independent of core version. | + +**Gate 2:** Both mobile apps build and run from versioned packages. Neither clones the main repository nor imports its private source. Revert consumer dependency changes to the previous known-good pin if packaging fails; retain the old source preparation path only until the package rollout is verified. + +### Phase 3: Establish behavior and artifact compatibility + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P3.1 | Run the initial task fixture in Go server, then TUI. Existing parser tests and fixture loader. **Separate slice per consumer.** | P0.3 | Both report equivalent intended results or explicitly recorded product differences. Expand later by individual behavior family. | +| P3.2 | Publish the existing self-hosted HTTP contract fixtures. One note read/write endpoint family plus errors. | P0.1 | Current web bridge and TUI remote client pass against Go. Authentication, invalid paths, and stale/missing resources remain correctly handled. | +| P3.3 | Separate Go unit/API tests from production web embedding. `run-go-server-test.mjs`, HTTP asset dependency, fixture files. | P0.1 | A Go-only environment runs `go test ./...` using deliberate test assets. Full release tests still verify the real embedded app. | +| P3.4 | Release a self-hosted web artifact. Web build config, artifact manifest/generator, release job. | P3.2 | Immutable archive includes all assets and provenance. Browser smoke test covers login, vault list, note edit/save, reload, lazy feature, and non-root base path. | +| P3.5 | Consume the pinned web artifact from the existing Go server. Build helper, embed preparation, Docker build. | P3.3, P3.4 | Clean Go release build needs no Node or source workspace. Digest mismatch fails; binary serves the tested UI and API. Existing deployment config still works. | +| P3.6 | Release the restored viewer independently. Viewer source/build and payload fixture. | P0.2 | Public shares render representative links/assets/math and fallback behavior. Dependency provenance and payload compatibility are recorded. | +| P3.7 | Pin and verify the viewer in Laravel. `ShareViewer.php`, artifact manifest/fetch step, browser integration check. | P3.6 | Laravel CI exercises the actual viewer bundle, not just mocked availability. `/s` and `/p` keep working; fallback and independent rollback are verified. | + +**Gate 3:** Go tests are independent; Go release builds consume a verified web artifact; Laravel consumes a reproducible viewer artifact. All consumers use explicit pins. This phase can overlap mobile packaging where files and contracts are independent. + +### Phase 4: Extract Go after the boundary works + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P4.1 | Rehearse extraction in a scratch clone. Go module/import map, workflow ownership map, history filter recipe. | P3.5 | Extracted tree builds and tests without the original repository. Inventory old Go import consumers before any module-path change. Original history/tags remain untouched. | +| P4.2 | Prepare the new repository with the extracted source and CI. Destination `go.mod`, workflows, artifact pin. | P4.1; destination contents verified | Fresh clone produces the same functional binary and Docker behavior. Required permissions, tags, security policy, and release ownership are configured. | +| P4.3 | Move one distribution channel at a time. Docker workflow, Nix server definition, other server release metadata. **Separate slice per channel.** | P4.2 | Preserve image name/tags, binary name, config/env/volume behavior, and supported architectures. Rehearse candidate install/upgrade/rollback before switching the publisher. | +| P4.4 | Cut over main-repository entrypoints. Server workspace references, root scripts, `dev-web-stack`, docs. | P4.3 channels complete | Main CI no longer builds server source. Local web-stack development remains straightforward. Existing release links remain usable and point to the new owner where appropriate. | +| P4.5 | Remove obsolete server source and temporary adapters. Old server tree and compatibility wrappers. | One verified destination release and rollback rehearsal | Exactly one active server source/release owner remains. Main desktop/web packaging still passes; no accidental removal of desktop Nix/AUR/Homebrew configuration. | + +**Gate 4:** The new repository has shipped a verified release with preserved installation behavior. Until then, keep the old server release path available. Do not maintain indefinitely writable copies in two repositories. + +Use a scratch clone for history filtering, following the documented subdirectory extraction process; do not rewrite the working repository or its historical releases. [GitHub repository extraction guidance](https://docs.github.com/en/get-started/using-git/splitting-a-subfolder-out-into-a-new-repository) + +### Phase 5: Cloud browser integration as a separate product stream + +This stream can begin after the relevant Phase 1 host interfaces and Phase 3 artifact conventions are ready. It does not depend on Phase 4. Start with synced vaults and online access; local-only notes do not become available merely through login. + +Prefer serving the Cloud editor under the existing Laravel origin, using its session cookies and CSRF protection. Choose the route after checking existing routes. A separate frontend origin would add cross-origin session configuration without being necessary for repository separation. + +| ID | Slice and likely files | Dependencies | Acceptance and verification | +| --- | --- | --- | --- | +| P5.1 | Define browser authentication and read-only vault access. Laravel browser routes/middleware and a service-level test. | Host contract; Cloud account/revision baseline | Existing session login and CSRF model apply. Another user's vault, revoked access, and expired session are denied. Preserve existing device-token clients. | +| P5.2 | Add the Cloud read-only browser adapter. `apps/web` bootstrap/adapter and Cloud artifact deployment pin. | P5.1; artifact conventions | Login, select synced vault, read note, refresh, and logout work. Unsupported capabilities are hidden or explained. No backend source is copied into the frontend. | +| P5.3 | Define cache/account isolation. Service worker and account-scoped browser storage. | P5.2 | Only explicitly allowed static assets enter the shell cache. Logout/account switching cannot expose another account's note data; old API caching policy is not reused. | +| P5.4 | Add safe browser mutations. Laravel browser actor integration, existing sync services, one save endpoint family. | P5.1 | Ownership, quotas, revision preconditions, idempotency, and revocation checks remain authoritative. New routes preserve exact Markdown bytes and fit rate limits. | +| P5.5 | Add recoverable browser editing. Cloud adapter save flow and draft storage. | P5.3, P5.4 | Stale revision, offline save, reload, and expired login preserve recoverable edits. Partial browser caches cannot generate filesystem-style deletions. | +| P5.6 | Validate encryption support and production rollout. Capability behavior, representative Cloud fixture, deployment manifest. | P5.5 | Encrypted payloads are either supported through an explicitly designed unlock flow or clearly unsupported in the browser. Ship behind a controlled rollout with artifact rollback; do not misrender ciphertext or overwrite it. | + +Use Laravel's first-party session authentication for browser access rather than putting a long-lived personal access token into browser storage. Browser mutations still need a server-controlled actor compatible with existing write semantics. [Laravel Sanctum guidance](https://laravel.com/framework/docs/13.x/sanctum) + +**Cloud gate:** Read-only access can ship before editing. Editing requires verified conflict handling and draft recovery. Full offline sync is a later scope with its own storage/eviction and deletion model. + +## 8. First five implementation PRs + +Start with these focused changes in the existing repositories: + +1. **Baseline and ownership map:** P0.1. Record client/artifact versions and update stale architecture assumptions. +2. **Viewer provenance:** P0.2. Prove the build source or document the exact recovery gap before changing Laravel assets. +3. **First cross-client behavior fixture:** P0.3. Establish the dated-task roundtrip baseline without changing behavior. +4. **First contracts dependency fix:** one P1.1 type family. Demonstrate the migration pattern with compatibility re-exports. +5. **Pure wikilink rename boundary:** first P1.3 slice. Remove one real desktop-internal dependency from mobile. + +After these, continue the remaining contract families and public mobile hooks toward the package milestone. Do not create six simultaneous restructuring branches. Finish one verified dependency boundary before stacking dependent moves on top of it. + +## 9. Validation and rollback matrix + +| Boundary | Required proof | Rollback | +| --- | --- | --- | +| Core packages to native clients | Clean install without sibling source; editor assets/lazy modules; native startup, file operations, lifecycle and sync | Revert package pin; retain previous artifact and compatible API | +| Shared vault semantics | Same applicable fixture results across implementations; no unintended note byte changes | Revert individual rule change; no data migration introduced | +| Browser to self-hosted API | Current shipped baseline plus new artifact; note flows, auth, watcher updates, TUI remote calls | Pin previous web artifact/server binary | +| Viewer to Laravel | Actual bundle renders payloads; public share/publication routes and fallback work | Restore previous viewer pin; keep old assets | +| Go repository extraction | Fresh-clone build; Docker/Nix/install paths; release candidate upgrade and rollback | Use previous publisher/release until cutover is verified | +| Cloud browser saves | Ownership isolation; stale revision; revocation; quota; draft recovery; account switching | Disable new browser access/editing and restore previous artifact; preserve synced revisions | + +Run scoped checks after each slice, and the relevant cross-client integration gate before removing its compatibility layer. Boundary work should preserve startup and editing performance; compare representative packaged builds to the recorded baseline, especially lazy loading and native preference bootstrap. + +## 10. Proposed architecture decisions + +The maintainer authorized implementation on September 15, 2026. The Go destination is `ZenNotes/znserver`. + +### ADR 1: Keep desktop and web with shared editor source + +**Context:** They already share app-core and regularly change together. +**Decision:** Keep them in the main repository, with independent CI and release targets. +**Alternative:** Split desktop, web, and core into three repositories immediately. +**Tradeoff:** One repository retains atomic editor changes. It requires import/build enforcement, but avoids three coordinated PRs for routine shared UI work. Revisit if ownership, access, or release independence actually becomes a constraint. + +### ADR 2: Preserve independent native and TUI products + +**Context:** Native integrations and distribution are platform-specific; mobile currently depends on main-repository source internals. +**Decision:** Keep current repositories and replace source clones with versioned packages. Keep TUI's existing backend adapter and release ownership. +**Alternative:** Merge all clients into one repository. +**Tradeoff:** Dependency updates become explicit release work, but platform toolchains and releases remain isolated. Share domain functions where language permits and behavioral fixtures otherwise. + +### ADR 3: Extract Go only after independent build and artifact consumption + +**Context:** Go owns a distinct self-hosted runtime but currently embeds workspace-built web assets. +**Decision:** Establish the artifact boundary first, then create one public server repository. +**Alternative:** Move the directory first or leave build coupling permanent. +**Tradeoff:** One additional release pipeline and artifact compatibility policy are necessary. The extraction is justified by a separate deployable/runtime, and its risk is reduced by proving independence before moving source. + +### ADR 4: Keep Laravel Cloud and website private and together + +**Context:** Account, billing, entitlement, sync, and publication behavior share existing Laravel services and deployment. +**Decision:** Preserve this ownership and consume public frontend artifacts through pinned manifests. +**Alternative:** Merge with Go, copy the editor into Laravel, or split marketing/accounts/Cloud into services now. +**Tradeoff:** Artifact contracts require maintenance, but there is one authoritative Cloud ownership/revision model and no duplicate editor fork. No extra production services are introduced by this plan. + +### ADR 5: Share semantics without forcing one universal API + +**Context:** Clients share vault behavior while native filesystem, self-hosted HTTP, and Cloud revisions have different failure and security models. +**Decision:** Separate format fixtures, host interfaces, and named network protocols. Keep public editor and share-viewer artifacts independent. +**Alternative:** A single storage API hiding every difference, or unrestricted duplicated logic. +**Tradeoff:** Adapters remain explicit and some cross-language implementations remain separate. Tests define the shared behavior, while capabilities expose meaningful differences. + +## 11. Decisions to settle at the relevant gate + +- The maintainer selected `ZenNotes/znserver` on September 15, 2026. Inspect its existing contents before extraction. +- Verify npm scope ownership and package publication permissions before P2.5. +- Establish the actual supported installed-client baseline before protocol deprecation; do not infer it only from repository HEADs. +- Resolve viewer source provenance before replacing the tracked Laravel bundle. +- Decide whether browser access to encrypted vaults is in the first Cloud release after confirming the intended encryption/unlock model. +- Consider a shared pure Go format module only after fixture adoption reveals sufficient identical behavior to justify it. + +None of these prevents starting the baseline and internal dependency work. + +## 12. Definition of done + +- [x] Every client/backend has a documented owner, public interface, and release artifact. +- [x] Contracts and domain packages have one-way dependencies; local checks pass and CI gates are prepared. +- [x] iOS and Android build from declared immutable package candidates with no source checkout or desktop-internal imports; clean publication remains gated. +- [x] Core package candidates are tested in real external consumers before release. +- [x] Applicable vault semantics and self-hosted network behavior have shared fixtures across implementations. +- [x] Go unit/API tests need only Go; candidate server builds use a pinned, verified web artifact. +- [ ] Laravel serves a reproducibly built, pinned viewer through its tested deployment commit; the Cloud editor follows the same rule when introduced. +- [ ] Go source and release ownership move once, with compatibility checks and rollback evidence. +- [ ] Existing vaults, share URLs, installers/updaters, CLI commands, Docker configuration, and package channels remain usable. +- [ ] Compatibility shims and stale architecture documentation are removed or explicitly tracked. + +The boundary cleanup is complete when those conditions hold. Cloud web access has its own read-only and editing gates and is not a prerequisite for the Go split. + +## Source anchors + +- [Main workspace configuration](/Users/adibhanna/Developer/opensource/zennotes/package.json), [bridge types](/Users/adibhanna/Developer/opensource/zennotes/packages/bridge-contract/src/bridge.ts), [app-core exports](/Users/adibhanna/Developer/opensource/zennotes/packages/app-core/package.json). +- [Existing architecture](/Users/adibhanna/Developer/opensource/zennotes/docs/monorepo-architecture.md), [web design](/Users/adibhanna/Developer/opensource/zennotes/docs/web-architecture.md), [web bridge](/Users/adibhanna/Developer/opensource/zennotes/apps/web/src/bridge/http-bridge.ts), [service worker](/Users/adibhanna/Developer/opensource/zennotes/apps/web/public/sw.js). +- [Android source preparation](/Users/adibhanna/Developer/apps/zennotesandroid/tooling/prepare-zennotes.sh), [Android startup](/Users/adibhanna/Developer/apps/zennotesandroid/src/bootstrap.ts), [iOS source preparation](/Users/adibhanna/Developer/apps/zennotesiphone/tooling/prepare-zennotes.sh). +- [Go test wrapper](/Users/adibhanna/Developer/opensource/zennotes/tooling/scripts/run-go-server-test.mjs), [Go asset embedding](/Users/adibhanna/Developer/opensource/zennotes/apps/server/web/embed.go), [TUI backend interface](/Users/adibhanna/Developer/opensource/zennotescli/internal/backend/backend.go), [TUI vault types](/Users/adibhanna/Developer/opensource/zennotescli/internal/vault/types.go). +- [Laravel viewer loader](/Users/adibhanna/Developer/Laravel/zennotes/app/Services/ShareViewer.php), [Cloud write service](/Users/adibhanna/Developer/Laravel/zennotes/app/Services/VaultSyncService.php), [device middleware](/Users/adibhanna/Developer/Laravel/zennotes/app/Http/Middleware/EnsureActiveDevice.php), [deployment script](/Users/adibhanna/Developer/Laravel/zennotes/.github/deploy-production.mjs). diff --git a/docs/v2.50.4-boundary-integration.md b/docs/v2.50.4-boundary-integration.md new file mode 100644 index 00000000..06c99c94 --- /dev/null +++ b/docs/v2.50.4-boundary-integration.md @@ -0,0 +1,80 @@ +# v2.50.4 integration into the boundary migration + +Verified locally on September 15, 2026 (America/Chicago). + +## Released source + +- Release: [v2.50.4](https://github.com/ZenNotes/zennotes/releases/tag/v2.50.4). +- Commit: `850cf5f8a7f7e10d3f47df1c5732209d8e0c2dcc`. +- Annotated tag object: `c82ec31a9be663d8fa3827c865c28f8bb5020bd7`. +- [Release build 35036589163](https://github.com/ZenNotes/zennotes/actions/runs/35036589163) + completed successfully across all platform jobs; 29 release assets were present. +- All 38 changed paths since the migration baseline were reconciled with the local + migration. The sole textual conflict was workspace metadata in `package-lock.json`; + both the v2.50.4 version and the migration's TypeScript dependency were retained. + +The included fixes suppress the wikilink picker inside code (#783), render live +modified-date tokens (#784), preserve note links when assets move or are renamed +(#785), and exclude forwarded/cancelled tasks from Kanban (#786). Desktop and Go +asset-rewrite implementations and their tests are included. + +The new asset actions also use the migration's workspace reservation. They drain +saves, prevent concurrent typing or vault switches during disk rewrites, refresh +open note bodies, and propagate failures while releasing the reservation. Four +additional tests cover rename/move, pending saves, save failure, and host failure. + +## Current local candidates + +| Consumer boundary | Candidate | +| --- | --- | +| Core, installed by both mobile apps | `2.50.4-core.h8a09555824b619b5` | +| Contracts and domain | `2.50.4-boundaries.h776a0eb6d6ed1862` | +| Self-hosted web | `2.50.4-web.hffc8c055ae2667f2` | +| Public viewer, selected by Laravel | `2.50.4-viewer.h475ea70770234498` | + +These are immutable local candidates with dirty-source provenance. Their source +commit field correctly names the unchanged local HEAD; it is not a claim that the +migration has been committed. Hashes in the archived manifests identify the actual +bytes. Native product versions remain independent of the shared package version. + +## Validation after integration + +- Eight workspace typechecks; 4,806 passing source tests and five existing skips. +- Desktop production build, including CLI execution without `node_modules`. +- Standalone nested package installs/builds and 50 browser checks each with Vite 6 + and Vite 8, using the exact new core candidate. +- Android: 138 tests, clean package install, typecheck, production/fixture builds, + native unit tests/lint/build, four instrumentation tests, 20 runtime checks and + three cold-start checks on the disposable emulator. +- iOS: 102 tests, clean package install, typecheck, production/fixture builds, + native simulator build, 20 runtime checks and three cold-start checks. +- Laravel: 17 focused tests (141 assertions), 11 importer tests, and six browser + cases using actual Laravel markup and the rebuilt viewer. The earlier full + Laravel run passed 793 tests (6,272 assertions); application PHP did not change + during this integration. +- Go-only extracted module: vet, full tests, API binary, verified web import, + embedded tests/build. TUI full tests/vet/build and real-server HTTP contracts pass. +- Docker arm64 and amd64 builds plus authenticated root/prefixed runtime checks. + Released v2.50.4 -> integrated candidate -> released v2.50.4 preserves exact + fixture bytes and continued writes at both mounts. +- Nix aarch64-linux build and authenticated exact Unicode read/write pass. +- Five artifact packer tests, shared fixture checksum check, and production + dependency audit (zero advisories). + +Detailed manifests, reports, and logs are in the ignored local +[validation evidence](../dist/ecosystem-boundary-validation/v2.50.4/). + +## Preserved state and next gates + +All five repositories retain their original HEAD and byte-identical Git index. +The main index still contains its original 23 files, 570 insertions and 487 +deletions. The unrelated iOS Xcode project edits are byte-identical to the backup. +Complete backups precede reconciliation; the backup location and incoming-file +plan are retained in the local evidence. + +No commits, pushes, publication, deployment, or repository-history rewrites were +performed. The existing Go source remains in main until an approved destination +release passes cutover checks. Clean publication, account-backed Cloud/iCloud +staging, remote CI/macOS Nix, and destination import/channel cutover remain in +[the cutover runbook](boundary-release-cutover.md). Cloud browser login/editing +remains a separate feature. diff --git a/docs/web-architecture.md b/docs/web-architecture.md index 6a373b64..eacc1606 100644 --- a/docs/web-architecture.md +++ b/docs/web-architecture.md @@ -1,5 +1,21 @@ # ZenNotes Web Architecture +> Historical self-hosted design. The Go-backed web client is implemented, while +> Laravel in the separate private `ZenNotes/website` repository now owns Cloud. +> The hosted-SaaS exclusions and proposed hosted-Go deployment below describe the +> original design, not the current ecosystem direction. Follow the +> [ecosystem boundaries plan](specs/ecosystem-boundaries-and-repository-plan.md) +> for the active migration and future Cloud browser adapter. + +## Current boundary implementation + +The maintained browser shell still uses the Go adapter. `artifact:web` emits a +verified immutable archive for Go-only server builds; `apps/share-viewer` emits a +separate read-only public artifact for Laravel. Mobile shells now consume exact +shared packages through public APIs. None of these adapters supplies authenticated +Laravel Cloud browser editing yet. Local build/runtime evidence and approval-gated +publication/cutover steps are in the ecosystem plan linked above. + Target: turn ZenNotes into a progressive web app (PWA) that can also be self-hosted on a home server and driven entirely from a browser, without losing what makes ZenNotes ZenNotes — keyboard-first editing, vim diff --git a/package-lock.json b/package-lock.json index 5f571790..230bf53f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -876,6 +876,77 @@ "name": "@zennotes/server", "version": "2.50.4" }, + "apps/share-viewer": { + "name": "@zennotes/share-viewer", + "version": "2.50.4", + "dependencies": { + "@codemirror/autocomplete": "^6.18.3", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.1", + "@codemirror/language": "^6.10.6", + "@codemirror/language-data": "^6.5.1", + "@codemirror/search": "^6.5.8", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.35.3", + "@lezer/highlight": "^1.2.1", + "@replit/codemirror-vim": "^6.3.0", + "@zennotes/app-core": "*", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", + "codemirror": "^6.0.1", + "dompurify": "^3.3.4", + "function-plot": "^1.25.3", + "gray-matter": "^4.0.3", + "highlight.js": "^11.10.0", + "jsxgraph": "^1.12.2", + "katex": "^0.16.15", + "mermaid": "^11.4.1", + "prettier": "^3.8.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rehype-highlight": "^7.0.1", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-breaks": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.1", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.4.3" + } + }, + "apps/share-viewer/node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "apps/share-viewer/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "apps/web": { "name": "@zennotes/web", "version": "2.50.4", @@ -6087,6 +6158,10 @@ "resolved": "apps/server", "link": true }, + "node_modules/@zennotes/share-viewer": { + "resolved": "apps/share-viewer", + "link": true + }, "node_modules/@zennotes/shared-domain": { "resolved": "packages/shared-domain", "link": true @@ -11115,6 +11190,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -13593,6 +13682,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-frontmatter": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", @@ -16311,12 +16415,15 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@excalidraw/excalidraw": "^0.18.1", + "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.1", "@myriaddreamin/typst-ts-renderer": "^0.7.0", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", "@xyflow/react": "^12.11.2", + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -16346,6 +16453,13 @@ "zustand": "^5.0.2" }, "devDependencies": { + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vfile": "^6.0.3", "vite": "^6.4.3", "vitest": "^3.2.6" } @@ -16363,7 +16477,10 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.50.4" + "version": "2.50.4", + "devDependencies": { + "typescript": "^5.7.2" + } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", @@ -16373,6 +16490,7 @@ "lz-string": "^1.5.0" }, "devDependencies": { + "typescript": "^5.7.2", "vitest": "^3.2.6" } }, diff --git a/package.json b/package.json index 9acf805b..2fa3bce5 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,12 @@ "typecheck": "turbo run typecheck", "test": "turbo run test", "test:run": "turbo run test:run", + "test:shared-packages": "node tooling/scripts/test-shared-packages.mjs", + "test:web-dist-lock": "node --test tooling/scripts/web-dist-lock.test.mjs", + "artifact:web": "node tooling/scripts/pack-web-artifact.mjs", + "test:web-artifact": "node --test tooling/scripts/pack-web-artifact.test.mjs", + "check:contract-fixtures": "node tooling/scripts/sync-contract-fixtures.mjs", + "sync:contract-fixtures": "node tooling/scripts/sync-contract-fixtures.mjs --write", "build": "turbo run build --filter=!@zennotes/server && npm run build --workspace @zennotes/server", "build:prod": "npm run typecheck && npm run test:run && npm run build", "perf:bench": "node tooling/scripts/perf-large-vault.mjs", @@ -46,7 +52,11 @@ "dist:mac": "npm run dist:mac --workspace @zennotes/desktop", "dist:win": "npm run dist:win --workspace @zennotes/desktop", "dist:linux": "npm run dist:linux --workspace @zennotes/desktop", - "perf:editor-scroll": "node tooling/scripts/perf-editor-scroll.mjs" + "perf:editor-scroll": "node tooling/scripts/perf-editor-scroll.mjs", + "artifact:app-core": "node tooling/scripts/pack-app-core.mjs", + "test:app-core-package": "node tooling/scripts/test-app-core-package.mjs", + "test:app-core-browser": "node tooling/scripts/test-app-core-browser.mjs", + "pack:share-viewer": "node tooling/scripts/pack-share-viewer.mjs" }, "devDependencies": { "patch-package": "8.0.1", diff --git a/packages/app-core/README.md b/packages/app-core/README.md new file mode 100644 index 00000000..f043a227 --- /dev/null +++ b/packages/app-core/README.md @@ -0,0 +1,535 @@ +# Shared application core + +This package owns the shared React application, editor, navigation state, and +feature orchestration. Hosts implement the bridge and own native I/O. Public +exports are the integration boundary; the internal store and pane tree remain +implementation details. + +## Public exports + +- `@zennotes/app-core/main`: application bootstrap and Cloud auto-sync request. +- `@zennotes/app-core/navigation`: note navigation and Home behavior for shells. +- `@zennotes/app-core/notes`: prompted note moves and renames with host-session and save guards. +- `@zennotes/app-core/shell`: immutable note metadata, shell observations, and mobile Browse ordering. +- `@zennotes/app-core/browse`: folder/database rows, date-directory settings, and confirmed folder actions for native drawers. +- `@zennotes/app-core/editor`: run formatting/search commands, inspect selection, + configure native typing/insets, and import attachments without accessing + CodeMirror or the store. +- `@zennotes/app-core/tasks`: immutable task observations, today's groups, refresh, navigation, and Kanban moves. +- `@zennotes/app-core/workspace`: restoration, profile metadata, workspace switching and save draining. +- `@zennotes/app-core/settings`: observed theme/editor settings and supported updates. +- `@zennotes/app-core/commands`: command descriptions, checked invocation, and core palettes. +- `@zennotes/app-core/dialogs`: host prompts and confirmations that do not replace pending dialogs. +- `@zennotes/app-core/host`: explicit host kind and advertised capabilities. +- `@zennotes/app-core/styles.css`: shared styles, also imported by `main`. +- `@zennotes/app-core/vite`: build integration for lazy WASM and drawing fonts. + +The navigation export provides: + +| API | Behavior | +| --- | --- | +| `openNote(path)` | Opens a vault-relative note or app-generated page path through normal saving and history. | +| `goBack()` / `goForward()` | Uses the existing note navigation history. | +| `goHome()` | Shows Home without closing tabs; starts normal saving for pending edits. | +| `useSelectedNotePath()` | React hook exposing the current path, or null. | +| `installHomeGuard()` | Keeps Home visible across background rescans; returns a cleanup function. | + +Shells that offer Home while retaining open tabs install the guard once during +bootstrap, before mounting React or registering other store subscribers. A mount +effect runs too late to protect those subscribers from a rescan transition: + +```tsx +// Install the host bridge and restore preferences first. +const { renderZenNotesApp } = await import('@zennotes/app-core/main') +const { installHomeGuard } = await import('@zennotes/app-core/navigation') +const disposeHomeGuard = installHomeGuard() +renderZenNotesApp(document.getElementById('root')!) +// Call disposeHomeGuard() when the host shell is torn down. +``` + +The iOS and Android shells now adopt these exports together with editor commands, +settings, workspace lifecycle, and attachment handling. Their installed package +checks reject the old private paths so only one application-state instance exists. + +## Note actions + +`requestMoveNote(host, path)` prompts for a logical `inbox` or `archive` destination, +including a subfolder such as `inbox/Projects`. The host resolves logical folders +through its vault settings. The prompt starts in the note's actual folder even +with custom folder names or primary notes at the vault root. Missing notes, +trashed notes, database record pages, hidden folders, traversal segments, and +database destinations are rejected. New ordinary subfolders may be created. + +The host supplies `isCurrent()` against a vault token captured before the prompt. +Results are `completed`, `cancelled`, `stale`, or `unavailable`; operational errors +reject. A dispatched operation may finish in its original vault after its token +becomes stale, so callers must not automatically retry it. Hosts must await the +normal save/drain step before replacing the active vault or bridge. + +Moves wait for note and comment saves, preserve edits made during the operation, +and reconcile tabs, comments, tasks, references, and manual order to the canonical +returned path. A move requested during a task write rejects so it can be retried +after that task settles; new task actions during the move show a wait message. +Desktop and Go roll back the note and comment file together on failure. If rollback +fails, the existing `FOLDER_STATE_UNCERTAIN` recovery guard retains buffers and +blocks writes until the vault is reloaded. + +`requestRenameNote(host, path)` prompts for a title and preserves the note's +existing directory and file type. The host's returned path/title is authoritative, +including collision suffixes. Renames hold note, comment, database, and task writes +across the vault while inbound wikilinks are updated. Open buffers, including edits +made during the rename, receive the same link rewrite before saving again. Existing +heading-sync preferences apply. Core note writers, including tag rewrites, task +rollover, record pages, imports, and templates, cannot overlap the operation. +Vault switching drains those writers as well as pending editor saves. A failed save +retains dirty buffers and rejects; +the rename may already have completed, so check the current snapshot before retrying. + +Host backlink rewriting remains best effort for closed notes. A successful rename +is not an atomic transaction covering every inbound file or another client's edits. +Desktop and Go roll back the renamed note and its comment sidecar together when +that relocation fails; failed rollback activates the recovery guard described above. + +`requestArchiveNote`, `requestTrashNote`, `restoreNote`, and +`requestDeleteNotePermanently` use the same host token and result contract. +Archive confirms when indexed unfinished tasks exist. Trash and permanent deletion +always confirm; permanent deletion is available only for trashed ordinary notes. +Restore accepts archive or trash and uses the host's configured primary notes +location. These public actions exclude database record pages. + +Archive and vault Trash save late edits at the returned path before closing the +editor. If that save fails, the destination stays open and dirty. Restore keeps +open tabs at the canonical returned path. Permanent deletion and temporary-session +system Trash lock every editor for the note after confirmation, save first, then +remove it. An active IME composition must finish before deletion. Failed saves or +host operations leave the note editable. The lock preserves existing Vim input and +read-only restrictions and covers the pinned reference editor and Preview writes. + +Desktop and Go detach content and comment sidecars together before permanent +cleanup. A cleanup failure is logged and may retain files in private quarantine; +this is not secure erasure. System Trash keeps the original note filename for file +manager restoration, rolls back comment detachment if the OS refuses, and removes +the old comment sidecar after success. Restoring that file through the OS does not +restore its discussion. Temporary sessions without comments gain no private folder. + +Single-note menus, file-task deletion, bulk Sidebar actions, Empty Trash, and +database row/page batches now use coordinated guards. Native storage adapters +implement matching note/comment rollback and have lifecycle fixture coverage. + +## Shell snapshots and Browse ordering + +`getShellSnapshot()` returns a frozen snapshot of the current vault metadata, +workspace mode/restoration, note index metadata, selected path/note, history +availability, and note sort preference. Each note contains only its path, title, +logical folder, folder-relative parent directory, and creation/update timestamps. +The snapshot does not expose note bodies, remote credentials, settings objects, +editor views, or store actions. Home and virtual pages have no selected note. + +`subscribeShell((next, previous) => ...)` observes public changes and returns a +disposer. It does not send an initial notification; use `getShellSnapshot()` for +the initial value. `useShellSnapshot()` provides the same data to React. Repeated +reads and unrelated editor changes retain snapshot identity; selection changes +reuse the frozen note index. An index refresh may produce a new snapshot even if +its metadata is equal. Previously returned values never change. + +`workspaceRestored` describes app-core's restoration step. Native note-index +readiness and keyboard/lifecycle state still belong to the host. Vault roots are +display/change metadata, not durable persistence keys or authorization for I/O. +In particular, iOS can expose a friendly remote-vault label there. Continue using +the host's stable vault token when storing pins or other native preferences. + +```ts +import { getShellSnapshot, getBrowseNotes, getAdjacentNotePath } from '@zennotes/app-core/shell' +import { openNote } from '@zennotes/app-core/navigation' + +const current = getShellSnapshot() +const rows = getBrowseNotes(current, 'Projects', pinnedPaths) +const next = current.selectedPath && + getAdjacentNotePath(current, current.selectedPath, 'next', pinnedPaths) +if (next) await openNote(next) +``` + +Browse helpers share the mobile drawer's ordering: pinned notes first, with the +chosen sort preserved within each group. `none` and `manual` retain the mobile +fallback to most recently edited; desktop manual ordering is unchanged. Names +use natural sorting, so Note 2 precedes Note 10. Ties retain note-index order. +Pins remain host-owned and must belong to the snapshot's vault. + +`getBrowseNotes` takes a directory relative to the primary notes area, with an +empty string for its root. Custom system-folder mappings and notes stored at the +vault root are resolved by app-core. Only immediate primary-folder notes appear; +database records below any `.base` ancestor are excluded. Adjacent navigation uses +that same list, does not wrap, and returns null for missing/virtual paths or notes +outside the primary area. Both helpers only query the supplied snapshot. Read a +fresh snapshot when handling an action, then use the normal navigation API. + +Database/note batches, tasks, settings, dialogs, commands, and workspace lifecycle +now have named public APIs. Both native shells use those exports; boundary checks +reject private imports and source-checkout aliases. + +## Browse folders and databases + +`getBrowseSnapshot()`, `subscribeBrowse()`, and `useBrowseSnapshot()` provide the +drawer's data without subscribing to editor selection or cursor changes. This +snapshot reuses the shell's frozen note metadata and adds frozen primary-folder +and database rows, the note sort order, vault display/change metadata, and enabled +daily/weekly/monthly directory settings. Disabled date directories are null. +Directory settings remain unchanged, including any patterns; this API does not +expand date patterns or check whether their folders exist. + +```tsx +import { getBrowseDirectory, useBrowseSnapshot } from '@zennotes/app-core/browse' +import { openNote } from '@zennotes/app-core/navigation' + +const snapshot = useBrowseSnapshot() +const rows = getBrowseDirectory(snapshot, directory, { + notes: pinnedNotePaths, + folders: pinnedFolderDirectories +}) +// A folder row's directory becomes the next local drawer location. +// Note and database row paths are navigation targets: +await openNote(rows.databases[0].path) +``` + +The directory argument and folder pins are relative to the primary notes area. +The empty string means its root. Results contain separate `folders`, `databases`, +and `notes` arrays. Folders sort by title with pinned folders first; databases +sort by title without pin partitioning; notes use the shared Browse ordering. +Empty folders remain visible. Database internals under `.base` never become +ordinary drawer rows, including nested `pages/` directories. + +Database paths are opaque app-generated navigation targets. Pass them to +`openNote`; do not construct their URLs or treat them as filesystem paths. +App-core handles custom system-folder mappings and notes stored at the vault +root. The snapshot exposes no mutable `FolderEntry` or `VaultSettings` objects. + +Subscriptions behave like `subscribeShell`: no initial notification, only public +changes, coherent previous/next snapshots, and a returned disposer. Unchanged +folder and date data retain identity when notes change. Pins remain host-owned, +keyed by the native host's stable vault token. + +### Folder and database actions + +- `createBrowseDatabase(host, directory?)` creates and opens an untitled database. Omitting the directory uses the configured database location, including the active note's folder. Explicit `''` selects the primary root; any other explicit directory must be an existing ordinary Browse folder. +- `requestRenameBrowseDatabase(host, directory)` prompts for a database title and preserves host collision numbering. Names starting with a dot are rejected because vault scanners hide those directories. Case-only renames retain existing host behavior and can receive a numbered suffix on case-insensitive filesystems. +- `requestCreateBrowseFolder(host, directory = '')` prompts for a child folder. +- `requestRenameBrowseFolder(host, directory)` prompts for an ordinary folder's leaf name. +- `requestDeleteBrowseDirectory(host, directory)` confirms permanent deletion of an ordinary folder or an entire database, with the appropriate warning. + +All directories are relative to the primary notes area. Root deletion, missing +rows, database internals, invalid names, and overlapping dialogs are rejected. +The actions return `completed`, `cancelled`, `stale`, or `unavailable`; host I/O +errors reject the promise and the caller must show the error. `stale` means the +context changed and further work stopped. An already dispatched operation may +have finished in the original vault, so do not automatically retry it. + +`host.isCurrent()` must compare a token captured before opening the dialog with +the native host's current vault/session token. Invalidate that token synchronously +when a switch or teardown begins. Then drain current folder operations and pending +saves before changing the bridge's active vault. An operation already dispatched +must finish reconciling paths and persisting favorites in its original vault. +Renderer vault labels alone are insufficient. The public workspace transition +owns this drain, and hosts compare the captured workspace generation as well as +their native session token. Cancelled or failed switches do not revive old captures. + +Folder operations coordinate pending note/database/comment saves, move open tabs, +manual order, references, and cached metadata to the host's canonical returned +path, and discard stale reads. Desktop and Go also move the parallel comment +subtree; a pre-existing destination comment subtree causes a rename to fail +before changing content. Delete quarantines content and comments together before +cleanup. Temporary desktop sessions with no comments delete directly without +creating ZenNotes metadata. Native menus, dismissal, and pins remain host-owned. + +If a host reports `FOLDER_STATE_UNCERTAIN:` after a failed rollback, app-core keeps +buffers in memory and blocks further writes to that subtree. A vault switch's save +step also rejects. The host must show the error and recover/reload the vault before +continuing; do not automatically retry a partially completed filesystem operation. + +Native `MobileVault` operations now implement equivalent comment-subtree rollback. +The host must keep its active vault fixed until database creation also finishes, +since HTTP creation spans multiple file operations. +Database renames use the same save and workspace reconciliation as folder renames. +Note lifecycle actions use the notes export described above. Future adapters must provide equivalent file/comment rollback before adoption. + +## Batch lifecycle and native shell actions + +`requestNoteBatch(host, paths, action)` confirms the selection once and applies +moves sequentially. The result distinguishes completed source paths from +unconfirmed paths. `NoteBatchError` retains both lists after an operational failure; +an unconfirmed item may already have moved before its final save failed. Refresh +and inspect the workspace before retrying. Previously completed moves stay complete. + +`requestEmptyTrash(host)` saves and freezes the entire configured Trash subtree, +including database grids, before deleting its contents and comments. Cancellation +or failure releases editors without removing their buffers. Desktop and Go use +transactional relocation before cleanup; remapped Trash paths are supported. + +Database row deletion materializes each exclusively owned linked page's latest +properties and body before committing the rows/schema. A save failure retains +recoverable rows. After the database commit, pages move sequentially through the +same note guard. A later move failure leaves remaining pages saved standalone and +reports partial completion. Shared or foreign page mappings are detached without +changing those files. The whole operation is drained before a vault switch. + +Task snapshots are frozen copies. `moveTaskToColumn` takes the host's captured +vault identity and expected grouping, then uses desktop's existing queued writer; +its boolean reports whether the request was recognized, while write failures use +core's existing toast UI. `getTodayTasks` applies the same display filtering and +file order as core. Hosts retain widget limits, theme sampling, and native updates. + +Workspace snapshots expose profile display metadata, never credentials or store +methods. Native vault tokens pass unchanged to the bridge. `flushWorkspace` +waits for pending file, row, task, database, and editor saves; unsaved buffers reject +instead of allowing a vault switch to discard them. Presentation options control +panel visibility without exposing the pane tree. `readPersistedHomeState` owns +interpretation of the persisted layout for mobile cold-start landing. + +`getAppCommands` returns descriptions; `runAppCommand` resolves availability again +at invocation. Editor presentation exposes the active mode and note availability, +without exposing CodeMirror. Navigation also accepts an initial note mode and +follows wikilinks without taking editor focus. Tag-presence observation includes +live note tags and excludes Typst preambles. + +Hosts may supply `ZenAppInfo.hostKind` as `desktop`, `browser`, `ios`, or `android`. +The legacy renderer `runtime` remains compatible with installed bridges. Use +capabilities for feature availability, not the reported OS or renderer family. + +## Native editor host integration + +Install host configuration after restoring preferences and before mounting React. +Typing attributes then exist before any editor receives its first focus. Installing +later also updates existing editors, without changing their note or selection. + +```ts +import { installEditorHost, revealEditorCaret } from '@zennotes/app-core/editor' + +const host = installEditorHost({ + nativeTyping: true, + measureBottomInsets: ({ editor, scroll }) => ({ + // These geometry helpers and overlay elements belong to the native shell. + layout: bottomOverlap(editor, selectionToolbarBounds()), + scroll: bottomOverlap(scroll, keyboardToolbarBounds()) + }) +}) + +// After keyboard resize, overlay mount/removal, or a toolbar size change: +host.refresh() +revealEditorCaret() +// On shell teardown, also cancel the host's observers/listeners/timers: +host.dispose() +``` + +`nativeTyping: true` enables sentence capitalization, autocorrect, spellchecking, +and writing suggestions through the editor's content attributes. The native +keyboard decides which features to provide. It does not install keyboard plugins +or change the host's spelling capabilities. + +The measurement callback receives frozen copies of editor and scroll-viewport +bounds (`top`, `bottom`, `left`, `right`, `width`, `height`) in CSS pixels. It receives +no DOM element or CodeMirror object. Read host geometry there; do not mutate layout +or call configuration APIs from the callback. + +- `layout` reserves physical space below the scroller, keeping native selection + handles above an overlay. Calculate it from the stable `editor` bounds. +- `scroll` adds clearance inside the remaining scroll viewport. Calculate it from + `scroll` bounds to avoid counting an area already reserved by `layout` twice. + +Core remeasures after changing layout clearance and on editor geometry changes. +Hosts call `refresh()` when their overlays change independently. Insets are +clamped to the available height; invalid values and failed measurements clear the +affected clearance. No configuration means the existing editor behavior remains. + +`revealEditorCaret()` returns whether a reveal was scheduled for a focused, active +note editor. It waits for measurement, uses the current caret in that note, and +never takes focus. Pending work is discarded if the note, vault, pane, focus, or +registration changes, or the editor is destroyed. Native keyboard timing and +delayed retries remain host-owned; cancel those timers during teardown. + +The newest registration owns configuration for all mounted and future note +editors. Older handles become no-ops. Disposing the current handle removes its +typing attributes and insets, returning to the underlying editor configuration; +it does not restore an older registration. + +## Editor commands + +Call `runEditorCommand(command)` from the host toolbar. It resolves the actual +active note editor immediately and returns the underlying command's handled +boolean. It returns `false` for an unavailable or transitioning editor, an unknown +command, or an unhandled operation such as Undo with no history. Formatting and +history commands restore editor focus even when there is nothing to change. + +| Commands | Behavior | +| --- | --- | +| `toggle-bold`, `toggle-italic`, `toggle-strikethrough`, `toggle-highlight`, `toggle-inline-code` | Wrap or unwrap every selection using the existing editor rules. | +| `set-bullet-list`, `set-task-list` | Convert the selected lines, or start a list on an empty line while retaining indentation. | +| `cycle-heading` | Choose the next heading level from the main selection's first line (1, 2, 3, then paragraph) and apply it to the selected nonblank lines. | +| `insert-link` | Wrap every selection as a Markdown link and place its caret in the URL. | +| `insert-wikilink`, `insert-tag` | Replace the main selection with `[[]]` or `#` and position a single caret for typing. | +| `indent`, `outdent`, `undo`, `redo` | Use the editor's existing settings and history. | +| `open-search`, `close-search` | Open and focus Find, or close it through the normal search command. | + +Search owns its focus: opening Find leaves its field focused, and closing it +returns focus to the editor only when the search panel held focus. Search also +works in a read-only note; commands that change text are rejected there. The +commands do not require editor focus, so toolbar buttons can receive it first. +Hosts should scope formatting controls to their editing UI. + +`hasEditorSelection()` returns whether any text range is selected in the active +registered editor. It returns `false` when no matching note editor is available. +For mobile swipe/gesture suppression, combine this with a noncollapsed DOM +selection check. Preview text uses DOM selection and must still suppress gestures. + +```ts +import { runEditorCommand, hasEditorSelection } from '@zennotes/app-core/editor' + +runEditorCommand('toggle-bold') +// In Android's back-button cascade: +if (runEditorCommand('close-search')) return +// In a gesture guard shared by Edit and Preview: +const selection = window.getSelection() +const hasSelection = Boolean(selection && !selection.isCollapsed) || hasEditorSelection() +``` + +These commands are synchronous. For a file picker or clipboard read, use the +captured attachment target below rather than running a command after an await. + +## Attachment integration + +Capture a target before opening the file picker or starting an asynchronous +clipboard read. The host binds storage operations to one vault instance: + +```ts +import { captureEditorInsertion, attachFiles } from '@zennotes/app-core/editor' + +const vault = activeVault() // Host-owned storage implementation. +const target = captureEditorInsertion({ + isCurrent: () => activeVault() === vault, + importFile: (notePath, file) => vault.importDroppedFile(notePath, file), + importPastedImage: (input) => vault.importPastedImage(input) +}) +if (target) { + const files = await pickFiles() // Host-owned picker. + const result = await attachFiles(target, files) + // Show the appropriate status below; do not assume every import was inserted. +} +``` + +Load the editor entrypoint after restoring native preferences, like navigation. +Never resolve `activeVault()` inside the two import methods. `isCurrent` checks +the host independently because it may switch vaults before renderer state updates. +Hosts continue to validate vault-relative paths and own filesystem/network I/O. + +The target captures the actual registered note editor, vault, document, and full +selection. It is opaque and single-use. Toolbar actions can capture the active +editor after a button takes focus; keyboard paste can pass `{ requireFocus: true }` +as the second capture argument. A captured target survives picker blur. Call +`cancelEditorInsertion(target)` on picker cancellation or host disposal. +Cancellation prevents further imports and insertion; it cannot undo or abort a +host save already in progress. + +`attachFiles(target, files)` snapshots the list, imports serially, and inserts at +the captured cursor head, preserving selected text. For clipboard images, call +`insertPastedImage(target, input)` after reading the bytes. It replaces the captured +selection. Both use the editor's existing attachment spacing rules and validate +the context before and after each save. A partial batch never inserts partial +Markdown. Successful insertion focuses the captured editor; stale work does not +steal focus or delete saved files. + +| Result status | Meaning | +| --- | --- | +| `inserted` | All confirmed assets were inserted through the normal editor update. | +| `empty` | The file list was empty; nothing was imported or inserted. | +| `stale` | Target unavailable, used, cancelled, or changed before any confirmed save. | +| `saved-only` | Context changed or was cancelled after a confirmed save. Assets remain in the captured vault; no Markdown was inserted. | +| `failed` | Import/insertion failed. `error` describes the failure; `assets` lists confirmed prior saves. | + +Every result includes `assets`. If a host commits a file and then rejects, the +core cannot know that file was saved; it reports only successful return values. +Map `saved-only` and partial failures to visible recovery guidance in the host. + +## Local package candidates + +From the repository root: + +```sh +npm run artifact:app-core +npm run test:app-core-package +npm run test:app-core-browser +``` + +The producer creates three immutable archives in ignored `dist/shared-packages`: +app-core, bridge-contract, and shared-domain. Install all three together. The core +candidate pins its companion packages exactly. Each archive has a SHA-256 and +source record. This is a local validation workflow; it does not publish packages. + +The package contains emitted JavaScript, declarations, compiled CSS, local fonts, +and image assets. Internal imports are relative ESM imports or declared package +dependencies. TypeScript sources, workspace aliases, and sibling checkouts are not +needed by consumers. `main` imports the shared CSS automatically. The source +workspace still uses Tailwind; the installed CSS is already compiled using the +same preset owned by app-core. + +React, ReactDOM, Zustand, CodeMirror state/view/language, and Lezer common/highlight +are peers in the candidate. Hosts must install compatible versions. Lezer's node +property identifiers must come from one shared instance across every parser and +highlighter. The consumer test deliberately uses a nested installation, checks +app-core's peer identity, and verifies one React/CodeMirror/Lezer copy and its +resolution from every declared consumer. Drawing libraries may own independent +Zustand stores. No dedupe aliases hide a second instance. + +### Vite host setup + +```ts +import { defineConfig } from 'vite' +import { zenNotesAssets } from '@zennotes/app-core/vite' + +export default defineConfig({ + plugins: zenNotesAssets(), + base: './' +}) +``` + +The helper resolves the Oniguruma binary as a data URL, resolves Harper's exported +binary entry, and serves/copies Excalidraw fonts. Hosts using native spelling can +pass `{ harper: false }` to omit Harper; they must also advertise +`supportsHarper: false` through their bridge. `{ excalidraw: false }` omits drawing +fonts for hosts that do not support drawings. + +Before opening a drawing, set `window.EXCALIDRAW_ASSET_PATH` to the deployment's +`excalidraw-assets/` URL, including any mount prefix. Install the host bridge and +restore preferences before dynamically importing `main`, `navigation`, `editor`, +`shell`, or `browse`, because each can evaluate application state. The build-only `vite` export does not load +the renderer. Avoid manual chunk rules that pull lazy features into bootstrap. + +### Validation and remaining work + +The package test copies the current HTTP bridge into a separate temporary app, +rewrites its domain imports to public exports, verifies candidate hashes, installs +with a nested dependency tree, typechecks, and builds with Vite. It records the +consumer path in `dist/shared-packages/app-core-consumer.json` and keeps that +directory for inspection. The browser test uses the built app, a temporary vault, +and a temporary Chrome profile. Set `ZEN_CHROME_PATH` when Chrome is not installed +at the platform default. Both tests leave production vaults and settings alone. + +Both native repositories now install exact immutable package archives without a +source clone. Clean builds, isolated Android/iOS runtime fixtures, and cold starts +pass. Live iCloud and account-backed Cloud sync remain staging-account acceptance +gates; browser fixtures alone do not prove those integrations. + + +## Native vault relocation and workspace identity + +`getWorkspaceSnapshot().generation` changes whenever a transition begins, including +one that is cancelled or fails. Capture it with the host's opaque vault identity +before asynchronous UI work, and require both to match before dispatching writes. +Never infer identity from the visible vault name or a remote profile's display root. + +`relocateLocalVault({ move, rollback, reopen })` reserves the workspace before save +draining and keeps it reserved through native filesystem work and reopen. Native +callbacks own platform I/O and must undo their own partial failure before rejecting. +`reopen` contains opaque source/destination tokens and is omitted for a closed vault. +If destination reopen fails, core rolls back, reopens the original token, and +restores the flushed state. If rollback/recovery also fails, core deactivates the +vault, preserves cached drafts, and reports the recovery failure. Editing must not +resume against an uncertain location. Both native bridges use this operation for +vault rename/move; callers must not implement separate flush/move/reopen sequences. diff --git a/packages/app-core/build/tailwind-preset.cjs b/packages/app-core/build/tailwind-preset.cjs new file mode 100644 index 00000000..e94cdda4 --- /dev/null +++ b/packages/app-core/build/tailwind-preset.cjs @@ -0,0 +1,85 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + theme: { + extend: { + colors: { + paper: { + 50: 'rgb(var(--z-bg-softer) / )', + 100: 'rgb(var(--z-bg) / )', + 200: 'rgb(var(--z-bg-1) / )', + 300: 'rgb(var(--z-bg-2) / )', + 400: 'rgb(var(--z-bg-3) / )', + 500: 'rgb(var(--z-bg-4) / )' + }, + ink: { + 900: 'rgb(var(--z-fg) / )', + 800: 'rgb(var(--z-fg-1) / )', + 700: 'rgb(var(--z-fg-2) / )', + 600: 'rgb(var(--z-grey-2) / )', + 500: 'rgb(var(--z-grey-1) / )', + 400: 'rgb(var(--z-grey-0) / )', + 300: 'rgb(var(--z-grey-dim) / )' + }, + accent: { + DEFAULT: 'rgb(var(--z-accent) / )', + soft: 'rgb(var(--z-accent-soft) / )', + muted: 'rgb(var(--z-accent-muted) / )' + }, + danger: 'rgb(var(--z-red) / )', + success: 'rgb(var(--z-green) / )', + warning: 'rgb(var(--z-yellow) / )' + }, + borderRadius: { + // Scale every rounded-* by --z-radius-scale (default 1) so one var can + // square all corners (Quick tweaks → Square corners sets it to 0). + // rounded-none / rounded-full keep Tailwind defaults, so pills and + // circles stay round. + DEFAULT: 'calc(0.25rem * var(--z-radius-scale, 1))', + sm: 'calc(0.125rem * var(--z-radius-scale, 1))', + md: 'calc(0.375rem * var(--z-radius-scale, 1))', + lg: 'calc(0.5rem * var(--z-radius-scale, 1))', + xl: 'calc(0.75rem * var(--z-radius-scale, 1))', + '2xl': 'calc(1rem * var(--z-radius-scale, 1))', + '3xl': 'calc(1.5rem * var(--z-radius-scale, 1))' + }, + fontFamily: { + sans: [ + '-apple-system', + 'BlinkMacSystemFont', + '"SF Pro Text"', + '"Inter"', + 'system-ui', + 'sans-serif' + ], + serif: ['"Iowan Old Style"', '"Source Serif Pro"', 'Georgia', 'serif'], + mono: ['"JetBrains Mono"', '"SF Mono"', 'Menlo', 'monospace'] + }, + boxShadow: { + panel: + '0 1px 0 0 rgb(var(--z-shadow) / 0.04), 0 8px 28px -12px rgb(var(--z-shadow) / 0.18)', + float: '0 20px 60px -20px rgb(var(--z-shadow) / 0.28)' + }, + fontSize: { + '2xs': ['0.6875rem', { lineHeight: '1rem' }] + }, + zIndex: { + dropdown: '40', + palette: '50', + modal: '70', + nested: '75', + popover: '80', + toast: '90' + }, + maxWidth: { + 'dialog-xs': '420px', + 'dialog-sm': '440px', + 'dialog-md': '560px', + 'dialog-lg': '720px', + 'dialog-xl': '900px', + 'dialog-2xl': '1120px', + 'dialog-3xl': '1360px' + } + } + }, + plugins: [] +} diff --git a/packages/app-core/build/vite.d.ts b/packages/app-core/build/vite.d.ts new file mode 100644 index 00000000..c84a23b9 --- /dev/null +++ b/packages/app-core/build/vite.d.ts @@ -0,0 +1,11 @@ +import type { Plugin } from 'vite' + +export interface ZenNotesAssetsOptions { + /** Disable the grammar checker and its binary for hosts using native spelling. */ + harper?: boolean + /** Serve drawing fonts locally when the host supports Excalidraw. */ + excalidraw?: boolean +} + +/** Build integrations for the shared editor's lazy assets. */ +export function zenNotesAssets(options?: ZenNotesAssetsOptions): Plugin[] diff --git a/packages/app-core/build/vite.mjs b/packages/app-core/build/vite.mjs new file mode 100644 index 00000000..02b2ad6d --- /dev/null +++ b/packages/app-core/build/vite.mjs @@ -0,0 +1,67 @@ +import { createReadStream, readFileSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative, resolve, sep } from 'node:path' + +const require = createRequire(import.meta.url) +const harperWasm = 'harper.js/dist/harper_wasm_slim_bg.wasm?url' +const onigWasm = 'vscode-oniguruma/release/onig.wasm?url' + +/** @type {typeof import('./vite').zenNotesAssets} */ +export function zenNotesAssets(options = {}) { + const onigVirtual = '\0zennotes-core:oniguruma' + const harperVirtual = '\0zennotes-core:harper-disabled' + const assets = { + name: 'zennotes-core-assets', + enforce: 'pre', + async resolveId(id, importer) { + if (id === onigWasm) return onigVirtual + if (options.harper === false && (id === 'harper.js' || id === harperWasm)) return harperVirtual + if (id !== harperWasm) return null + const entry = await this.resolve('harper.js/slimBinary', importer, { skipSelf: true }) + if (!entry) throw new Error('Cannot locate the installed Harper binary') + return join(dirname(entry.id.split('?')[0]), 'harper_wasm_slim_bg.wasm') + '?url' + }, + load(id) { + if (id === harperVirtual) return 'export default ""' + if (id !== onigVirtual) return null + const bytes = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm')) + return `export default ${JSON.stringify(`data:application/wasm;base64,${bytes.toString('base64')}`)}` + } + } + if (options.excalidraw === false) return [assets] + + const fonts = join(dirname(require.resolve('@excalidraw/excalidraw')), 'fonts') + let base = '/' + return [assets, { + name: 'zennotes-core-drawing-fonts', + configResolved(config) { base = config.base }, + configureServer(server) { + const prefix = `${base === './' || base === '' ? '/' : base}excalidraw-assets/fonts/` + server.middlewares.use((req, res, next) => { + const path = req.url?.split('?')[0] + if (!path?.startsWith(prefix)) return next() + let file + try { file = resolve(fonts, decodeURIComponent(path.slice(prefix.length))) } + catch { res.statusCode = 400; res.end(); return } + if (!file.startsWith(fonts + sep) || !/\.(woff2?|otf|ttf)$/i.test(file)) { + res.statusCode = 404; res.end(); return + } + const mime = file.endsWith('.woff2') ? 'font/woff2' : file.endsWith('.woff') ? 'font/woff' : file.endsWith('.otf') ? 'font/otf' : 'font/ttf' + res.setHeader('Content-Type', mime) + createReadStream(file).on('error', () => { res.statusCode = 404; res.end() }).pipe(res) + }) + }, + generateBundle() { + const walk = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const file = join(directory, entry.name) + if (entry.isDirectory()) walk(file) + else if (entry.isFile() && /\.(woff2?|otf|ttf)$/i.test(file)) { + this.emitFile({ type: 'asset', fileName: `excalidraw-assets/fonts/${relative(fonts, file).split(sep).join('/')}`, source: readFileSync(file) }) + } + } + } + walk(fonts) + } + }] +} diff --git a/packages/app-core/package.json b/packages/app-core/package.json index bf1e194a..694be7f3 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -4,9 +4,27 @@ "version": "2.50.4", "type": "module", "exports": { - "./main": "./src/main.tsx" + "./main": "./src/main.tsx", + "./navigation": "./src/navigation.ts", + "./notes": "./src/notes.ts", + "./shell": "./src/shell.ts", + "./browse": "./src/browse.ts", + "./editor": "./src/editor.ts", + "./vite": { + "types": "./build/vite.d.ts", + "import": "./build/vite.mjs" + }, + "./styles.css": "./src/styles/index.css", + "./tasks": "./src/tasks.ts", + "./workspace": "./src/workspace.ts", + "./settings": "./src/settings.ts", + "./commands": "./src/commands.ts", + "./dialogs": "./src/dialogs.ts", + "./host": "./src/host.ts" }, "dependencies": { + "@zennotes/bridge-contract": "*", + "@zennotes/shared-domain": "*", "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-cpp": "^6.0.3", @@ -30,6 +48,7 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@excalidraw/excalidraw": "^0.18.1", + "@lezer/common": "^1.5.2", "@lezer/highlight": "^1.2.1", "@myriaddreamin/typst-ts-renderer": "^0.7.0", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", @@ -66,7 +85,14 @@ }, "devDependencies": { "vite": "^6.4.3", - "vitest": "^3.2.6" + "vitest": "^3.2.6", + "typescript": "^5.7.2", + "postcss": "^8.5.10", + "tailwindcss": "^3.4.17", + "autoprefixer": "^10.4.20", + "vfile": "^6.0.3", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7" }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 18da506e..0a6961f9 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -346,10 +346,10 @@ function App(): JSX.Element { const mountedAtRef = useRef(performance.now()) const workspaceReadyLoggedRef = useRef(false) const searchPaletteWarmupCleanupRef = useRef<(() => void) | null>(null) - const pendingOpenNoteRequestsRef = useRef([]) + const pendingOpenNoteRequestsRef = useRef['vault'] }>>([]) const vault = useStore((s) => s.vault) const init = useStore((s) => s.init) - const workspaceRestored = useStore((s) => s.workspaceRestored) + const workspaceRestored = useStore((s) => s.workspaceRestored && !s.workspaceTransitioning) const searchOpen = useStore((s) => s.searchOpen) const setSearchOpen = useStore((s) => s.setSearchOpen) const vaultTextSearchOpen = useStore((s) => s.vaultTextSearchOpen) @@ -464,11 +464,11 @@ function App(): JSX.Element { useEffect(() => { return window.zen.onOpenNoteRequested((relPath) => { const state = useStore.getState() - if (state.vault && state.workspaceRestored) { + if (state.vault && state.workspaceRestored && !state.workspaceTransitioning) { void state.openNoteInTab(relPath) return } - pendingOpenNoteRequestsRef.current.push(relPath) + pendingOpenNoteRequestsRef.current.push({ path: relPath, vault: state.vault }) }) }, []) @@ -529,8 +529,9 @@ function App(): JSX.Element { useEffect(() => { if (!vault || !workspaceRestored || pendingOpenNoteRequestsRef.current.length === 0) return const requests = pendingOpenNoteRequestsRef.current.splice(0) - for (const relPath of requests) { - void useStore.getState().openNoteInTab(relPath) + for (const request of requests) { + if (request.vault && request.vault !== vault) continue + void useStore.getState().openNoteInTab(request.path) } }, [vault, workspaceRestored]) diff --git a/packages/app-core/src/asset-actions.test.ts b/packages/app-core/src/asset-actions.test.ts new file mode 100644 index 00000000..2487cef0 --- /dev/null +++ b/packages/app-core/src/asset-actions.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeLeaf } from './lib/pane-layout' + +beforeEach(() => { vi.resetModules(); localStorage.clear() }) +function deferred() { + let resolve!: () => void + const promise = new Promise(r => { resolve = r }) + return { promise, resolve } +} +async function setup() { + const path = 'inbox/Note.md' + let disk = '![image](attachements/old.png)' + const meta = { path, title: 'Note', folder: 'inbox' as const, siblingOrder: 0, + createdAt: 0, updatedAt: 1, size: disk.length, tags: [], wikilinks: [], + assetEmbeds: [], hasAttachments: true, excerpt: '' } + const asset = { path: 'attachements/new.png', name: 'new.png', size: 1, updatedAt: 2 } + const bridge = { + getCapabilities: () => ({}), getAppInfo: () => ({ runtime: 'web' }), + listNotes: async () => [meta], listFolders: async () => [], + listAssets: async () => [], hasAssetsDir: async () => true, + scanTasks: async () => [], scanTasksForPath: async () => [], + getRemoteWorkspaceInfo: async () => null, + readNote: async () => ({ ...meta, body: disk }), + writeNote: vi.fn(async (_path: string, body: string) => { disk = body; return meta }), + renameAsset: vi.fn(async () => { disk = disk.replace('old.png', 'new.png'); return asset }), + moveAsset: vi.fn(async () => { disk = disk.replace('old.png', 'new.png'); return asset }), + openLocalVault: vi.fn().mockResolvedValue(null) + } + Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) + const { useStore } = await import('./store') + const leaf = makeLeaf([path], path) + useStore.setState({ vault: { root: '/test', name: 'Test' }, notes: [meta], + paneLayout: leaf, activePaneId: leaf.id, selectedPath: path, + noteContents: { [path]: { ...meta, body: disk + '\nUnsaved edit' } }, + noteDirty: { [path]: true }, activeNote: { ...meta, body: disk + '\nUnsaved edit' }, activeDirty: true }) + return { useStore, bridge, path, asset, disk: () => disk } +} + +describe('asset link rewrites across workspace boundaries', () => { + it.each(['renameAsset', 'moveAsset'] as const)('%s drains edits, reserves the vault and refreshes open bodies', async action => { + const s = await setup(), saving = deferred(), moving = deferred() + const write = s.bridge.writeNote.getMockImplementation()! + s.bridge.writeNote.mockImplementation(async (...args) => { await saving.promise; return write(...args) }) + const rewrite = s.bridge[action].getMockImplementation()! + s.bridge[action].mockImplementation(async () => { await moving.promise; return rewrite() }) + const pending = s.useStore.getState()[action]('attachements/old.png', 'new.png') + expect(s.useStore.getState().workspaceTransitioning).toBe(true) + await s.useStore.getState().openLocalVault('/other') + expect(s.bridge.openLocalVault).not.toHaveBeenCalled() + expect(s.bridge[action]).not.toHaveBeenCalled() + saving.resolve() + await vi.waitFor(() => expect(s.bridge[action]).toHaveBeenCalledOnce()) + s.useStore.getState().updateNoteBody(s.path, 'Typing during rewrite') + expect(s.useStore.getState().noteContents[s.path].body).toContain('Unsaved edit') + await expect(s.useStore.getState()[action]('attachements/old.png', 'other.png')).rejects.toThrow('Wait') + moving.resolve() + expect(await pending).toEqual(s.asset) + expect(s.disk()).toBe('![image](attachements/new.png)\nUnsaved edit') + expect(s.useStore.getState().activeNote?.body).toBe(s.disk()) + expect(s.useStore.getState().workspaceTransitioning).toBe(false) + s.useStore.getState().updateNoteBody(s.path, s.disk() + '\nAfter rewrite') + await s.useStore.getState().persistNote(s.path) + expect(s.disk()).toBe('![image](attachements/new.png)\nUnsaved edit\nAfter rewrite') + }) + it('does not rewrite links when the save drain fails', async () => { + const s = await setup() + s.bridge.writeNote.mockRejectedValue(new Error('disk full')) + await expect(s.useStore.getState().renameAsset('old.png', 'new.png')).rejects.toThrow('unsaved') + expect(s.bridge.renameAsset).not.toHaveBeenCalled() + expect(s.useStore.getState().noteDirty[s.path]).toBe(true) + expect(s.useStore.getState().workspaceTransitioning).toBe(false) + }) + it('releases editing and the workspace reservation after a host failure', async () => { + const s = await setup() + s.bridge.moveAsset.mockRejectedValue(new Error('permission denied')) + await expect(s.useStore.getState().moveAsset('old.png', 'folder')).rejects.toThrow('permission denied') + expect(s.useStore.getState().workspaceTransitioning).toBe(false) + s.useStore.getState().updateNoteBody(s.path, 'Recovered edit') + expect(s.useStore.getState().noteContents[s.path].body).toBe('Recovered edit') + }) +}) diff --git a/packages/app-core/src/browse-actions.test.ts b/packages/app-core/src/browse-actions.test.ts new file mode 100644 index 00000000..001df601 --- /dev/null +++ b/packages/app-core/src/browse-actions.test.ts @@ -0,0 +1,290 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) + +async function setup() { + const { useStore } = await import('./store') + const actions = await import('./lib/browse-actions') + const prompts = await import('./lib/prompt-requests') + const confirms = await import('./lib/confirm-requests') + const create = vi.fn(async () => {}) + const rename = vi.fn(async () => {}) + const remove = vi.fn(async () => {}) + const createDatabase = vi.fn(async () => {}) + const renameDatabase = vi.fn(async () => {}) + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + folders: ['Work', 'Work/Nested', 'People.base'].map((subpath) => ({ + folder: 'inbox', + subpath, + siblingOrder: 0 + })), + createFolder: create, + renameFolder: rename, + deleteFolder: remove, + createDatabase, + renameDatabase + }) + const host = { isCurrent: () => true } + const answer = (value: string | null) => { + const request = prompts.getPromptRequest() + expect(request).not.toBeNull() + prompts.settlePromptRequest(request!, value) + } + const confirm = (value: boolean) => { + const request = confirms.getConfirmRequest() + expect(request).not.toBeNull() + confirms.settleConfirmRequest(request!, value) + } + return { + useStore, + ...actions, + ...prompts, + ...confirms, + create, + rename, + remove, + createDatabase, + renameDatabase, + host, + answer, + confirm + } +} + +describe('public Browse actions', () => { + it('creates a trimmed child through the normal store action', async () => { + const s = await setup() + const result = s.requestCreateBrowseFolder(s.host, 'Work') + expect(s.getPromptRequest()?.options.title).toBe('New folder in Work') + s.answer(' Research ') + expect(await result).toBe('completed') + expect(s.create).toHaveBeenCalledWith('inbox', 'Work/Research', expect.any(Function)) + }) + + it('renames only the leaf and retains the parent', async () => { + const s = await setup() + const result = s.requestRenameBrowseFolder(s.host, 'Work/Nested') + expect(s.getPromptRequest()?.options.initialValue).toBe('Nested') + s.answer('Renamed') + expect(await result).toBe('completed') + expect(s.rename).toHaveBeenCalledWith( + 'inbox', + 'Work/Nested', + 'Work/Renamed', + expect.any(Function) + ) + }) + + it('cancels names without writes and refuses invalid names at submission', async () => { + const s = await setup() + for (const value of [ + null, + '', + ' ', + '../Elsewhere', + 'a/b', + 'a\\b', + '.', + '..', + 'New.base', + 'bad\0name' + ]) { + const result = s.requestCreateBrowseFolder(s.host) + if (value?.trim()) expect(s.getPromptRequest()?.options.validate?.(value)).toBeTruthy() + s.answer(value) + expect(await result).toBe('cancelled') + } + const same = s.requestRenameBrowseFolder(s.host, 'Work') + s.answer(' Work ') + expect(await same).toBe('cancelled') + expect(s.create).not.toHaveBeenCalled() + expect(s.rename).not.toHaveBeenCalled() + }) + + it('requires confirmation for folder and database deletion with distinct explanations', async () => { + const s = await setup() + const cancelled = s.requestDeleteBrowseDirectory(s.host, 'Work') + expect(s.getConfirmRequest()?.options).toMatchObject({ + danger: true, + description: expect.stringContaining('Everything inside') + }) + s.confirm(false) + expect(await cancelled).toBe('cancelled') + expect(s.remove).not.toHaveBeenCalled() + const deleted = s.requestDeleteBrowseDirectory(s.host, 'People.base') + expect(s.getConfirmRequest()?.options).toMatchObject({ + title: 'Delete "People"?', + description: expect.stringContaining('All records') + }) + s.confirm(true) + expect(await deleted).toBe('completed') + expect(s.remove).toHaveBeenCalledWith('inbox', 'People.base', expect.any(Function)) + }) + + it('rejects roots, missing folders, database internals, and database renames', async () => { + const s = await setup() + for (const directory of ['', 'Missing', 'People.base/pages']) { + expect(await s.requestDeleteBrowseDirectory(s.host, directory)).toBe('unavailable') + expect(await s.requestRenameBrowseFolder(s.host, directory)).toBe('unavailable') + } + expect(await s.requestRenameBrowseFolder(s.host, 'People.base')).toBe('unavailable') + expect(await s.requestCreateBrowseFolder(s.host, 'People.base')).toBe('unavailable') + expect(s.getPromptRequest()).toBeNull() + expect(s.getConfirmRequest()).toBeNull() + }) + + it.each(['vault', 'host', 'layout', 'missing'] as const)( + 'stops a pending action after a %s context change', + async (kind) => { + const s = await setup() + let current = true + const result = s.requestDeleteBrowseDirectory({ isCurrent: () => current }, 'Work') + if (kind === 'vault') s.useStore.setState({ vault: { root: '/other', name: 'Other' } }) + if (kind === 'host') current = false + if (kind === 'layout') + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: 'root' + } + }) + if (kind === 'missing') s.useStore.setState({ folders: [] }) + s.confirm(true) + expect(await result).toBe('stale') + expect(s.remove).not.toHaveBeenCalled() + } + ) + + it('does not replace an existing dialog or start two Browse requests', async () => { + const s = await setup() + const first = s.requestCreateBrowseFolder(s.host) + const original = s.getPromptRequest() + expect(await s.requestDeleteBrowseDirectory(s.host, 'Work')).toBe('unavailable') + expect(s.getPromptRequest()).toBe(original) + s.answer(null) + await first + const other = s.promptApp({ title: 'Unrelated prompt' }) + expect(await s.requestCreateBrowseFolder(s.host)).toBe('unavailable') + s.answer(null) + await other + }) + + it('rejects host errors and releases the pending action', async () => { + const s = await setup() + s.create.mockRejectedValueOnce(new Error('Read-only vault')) + const failed = s.requestCreateBrowseFolder(s.host) + s.answer('New') + await expect(failed).rejects.toThrow('Read-only vault') + const next = s.requestCreateBrowseFolder(s.host) + s.answer(null) + expect(await next).toBe('cancelled') + }) + it('creates an untitled database in the explicit Browse directory without a prompt', async () => { + const s = await setup() + expect(await s.createBrowseDatabase(s.host, 'Work')).toBe('completed') + expect(s.createDatabase).toHaveBeenCalledWith('inbox', 'Work', undefined, expect.any(Function)) + expect(s.getPromptRequest()).toBeNull() + expect(await s.createBrowseDatabase(s.host, 'People.base')).toBe('unavailable') + expect(await s.createBrowseDatabase(s.host, 'Missing')).toBe('unavailable') + }) + + it.each([false, true])('renames a database with primary root mode %s', async (root) => { + const s = await setup() + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: root ? 'root' : 'inbox', + systemFolderPaths: { inbox: 'My Notes' } + } + }) + const result = s.requestRenameBrowseDatabase(s.host, 'People.base') + expect(s.getPromptRequest()?.options.initialValue).toBe('People') + s.answer(' Customers ') + expect(await result).toBe('completed') + expect(s.renameDatabase).toHaveBeenCalledWith( + root ? 'People.base/data.csv' : 'My Notes/People.base/data.csv', + 'Customers', + expect.any(Function) + ) + }) + + it('cancels invalid database names and refuses folders or database internals', async () => { + const s = await setup() + for (const directory of ['', 'Work', 'People.base/pages']) + expect(await s.requestRenameBrowseDatabase(s.host, directory)).toBe('unavailable') + for (const title of [null, ' ', 'People', '.', '..', '.Hidden', '../Elsewhere', 'a\\b', 'bad\0name']) { + const result = s.requestRenameBrowseDatabase(s.host, 'People.base') + s.answer(title) + expect(await result).toBe('cancelled') + } + expect(s.renameDatabase).not.toHaveBeenCalled() + }) + + it('stops a database rename when its host identity changes during the prompt', async () => { + const s = await setup() + let current = true + const result = s.requestRenameBrowseDatabase({ isCurrent: () => current }, 'People.base') + current = false + s.answer('Customers') + expect(await result).toBe('stale') + expect(s.renameDatabase).not.toHaveBeenCalled() + }) + + it('propagates a database create failure and releases the action guard', async () => { + const s = await setup() + s.createDatabase.mockRejectedValueOnce(new Error('No space')) + await expect(s.createBrowseDatabase(s.host)).rejects.toThrow('No space') + expect(await s.createBrowseDatabase(s.host)).toBe('completed') + }) + + it('honors configured database placement when directory is omitted, and explicit root overrides it', async () => { + const s = await setup() + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + databasesLocation: { mode: 'folder', folder: 'Databases' } + } + }) + expect(await s.createBrowseDatabase(s.host)).toBe('completed') + expect(s.createDatabase).toHaveBeenLastCalledWith( + 'inbox', + 'Databases', + undefined, + expect.any(Function) + ) + expect(await s.createBrowseDatabase(s.host, '')).toBe('completed') + expect(s.createDatabase).toHaveBeenLastCalledWith('inbox', '', undefined, expect.any(Function)) + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + databasesLocation: { mode: 'active-note' } + }, + activeNote: { path: 'quick/Project/Note.md', folder: 'quick' } as never + }) + expect(await s.createBrowseDatabase(s.host)).toBe('completed') + expect(s.createDatabase).toHaveBeenLastCalledWith( + 'quick', + 'Project', + undefined, + expect.any(Function) + ) + }) + it('retains legacy configured placement in an active database record folder', async () => { + const s = await setup() + s.useStore.setState({ vaultSettings: { ...s.useStore.getState().vaultSettings, databasesLocation: { mode: 'active-note' } }, activeNote: { path: 'inbox/People.base/Record.md', folder: 'inbox' } as never }) + expect(await s.createBrowseDatabase(s.host)).toBe('completed') + expect(s.createDatabase).toHaveBeenLastCalledWith('inbox', 'People.base', undefined, expect.any(Function)) + expect(await s.createBrowseDatabase(s.host, 'People.base')).toBe('unavailable') + }) + +}) diff --git a/packages/app-core/src/browse.test.ts b/packages/app-core/src/browse.test.ts new file mode 100644 index 00000000..073a5973 --- /dev/null +++ b/packages/app-core/src/browse.test.ts @@ -0,0 +1,286 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderEntry, NoteMeta } from '@bridge-contract/ipc' +import { databaseTabPath } from '@shared/databases' + +const disposers: Array<() => void> = [] +const folder = ( + subpath: string, + kind: FolderEntry['folder'] = 'inbox' +): FolderEntry => ({ folder: kind, subpath, siblingOrder: 0 }) +const note = (path: string): NoteMeta => ({ + path, + title: path.split('/').pop()!.replace(/\.md$/, ''), + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' +}) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() +}) + +async function setup(folders: FolderEntry[] = []) { + const { useStore } = await import('./store') + const browse = await import('./browse') + const shell = await import('./shell') + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + folders, + notes: [note('inbox/One.md')], + noteSortOrder: 'name-asc' + }) + return { useStore, ...browse, ...shell } +} + +describe('public Browse model', () => { + it('copies and freezes folder/database metadata without exposing mutable source entries or settings', async () => { + const s = await setup([folder('Work'), folder('People.base')]) + const snapshot = s.getBrowseSnapshot() + expect(snapshot.folders).toEqual([{ directory: 'Work', title: 'Work' }]) + expect(snapshot.databases).toEqual([ + { + directory: 'People.base', + title: 'People', + path: databaseTabPath('inbox/People.base/data.csv') + } + ]) + expect(snapshot.notes).toBe(s.getShellSnapshot().notes) + expect(snapshot).not.toHaveProperty('vaultSettings') + expect(snapshot.folders[0]).not.toBe(s.useStore.getState().folders[0]) + for (const value of [ + snapshot, + snapshot.folders, + snapshot.databases, + snapshot.folders[0], + snapshot.databases[0], + snapshot.dateDirectories + ]) + expect(Object.isFrozen(value)).toBe(true) + expect(() => + Object.assign(snapshot.folders[0], { title: 'Changed' }) + ).toThrow() + expect(s.useStore.getState().folders[0].subpath).toBe('Work') + }) + + it('lists only immediate children, preserves empty folders, and deduplicates folder entries', async () => { + const s = await setup([ + folder(''), + folder('Empty'), + folder('Work'), + folder('Work'), + folder('Work/Nested'), + folder('Saved', 'archive'), + folder('Deleted', 'trash') + ]) + const root = s.getBrowseDirectory(s.getBrowseSnapshot()) + expect(root.folders.map((row) => row.directory)).toEqual(['Empty', 'Work']) + expect(root.notes.map((row) => row.title)).toEqual(['One']) + expect( + s + .getBrowseDirectory(s.getBrowseSnapshot(), 'Work') + .folders.map((row) => row.directory) + ).toEqual(['Work/Nested']) + expect(s.getBrowseDirectory(s.getBrowseSnapshot(), 'Empty')).toEqual({ + folders: [], + databases: [], + notes: [] + }) + }) + + it('keeps pinned folders and notes at the front of their own sorted groups', async () => { + const s = await setup([ + folder('Zulu'), + folder('Alpha'), + folder('Beta'), + folder('Zoo.base'), + folder('Accounts.base') + ]) + s.useStore.setState({ + notes: [note('inbox/B.md'), note('inbox/C.md'), note('inbox/A.md')] + }) + const rows = s.getBrowseDirectory(s.getBrowseSnapshot(), '', { + folders: ['Zulu', 'Zoo.base', 'Gone'], + notes: ['inbox/C.md'] + }) + expect(rows.folders.map((row) => row.title)).toEqual([ + 'Zulu', + 'Alpha', + 'Beta' + ]) + expect(rows.databases.map((row) => row.title)).toEqual(['Accounts', 'Zoo']) + expect(rows.notes.map((row) => row.title)).toEqual(['C', 'A', 'B']) + for (const value of [rows, rows.folders, rows.databases, rows.notes]) + expect(Object.isFrozen(value)).toBe(true) + }) + + it('never exposes the contents of database directories as Browse folders or note rows', async () => { + const s = await setup([ + folder('People.BASE'), + folder('People.BASE/pages'), + folder('People.BASE/pages/Nested'), + folder('People.BASE/Other.base'), + folder('People.base-notes') + ]) + s.useStore.setState({ notes: [note('inbox/People.BASE/pages/Hidden.md')] }) + const snapshot = s.getBrowseSnapshot() + expect(snapshot.folders.map((row) => row.directory)).toEqual([ + 'People.base-notes' + ]) + expect(snapshot.databases.map((row) => row.directory)).toEqual([ + 'People.BASE' + ]) + for (const directory of [ + 'People.BASE', + 'People.BASE/pages', + 'People.BASE/pages/Nested' + ]) + expect(s.getBrowseDirectory(snapshot, directory)).toEqual({ + folders: [], + databases: [], + notes: [] + }) + }) + + it.each(['inbox', 'root'] as const)( + 'composes encoded database targets in %s mode with a custom primary path', + async (primaryNotesLocation) => { + const s = await setup([folder('Work/People & café.BASE')]) + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation, + systemFolderPaths: { inbox: '01 - Notes' } + } + }) + const row = s.getBrowseDirectory(s.getBrowseSnapshot(), 'Work') + .databases[0] + const prefix = primaryNotesLocation === 'root' ? '' : '01 - Notes/' + expect(row).toEqual({ + directory: 'Work/People & café.BASE', + title: 'People & café', + path: databaseTabPath(`${prefix}Work/People & café.BASE/data.csv`) + }) + } + ) + + it('reports enabled date directories without exposing or mutating date settings', async () => { + const s = await setup() + const settings = s.useStore.getState().vaultSettings + s.useStore.setState({ + vaultSettings: { + ...settings, + dailyNotes: { + ...settings.dailyNotes, + enabled: true, + directory: 'Journal/Daily' + }, + weeklyNotes: { + ...settings.weeklyNotes, + enabled: false, + directory: 'Journal/Weekly' + }, + monthlyNotes: { + ...settings.monthlyNotes, + enabled: true, + directory: 'Journal/Monthly' + } + } + }) + const before = s.getBrowseSnapshot() + expect(before.dateDirectories).toEqual({ + daily: 'Journal/Daily', + weekly: null, + monthly: 'Journal/Monthly' + }) + const current = s.useStore.getState().vaultSettings + s.useStore.setState({ + vaultSettings: { + ...current, + dailyNotes: { ...current.dailyNotes, enabled: false } + } + }) + expect(s.getBrowseSnapshot().dateDirectories.daily).toBeNull() + expect(before.dateDirectories.daily).toBe('Journal/Daily') + }) + + it('retains identity during selection and editor changes and refreshes after folder changes', async () => { + const s = await setup([folder('Work')]) + const before = s.getBrowseSnapshot() + s.useStore.setState({ + selectedPath: 'inbox/One.md', + activeDirty: true, + editorFontSize: 25 + }) + expect(s.getBrowseSnapshot()).toBe(before) + s.useStore.setState({ folders: [folder('Renamed')] }) + expect(s.getBrowseSnapshot().folders[0].title).toBe('Renamed') + expect(before.folders[0].title).toBe('Work') + expect(s.getBrowseSnapshot().notes).toBe(before.notes) + }) + + it('preserves folder array identity when only notes or unrelated settings change', async () => { + const s = await setup([folder('Work')]) + const before = s.getBrowseSnapshot() + s.useStore.setState({ + notes: [note('inbox/New.md')], + vaultSettings: { ...s.useStore.getState().vaultSettings, folderIcons: {} } + }) + const after = s.getBrowseSnapshot() + expect(after.folders).toBe(before.folders) + expect(after.databases).toBe(before.databases) + expect(after.dateDirectories).toBe(before.dateDirectories) + expect(after.notes[0].title).toBe('New') + }) + + it('updates database paths after layout changes without changing delivered snapshots', async () => { + const s = await setup([folder('People.base')]) + const before = s.getBrowseSnapshot() + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: 'root' + } + }) + expect(s.getBrowseSnapshot().databases[0].path).toBe( + databaseTabPath('People.base/data.csv') + ) + expect(before.databases[0].path).toBe( + databaseTabPath('inbox/People.base/data.csv') + ) + }) + + it('notifies only on Browse changes and stops after disposal', async () => { + const s = await setup() + const before = s.getBrowseSnapshot() + const listener = vi.fn() + const dispose = s.subscribeBrowse(listener) + disposers.push(dispose) + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + expect(listener).not.toHaveBeenCalled() + s.useStore.setState({ folders: [folder('New')] }) + expect(listener).toHaveBeenCalledExactlyOnceWith( + s.getBrowseSnapshot(), + before + ) + dispose() + s.useStore.setState({ folders: [] }) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app-core/src/browse.ts b/packages/app-core/src/browse.ts new file mode 100644 index 00000000..d5b46f5a --- /dev/null +++ b/packages/app-core/src/browse.ts @@ -0,0 +1,184 @@ +import { useSyncExternalStore } from 'react' +import type { FolderEntry } from '@bridge-contract/ipc' +import { + csvPathForFormDir, + databaseTabPath, + formDirContaining, + formTitleFromDir, + isFormDirName +} from '@shared/databases' +import { resolveFolderPath } from '@shared/system-folder-paths' +import { useStore } from './store' +import { + getBrowseNotes, + getShellSnapshot, + type NoteSortOrder, + type ShellNote, + type ShellSnapshot +} from './shell' +import { parentDirOf } from './lib/manual-order' + +export interface BrowseFolder { + /** Relative to the primary notes area, not the vault root. */ + readonly directory: string + readonly title: string +} + +export interface BrowseDatabase extends BrowseFolder { + /** Opaque application path; pass to the public navigation openNote action. */ + readonly path: string +} + +export interface BrowseSnapshot { + /** Display/change metadata. Native persistence uses the host's stable vault token. */ + readonly vault: ShellSnapshot['vault'] + readonly folders: readonly BrowseFolder[] + readonly databases: readonly BrowseDatabase[] + readonly notes: readonly ShellNote[] + readonly noteSortOrder: NoteSortOrder + /** Enabled directory settings, unchanged; null when disabled. Patterns are not expanded. */ + readonly dateDirectories: Readonly<{ + daily: string | null + weekly: string | null + monthly: string | null + }> +} + +export interface BrowsePins { + readonly notes?: readonly string[] + readonly folders?: readonly string[] +} + +export interface BrowseDirectory { + readonly folders: readonly BrowseFolder[] + readonly databases: readonly BrowseDatabase[] + readonly notes: readonly ShellNote[] +} + +let folderSource: readonly FolderEntry[] | undefined +let primaryDirectory = '' +let folders: readonly BrowseFolder[] = Object.freeze([]) +let databases: readonly BrowseDatabase[] = Object.freeze([]) +let dates: BrowseSnapshot['dateDirectories'] = Object.freeze({ + daily: null, + weekly: null, + monthly: null +}) +let snapshot: BrowseSnapshot | undefined + +export function getBrowseSnapshot(): BrowseSnapshot { + const state = useStore.getState() + const shell = getShellSnapshot() + const settings = state.vaultSettings + const primary = + settings.primaryNotesLocation === 'root' + ? '' + : resolveFolderPath('inbox', settings.systemFolderPaths) + if (folderSource !== state.folders || primaryDirectory !== primary) { + folderSource = state.folders + primaryDirectory = primary + const folderRows = new Map() + const databaseRows = new Map() + for (const entry of state.folders) { + const directory = entry.subpath + if (entry.folder !== 'inbox' || !directory || formDirContaining(parentDirOf(directory))) + continue + if (isFormDirName(directory)) { + const path = primary ? `${primary}/${directory}` : directory + databaseRows.set( + directory, + Object.freeze({ + directory, + title: formTitleFromDir(directory), + path: databaseTabPath(csvPathForFormDir(path)) + }) + ) + } else { + folderRows.set(directory, Object.freeze({ directory, title: directory.split('/').pop()! })) + } + } + folders = Object.freeze([...folderRows.values()]) + databases = Object.freeze([...databaseRows.values()]) + } + const daily = settings.dailyNotes.enabled ? settings.dailyNotes.directory : null + const weekly = settings.weeklyNotes.enabled ? settings.weeklyNotes.directory : null + const monthly = settings.monthlyNotes.enabled ? settings.monthlyNotes.directory : null + if (daily !== dates.daily || weekly !== dates.weekly || monthly !== dates.monthly) + dates = Object.freeze({ daily, weekly, monthly }) + const next: BrowseSnapshot = { + vault: shell.vault, + notes: shell.notes, + noteSortOrder: shell.noteSortOrder, + folders, + databases, + dateDirectories: dates + } + if ( + !snapshot || + (Object.keys(next) as Array).some((key) => next[key] !== snapshot![key]) + ) + snapshot = Object.freeze(next) + return snapshot +} + +/** Observe Browse data changes without notifications for editor selection or cursor changes. */ +export function subscribeBrowse( + listener: (snapshot: BrowseSnapshot, previous: BrowseSnapshot) => void +): () => void { + let previous = getBrowseSnapshot() + return useStore.subscribe(() => { + const next = getBrowseSnapshot() + if (next === previous) return + const before = previous + previous = next + listener(next, before) + }) +} + +function subscribeReact(notify: () => void): () => void { + return subscribeBrowse(() => notify()) +} + +export function useBrowseSnapshot(): BrowseSnapshot { + return useSyncExternalStore(subscribeReact, getBrowseSnapshot, getBrowseSnapshot) +} + +/** Immediate mobile Browse rows, with separate folder, database, and note groups. */ +export function getBrowseDirectory( + snapshot: BrowseSnapshot, + directory = '', + pins: BrowsePins = {} +): BrowseDirectory { + if (formDirContaining(directory)) + return Object.freeze({ + folders: Object.freeze([]), + databases: Object.freeze([]), + notes: Object.freeze([]) + }) + const childFolders = snapshot.folders + .filter((row) => parentDirOf(row.directory) === directory) + .sort((a, b) => a.title.localeCompare(b.title)) + const pinned = new Set(pins.folders) + return Object.freeze({ + folders: Object.freeze([ + ...childFolders.filter((row) => pinned.has(row.directory)), + ...childFolders.filter((row) => !pinned.has(row.directory)) + ]), + databases: Object.freeze( + snapshot.databases + .filter((row) => parentDirOf(row.directory) === directory) + .sort((a, b) => a.title.localeCompare(b.title)) + ), + notes: getBrowseNotes(snapshot, directory, pins.notes) + }) +} + +export { + createBrowseDatabase, + requestRenameBrowseDatabase, + requestCreateBrowseFolder, + requestRenameBrowseFolder, + requestDeleteBrowseDirectory, + type BrowseActionHost, + type BrowseActionResult +} from './lib/browse-actions' diff --git a/packages/app-core/src/commands.ts b/packages/app-core/src/commands.ts new file mode 100644 index 00000000..e015bbfd --- /dev/null +++ b/packages/app-core/src/commands.ts @@ -0,0 +1,33 @@ +import { isWorkspaceTransitionPending } from './lib/workspace-transition' +import { buildCommands } from './lib/commands' +import { useStore } from './store' + +export interface AppCommand { + readonly id: string + readonly title: string + readonly category: string + readonly keywords?: string + readonly shortcut?: string + readonly available: boolean +} +/** Descriptions are snapshots. Invocation always rechecks the current command. */ +export function getAppCommands(): readonly AppCommand[] { + return Object.freeze(buildCommands({ includeUnavailable: true }).map(command => Object.freeze({ + id: command.id, title: command.title, category: command.category, + keywords: command.keywords, shortcut: command.shortcut, + available: !command.when || command.when() + }))) +} +export async function runAppCommand(id: string): Promise { + if (isWorkspaceTransitionPending()) return false + const command = buildCommands({ includeUnavailable: true }).find(command => command.id === id) + if (!command || (command.when && !command.when())) return false + await command.run() + return true +} +export function showCommandPalette(): void { useStore.getState().setCommandPaletteOpen(true) } +export function showSearch(): void { useStore.getState().setSearchOpen(true) } +export function showTemplates(): void { useStore.getState().setTemplatePaletteOpen(true) } +export function showOutline(): void { + if (useStore.getState().activeNote) useStore.getState().setOutlinePaletteOpen(true) +} diff --git a/packages/app-core/src/components/ArchiveView.tsx b/packages/app-core/src/components/ArchiveView.tsx index 95d6a2d7..39cae579 100644 --- a/packages/app-core/src/components/ArchiveView.tsx +++ b/packages/app-core/src/components/ArchiveView.tsx @@ -1,10 +1,10 @@ +import { runNoteLifecycleAction } from '../lib/note-lifecycle-actions' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ContextMenuItem } from './ContextMenu' import type { NoteMeta } from '@shared/ipc' import { isArchiveViewActive, useStore } from '../store' import { ArchiveIcon, ArrowUpRightIcon, TrashIcon } from './icons' import { CollectionViewHeader } from './CollectionViewHeader' -import { confirmMoveToTrash } from '../lib/confirm-trash' import { ContextMenu } from './ContextMenu' import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' import { promptApp } from '../lib/prompt-requests' @@ -108,17 +108,14 @@ export function ArchiveView(): JSX.Element { const unarchiveNote = useCallback( async (note: NoteMeta) => { - await window.zen.unarchiveNote(note.path) - await refreshNotes() + await runNoteLifecycleAction(note.path, 'restore') }, [refreshNotes] ) const moveNoteToTrash = useCallback( async (note: NoteMeta) => { - if (!(await confirmMoveToTrash(note.title))) return - await window.zen.moveToTrash(note.path) - await refreshNotes() + await runNoteLifecycleAction(note.path, 'trash') }, [refreshNotes] ) @@ -233,9 +230,7 @@ export function ArchiveView(): JSX.Element { label: `Move to ${folderLabels.inbox}`, icon: , onSelect: async () => { - const meta = await window.zen.unarchiveNote(note.path) - await refreshNotes() - if (selectedPath === note.path) await selectNote(meta.path) + await runNoteLifecycleAction(note.path, 'restore') } }) items.push({ @@ -243,10 +238,7 @@ export function ArchiveView(): JSX.Element { icon: , danger: true, onSelect: async () => { - if (!(await confirmMoveToTrash(note.title))) return - await window.zen.moveToTrash(note.path) - await refreshNotes() - if (selectedPath === note.path) await selectNote(null) + await runNoteLifecycleAction(note.path, 'trash') } }) diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index c490085a..5df5281a 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -1,3 +1,4 @@ +import { runNoteLifecycleAction } from '../lib/note-lifecycle-actions' /** * Right-side calendar panel — a date navigator for daily and weekly notes, * modelled on Obsidian's Calendar plugin. @@ -37,8 +38,6 @@ import { CloudTaskConflictIndicator } from './CloudTaskConflictIndicator' import { resolveWeekStartDay } from '../lib/week-start' import { ChevronLeftIcon, ChevronRightIcon } from './icons' import { confirmApp } from '../lib/confirm-requests' -import { confirmMoveToTrash } from '../lib/confirm-trash' -import { moveNoteToTrash } from '../lib/trash-note' import { usePanelResize } from '../lib/use-panel-resize' import { PanelResizeHandle } from './PanelResizeHandle' import { ContextMenu, type ContextMenuItem } from './ContextMenu' @@ -389,10 +388,7 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { // --- Context menu -------------------------------------------------------- const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) const trashNote = useCallback(async (meta: NoteMeta) => { - if (!(await confirmMoveToTrash(meta.title))) return - await moveNoteToTrash(meta.path, { - temporarySession: useStore.getState().vault?.temporary === true - }) + await runNoteLifecycleAction(meta.path, 'trash') }, []) const openDayMenu = useCallback( (e: React.MouseEvent, day: Date, iso: string) => { diff --git a/packages/app-core/src/components/DatabaseView.tsx b/packages/app-core/src/components/DatabaseView.tsx index e398276f..d8859131 100644 --- a/packages/app-core/src/components/DatabaseView.tsx +++ b/packages/app-core/src/components/DatabaseView.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' import { csvPathFromDatabaseTab, formDirFromCsvPath } from '@shared/databases' import { serializeRows } from '@shared/database-csv' import { useStore } from '../store' +import { isNoteEditingLocked, subscribeNoteEditingLocks } from '../lib/note-lifecycle-lock' import { addField, addRow, @@ -31,6 +32,9 @@ export function DatabaseView({ }): JSX.Element { const csvPath = csvPathFromDatabaseTab(tabPath) const doc = useStore((s) => (csvPath ? s.databases[csvPath] : undefined)) + const vault = useStore(s => s.vault) + const locked = useSyncExternalStore(subscribeNoteEditingLocks, () => isNoteEditingLocked(vault, csvPath)) + const deletingRows = useStore((s) => !!(csvPath && s.databasesDeletingRows[csvPath])) const loading = useStore((s) => (csvPath ? !!s.databasesLoading[csvPath] : false)) const loadDatabase = useStore((s) => s.loadDatabase) const updateDatabaseRows = useStore((s) => s.updateDatabaseRows) @@ -45,9 +49,10 @@ export function DatabaseView({ // Only `.base` databases rename by title (a legacy loose `.csv` doesn't). const canRenameTitle = !!csvPath && !!formDirFromCsvPath(csvPath) + const transitioning = useStore(s => s.workspaceTransitioning) useEffect(() => { - if (csvPath && !doc && !loading) void loadDatabase(csvPath) - }, [csvPath, doc, loading, loadDatabase]) + if (csvPath && !doc && !loading && !transitioning) void loadDatabase(csvPath) + }, [csvPath, doc, loading, loadDatabase, transitioning]) if (!csvPath) { return ( @@ -77,7 +82,7 @@ export function DatabaseView({ ] return ( -
+
{editingTitle && canRenameTitle ? ( diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index c5fc9aaa..cb48e070 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -1,3 +1,4 @@ +import { noteEditingSync, noteEditingLockExtension, refreshNoteEditingLock } from '../lib/note-lifecycle-lock' /** * Single pane of the editor split view. Each leaf in the pane-layout * tree renders an `EditorPane` — owning its own CodeMirror view, tab @@ -38,6 +39,8 @@ import { } from '@codemirror/view' import { Vim, getCM, vim } from '@replit/codemirror-vim' import type { AssetMeta, ImportedAsset, NoteComment, NoteFolder } from '@shared/ipc' +import { registerNoteEditor } from '../lib/note-editor-context' +import { noteEditorHostExtension } from '../lib/editor-host' import { history, historyKeymap, @@ -1727,9 +1730,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { initialBody.length >= LARGE_DOC_LIVE_PREVIEW_DEFER_CHARS && !s0.livePreview richMarkdownDeferredRef.current = deferInitialRichMarkdown const stateStartedAt = performance.now() + viewPathRef.current = initialPath const state = EditorState.create({ doc: initialBody, extensions: [ + noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), + noteEditorHostExtension(), appMarkdownSnippetExtension(), vimCompartment.of(s0.vimMode ? vim() : []), // No text input outside Vim insert mode, so a CJK input method @@ -1980,6 +1986,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }) viewRef.current = view viewPathRef.current = initialPath + registerNoteEditor(view, () => viewPathRef.current, paneId) if (initialContent && useStore.getState().activePaneId === paneId) { setEditorViewRef(view) } @@ -2097,9 +2104,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } } const dispatchStartedAt = performance.now() + viewPathRef.current = nextPath + refreshNoteEditingLock(view) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextBody }, annotations: [ + noteEditingSync.of(true), programmatic.of(true), skipOrderedListRenumber.of(true), // A programmatic swap (tab switch / external file sync) must never be diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index 98937074..7c955f50 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -1,3 +1,4 @@ +import { runNoteLifecycleAction, runEmptyTrash } from '../lib/note-lifecycle-actions' import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { focusEditorNormalMode } from '../lib/editor-focus' @@ -14,7 +15,6 @@ import { import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { ResizeHandle } from './ResizeHandle' import { Button, IconButton } from './ui/Button' -import { confirmMoveToTrash } from '../lib/confirm-trash' import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' import { naturalCompare } from '../lib/natural-sort' import { extractTags } from '../lib/tags' @@ -155,8 +155,7 @@ export function NoteList(): JSX.Element { return () => observer.disconnect() }, []) const emptyTrash = async (): Promise => { - await window.zen.emptyTrash() - await useStore.getState().refreshNotes() + await runEmptyTrash() } const menuItems = useMemo(() => { @@ -178,21 +177,13 @@ export function NoteList(): JSX.Element { await navigator.clipboard.writeText(`[[${n.title}]]`) } const onArchive = async (): Promise => { - if (!(await useStore.getState().confirmArchiveNotes([n.path]))) return - await window.zen.archiveNote(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(null) + await runNoteLifecycleAction(n.path, 'archive') } const onUnarchive = async (): Promise => { - const meta = await window.zen.unarchiveNote(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(meta.path) + await runNoteLifecycleAction(n.path, 'restore') } const onTrash = async (): Promise => { - if (!(await confirmMoveToTrash(n.title))) return - await window.zen.moveToTrash(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(null) + await runNoteLifecycleAction(n.path, 'trash') } const onMove = async (): Promise => { const target = await promptApp(buildMoveNotePrompt(n, folders)) @@ -201,14 +192,10 @@ export function NoteList(): JSX.Element { await moveNote(n.path, dest.folder, dest.subpath) } const onRestore = async (): Promise => { - const meta = await window.zen.restoreFromTrash(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(meta.path) + await runNoteLifecycleAction(n.path, 'restore') } const onDeleteForever = async (): Promise => { - await window.zen.deleteNote(n.path) - await refreshNotes() - if (selectedPath === n.path) await selectNote(null) + await runNoteLifecycleAction(n.path, 'delete') } const onNew = async (): Promise => { await useStore diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 254b2399..75f04cf1 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -1,3 +1,4 @@ +import { noteEditingSync, noteEditingLockExtension, refreshNoteEditingLock } from '../lib/note-lifecycle-lock' /** * Always-visible side panel that shows a single companion note — a * "reference pane" writers and researchers can keep open while drafting @@ -208,9 +209,11 @@ export function PinnedReferencePane(): JSX.Element | null { const s0 = useStore.getState() const initialPath = s0.pinnedRefPath const initialContent = initialPath ? s0.noteContents[initialPath] ?? null : null + viewPathRef.current = initialPath const state = EditorState.create({ doc: initialContent?.body ?? '', extensions: [ + noteEditingLockExtension(() => ({ vault: useStore.getState().vault, path: viewPathRef.current })), appMarkdownSnippetExtension(), vimCompartment.of(s0.vimMode ? vim() : []), vimVisualHighlightExtension, @@ -306,9 +309,11 @@ export function PinnedReferencePane(): JSX.Element | null { const sel = view.state.selection.main const clampedAnchor = Math.min(sel.anchor, nextBody.length) const clampedHead = Math.min(sel.head, nextBody.length) + viewPathRef.current = nextPath + refreshNoteEditingLock(view) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextBody }, - annotations: programmatic.of(true), + annotations: [programmatic.of(true), noteEditingSync.of(true)], selection: pathChanged ? { anchor: 0 } : { anchor: clampedAnchor, head: clampedHead } }) viewPathRef.current = nextPath diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index 7128af26..364426fb 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { runNoteLifecycleAction, runNoteBatchAction, runEmptyTrash } from "../lib/note-lifecycle-actions"; import { createContext, memo, @@ -23,8 +24,6 @@ import { useStore, } from "../store"; import { Button } from "./ui/Button"; -import { confirmMoveToTrash } from "../lib/confirm-trash"; -import { moveNoteToTrash } from "../lib/trash-note"; import { buildMoveNotePrompt, parseMoveNoteTarget } from "../lib/move-note"; import { buildTagTree, extractTags, flattenTagTree } from "../lib/tags"; import { isTypstPreamblePath, resolveTypstPreambleFolder } from "../lib/typst-preamble"; @@ -1692,19 +1691,7 @@ export function Sidebar(): JSX.Element { items.push({ label: `Move ${liveNotes.length} note${liveNotes.length === 1 ? "" : "s"}…`, onSelect: async () => { - const target = await promptApp( - buildMoveNotePrompt( - { title: `${liveNotes.length} notes`, path: liveNotes[0]!.path }, - allFolders, - ), - ); - if (!target) return; - const dest = parseMoveNoteTarget(target); - for (const note of liveNotes) { - await window.zen.moveNote(note.path, dest.folder, dest.subpath); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(liveNotes.map(note => note.path), "move")) clearSelection(); }, }); } @@ -1714,13 +1701,7 @@ export function Sidebar(): JSX.Element { label: `Move ${archivableNotes.length} note${archivableNotes.length === 1 ? "" : "s"} to ${folderLabels.archive}`, icon: , onSelect: async () => { - const paths = archivableNotes.map((note) => note.path); - if (!(await useStore.getState().confirmArchiveNotes(paths))) return; - for (const note of archivableNotes) { - await window.zen.archiveNote(note.path); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(archivableNotes.map(note => note.path), "archive")) clearSelection(); }, }); } @@ -1730,11 +1711,7 @@ export function Sidebar(): JSX.Element { label: `Move ${archivedNotes.length} archived note${archivedNotes.length === 1 ? "" : "s"} to ${folderLabels.inbox}`, icon: , onSelect: async () => { - for (const note of archivedNotes) { - await window.zen.unarchiveNote(note.path); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(archivedNotes.map(note => note.path), "restore")) clearSelection(); }, }); } @@ -1745,20 +1722,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - const ok = await confirmApp({ - title: `Move ${liveNotes.length} note${liveNotes.length === 1 ? "" : "s"} to ${folderLabels.trash}?`, - description: "You can restore them from Trash later.", - confirmLabel: `Move to ${folderLabels.trash}`, - danger: true, - }); - if (!ok) return; - for (const note of liveNotes) { - await moveNoteToTrash(note.path, { - temporarySession: vault?.temporary === true, - }); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(liveNotes.map(note => note.path), "trash")) clearSelection(); }, }); } @@ -1768,11 +1732,7 @@ export function Sidebar(): JSX.Element { label: `Restore ${trashedNotes.length} note${trashedNotes.length === 1 ? "" : "s"}`, icon: , onSelect: async () => { - for (const note of trashedNotes) { - await window.zen.restoreFromTrash(note.path); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(trashedNotes.map(note => note.path), "restore")) clearSelection(); }, }); items.push({ @@ -1780,18 +1740,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - const ok = await confirmApp({ - title: `Delete ${trashedNotes.length} note${trashedNotes.length === 1 ? "" : "s"} permanently?`, - description: "This cannot be undone.", - confirmLabel: "Delete permanently", - danger: true, - }); - if (!ok) return; - for (const note of trashedNotes) { - await window.zen.deleteNote(note.path); - } - if (selectedActiveNote) await selectNote(null); - await refreshAndClear(); + if (await runNoteBatchAction(trashedNotes.map(note => note.path), "delete")) clearSelection(); }, }); } @@ -1905,18 +1854,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, disabled: trashCount === 0, - onSelect: async () => { - const ok = await confirmApp({ - title: `Delete ${trashCount} trashed note${trashCount === 1 ? "" : "s"} permanently?`, - description: "This cannot be undone.", - confirmLabel: `Empty ${folderLabels.trash}`, - danger: true, - }); - if (!ok) return; - await window.zen.emptyTrash(); - await refreshNotes(); - if (selectedPath?.startsWith("trash/")) await selectNote(null); - }, + onSelect: runEmptyTrash, }, { kind: "separator" }, ...iconItems, @@ -2435,10 +2373,7 @@ export function Sidebar(): JSX.Element { label: folderLabels.archive, icon: , onSelect: async () => { - if (!(await useStore.getState().confirmArchiveNotes([n.path]))) return; - await window.zen.archiveNote(n.path); - await refreshNotes(); - if (selectedPath === n.path) await selectNote(null); + await runNoteLifecycleAction(n.path, "archive"); }, }); items.push({ @@ -2446,15 +2381,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - if (!(await confirmMoveToTrash(n.title))) return; - if ( - !(await moveNoteToTrash(n.path, { - temporarySession: vault?.temporary === true, - })) - ) - return; - await refreshNotes(); - if (selectedPath === n.path) await selectNote(null); + await runNoteLifecycleAction(n.path, "trash"); }, }); } else if (n.folder === "archive") { @@ -2462,9 +2389,7 @@ export function Sidebar(): JSX.Element { label: `Move to ${folderLabels.inbox}`, icon: , onSelect: async () => { - const meta = await window.zen.unarchiveNote(n.path); - await refreshNotes(); - if (selectedPath === n.path) await selectNote(meta.path); + await runNoteLifecycleAction(n.path, "restore"); }, }); items.push({ @@ -2472,15 +2397,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - if (!(await confirmMoveToTrash(n.title))) return; - if ( - !(await moveNoteToTrash(n.path, { - temporarySession: vault?.temporary === true, - })) - ) - return; - await refreshNotes(); - if (selectedPath === n.path) await selectNote(null); + await runNoteLifecycleAction(n.path, "trash"); }, }); } else { @@ -2488,9 +2405,7 @@ export function Sidebar(): JSX.Element { label: "Restore", icon: , onSelect: async () => { - const meta = await window.zen.restoreFromTrash(n.path); - await refreshNotes(); - if (selectedPath === n.path) await selectNote(meta.path); + await runNoteLifecycleAction(n.path, "restore"); }, }); items.push({ @@ -2498,9 +2413,7 @@ export function Sidebar(): JSX.Element { icon: , danger: true, onSelect: async () => { - await window.zen.deleteNote(n.path); - await refreshNotes(); - if (selectedPath === n.path) await selectNote(null); + await runNoteLifecycleAction(n.path, "delete"); }, }); } diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 37d9f3d2..249a21f9 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -1,3 +1,4 @@ +import { dropMutationsFor } from '../lib/task-column-mutations' /** * Kanban view for the Tasks tab. * @@ -79,79 +80,7 @@ function columnAccent(id: string): string | null { return COLUMN_ACCENTS[hash % COLUMN_ACCENTS.length] } -/** Map a (groupBy, columnId) drop target to the task-line mutations - * that should land. Returns `null` when the drop has no defined - * semantics (e.g. when group-by is 'folder'). Returns `[]` when the - * task is already in the target column — caller can short-circuit. */ -export function dropMutationsFor( - groupBy: KanbanGroupBy, - columnId: string, - task: VaultTask, - today: Date -): TaskMutation[] | null { - if (groupBy === 'status') { - const todayIso = toIsoDateLocal(today) - switch (columnId) { - case 'today': - // "Live" columns — make sure neither @waiting, [x] nor [/] keep the - // task glued to a different bucket. - return [ - { kind: 'set-checked', checked: false }, - { kind: 'set-waiting', waiting: false }, - { kind: 'set-in-progress', inProgress: false }, - { kind: 'set-due', due: todayIso } - ] - case 'upcoming': { - const tomorrow = new Date(today) - tomorrow.setDate(tomorrow.getDate() + 1) - return [ - { kind: 'set-checked', checked: false }, - { kind: 'set-waiting', waiting: false }, - { kind: 'set-in-progress', inProgress: false }, - { - kind: 'set-due', - due: task.due && task.due > todayIso ? task.due : toIsoDateLocal(tomorrow) - } - ] - } - case IN_PROGRESS_COLUMN_ID: - // Started work: `[/]`. The due date is left alone, so a card dragged - // back to Today or Upcoming keeps the date it had. - return [ - { kind: 'set-checked', checked: false }, - { kind: 'set-waiting', waiting: false }, - { kind: 'set-in-progress', inProgress: true } - ] - case 'waiting': - // `[/]` survives underneath on purpose: clearing the wait returns the - // card to In progress, where it came from. - return [ - { kind: 'set-checked', checked: false }, - { kind: 'set-waiting', waiting: true } - ] - case 'done': - return [{ kind: 'set-checked', checked: true }] - default: - return null - } - } - if (groupBy === 'priority') { - if (columnId === 'high') return [{ kind: 'set-priority', priority: 'high' }] - if (columnId === 'med') return [{ kind: 'set-priority', priority: 'med' }] - if (columnId === 'low') return [{ kind: 'set-priority', priority: 'low' }] - if (columnId === 'none') return [{ kind: 'set-priority', priority: null }] - return null - } - if (groupBy.startsWith('field:')) { - // Drop sets the `@:` token; the No- column clears it. - const key = groupBy.slice('field:'.length) - return [{ kind: 'set-field', key, value: columnId === NO_VALUE_COLUMN_ID ? null : columnId }] - } - // Folder grouping is read-only — moving the task across folders - // means moving the source note, which the user does explicitly via - // the sidebar. - return null -} +export { dropMutationsFor } from '../lib/task-column-mutations' export interface Column { id: string diff --git a/packages/app-core/src/components/TrashView.tsx b/packages/app-core/src/components/TrashView.tsx index 0002056a..9d132b99 100644 --- a/packages/app-core/src/components/TrashView.tsx +++ b/packages/app-core/src/components/TrashView.tsx @@ -1,3 +1,4 @@ +import { runNoteLifecycleAction, runEmptyTrash } from '../lib/note-lifecycle-actions' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { DeletedAsset, NoteMeta } from '@shared/ipc' import { isTrashViewActive, useStore } from '../store' @@ -6,7 +7,6 @@ import { CollectionViewHeader } from './CollectionViewHeader' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { confirmApp } from '../lib/confirm-requests' -import { confirmDeletePermanently } from '../lib/confirm-trash' import { isAppOverlayOpen } from '../lib/overlay-open' function formatDate(ms: number): string { @@ -93,33 +93,19 @@ export function TrashView(): JSX.Element { const restoreNote = useCallback( async (note: NoteMeta) => { - await window.zen.restoreFromTrash(note.path) - await refreshNotes() + await runNoteLifecycleAction(note.path, 'restore') }, [refreshNotes] ) const deleteNoteForever = useCallback( async (note: NoteMeta) => { - if (!(await confirmDeletePermanently(note.title))) return - await window.zen.deleteNote(note.path) - await refreshNotes() + await runNoteLifecycleAction(note.path, 'delete') }, [refreshNotes] ) - const emptyTrash = useCallback(async () => { - if (trashed.length === 0) return - const ok = await confirmApp({ - title: `Delete ${trashed.length} trashed note${trashed.length === 1 ? '' : 's'} permanently?`, - description: 'This cannot be undone.', - confirmLabel: 'Empty trash', - danger: true - }) - if (!ok) return - await window.zen.emptyTrash() - await refreshNotes() - }, [refreshNotes, trashed.length]) + const emptyTrash = useCallback(runEmptyTrash, []) // Deleted assets live in a separate on-disk store (.zennotes/deleted-assets), // surfaced here so they're recoverable like notes rather than lost after the diff --git a/packages/app-core/src/database-row-actions.test.ts b/packages/app-core/src/database-row-actions.test.ts new file mode 100644 index 00000000..075ce199 --- /dev/null +++ b/packages/app-core/src/database-row-actions.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DatabaseDoc } from '@shared/databases' + +const CSV = 'Projects.base/data.csv' +const PAGE = 'Projects.base/pages/One.md' +const TWO = 'Projects.base/pages/Two.md' +const body = '# One\n\nKeep café 日本語. \n' +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} +beforeEach(() => { vi.resetModules(); localStorage.clear() }) +async function setup() { + const files = new Map([[PAGE, body], [TWO, '# Two\n\nSecond.\n']]) + const metadata = (path: string) => ({ path, title: path.split('/').pop()!, folder: path.startsWith('trash/') ? 'trash' as const : 'inbox' as const, + siblingOrder: 0, createdAt: 1, updatedAt: 1, size: 0, tags: [], wikilinks: [], assetEmbeds: [], hasAttachments: false, excerpt: '' }) + const doc: DatabaseDoc = { + version: 1, path: CSV, title: 'Projects', idFieldId: 'id', activeViewId: 'table', views: [], + fields: [{ id: 'id', name: 'ID', type: 'text' }, { id: 'name', name: 'Name', type: 'text' }, { id: 'status', name: 'Status', type: 'text' }], + rows: [{ id: 'one', cells: { id: 'one', name: 'One', status: 'Pending' } }, { id: 'two', cells: { id: 'two', name: 'Two', status: 'Open' } }], + pages: { one: PAGE, two: TWO }, pageHasContent: { one: true, two: true } + } + let diskDoc = structuredClone(doc) + const bridge = { + getCapabilities: () => ({}), listNotes: async () => [...files.keys()].map(metadata), listFolders: async () => [], + scanTasks: async () => [], scanTasksForPath: async () => [], hasAssetsDir: async () => false, + getRemoteWorkspaceInfo: async () => null, setVaultSettings: async (s: unknown) => s, + readNote: vi.fn(async (path: string) => { + if (!files.has(path)) throw new Error('Missing page') + return { ...metadata(path), body: files.get(path)! } + }), + writeNote: vi.fn(async (path: string, text: string) => { files.set(path, text); return metadata(path) }), + writeDatabaseRows: vi.fn(async (_path: string, rows: DatabaseDoc['rows']) => { diskDoc.rows = structuredClone(rows) }), + writeDatabaseSchema: vi.fn(async (_path: string, schema: object, rows: DatabaseDoc['rows']) => { + diskDoc = { ...diskDoc, ...structuredClone(schema), rows: structuredClone(rows) } + }), + moveToTrash: vi.fn(async (path: string) => { + const next = `trash/${path.split('/').pop()}` + files.set(next, files.get(path)!); files.delete(path); return metadata(next) + }) + } + Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) + const { useStore } = await import('./store') + const prompts = await import('./lib/confirm-requests') + useStore.setState({ vault: { root: '/test', name: 'Test' }, databases: { [CSV]: doc }, notes: [...files.keys()].map(metadata), noteContents: {}, noteDirty: {} }) + const start = (ids = ['one'], trash = true) => { + const running = useStore.getState().deleteDatabaseRows(CSV, ids) + const request = prompts.getConfirmRequest() + if (request) prompts.settleConfirmRequest(request, trash) + return running + } + return { useStore, bridge, files, doc, metadata, prompts, start, disk: () => diskDoc } +} + +describe('database row lifecycle', () => { + it('materializes the latest properties and exact closed-page body before detaching', async () => { + const s = await setup() + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + s.useStore.getState().updateDatabaseRows(CSV, { ...s.doc, rows: s.doc.rows.map(r => r.id === 'one' ? { ...r, cells: { ...r.cells, status: 'Ready' } } : r) }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, false) + await pending + expect(s.files.get(PAGE)).toBe(`---\nStatus: Ready\n---\n${body}`) + expect(s.disk().rows.map(r => r.id)).toEqual(['two']) + expect(s.disk().pages).toEqual({ two: TWO }) + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) + it('saves the dirty open page and prevents edits until its move completes', async () => { + const s = await setup(), gate = deferred() + s.useStore.setState({ noteContents: { [PAGE]: { ...s.metadata(PAGE), body: body + 'Unsaved\n' } }, noteDirty: { [PAGE]: true } }) + const move = s.bridge.moveToTrash.getMockImplementation()! + s.bridge.moveToTrash.mockImplementation(async p => { await gate.promise; return move(p) }) + const pending = s.start() + await vi.waitFor(() => expect(s.bridge.moveToTrash).toHaveBeenCalled()) + s.useStore.getState().updateNoteBody(PAGE, 'must not overwrite') + s.useStore.getState().updateDatabaseRows(CSV, s.doc) + expect(s.useStore.getState().databases[CSV].rows.map(r => r.id)).toEqual(['two']) + gate.resolve(); await pending + expect(s.files.get('trash/One.md')).toBe(`---\nStatus: Pending\n---\n${body}Unsaved\n`) + expect(s.useStore.getState().databasesDeletingRows[CSV]).toBe(false) + }) + it('leaves rows intact and dispatches no trash when a page cannot be saved', async () => { + const s = await setup() + s.bridge.writeNote.mockRejectedValue(new Error('disk full')) + await s.start() + expect(s.useStore.getState().databases[CSV]).toEqual(s.doc) + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) + it('restores recoverable rows and dispatches no trash when the database commit fails', async () => { + const s = await setup() + s.bridge.writeDatabaseSchema.mockRejectedValueOnce(new Error('schema unavailable')) + await s.start() + expect(s.useStore.getState().databases[CSV]).toEqual(s.doc) + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + await s.useStore.getState().flushDirtyNotes() + expect(s.disk().rows).toEqual(s.doc.rows) + expect(s.disk().pages).toEqual(s.doc.pages) + }) + it('retains standalone remaining pages if a later move fails after the row commit', async () => { + const s = await setup(), move = s.bridge.moveToTrash.getMockImplementation()! + s.bridge.moveToTrash.mockImplementation(async p => { if (p === TWO) throw new Error('locked'); return move(p) }) + await s.start(['one', 'two', 'one']) + expect(s.disk().rows).toEqual([]) + expect(s.disk().pages).toEqual({}) + expect(s.files.has(PAGE)).toBe(false) + expect(s.files.get(TWO)).toBe('---\nStatus: Open\n---\n# Two\n\nSecond.\n') + expect(s.bridge.moveToTrash).toHaveBeenCalledTimes(2) + }) + it('does not change a page shared by a surviving row or a foreign mapping', async () => { + const s = await setup() + s.useStore.setState({ databases: { [CSV]: { ...s.doc, pages: { one: TWO, two: TWO } } } }) + await s.start() + expect(s.bridge.writeNote).not.toHaveBeenCalled() + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + s.useStore.setState({ databases: { [CSV]: { ...s.doc, pages: { one: 'inbox/Foreign.md' } } } }) + await s.start() + expect(s.bridge.readNote).not.toHaveBeenCalled() + expect(s.bridge.writeNote).not.toHaveBeenCalled() + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) + it('abandons a changed mapping after the confirmation', async () => { + const s = await setup() + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + s.useStore.setState({ databases: { [CSV]: { ...s.doc, pages: { one: TWO } } } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await pending + expect(s.bridge.writeNote).not.toHaveBeenCalled() + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + }) + it('abandons a vault switch during confirmation', async () => { + const s = await setup() + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + s.useStore.setState({ vault: { root: '/other', name: 'Other' } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await pending + expect(s.bridge.readNote).not.toHaveBeenCalled() + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + }) + it('waits for the entire page batch before a vault-switch save finishes', async () => { + const s = await setup(), gate = deferred(), move = s.bridge.moveToTrash.getMockImplementation()! + s.bridge.moveToTrash.mockImplementation(async p => { await gate.promise; return move(p) }) + const pending = s.start(['one', 'two']) + await vi.waitFor(() => expect(s.bridge.moveToTrash).toHaveBeenCalled()) + let flushed = false + const flush = s.useStore.getState().flushDirtyNotes().then(() => { flushed = true }) + await new Promise(resolve => setTimeout(resolve, 5)) + expect(flushed).toBe(false) + gate.resolve(); await pending; await flush + expect(s.files.has(TWO)).toBe(false) + expect(flushed).toBe(true) + }) + it('does not replace the first row confirmation when deletion is dispatched twice', async () => { + const s = await setup() + const first = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + const request = s.prompts.getConfirmRequest()! + await s.useStore.getState().deleteDatabaseRows(CSV, ['two']) + expect(s.prompts.getConfirmRequest()).toBe(request) + s.prompts.settleConfirmRequest(request, false) + await first + expect(s.disk().rows.map(row => row.id)).toEqual(['two']) + }) + it('does not trash a page when its selected row disappears during confirmation', async () => { + const s = await setup() + const pending = s.useStore.getState().deleteDatabaseRows(CSV, ['one']) + s.useStore.setState({ databases: { [CSV]: { ...s.doc, rows: s.doc.rows.filter(row => row.id !== 'one') } } }) + s.prompts.settleConfirmRequest(s.prompts.getConfirmRequest()!, true) + await pending + expect(s.bridge.writeNote).not.toHaveBeenCalled() + expect(s.bridge.writeDatabaseSchema).not.toHaveBeenCalled() + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + }) + it('freezes remapped Trash database edits until an Empty Trash failure releases them', async () => { + const s = await setup(), gate = deferred(), csv = 'Bin/Projects.base/data.csv' + const emptyTrash = vi.fn(async () => { await gate.promise; throw new Error('denied') }) + Object.assign(s.bridge, { emptyTrash }) + s.useStore.setState({ databases: { [csv]: { ...s.doc, path: csv } }, + vaultSettings: { ...s.useStore.getState().vaultSettings, systemFolderPaths: { trash: 'Bin' } } }) + const pending = s.useStore.getState().emptyTrash() + const failed = expect(pending).rejects.toThrow('denied') + await vi.waitFor(() => expect(emptyTrash).toHaveBeenCalled()) + const original = s.useStore.getState().databases[csv] + s.useStore.getState().updateDatabaseRows(csv, { ...original, rows: [] }) + s.useStore.getState().updateDatabaseSchema(csv, { ...original, pages: {} }) + expect(s.useStore.getState().databases[csv]).toBe(original) + gate.resolve(); await failed + s.useStore.getState().updateDatabaseRows(csv, { ...original, rows: [] }) + expect(s.useStore.getState().databases[csv].rows).toEqual([]) + await s.useStore.getState().flushDirtyNotes() + }) + +}) diff --git a/packages/app-core/src/dialogs.ts b/packages/app-core/src/dialogs.ts new file mode 100644 index 00000000..fcacaf04 --- /dev/null +++ b/packages/app-core/src/dialogs.ts @@ -0,0 +1,16 @@ +import { promptApp, getPromptRequest } from './lib/prompt-requests' +import { confirmApp, getConfirmRequest } from './lib/confirm-requests' +import type { PromptOptions } from './components/PromptModal' +import type { ConfirmOptions } from './components/ConfirmModal' + +export type { PromptOptions, PromptSuggestion } from './components/PromptModal' +export type { ConfirmOptions } from './components/ConfirmModal' +/** A second host dialog is cancelled instead of replacing an unresolved request. */ +export function prompt(options: PromptOptions): Promise { + if (getPromptRequest() || getConfirmRequest()) return Promise.resolve(null) + return promptApp({ ...options, suggestions: options.suggestions?.map(suggestion => ({ ...suggestion })) }) +} +export function confirm(options: ConfirmOptions): Promise { + if (getPromptRequest() || getConfirmRequest()) return Promise.resolve(false) + return confirmApp({ ...options }) +} diff --git a/packages/app-core/src/editor-commands.test.ts b/packages/app-core/src/editor-commands.test.ts new file mode 100644 index 00000000..ec784239 --- /dev/null +++ b/packages/app-core/src/editor-commands.test.ts @@ -0,0 +1,263 @@ +// @vitest-environment jsdom + +import { history } from '@codemirror/commands' +import { search } from '@codemirror/search' +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { EditorCommand } from './editor' + +const views: EditorView[] = [] + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) + +afterEach(() => { + for (const view of views.splice(0)) view.destroy() + document.body.replaceChildren() +}) + +async function setup(body = 'hello', anchor = 0, head = body.length, readOnly = false) { + const { useStore } = await import('./store') + const api = await import('./editor') + const { registerNoteEditor } = await import('./lib/note-editor-context') + let path = 'one.md' + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: body, + selection: EditorSelection.single(anchor, head), + extensions: [ + history(), + search({ top: true }), + EditorState.readOnly.of(readOnly), + EditorState.allowMultipleSelections.of(true) + ] + }) + }) + views.push(view) + registerNoteEditor(view, () => path, useStore.getState().activePaneId) + useStore.setState({ + vault: { root: '/test-vault', name: 'Test' }, + selectedPath: path, + editorViewRef: view, + activeNote: { path, body } as NonNullable['activeNote']> + }) + return { + ...api, + useStore, + view, + setViewPath: (next: string) => { + path = next + } + } +} + +describe('public editor commands', () => { + for (const [command, marker] of [ + ['toggle-bold', '**'], + ['toggle-italic', '*'], + ['toggle-strikethrough', '~~'], + ['toggle-highlight', '=='], + ['toggle-inline-code', '`'] + ] as const) { + it(`${command} wraps and unwraps the selection`, async () => { + const s = await setup() + expect(s.runEditorCommand(command)).toBe(true) + expect(s.view.state.doc.toString()).toBe(`${marker}hello${marker}`) + expect( + s.view.state.sliceDoc(s.view.state.selection.main.from, s.view.state.selection.main.to) + ).toBe('hello') + expect(s.view.hasFocus).toBe(true) + expect(s.runEditorCommand(command)).toBe(true) + expect(s.view.state.doc.toString()).toBe('hello') + }) + } + + it('inserts an empty inline pair and exits formatting after text is entered', async () => { + const s = await setup('', 0, 0) + s.runEditorCommand('toggle-bold') + expect(s.view.state.doc.toString()).toBe('****') + expect(s.view.state.selection.main.head).toBe(2) + s.view.dispatch({ changes: { from: 2, insert: 'word' }, selection: { anchor: 6 } }) + s.runEditorCommand('toggle-bold') + expect(s.view.state.doc.toString()).toBe('**word**') + expect(s.view.state.selection.main.head).toBe(8) + }) + + for (const [command, expected, caret] of [ + ['insert-link', '[hello]()', 8], + ['insert-wikilink', '[[]]', 2], + ['insert-tag', '#', 1] + ] as const) { + it(`${command} preserves the mobile snippet and caret behavior`, async () => { + const s = await setup() + expect(s.runEditorCommand(command)).toBe(true) + expect(s.view.state.doc.toString()).toBe(expected) + expect(s.view.state.selection.main.head).toBe(caret) + expect(s.view.state.selection.main.empty).toBe(true) + }) + } + + for (const [command, marker] of [ + ['set-bullet-list', '- '], + ['set-task-list', '- [ ] '], + ['cycle-heading', '# '] + ] as const) { + it(`${command} starts a block on an indented empty line`, async () => { + const s = await setup(' ', 1, 1) + s.runEditorCommand(command) + expect(s.view.state.doc.toString()).toBe(' ' + marker) + expect(s.view.state.selection.main.head).toBe(2 + marker.length) + }) + } + + it('replaces existing block markers and preserves blank lines in a selection', async () => { + const s = await setup('# First\n\n> Second') + s.runEditorCommand('set-task-list') + expect(s.view.state.doc.toString()).toBe('- [ ] First\n\n- [ ] Second') + }) + + it('cycles headings through levels one, two, three, then paragraph', async () => { + const s = await setup('Title', 0, 0) + for (const expected of ['# Title', '## Title', '### Title', 'Title']) { + s.runEditorCommand('cycle-heading') + expect(s.view.state.doc.toString()).toBe(expected) + } + const deep = await setup('##### Title', 0, 0) + deep.runEditorCommand('cycle-heading') + expect(deep.view.state.doc.toString()).toBe('Title') + const indented = await setup(' ## Title', 0, 0) + indented.runEditorCommand('cycle-heading') + expect(indented.view.state.doc.toString()).toBe(' # Title') + }) + + it('indents and outdents the selected lines using the editor settings', async () => { + const s = await setup('one\ntwo') + s.runEditorCommand('indent') + expect(s.view.state.doc.toString()).toBe(' one\n two') + s.runEditorCommand('outdent') + expect(s.view.state.doc.toString()).toBe('one\ntwo') + }) + + it('uses the normal editor undo and redo history', async () => { + const s = await setup() + expect(s.runEditorCommand('undo')).toBe(false) + expect(s.view.hasFocus).toBe(true) + s.runEditorCommand('toggle-bold') + expect(s.runEditorCommand('undo')).toBe(true) + expect(s.view.state.doc.toString()).toBe('hello') + expect(s.runEditorCommand('redo')).toBe(true) + expect(s.view.state.doc.toString()).toBe('**hello**') + }) + + it('seeds Find from the selection and focuses its existing field on reopening', async () => { + const s = await setup() + expect(s.runEditorCommand('open-search')).toBe(true) + const field = document.querySelector('.cm-search [main-field]')! + expect(field.value).toBe('hello') + expect([field.selectionStart, field.selectionEnd]).toEqual([0, 5]) + // jsdom's input.select() does not focus on initial mount. The browser smoke + // covers that native behavior; reopening explicitly focuses the same field. + s.view.focus() + expect(s.runEditorCommand('open-search')).toBe(true) + expect(document.activeElement?.closest('.cm-search')).not.toBeNull() + expect(s.view.hasFocus).toBe(false) + expect(s.runEditorCommand('close-search')).toBe(true) + expect(document.querySelector('.cm-search')).toBeNull() + expect(s.view.hasFocus).toBe(true) + expect(s.runEditorCommand('close-search')).toBe(false) + }) + + for (const change of [ + 'vault', + 'note', + 'virtual note', + 'content', + 'view', + 'pane', + 'registered path', + 'destroyed' + ] as const) { + it(`ignores commands when the active ${change} is unavailable or transitioning`, async () => { + const s = await setup() + if (change === 'vault') s.useStore.setState({ vault: null }) + if (change === 'note') s.useStore.setState({ selectedPath: 'two.md' }) + if (change === 'virtual note') { + s.setViewPath('zen://tasks') + s.useStore.setState({ + selectedPath: 'zen://tasks', + activeNote: { ...s.useStore.getState().activeNote!, path: 'zen://tasks' } + }) + } + if (change === 'content') s.useStore.setState({ activeNote: null }) + if (change === 'view') s.useStore.setState({ editorViewRef: null }) + if (change === 'pane') s.useStore.setState({ activePaneId: 'other-pane' }) + if (change === 'registered path') s.setViewPath('two.md') + if (change === 'destroyed') s.view.destroy() + expect(s.runEditorCommand('toggle-bold')).toBe(false) + expect(s.runEditorCommand('open-search')).toBe(false) + expect(s.hasEditorSelection()).toBe(false) + expect(s.view.state.doc.toString()).toBe('hello') + }) + } + + it('allows Find and selection inspection in a read-only editor but rejects edits', async () => { + const s = await setup('hello', 0, 5, true) + expect(s.runEditorCommand('toggle-bold')).toBe(false) + expect(s.hasEditorSelection()).toBe(true) + expect(s.runEditorCommand('open-search')).toBe(true) + expect(s.view.state.doc.toString()).toBe('hello') + }) + + it('reports selections without returning mutable editor state', async () => { + const s = await setup() + expect(s.hasEditorSelection()).toBe(true) + s.view.dispatch({ selection: { anchor: 2 } }) + expect(s.hasEditorSelection()).toBe(false) + s.view.dispatch({ + selection: EditorSelection.create([EditorSelection.range(0, 1), EditorSelection.cursor(3)], 1) + }) + expect(s.hasEditorSelection()).toBe(true) + }) + + it('passes multiple selections through to inline formatting and links', async () => { + const s = await setup('one two') + s.view.dispatch({ + selection: EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.range(4, 7)]) + }) + s.runEditorCommand('toggle-bold') + expect(s.view.state.doc.toString()).toBe('**one** **two**') + expect(s.view.state.selection.ranges).toHaveLength(2) + s.runEditorCommand('insert-link') + expect(s.view.state.doc.toString()).toBe('**[one]()** **[two]()**') + expect(s.view.state.selection.ranges).toHaveLength(2) + }) + + it('replaces only the reversed main selection for a wikilink snippet', async () => { + const s = await setup('one two') + s.view.dispatch({ + selection: EditorSelection.create( + [EditorSelection.range(0, 3), EditorSelection.range(7, 4)], + 1 + ) + }) + s.runEditorCommand('insert-wikilink') + expect(s.view.state.doc.toString()).toBe('one [[]]') + expect(s.view.state.selection.ranges).toHaveLength(1) + expect(s.view.state.selection.main.head).toBe(6) + }) + + it('ignores unsupported command names passed by an untyped host', async () => { + const s = await setup() + expect(s.runEditorCommand('dispatch' as EditorCommand)).toBe(false) + expect(s.view.state.doc.toString()).toBe('hello') + expect(s.view.hasFocus).toBe(false) + }) +}) diff --git a/packages/app-core/src/editor-host.test.ts b/packages/app-core/src/editor-host.test.ts new file mode 100644 index 00000000..5439fe7e --- /dev/null +++ b/packages/app-core/src/editor-host.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment jsdom + +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const views: EditorView[] = [] + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) + +afterEach(() => { + for (const view of views.splice(0)) view.destroy() + vi.restoreAllMocks() + document.body.replaceChildren() +}) + +async function setup() { + const api = await import('./editor') + const { useStore } = await import('./store') + const { noteEditorHostExtension } = await import('./lib/editor-host') + const { registerNoteEditor } = await import('./lib/note-editor-context') + function createView() { + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ + doc: 'hello', + selection: { anchor: 5 }, + extensions: [noteEditorHostExtension()] + }) + }) + views.push(view) + registerNoteEditor(view, () => 'one.md', useStore.getState().activePaneId) + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + selectedPath: 'one.md', + editorViewRef: view, + activeNote: { path: 'one.md', body: 'hello' } as NonNullable< + ReturnType['activeNote'] + > + }) + return view + } + return { ...api, useStore, createView } +} + +function typingAttributes(view: EditorView) { + return ['autocorrect', 'autocapitalize', 'spellcheck', 'writingsuggestions'].map((name) => + view.contentDOM.getAttribute(name) + ) +} + +function measureQueue(view: EditorView) { + const queue: NonNullable[0]>[] = [] + vi.spyOn(view, 'requestMeasure').mockImplementation((request) => { + if (request) queue.push(request) + }) + vi.spyOn(view.dom, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 0, 400, 300)) + vi.spyOn(view.scrollDOM, 'getBoundingClientRect').mockImplementation(() => { + const layout = + Number.parseFloat(view.dom.style.getPropertyValue('--zen-editor-host-bottom-inset')) || 0 + return new DOMRect(0, 0, 400, 300 - layout) + }) + return async () => { + for (let count = 0; queue.length; count++) { + if (count > 10) throw new Error('Host layout did not stabilize') + const request = queue.shift()! + request.write?.(request.read(view), view) + } + await Promise.resolve() + } +} + +describe('public editor host integration', () => { + it('installs native typing before newly created editors can receive focus', async () => { + const s = await setup() + const registration = s.installEditorHost({ nativeTyping: true }) + const first = s.createView() + const second = s.createView() + expect(typingAttributes(first)).toEqual(['on', 'sentences', 'true', 'true']) + expect(typingAttributes(second)).toEqual(typingAttributes(first)) + expect(first.hasFocus).toBe(false) + registration.dispose() + expect(typingAttributes(first)).toEqual(['off', 'off', 'false', 'false']) + expect(typingAttributes(s.createView())).toEqual(typingAttributes(first)) + }) + + it('configures existing views without changing their document, selection, or focus', async () => { + const s = await setup() + const view = s.createView() + const before = view.state + s.installEditorHost({ nativeTyping: true }) + expect(typingAttributes(view)).toEqual(['on', 'sentences', 'true', 'true']) + expect(view.state.doc).toBe(before.doc) + expect(view.state.selection).toBe(before.selection) + expect(view.hasFocus).toBe(false) + }) + + it('does not call a host measurer for a destroyed editor', async () => { + const s = await setup() + const view = s.createView() + const measure = vi.fn(() => ({ scroll: 20 })) + const host = s.installEditorHost({ measureBottomInsets: measure }) + const flush = measureQueue(view) + host.refresh() + view.destroy() + await flush() + expect(measure).not.toHaveBeenCalled() + }) + + it('lets only the latest registration refresh or dispose the host configuration', async () => { + const s = await setup() + const old = s.installEditorHost({ nativeTyping: true }) + const view = s.createView() + const measure = vi.fn(() => ({ scroll: 10 })) + const current = s.installEditorHost({ nativeTyping: true, measureBottomInsets: measure }) + const flush = measureQueue(view) + old.dispose() + old.refresh() + await flush() + expect(measure).not.toHaveBeenCalled() + expect(typingAttributes(view)).toEqual(['on', 'sentences', 'true', 'true']) + current.refresh() + await flush() + expect(measure).toHaveBeenCalled() + current.dispose() + current.dispose() + expect(typingAttributes(view)).toEqual(['off', 'off', 'false', 'false']) + }) + + it('remeasures the shrunken scroller and avoids counting the overlay twice', async () => { + const s = await setup() + const view = s.createView() + const seen: number[] = [] + const host = s.installEditorHost({ + measureBottomInsets: (viewport) => { + expect(Object.isFrozen(viewport)).toBe(true) + expect(Object.isFrozen(viewport.editor)).toBe(true) + seen.push(viewport.scroll.bottom) + return { layout: 80, scroll: Math.max(0, viewport.scroll.bottom - 250) } + } + }) + const flush = measureQueue(view) + host.refresh() + await flush() + expect(seen).toEqual([300, 220]) + expect(view.dom.style.getPropertyValue('--zen-editor-host-bottom-inset')).toBe('80px') + expect(view.state.facet(EditorView.scrollMargins).map((source) => source(view))).toContainEqual( + { bottom: 0 } + ) + host.dispose() + expect(view.dom.style.getPropertyValue('--zen-editor-host-bottom-inset')).toBe('') + }) + + it('clamps invalid insets and recovers from host measurement failures', async () => { + const s = await setup() + const view = s.createView() + const measure = vi.fn(() => ({ layout: -10, scroll: Infinity })) + const host = s.installEditorHost({ measureBottomInsets: measure }) + const flush = measureQueue(view) + host.refresh() + await flush() + expect(view.dom.style.getPropertyValue('--zen-editor-host-bottom-inset')).toBe('0px') + measure.mockImplementation(() => { + throw new Error('Host UI was disposed') + }) + host.refresh() + await expect(flush()).resolves.toBeUndefined() + measure.mockImplementation(() => ({ layout: 0, scroll: 9999 })) + host.refresh() + await flush() + expect(view.state.facet(EditorView.scrollMargins).map((source) => source(view))).toContainEqual( + { bottom: 300 } + ) + }) + + it('reveals only a focused, still-current editor after measurement', async () => { + const s = await setup() + const view = s.createView() + s.installEditorHost({ measureBottomInsets: () => ({ scroll: 20 }) }) + const flush = measureQueue(view) + expect(s.revealEditorCaret()).toBe(false) + view.focus() + const dispatch = vi.spyOn(view, 'dispatch') + expect(s.revealEditorCaret()).toBe(true) + expect(dispatch).not.toHaveBeenCalled() + await flush() + expect(dispatch).toHaveBeenCalledTimes(1) + expect(view.state.doc.toString()).toBe('hello') + expect(view.state.selection.main.head).toBe(5) + }) + + for (const change of ['note', 'vault', 'focus', 'dispose', 'destroy'] as const) { + it(`cancels a queued caret reveal after ${change} changes`, async () => { + const s = await setup() + const view = s.createView() + const host = s.installEditorHost({ measureBottomInsets: () => ({ scroll: 20 }) }) + const flush = measureQueue(view) + view.focus() + expect(s.revealEditorCaret()).toBe(true) + if (change === 'note') s.useStore.setState({ selectedPath: 'two.md' }) + if (change === 'vault') s.useStore.setState({ vault: { root: '/other', name: 'Other' } }) + if (change === 'focus') view.contentDOM.blur() + if (change === 'dispose') host.dispose() + if (change === 'destroy') view.destroy() + const dispatch = vi.spyOn(view, 'dispatch') + await flush() + expect(dispatch).not.toHaveBeenCalled() + }) + } +}) diff --git a/packages/app-core/src/editor.test.ts b/packages/app-core/src/editor.test.ts new file mode 100644 index 00000000..1498fdde --- /dev/null +++ b/packages/app-core/src/editor.test.ts @@ -0,0 +1,243 @@ +// @vitest-environment jsdom + +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ImportedAsset } from '@bridge-contract/ipc' +import type { EditorInsertionTarget } from './editor' + +const views: EditorView[] = [] +const file = (name = 'one.pdf') => new File(['bytes'], name) +const asset = (name = 'one.pdf'): ImportedAsset => ({ name, path: `assets/${name}`, kind: 'pdf', markdown: `![[assets/${name}]]` }) +const image = { data: new Uint8Array([1, 2]), mimeType: 'image/png' } + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { configurable: true, value: { getCapabilities: () => ({}) } }) +}) +afterEach(() => { + for (const view of views.splice(0)) view.destroy() + document.body.replaceChildren() +}) + +async function setup(body = 'before after', anchor = 7, head = anchor) { + const { useStore } = await import('./store') + const api = await import('./editor') + const { registerNoteEditor } = await import('./lib/note-editor-context') + let editorPath: string | null = 'one.md' + const view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: body, selection: EditorSelection.single(anchor, head) }) + }) + views.push(view) + registerNoteEditor(view, () => editorPath, useStore.getState().activePaneId) + useStore.setState({ vault: { root: '/test-vault', name: 'Test' }, selectedPath: 'one.md', editorViewRef: view, + activeNote: { path: 'one.md', body } as NonNullable['activeNote']> }) + const importer = { + isCurrent: vi.fn(() => true), + importFile: vi.fn(async (_path: string, input: File) => asset(input.name)), + importPastedImage: vi.fn(async () => asset('paste.png')) + } + return { ...api, importer, view, useStore, setEditorPath: (path: string | null) => { editorPath = path } } +} + +describe('public editor attachment insertion', () => { + it('imports in order, inserts at the captured cursor, and returns the saved assets', async () => { + const s = await setup() + const target = s.captureEditorInsertion(s.importer)! + const result = await s.attachFiles(target, [file(), file('two.pdf')]) + expect(result).toEqual({ status: 'inserted', assets: [asset(), asset('two.pdf')] }) + expect(s.importer.importFile.mock.calls.map(([path, input]) => [path, input.name])).toEqual([ + ['one.md', 'one.pdf'], ['one.md', 'two.pdf'] + ]) + expect(s.view.state.doc.toString()).toBe('before \n\n![[assets/one.pdf]]\n\n![[assets/two.pdf]]\n\nafter') + expect(s.view.hasFocus).toBe(true) + }) + + it('preserves selected text for file attachment and replaces it for image paste', async () => { + const s = await setup('before selected after', 7, 15) + await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file()]) + expect(s.view.state.doc.toString()).toBe('before selected\n\n![[assets/one.pdf]]\n\n after') + const paste = await setup('before selected after', 15, 7) + await paste.insertPastedImage(paste.captureEditorInsertion(paste.importer)!, image) + expect(paste.view.state.doc.toString()).toBe('before \n\n![[assets/paste.png]]\n\n after') + expect(paste.importer.importPastedImage).toHaveBeenCalledWith(image) + }) + + it('returns no target for unavailable, virtual, read-only, or transitioning editors', async () => { + const s = await setup() + s.useStore.setState({ vault: null }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + s.useStore.setState({ vault: { root: '/test', name: 'Test' }, selectedPath: 'zen://tasks' }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + s.useStore.setState({ selectedPath: 'other.md' }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + s.useStore.setState({ selectedPath: 'one.md' }) + s.view.setState(EditorState.create({ doc: 'read only', extensions: EditorState.readOnly.of(true) })) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + s.view.destroy() + expect(s.captureEditorInsertion(s.importer)).toBeNull() + }) + + for (const change of ['note', 'document', 'cursor', 'vault', 'editor', 'pane', 'host', 'registered path', 'read-only'] as const) { + it(`rejects a changed ${change} before importing anything`, async () => { + const s = await setup() + const target = s.captureEditorInsertion(s.importer)! + if (change === 'note') s.useStore.setState({ selectedPath: 'two.md' }) + if (change === 'document') s.view.dispatch({ changes: { from: 0, insert: 'edit' } }) + if (change === 'cursor') s.view.dispatch({ selection: { anchor: 0 } }) + if (change === 'vault') s.useStore.setState({ vault: { root: '/other', name: 'Other' } }) + if (change === 'editor') s.useStore.setState({ editorViewRef: null }) + if (change === 'pane') s.useStore.setState({ activePaneId: 'different-pane' }) + if (change === 'host') s.importer.isCurrent.mockReturnValue(false) + if (change === 'registered path') s.setEditorPath('two.md') + if (change === 'read-only') s.view.setState(EditorState.create({ doc: 'before after', extensions: EditorState.readOnly.of(true) })) + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + } + + it('stops a multi-file import after a note switch and preserves already saved files', async () => { + const s = await setup() + const focus = vi.spyOn(s.view, 'focus') + const original = s.view.state.doc.toString() + s.importer.importFile.mockImplementationOnce(async () => { + s.useStore.setState({ selectedPath: 'two.md' }) + return asset() + }) + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file(), file('two.pdf')])) + .toEqual({ status: 'saved-only', assets: [asset()] }) + expect(s.importer.importFile).toHaveBeenCalledTimes(1) + expect(s.view.state.doc.toString()).toBe(original) + expect(focus).not.toHaveBeenCalled() + }) + + it('stops when the host switches vaults before the store catches up', async () => { + const s = await setup() + s.importer.importFile.mockImplementationOnce(async () => { + s.importer.isCurrent.mockReturnValue(false) + return asset() + }) + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file(), file('two.pdf')])) + .toEqual({ status: 'saved-only', assets: [asset()] }) + expect(s.importer.importFile).toHaveBeenCalledTimes(1) + expect(s.view.state.doc.toString()).toBe('before after') + }) + + it('reports partial storage failure without inserting incomplete references', async () => { + const s = await setup() + s.importer.importFile.mockResolvedValueOnce(asset()).mockRejectedValueOnce(new Error('Disk full')) + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, [file(), file('two.pdf')])) + .toEqual({ status: 'failed', assets: [asset()], error: 'Disk full' }) + expect(s.view.state.doc.toString()).toBe('before after') + }) + + it('allows each target only once, including while its import is in flight', async () => { + const s = await setup() + let finish!: (value: ImportedAsset) => void + s.importer.importFile.mockImplementation(() => new Promise(resolve => { finish = resolve })) + const target = s.captureEditorInsertion(s.importer)! + const first = s.attachFiles(target, [file()]) + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + finish(asset()) + expect((await first).status).toBe('inserted') + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).toHaveBeenCalledTimes(1) + }) + + it('cancels an in-flight insertion without deleting its saved asset', async () => { + const s = await setup() + const target = s.captureEditorInsertion(s.importer)! + s.importer.importFile.mockImplementationOnce(async () => { + s.cancelEditorInsertion(target) + return asset() + }) + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'saved-only', assets: [asset()] }) + expect(s.view.state.doc.toString()).toBe('before after') + }) + + it('rejects stale image reads and image writes without inserting into another note', async () => { + const s = await setup() + const beforeRead = s.captureEditorInsertion(s.importer)! + s.view.dispatch({ selection: { anchor: 0 } }) + expect(await s.insertPastedImage(beforeRead, image)).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importPastedImage).not.toHaveBeenCalled() + s.importer.importPastedImage.mockImplementationOnce(async () => { + s.setEditorPath('other.md') + return asset('paste.png') + }) + expect(await s.insertPastedImage(s.captureEditorInsertion(s.importer)!, image)) + .toEqual({ status: 'saved-only', assets: [asset('paste.png')] }) + expect(s.view.state.doc.toString()).toBe('before after') + }) + + it('supports focused clipboard capture while preserving targets through picker blur', async () => { + const s = await setup() + expect(s.captureEditorInsertion(s.importer, { requireFocus: true })).toBeNull() + s.view.focus() + const target = s.captureEditorInsertion(s.importer, { requireFocus: true })! + s.view.contentDOM.blur() + expect(s.view.hasFocus).toBe(false) + expect((await s.attachFiles(target, [file()])).status).toBe('inserted') + }) + + it('treats host teardown as stale rather than throwing from validation', async () => { + const s = await setup() + const target = s.captureEditorInsertion(s.importer)! + s.importer.isCurrent.mockImplementation(() => { throw new Error('No active vault') }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + expect(await s.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + + it('rejects a destroyed view and a cancelled target before calling the host', async () => { + const s = await setup() + const destroyed = s.captureEditorInsertion(s.importer)! + const cancelled = s.captureEditorInsertion(s.importer)! + s.cancelEditorInsertion(cancelled) + expect(await s.attachFiles(cancelled, [file()])).toEqual({ status: 'stale', assets: [] }) + s.view.destroy() + expect(s.captureEditorInsertion(s.importer)).toBeNull() + expect(await s.attachFiles(destroyed, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + + it('rejects fabricated tokens and tokens from another package instance', async () => { + const s = await setup() + expect(await s.attachFiles({} as EditorInsertionTarget, [file()])).toEqual({ status: 'stale', assets: [] }) + const target = s.captureEditorInsertion(s.importer)! + expect(Object.keys(target)).toEqual([]) + expect(Object.isFrozen(target)).toBe(true) + vi.resetModules() + const other = await import('./editor') + expect(await other.attachFiles(target, [file()])).toEqual({ status: 'stale', assets: [] }) + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + + it('snapshots the file list before awaiting storage', async () => { + const s = await setup() + const files = [file()] + s.importer.importFile.mockImplementationOnce(async () => { + files.push(file('unexpected.pdf')) + return asset() + }) + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, files)) + .toEqual({ status: 'inserted', assets: [asset()] }) + expect(s.importer.importFile).toHaveBeenCalledTimes(1) + }) + + it('reports an empty batch without inserting text or calling storage', async () => { + const s = await setup() + expect(await s.attachFiles(s.captureEditorInsertion(s.importer)!, [])) + .toEqual({ status: 'empty', assets: [] }) + expect(s.view.state.doc.toString()).toBe('before after') + expect(s.importer.importFile).not.toHaveBeenCalled() + }) + + it('rejects capture after switching panes before the editor reference catches up', async () => { + const s = await setup() + s.useStore.setState({ activePaneId: 'other-pane-showing-the-same-note' }) + expect(s.captureEditorInsertion(s.importer)).toBeNull() + }) +}) diff --git a/packages/app-core/src/editor.ts b/packages/app-core/src/editor.ts new file mode 100644 index 00000000..8c83b05d --- /dev/null +++ b/packages/app-core/src/editor.ts @@ -0,0 +1,297 @@ +import { useSyncExternalStore } from 'react' +import { requestPaneMode } from './lib/pane-mode' +import type { EditorSelection, Text } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import type { ImportedAsset, PastedImageInput, VaultInfo } from '@bridge-contract/ipc' +import { useStore } from './store' +import { formatImportedAssetsForInsertion } from './lib/editor-drops' +import { noteEditorMatches } from './lib/note-editor-context' +import { runNoteEditorCommand } from './lib/editor-commands' +import { installNoteEditorHost, requestNoteEditorReveal } from './lib/editor-host' + +export interface EditorBounds { + readonly top: number + readonly bottom: number + readonly left: number + readonly right: number + readonly width: number + readonly height: number +} + +export interface EditorViewport { + readonly editor: EditorBounds + readonly scroll: EditorBounds +} + +export interface EditorBottomInsets { + /** Reserve physical space below the scroller, for native selection handles. */ + readonly layout?: number + /** Additional clearance inside the remaining scroll viewport. */ + readonly scroll?: number +} + +export interface EditorHostOptions { + readonly nativeTyping?: boolean + /** Read-only measurement callback. Return CSS pixels; do not change layout here. */ + readonly measureBottomInsets?: (viewport: EditorViewport) => EditorBottomInsets +} + +export interface EditorHostRegistration { + refresh(): void + dispose(): void +} + +/** Configure existing and future editors. The newest registration owns the configuration. */ +export function installEditorHost(options: EditorHostOptions): EditorHostRegistration { + return installNoteEditorHost(options) +} + +/** Schedule a focused note's caret reveal after measuring host overlays. Never takes focus. */ +export function revealEditorCaret(): boolean { + const editor = currentNoteEditor() + if (!editor || !editor.view.hasFocus) return false + return requestNoteEditorReveal(editor.view, () => { + const current = currentNoteEditor() + return ( + current?.view === editor.view && + current.path === editor.path && + current.vault === editor.vault && + editor.view.hasFocus + ) + }) +} + +/** Semantic toolbar actions. Hosts never receive the editor's command or view objects. */ +export type EditorCommand = + | 'undo' + | 'redo' + | 'open-search' + | 'close-search' + | 'toggle-bold' + | 'toggle-italic' + | 'toggle-strikethrough' + | 'toggle-highlight' + | 'toggle-inline-code' + | 'set-bullet-list' + | 'set-task-list' + | 'cycle-heading' + | 'insert-link' + | 'insert-wikilink' + | 'insert-tag' + | 'indent' + | 'outdent' + +function currentNoteEditor(): { view: EditorView; path: string; vault: VaultInfo } | null { + const { + editorViewRef: view, + selectedPath: path, + vault, + activeNote, + activePaneId + } = useStore.getState() + if ( + !view || + !vault || + !path || + path.startsWith('zen://') || + !view.dom.isConnected || + activeNote?.path !== path || + !noteEditorMatches(view, path, activePaneId) + ) + return null + return { view, path, vault } +} + +/** Run immediately against the active note. False means unavailable or not handled. */ +export function runEditorCommand(command: EditorCommand): boolean { + const editor = currentNoteEditor() + return editor ? runNoteEditorCommand(editor.view, command) : false +} + +/** Inspect text selections, including in read-only notes, without exposing them. */ +export function hasEditorSelection(): boolean { + const editor = currentNoteEditor() + return editor ? editor.view.state.selection.ranges.some((range) => !range.empty) : false +} + +/** Bind these operations to one host vault before opening a picker or reading a clipboard. */ +export interface EditorAssetImporter { + /** Check the host's actual vault identity, even while renderer state is catching up. */ + isCurrent(): boolean + importFile(notePath: string, file: File): Promise + importPastedImage(input: PastedImageInput): Promise +} + +declare const insertionTarget: unique symbol +/** Opaque, single-use context. It contains no public editor or filesystem state. */ +export interface EditorInsertionTarget { + readonly [insertionTarget]: true +} + +export type EditorInsertionResult = + | { status: 'inserted' | 'stale' | 'saved-only' | 'empty'; assets: readonly ImportedAsset[] } + | { status: 'failed'; assets: readonly ImportedAsset[]; error: string } + +interface InsertionContext { + view: EditorView + path: string + vault: VaultInfo + document: Text + selection: EditorSelection + importer: EditorAssetImporter + started: boolean +} +const insertions = new WeakMap() + +function hostIsCurrent(importer: EditorAssetImporter): boolean { + try { + return importer.isCurrent() + } catch { + return false + } +} + +/** Capture before asynchronous host work. Losing focus to a picker is allowed. */ +export function captureEditorInsertion( + importer: EditorAssetImporter, + options: { requireFocus?: boolean } = {} +): EditorInsertionTarget | null { + const editor = currentNoteEditor() + if (!editor) return null + const { view, path, vault } = editor + if (view.state.readOnly || (options.requireFocus && !view.hasFocus) || !hostIsCurrent(importer)) + return null + const target = Object.freeze({}) as EditorInsertionTarget + insertions.set(target, { + view, + path, + vault, + document: view.state.doc, + selection: view.state.selection, + importer, + started: false + }) + return target +} + +/** Invalidate insertion on dismissal/disposal. An in-flight host save cannot be undone. */ +export function cancelEditorInsertion(target: EditorInsertionTarget): void { + insertions.delete(target) +} + +function isCurrent(target: EditorInsertionTarget, context: InsertionContext): boolean { + const state = useStore.getState() + const { view } = context + return ( + insertions.get(target) === context && + state.vault === context.vault && + state.selectedPath === context.path && + state.activeNote?.path === context.path && + state.editorViewRef === view && + view.dom.isConnected && + noteEditorMatches(view, context.path, state.activePaneId) && + !view.state.readOnly && + view.state.doc === context.document && + view.state.selection.eq(context.selection) && + hostIsCurrent(context.importer) + ) +} + +function staleResult(assets: ImportedAsset[]): EditorInsertionResult { + return { status: assets.length ? 'saved-only' : 'stale', assets } +} + +async function importAndInsert( + target: EditorInsertionTarget, + inputs: readonly T[], + save: (context: InsertionContext, input: T) => Promise, + replaceSelection: boolean +): Promise { + const context = insertions.get(target) + if (!context || context.started) return { status: 'stale', assets: [] } + context.started = true + const assets: ImportedAsset[] = [] + try { + if (!isCurrent(target, context)) return staleResult(assets) + if (inputs.length === 0) return { status: 'empty', assets } + for (const input of inputs) { + if (!isCurrent(target, context)) return staleResult(assets) + assets.push(await save(context, input)) + if (!isCurrent(target, context)) return staleResult(assets) + } + const { view, document, selection } = context + const from = replaceSelection ? selection.main.from : selection.main.head + const to = replaceSelection ? selection.main.to : from + const before = from > 0 ? document.sliceString(from - 1, from) : '' + const after = document.sliceString(to, to + 1) + const insert = formatImportedAssetsForInsertion(assets, before, after) + view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } }) + view.focus() + return { status: 'inserted', assets } + } catch (error) { + return { + status: 'failed', + assets, + error: error instanceof Error ? error.message : 'Could not import the attachment.' + } + } finally { + insertions.delete(target) + } +} + +/** Import files serially and insert at the captured cursor only if its context still matches. */ +export function attachFiles( + target: EditorInsertionTarget, + files: readonly File[] +): Promise { + return importAndInsert( + target, + [...files], + (context, file) => context.importer.importFile(context.path, file), + false + ) +} + +/** Replace the captured selection after an asynchronous clipboard read. */ +export function insertPastedImage( + target: EditorInsertionTarget, + input: PastedImageInput +): Promise { + return importAndInsert( + target, + [input], + (context, image) => context.importer.importPastedImage(image), + true + ) +} + +export type EditorMode = 'edit' | 'preview' | 'split' +export interface EditorPresentation { + readonly path: string | null + readonly hasOpenNote: boolean + readonly mode: EditorMode +} +let presentation: EditorPresentation | undefined +export function getEditorPresentation(): EditorPresentation { + const state = useStore.getState() + const path = state.selectedPath + const sticky = state.paneStickyModes[state.activePaneId] + const mode = state.keepViewModeAcrossNotes && sticky ? sticky + : (path ? state.paneModes[state.activePaneId]?.[path] : undefined) ?? state.defaultPaneMode + const hasOpenNote = !!path && state.activeNote?.path === path + if (!presentation || presentation.path !== path || presentation.mode !== mode || presentation.hasOpenNote !== hasOpenNote) + presentation = Object.freeze({ path, mode, hasOpenNote }) + return presentation +} +export function subscribeEditorPresentation(listener: () => void): () => void { + let previous = getEditorPresentation() + return useStore.subscribe(() => { + const next = getEditorPresentation() + if (next === previous) return + previous = next; listener() + }) +} +export function useEditorPresentation(): EditorPresentation { + return useSyncExternalStore(subscribeEditorPresentation, getEditorPresentation, getEditorPresentation) +} +export function setEditorMode(mode: EditorMode): void { requestPaneMode(mode) } diff --git a/packages/app-core/src/folder-actions.test.ts b/packages/app-core/src/folder-actions.test.ts new file mode 100644 index 00000000..49a68c89 --- /dev/null +++ b/packages/app-core/src/folder-actions.test.ts @@ -0,0 +1,744 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { databaseTabPath } from '@shared/databases' +import { parseTasksFromBody } from '@shared/tasks' +import { makeLeaf, allLeaves } from './lib/pane-layout' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() +}) +afterEach(() => { + vi.useRealTimers() +}) + +async function setup(root = false) { + const prefix = root ? '' : 'My Notes/' + let folders = ['Work', 'Work/People.base', 'Other'] + const files = new Map([ + [`${prefix}Work/Note.md`, 'Saved body.\n'], + [`${prefix}Work/People.base/data.csv`, 'id,Name\n1,Example\n'], + [`${prefix}Other/Keep.md`, 'Unchanged.\n'] + ]) + const meta = (path: string, body: string) => ({ + path, + title: path.split('/').pop()!, + folder: 'inbox' as const, + subpath: path.slice(prefix.length, path.lastIndexOf('/')), + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: body.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' + }) + const folderRows = () => + folders.map((subpath) => ({ + folder: 'inbox' as const, + subpath, + siblingOrder: 0 + })) + const notes = () => [...files].filter(([p]) => p.endsWith('.md')).map(([p, b]) => meta(p, b)) + const bridge = { + getCapabilities: () => ({}), + listNotes: async () => notes(), + listFolders: async () => folderRows(), + hasAssetsDir: async () => false, + scanTasks: async () => [], + scanTasksForPath: async () => [], + listAssets: async () => [], + getRemoteWorkspaceInfo: async () => null, + readNote: async (path: string) => ({ + ...meta(path, files.get(path)!), + body: files.get(path)! + }), + writeNote: vi.fn(async (path: string, body: string) => { + files.set(path, body) + return meta(path, body) + }), + writeDatabaseRows: vi.fn(async (path: string, rows: unknown) => { + files.set(path, JSON.stringify(rows)) + }), + writeDatabaseSchema: vi.fn(async (path: string, _schema: unknown, rows: unknown) => { + files.set(path, JSON.stringify(rows)) + }), + setVaultSettings: vi.fn(async (settings) => settings), + createFolder: vi.fn(async (_folder: string, directory: string) => { + folders.push(directory) + }), + renameFolder: vi.fn(async (_folder: string, from: string, to: string) => { + folders = folders.map((path) => + path === from || path.startsWith(`${from}/`) ? to + path.slice(from.length) : path + ) + for (const [path, body] of [...files]) + if (path.startsWith(`${prefix}${from}/`)) { + files.delete(path) + files.set(`${prefix}${to}/${path.slice(`${prefix}${from}/`.length)}`, body) + } + return to + }), + deleteFolder: vi.fn(async (_folder: string, directory: string) => { + folders = folders.filter((path) => path !== directory && !path.startsWith(`${directory}/`)) + for (const path of [...files.keys()]) + if (path.startsWith(`${prefix}${directory}/`)) files.delete(path) + }) + } + Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) + const { useStore } = await import('./store') + const path = `${prefix}Work/Note.md` + const csv = `${prefix}Work/People.base/data.csv` + const tab = databaseTabPath(csv) + const leaf = makeLeaf([path, tab, `${prefix}Other/Keep.md`], path) + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + notes: notes(), + folders: folderRows(), + vaultSettings: { + ...useStore.getState().vaultSettings, + primaryNotesLocation: root ? 'root' : 'inbox', + systemFolderPaths: { inbox: 'My Notes' } + }, + paneLayout: leaf, + activePaneId: leaf.id, + selectedPath: path, + noteContents: Object.fromEntries( + notes().map((n) => [n.path, { ...n, body: files.get(n.path)! }]) + ), + noteDirty: { [path]: true }, + activeNote: { ...meta(path, 'Unsaved edit.\n'), body: 'Unsaved edit.\n' }, + activeDirty: true, + databases: { + [csv]: { + version: 1, + path: csv, + title: 'People', + fields: [], + rows: [], + views: [], + activeViewId: '', + idFieldId: 'id' + } + } + }) + useStore.setState({ + noteContents: { + ...useStore.getState().noteContents, + [path]: { ...meta(path, 'Unsaved edit.\n'), body: 'Unsaved edit.\n' } + } + }) + return { + useStore, + bridge, + files, + prefix, + path, + csv, + tab, + tabs: () => allLeaves(useStore.getState().paneLayout).flatMap((leaf) => leaf.tabs) + } +} + +describe('folder mutation state', () => { + it.each([false, true])('renames open notes and database tabs with root mode %s', async (root) => { + const s = await setup(root) + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + expect(s.tabs()).toEqual([ + `${s.prefix}Renamed/Note.md`, + databaseTabPath(`${s.prefix}Renamed/People.base/data.csv`), + `${s.prefix}Other/Keep.md` + ]) + const state = s.useStore.getState() + expect(state.activeNote?.body).toBe('Unsaved edit.\n') + expect(state.selectedPath).toBe(`${s.prefix}Renamed/Note.md`) + expect(state.databases[`${s.prefix}Renamed/People.base/data.csv`]?.path).toBe( + `${s.prefix}Renamed/People.base/data.csv` + ) + expect(state.databases[s.csv]).toBeUndefined() + await state.persistNote(`${s.prefix}Renamed/Note.md`) + expect(s.files.get(`${s.prefix}Renamed/Note.md`)).toBe('Unsaved edit.\n') + expect(s.files.has(s.path)).toBe(false) + expect(s.files.get(`${s.prefix}Other/Keep.md`)).toBe('Unchanged.\n') + }) + + it.each([false, true])( + 'deletes only the target folder and closes database tabs with root mode %s', + async (root) => { + const s = await setup(root) + await s.useStore.getState().deleteFolder('inbox', 'Work') + expect(s.tabs()).toEqual([`${s.prefix}Other/Keep.md`]) + expect(s.useStore.getState().databases[s.csv]).toBeUndefined() + expect(s.useStore.getState().noteContents[s.path]).toBeUndefined() + expect(s.files.size).toBe(1) + expect(s.files.get(`${s.prefix}Other/Keep.md`)).toBe('Unchanged.\n') + } + ) + + it.each(['createFolder', 'renameFolder', 'deleteFolder'] as const)( + 'ignores a %s response after a vault switch', + async (action) => { + const s = await setup() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + if (action === 'renameFolder') + s.bridge.renameFolder.mockImplementationOnce(async () => { + await gate + return 'Renamed' + }) + else + s.bridge[action].mockImplementationOnce(async () => { + await gate + }) + const promise = + action === 'renameFolder' + ? s.useStore.getState()[action]('inbox', 'Work', 'Renamed') + : s.useStore.getState()[action]('inbox', 'Work') + await vi.waitFor(() => expect(s.bridge[action]).toHaveBeenCalled()) + s.useStore.setState({ + vault: { root: '/other', name: 'Other' }, + notes: [], + folders: [], + view: { kind: 'folder', folder: 'inbox', subpath: 'Other vault' } + }) + const before = s.useStore.getState() + release() + await promise + expect(s.useStore.getState()).toBe(before) + expect(s.bridge.setVaultSettings).not.toHaveBeenCalled() + } + ) + it('uses the canonical directory returned by the host', async () => { + const s = await setup(true) + const rename = s.bridge.renameFolder.getMockImplementation()! + s.bridge.renameFolder.mockImplementationOnce((folder, from) => + rename(folder, from, 'Canonical') + ) + await s.useStore.getState().renameFolder('inbox', 'Work', 'Requested') + expect(s.useStore.getState().selectedPath).toBe('Canonical/Note.md') + expect(s.files.has('Canonical/Note.md')).toBe(true) + }) + + it('preserves note and database edits made while a rename is pending', async () => { + const s = await setup(true) + const initial = s.useStore.getState().databases[s.csv] + const before = { ...initial, rows: [{ id: '1', cells: { name: 'Before rename' } }] } + s.useStore.getState().updateDatabaseRows(s.csv, before) + const rename = s.bridge.renameFolder.getMockImplementation()! + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + s.bridge.renameFolder.mockImplementationOnce(async (...args) => { + await gate + return rename(...args) + }) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + await vi.waitFor(() => expect(s.bridge.renameFolder).toHaveBeenCalled()) + expect(s.bridge.writeDatabaseRows).toHaveBeenCalledWith(s.csv, before.rows) + await s.useStore + .getState() + .applyChange({ kind: 'unlink', path: s.path, scope: 'content', folder: 'inbox' }) + await s.useStore + .getState() + .applyChange({ kind: 'unlink', path: s.csv, scope: 'database', folder: 'inbox' }) + expect(s.tabs()).toContain(s.path) + expect(s.tabs()).toContain(s.tab) + vi.useFakeTimers() + s.useStore.getState().updateNoteBody(s.path, 'Typed during rename.\n') + const during = { ...initial, rows: [{ id: '1', cells: { name: 'During rename' } }] } + s.useStore.getState().updateDatabaseRows(s.csv, during) + await vi.advanceTimersByTimeAsync(500) + expect(s.bridge.writeDatabaseRows).toHaveBeenCalledTimes(1) + release() + await operation + await vi.advanceTimersByTimeAsync(500) + expect(s.files.get('Renamed/Note.md')).toBe('Typed during rename.\n') + expect(s.files.get('Renamed/People.base/data.csv')).toBe(JSON.stringify(during.rows)) + expect([...s.files.keys()].some((path) => path.startsWith('Work/'))).toBe(false) + }) + + it('drains a pending database write before deletion and never recreates its files', async () => { + const s = await setup(true) + vi.useFakeTimers() + const doc = s.useStore.getState().databases[s.csv] + s.useStore + .getState() + .updateDatabaseRows(s.csv, { ...doc, rows: [{ id: '1', cells: { name: 'Pending' } }] }) + await s.useStore.getState().deleteFolder('inbox', 'Work/People.base') + await vi.advanceTimersByTimeAsync(1000) + expect(s.files.has(s.csv)).toBe(false) + expect(s.bridge.writeDatabaseRows).toHaveBeenCalledTimes(1) + expect(s.tabs()).not.toContain(s.tab) + expect(s.tabs()).toContain(s.path) + }) + + it('resumes pending saves at their original paths when a rename fails', async () => { + const s = await setup(true) + let reject!: (error: Error) => void + const gate = new Promise((_resolve, no) => { + reject = no + }) + s.bridge.renameFolder.mockImplementationOnce(() => gate) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + const failure = expect(operation).rejects.toThrow('Name taken') + await vi.waitFor(() => expect(s.bridge.renameFolder).toHaveBeenCalled()) + vi.useFakeTimers() + s.useStore.getState().updateNoteBody(s.path, 'Keep this edit.\n') + const doc = s.useStore.getState().databases[s.csv] + const rows = [{ id: '1', cells: { name: 'Keep this cell' } }] + s.useStore.getState().updateDatabaseRows(s.csv, { ...doc, rows }) + reject(new Error('Name taken')) + await failure + await vi.advanceTimersByTimeAsync(500) + expect(s.files.get(s.path)).toBe('Keep this edit.\n') + expect(s.files.get(s.csv)).toBe(JSON.stringify(rows)) + expect(s.tabs()).toContain(s.path) + }) + + it('ignores a listing fetched before a vault switch', async () => { + const s = await setup() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const listing = s.bridge.listNotes + s.bridge.listNotes = async () => { + await gate + return listing() + } + const refresh = s.useStore.getState().refreshNotes() + s.useStore.setState({ vault: { root: '/other', name: 'Other' }, notes: [], folders: [] }) + const before = s.useStore.getState() + release() + await refresh + expect(s.useStore.getState()).toBe(before) + }) + it('does not restore an old database cache from a read completed after rename', async () => { + const s = await setup(true) + const old = s.useStore.getState().databases[s.csv] + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + Object.assign(s.bridge, { + openDatabase: async () => { + await gate + return old + } + }) + const read = s.useStore.getState().loadDatabase(s.csv) + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + release() + await read + expect(s.useStore.getState().databases[s.csv]).toBeUndefined() + expect(s.useStore.getState().databases['Renamed/People.base/data.csv']).toBeDefined() + expect(s.tabs()).not.toContain(s.tab) + }) + + it('rejects an old listing completed after a rename and its fresh listing', async () => { + const s = await setup(true) + const notes = await s.bridge.listNotes() + const folders = await s.bridge.listFolders() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const originalNotes = s.bridge.listNotes + const originalFolders = s.bridge.listFolders + s.bridge.listNotes = async () => { + await gate + return notes + } + s.bridge.listFolders = async () => { + await gate + return folders + } + const oldRefresh = s.useStore.getState().refreshNotes() + s.bridge.listNotes = originalNotes + s.bridge.listFolders = originalFolders + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + release() + await oldRefresh + expect(s.useStore.getState().folders.some((row) => row.subpath === 'Work')).toBe(false) + expect(s.useStore.getState().notes.some((row) => row.path === 'Renamed/Note.md')).toBe(true) + expect(s.tabs()).toContain('Renamed/Note.md') + }) + + it('lets a waiting vault switch flush edits at the renamed path after invalidation', async () => { + const s = await setup(true) + let current = true + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const rename = s.bridge.renameFolder.getMockImplementation()! + s.bridge.renameFolder.mockImplementationOnce(async (...args) => { + await gate + return rename(...args) + }) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed', () => current) + await vi.waitFor(() => expect(s.bridge.renameFolder).toHaveBeenCalled()) + s.useStore.getState().updateNoteBody(s.path, 'Save before switching.\n') + const doc = s.useStore.getState().databases[s.csv] + const rows = [{ id: '1', cells: { name: 'Save before switching' } }] + s.useStore.getState().updateDatabaseRows(s.csv, { ...doc, rows }) + current = false + const flush = s.useStore.getState().flushDirtyNotes() + release() + await operation + await flush + expect(s.files.get('Renamed/Note.md')).toBe('Save before switching.\n') + expect(s.files.get('Renamed/People.base/data.csv')).toBe(JSON.stringify(rows)) + expect(s.files.has(s.csv)).toBe(false) + expect(s.files.has(s.path)).toBe(false) + expect(s.useStore.getState().selectedPath).toBe('Renamed/Note.md') + }) + + it('does not resume old-path writes after an uncertain rollback', async () => { + const s = await setup(true) + let reject!: (error: Error) => void + const gate = new Promise((_yes, no) => { + reject = no + }) + s.bridge.renameFolder.mockImplementationOnce(() => gate) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + const failure = expect(operation).rejects.toThrow('FOLDER_STATE_UNCERTAIN:') + await vi.waitFor(() => expect(s.bridge.renameFolder).toHaveBeenCalled()) + vi.useFakeTimers() + s.useStore.getState().updateNoteBody(s.path, 'Keep in memory.\n') + const writes = s.bridge.writeNote.mock.calls.length + reject(new Error('FOLDER_STATE_UNCERTAIN: simulated rollback failure')) + await failure + await vi.advanceTimersByTimeAsync(1000) + await expect(s.useStore.getState().flushDirtyNotes()).rejects.toThrow('Reload the vault') + expect(s.bridge.writeNote).toHaveBeenCalledTimes(writes) + expect(s.useStore.getState().noteContents[s.path].body).toBe('Keep in memory.\n') + }) + + it.each(['rename', 'delete'] as const)( + 'reconciles path-bearing state during a waiting switch: %s', + async (action) => { + const s = await setup(true) + const state = s.useStore.getState() + const asset = { + path: 'Work/image.png', + name: 'image.png', + kind: 'image' as const, + siblingOrder: 0, + size: 1, + updatedAt: 1 + } + const tasks = parseTasksFromBody('- [ ] Keep task', { + path: s.path, + title: 'Note', + folder: 'inbox' + }) + s.useStore.setState({ + view: { kind: 'folder', folder: 'inbox', subpath: 'Work' }, + paneModes: { [state.activePaneId]: { [s.path]: 'preview' } }, + noteRefs: { [s.path]: { path: asset.path, kind: 'asset', fragment: 'page=2' } }, + manualNoteOrder: { Work: ['Work/Z.md', s.path] }, + vaultSettings: { + ...state.vaultSettings, + favorites: ['inbox:Work', s.path, 'Other/Keep.md'] + }, + assetFiles: [asset], + vaultTasks: tasks + }) + localStorage.setItem( + 'zen.notes.manualOrder./test', + JSON.stringify(s.useStore.getState().manualNoteOrder) + ) + let current = true + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const rename = s.bridge.renameFolder.getMockImplementation()! + const remove = s.bridge.deleteFolder.getMockImplementation()! + s.bridge.renameFolder.mockImplementationOnce(async (...args) => { + await gate + return rename(...args) + }) + s.bridge.deleteFolder.mockImplementationOnce(async (...args) => { + await gate + return remove(...args) + }) + const operation = + action === 'rename' + ? state.renameFolder('inbox', 'Work', 'Renamed', () => current) + : state.deleteFolder('inbox', 'Work', () => current) + await vi.waitFor(() => + expect( + action === 'rename' ? s.bridge.renameFolder : s.bridge.deleteFolder + ).toHaveBeenCalled() + ) + const oldNotes = await s.bridge.listNotes() + const oldFolders = await s.bridge.listFolders() + let releaseRead!: () => void + const readGate = new Promise((resolve) => { + releaseRead = resolve + }) + s.bridge.listNotes = async () => { + await readGate + return oldNotes + } + s.bridge.listFolders = async () => { + await readGate + return oldFolders + } + Object.assign(s.bridge, { + listAssets: async () => { + await readGate + return [asset] + } + }) + const reads = [state.refreshNotes(), state.refreshAssets()] + current = false + release() + await operation + releaseRead() + await Promise.all(reads) + const next = s.useStore.getState() + expect(s.bridge.setVaultSettings).toHaveBeenCalled() + expect(s.bridge.setVaultSettings.mock.calls.at(-1)?.[0].favorites).toEqual( + action === 'rename' + ? ['inbox:Renamed', 'Renamed/Note.md', 'Other/Keep.md'] + : ['Other/Keep.md'] + ) + expect(next.notes.some((n) => n.path.startsWith('Work/'))).toBe(false) + expect(next.folders.some((f) => f.subpath === 'Work')).toBe(false) + expect(next.paneModes[state.activePaneId][s.path]).toBeUndefined() + expect(next.noteRefs[s.path]).toBeUndefined() + expect(next.manualNoteOrder.Work).toBeUndefined() + expect(JSON.parse(localStorage.getItem('zen.notes.manualOrder./test')!)).toEqual( + next.manualNoteOrder + ) + if (action === 'rename') { + expect(next.view).toEqual({ kind: 'folder', folder: 'inbox', subpath: 'Renamed' }) + expect(next.paneModes[state.activePaneId]['Renamed/Note.md']).toBe('preview') + expect(next.noteRefs['Renamed/Note.md']).toEqual({ + path: 'Renamed/image.png', + kind: 'asset', + fragment: 'page=2' + }) + expect(next.manualNoteOrder.Renamed).toEqual(['Renamed/Z.md', 'Renamed/Note.md']) + expect(next.assetFiles[0].path).toBe('Renamed/image.png') + expect(next.vaultTasks[0]).toMatchObject({ + sourcePath: 'Renamed/Note.md', + id: 'Renamed/Note.md#0' + }) + } else { + expect(next.view).toEqual({ kind: 'folder', folder: 'inbox', subpath: '' }) + expect(next.assetFiles).toEqual([]) + expect(next.vaultTasks).toEqual([]) + } + } + ) + + it('rejects note and task reads that finish after their folder moves', async () => { + const s = await setup(true) + const note = s.useStore.getState().noteContents[s.path] + s.useStore.setState({ noteContents: {}, noteDirty: {} }) + const tasks = parseTasksFromBody('- [ ] Keep task', { + path: s.path, + title: 'Note', + folder: 'inbox' + }) + s.useStore.setState({ vaultTasks: tasks }) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + Object.assign(s.bridge, { + readNote: async () => { + await gate + return note + }, + scanTasks: async () => { + await gate + return tasks + }, + scanTasksForPath: async () => { + await gate + return tasks + } + }) + const reads = [ + s.useStore.getState().selectNote(s.path), + s.useStore.getState().refreshTasks(), + s.useStore.getState().rescanTasksForPath(s.path) + ] + await s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + release() + await Promise.all(reads) + expect(s.useStore.getState().noteContents[s.path]).toBeUndefined() + expect(s.tabs()).not.toContain(s.path) + expect(s.useStore.getState().vaultTasks[0].sourcePath).toBe('Renamed/Note.md') + }) + + it.each(['read', 'write'] as const)( + 'drains a pending comment %s before moving its note', + async (kind) => { + const s = await setup(true) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const comments = [ + { + id: 'comment', + notePath: s.path, + body: 'Keep this thread', + createdAt: 1, + updatedAt: 1, + anchorStart: 0, + anchorEnd: 0, + anchorText: '', + resolvedAt: null + } + ] + const read = vi.fn(async () => { + if (kind === 'read') await gate + return comments + }) + const write = vi.fn(async () => { + await gate + return comments + }) + Object.assign(s.bridge, { readNoteComments: read, writeNoteComments: write }) + if (kind === 'write') s.useStore.setState({ noteComments: { [s.path]: comments } }) + const comment = + kind === 'read' + ? s.useStore.getState().loadNoteComments(s.path) + : s.useStore.getState().updateNoteComment(s.path, 'comment', { body: 'Keep this thread' }) + const operation = s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed') + await Promise.resolve() + expect(s.bridge.renameFolder).not.toHaveBeenCalled() + expect(await s.useStore.getState().loadNoteComments(s.path)).toEqual([]) + release() + await Promise.all([comment, operation]) + expect(s.useStore.getState().noteComments[s.path]).toBeUndefined() + expect(s.useStore.getState().noteComments['Renamed/Note.md']).toMatchObject([ + { notePath: 'Renamed/Note.md', body: 'Keep this thread' } + ]) + expect(read).toHaveBeenCalledTimes(kind === 'read' ? 1 : 0) + } + ) + it('allows database reload and clears task loading after a rejected rename', async () => { + const s = await setup(true) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const doc = s.useStore.getState().databases[s.csv] + const open = vi.fn(async () => { + await gate + return doc + }) + Object.assign(s.bridge, { + openDatabase: open, + scanTasks: async () => { + await gate + return [] + } + }) + const reads = [s.useStore.getState().loadDatabase(s.csv), s.useStore.getState().refreshTasks()] + s.bridge.renameFolder.mockRejectedValueOnce(new Error('Name taken')) + await expect(s.useStore.getState().renameFolder('inbox', 'Work', 'Renamed')).rejects.toThrow( + 'Name taken' + ) + release() + await Promise.all(reads) + expect(s.useStore.getState().tasksLoading).toBe(false) + expect(s.useStore.getState().databasesLoading[s.csv]).toBe(false) + await s.useStore.getState().loadDatabase(s.csv) + expect(open).toHaveBeenCalledTimes(2) + }) + + it('renames a database with its open record page and edits made during the move', async () => { + const s = await setup(true) + const state = s.useStore.getState() + const page = 'Work/People.base/Record.md' + const note = { ...state.noteContents[s.path], path: page, body: 'Record body.' } + s.files.set(page, note.body) + s.useStore.setState({ + noteContents: { ...state.noteContents, [page]: note }, + databases: { + ...state.databases, + [s.csv]: { ...state.databases[s.csv], pages: { row: page } } + }, + paneLayout: makeLeaf([s.tab, page], page), + selectedPath: page + }) + s.useStore.setState({ activePaneId: allLeaves(s.useStore.getState().paneLayout)[0].id }) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const rename = vi.fn(async () => { + await gate + await s.bridge.renameFolder('inbox', 'Work/People.base', 'Work/Customers 2.base') + return 'Work/Customers 2.base/data.csv' + }) + Object.assign(s.bridge, { renameDatabase: rename }) + const operation = state.renameDatabase(s.csv, 'Customers', () => true) + await vi.waitFor(() => expect(rename).toHaveBeenCalled()) + s.useStore.getState().updateNoteBody(page, 'During rename.') + const doc = s.useStore.getState().databases[s.csv] + const rows = [{ id: 'row', cells: { name: 'Keep this edit' } }] + s.useStore.getState().updateDatabaseRows(s.csv, { ...doc, rows }) + const expectedPage = s.useStore.getState().noteContents[page].body + release() + await operation + await s.useStore.getState().flushDirtyNotes() + expect(s.files.has(page)).toBe(false) + expect(s.files.has(s.csv)).toBe(false) + expect(s.files.get('Work/Customers 2.base/Record.md')).toBe(expectedPage) + expect(s.files.get('Work/Customers 2.base/data.csv')).toBe(JSON.stringify(rows)) + expect(s.useStore.getState().databases['Work/Customers 2.base/data.csv'].pages?.row).toBe( + 'Work/Customers 2.base/Record.md' + ) + expect(s.tabs()).toEqual([ + databaseTabPath('Work/Customers 2.base/data.csv'), + 'Work/Customers 2.base/Record.md' + ]) + }) + + it('waits for database creation before switching and ignores its late UI result', async () => { + const s = await setup(true) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let current = true + const create = vi.fn(async () => { + await gate + return s.useStore.getState().databases[s.csv] + }) + Object.assign(s.bridge, { createDatabase: create }) + const operation = s.useStore + .getState() + .createDatabase('inbox', 'Work', undefined, () => current) + await vi.waitFor(() => expect(create).toHaveBeenCalled()) + current = false + let flushed = false + const flush = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true + }) + await Promise.resolve() + expect(flushed).toBe(false) + release() + await Promise.all([operation, flush]) + expect(s.useStore.getState().selectedPath).toBe(s.path) + expect(flushed).toBe(true) + }) +}) diff --git a/packages/app-core/src/host.ts b/packages/app-core/src/host.ts new file mode 100644 index 00000000..a87f08e6 --- /dev/null +++ b/packages/app-core/src/host.ts @@ -0,0 +1,16 @@ +import type { ZenCapabilities, ZenAppInfo } from '@bridge-contract/bridge' + +export type HostKind = NonNullable +export interface HostInfo { + readonly kind: HostKind + readonly name: string + readonly version: string + readonly capabilities: Readonly +} +/** Capabilities are authoritative; OS names and renderer families are not feature flags. */ +export function getHostInfo(): HostInfo { + const info = window.zen.getAppInfo() + return Object.freeze({ kind: info.hostKind ?? (info.runtime === 'desktop' ? 'desktop' : 'browser'), + name: info.productName, version: info.version, + capabilities: Object.freeze({ ...window.zen.getCapabilities() }) }) +} diff --git a/packages/app-core/src/lib/browse-actions.ts b/packages/app-core/src/lib/browse-actions.ts new file mode 100644 index 00000000..5f7b753b --- /dev/null +++ b/packages/app-core/src/lib/browse-actions.ts @@ -0,0 +1,221 @@ +import { isWorkspaceTransitionPending } from './workspace-transition' +import { + csvPathForFormDir, + formDirContaining, + formTitleFromDir, + isFormDirName +} from '@shared/databases' +import { resolveFolderPath } from '@shared/system-folder-paths' +import { useStore } from '../store' +import { getConfirmRequest, confirmApp } from './confirm-requests' +import { getPromptRequest, promptApp } from './prompt-requests' +import { parentDirOf } from './manual-order' +import { resolveCreateLocation } from './vault-layout' + +export interface BrowseActionHost { + /** Capture the native vault token before requesting a dialog, then compare it here. */ + isCurrent(): boolean +} + +/** Host errors reject the promise; stale work never starts another mutation. */ +export type BrowseActionResult = 'completed' | 'cancelled' | 'stale' | 'unavailable' + +let pending = false + +function findDirectory(directory: string) { + if (!directory || formDirContaining(parentDirOf(directory))) return undefined + return useStore + .getState() + .folders.find((row) => row.folder === 'inbox' && row.subpath === directory) +} + +function primaryDirectory(): string { + const settings = useStore.getState().vaultSettings + return settings.primaryNotesLocation === 'root' + ? '' + : resolveFolderPath('inbox', settings.systemFolderPaths) +} + +function start(host: BrowseActionHost, directory: string, allowRoot = false) { + if (pending || getPromptRequest() || getConfirmRequest()) return null + const vault = useStore.getState().vault + const bridge = window.zen + const primary = primaryDirectory() + const isCurrent = (): boolean => { + try { + return ( + !isWorkspaceTransitionPending() && + !!vault && + useStore.getState().vault === vault && + window.zen === bridge && + primaryDirectory() === primary && + host.isCurrent() + ) + } catch { + return false + } + } + const targetExists = () => (allowRoot && directory === '') || !!findDirectory(directory) + if (!isCurrent() || !targetExists()) return null + pending = true + return { isCurrent, targetExists } +} + +function validateName(value: string): string | null { + const name = value.trim() + if (!name || name === '.' || name === '..' || /[\\/\u0000-\u001f]/.test(name)) + return 'Enter a folder name without / or \\.' + if (isFormDirName(name)) return 'The .base suffix is reserved for databases.' + return null +} + +/** Prompt for a child of a primary-notes directory. An empty directory means its root. */ +export async function requestCreateBrowseFolder( + host: BrowseActionHost, + directory = '' +): Promise { + if (formDirContaining(directory)) return 'unavailable' + const context = start(host, directory, true) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + const leaf = directory.split('/').pop() + const name = ( + await promptApp({ + title: leaf ? `New folder in ${leaf}` : 'New folder', + placeholder: 'Folder name', + okLabel: 'Create', + validate: validateName + }) + )?.trim() + if (!name || validateName(name)) return 'cancelled' + if (!isCurrent() || !targetExists()) return 'stale' + await useStore + .getState() + .createFolder('inbox', directory ? `${directory}/${name}` : name, isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + +/** Prompt for an ordinary folder's leaf name. Database renaming uses its own feature. */ +export async function requestRenameBrowseFolder( + host: BrowseActionHost, + directory: string +): Promise { + if (formDirContaining(directory)) return 'unavailable' + const context = start(host, directory) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + const title = directory.split('/').pop()! + const name = ( + await promptApp({ + title: 'Rename folder', + initialValue: title, + okLabel: 'Rename', + validate: validateName + }) + )?.trim() + if (!name || name === title || validateName(name)) return 'cancelled' + if (!isCurrent() || !targetExists()) return 'stale' + const parent = parentDirOf(directory) + await useStore + .getState() + .renameFolder('inbox', directory, parent ? `${parent}/${name}` : name, isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + +/** Confirm permanent deletion of an ordinary folder or a whole database directory. */ +export async function requestDeleteBrowseDirectory( + host: BrowseActionHost, + directory: string +): Promise { + const context = start(host, directory) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + const database = isFormDirName(directory) + const title = database ? formTitleFromDir(directory) : directory.split('/').pop()! + const confirmed = await confirmApp({ + title: `Delete "${title}"?`, + description: database + ? 'All records will be permanently deleted. This cannot be undone.' + : 'Everything inside will be permanently deleted. This cannot be undone.', + confirmLabel: 'Delete', + danger: true + }) + if (!confirmed) return 'cancelled' + if (!isCurrent() || !targetExists()) return 'stale' + await useStore.getState().deleteFolder('inbox', directory, isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + +/** Omit directory for configured placement; an explicit directory is primary-relative. */ +export async function createBrowseDatabase( + host: BrowseActionHost, + directory?: string +): Promise { + const state = useStore.getState() + const target = + directory === undefined + ? resolveCreateLocation( + state.vaultSettings.databasesLocation, + state.activeNote, + state.vaultSettings + ) + : { folder: 'inbox' as const, subpath: directory } + if (directory !== undefined && formDirContaining(target.subpath)) return 'unavailable' + const context = start(host, directory ?? '', true) + if (!context) return 'unavailable' + try { + await useStore + .getState() + .createDatabase(target.folder, target.subpath, undefined, context.isCurrent) + return context.isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} + +/** Rename a database by its Browse directory, retaining the host's collision behavior. */ +export async function requestRenameBrowseDatabase( + host: BrowseActionHost, + directory: string +): Promise { + if (!isFormDirName(directory)) return 'unavailable' + const context = start(host, directory) + if (!context) return 'unavailable' + const { isCurrent, targetExists } = context + try { + const title = formTitleFromDir(directory) + const validate = (value: string): string | null => + value.trim().startsWith('.') ? 'Database names cannot start with a dot.' : + !value.trim() || /[\\/\u0000-\u001f]/.test(value) + ? 'Enter a database name without / or \\.' + : null + const name = ( + await promptApp({ + title: 'Rename database', + initialValue: title, + okLabel: 'Rename', + validate + }) + )?.trim() + if (!name || name === title || validate(name)) return 'cancelled' + if (!isCurrent() || !targetExists()) return 'stale' + const primary = primaryDirectory() + const csv = csvPathForFormDir(primary ? `${primary}/${directory}` : directory) + await useStore.getState().renameDatabase(csv, name, isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally { + pending = false + } +} diff --git a/packages/app-core/src/lib/cm-vim-clipboard.ts b/packages/app-core/src/lib/cm-vim-clipboard.ts index 8eb23e26..ebb41527 100644 --- a/packages/app-core/src/lib/cm-vim-clipboard.ts +++ b/packages/app-core/src/lib/cm-vim-clipboard.ts @@ -14,6 +14,7 @@ * on yank so the active view can flash the yanked range. */ import { ViewPlugin, type EditorView } from '@codemirror/view' +import type { Extension } from '@codemirror/state' import { Vim, getCM } from '@replit/codemirror-vim' interface PatchableRegisterController { @@ -101,7 +102,7 @@ export function setPasteFromClipboardEnabled(on: boolean): void { * hand the key back to Vim so all of its paste behaviour (linewise handling, * counts, visual-mode replace) runs unchanged. */ -export const vimClipboardPasteExtension = ViewPlugin.fromClass( +export const vimClipboardPasteExtension: Extension = ViewPlugin.fromClass( class { private readonly view: EditorView private readonly onKeyDown: (e: KeyboardEvent) => void diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index a9eeb6cd..a1c8d348 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -9,6 +9,7 @@ import { isTagsViewActive, isTasksViewActive, isTrashViewActive, useStore } from '../store' import { confirmApp } from './confirm-requests' import { promptApp } from './prompt-requests' +import { captureNavigationContext } from './navigation-context' import { buildMoveNotePrompt, parseMoveNoteTarget } from './move-note' import { focusPaneInDirection } from './pane-nav' import { focusSidebarPanel } from './sidebar-focus' @@ -314,6 +315,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma category: 'Note', when: () => !!getState().activeNote, run: async () => { + const isCurrent = captureNavigationContext() const active = getState().activeNote if (!active) return const next = await promptApp({ @@ -321,7 +323,8 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma initialValue: active.title, okLabel: 'Rename' }) - if (next && next !== active.title) await getState().renameActive(next) + if (next && next !== active.title && isCurrent() && getState().selectedPath === active.path) + await getState().renameActive(next) } }, { @@ -524,13 +527,14 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma keywords: 'move mv relocate folder archive inbox', when: () => !!getState().activeNote, run: async () => { + const isCurrent = captureNavigationContext() const state = getState() const active = state.activeNote if (!active) return const target = await promptApp(buildMoveNotePrompt(active, state.folders)) - if (!target) return + if (!target || !isCurrent()) return const dest = parseMoveNoteTarget(target) - await state.moveNote(active.path, dest.folder, dest.subpath) + await state.moveNote(active.path, dest.folder, dest.subpath, isCurrent) } } ) diff --git a/packages/app-core/src/lib/confirm-trash.ts b/packages/app-core/src/lib/confirm-trash.ts index d1e1bb22..9718058e 100644 --- a/packages/app-core/src/lib/confirm-trash.ts +++ b/packages/app-core/src/lib/confirm-trash.ts @@ -1,11 +1,13 @@ import { confirmApp } from './confirm-requests' -export function confirmMoveToTrash(title?: string | null): Promise { +export function confirmMoveToTrash(title?: string | null, systemTrash = false): Promise { const trimmed = title?.trim() const target = trimmed ? `"${trimmed}"` : 'this note' return confirmApp({ title: `Move ${target} to Trash?`, - description: 'You can restore it later from the Trash view.', + description: systemTrash + ? 'The file will move to your system Trash. Restore it using your file manager.' + : 'You can restore it later from the Trash view.', confirmLabel: 'Move to Trash' }) } diff --git a/packages/app-core/src/lib/editor-commands.ts b/packages/app-core/src/lib/editor-commands.ts new file mode 100644 index 00000000..77e635c9 --- /dev/null +++ b/packages/app-core/src/lib/editor-commands.ts @@ -0,0 +1,104 @@ +import { indentLess, indentMore, redo, undo } from '@codemirror/commands' +import { closeSearchPanel, openSearchPanel } from '@codemirror/search' +import type { EditorView } from '@codemirror/view' +import type { EditorCommand } from '../editor' +import { setBlockType, toggleWrap, wrapLink, type BlockType } from './cm-format' + +const BLANK_LINE_MARKERS: Partial> = { + bullet: '- ', + todo: '- [ ] ', + h1: '# ', + h2: '## ', + h3: '### ' +} + +// A mobile toolbar is also how users start an empty list. The selection toolbar's +// conversion helper intentionally skips blank lines, so retain this shell behavior. +function applyBlockType(view: EditorView, type: BlockType): boolean { + const { from, to } = view.state.selection.main + const line = view.state.doc.lineAt(from) + const marker = BLANK_LINE_MARKERS[type] + if (marker !== undefined && from === to && line.text.trim() === '') { + const insert = line.text + marker + view.dispatch({ + changes: { from: line.from, to: line.to, insert }, + selection: { anchor: line.from + insert.length } + }) + return true + } + return setBlockType(view, type) +} + +function cycleHeading(view: EditorView): boolean { + const line = view.state.doc.lineAt(view.state.selection.main.from) + const level = line.text.match(/^(#{1,6})\s/)?.[1].length ?? 0 + const next = level >= 3 ? 'paragraph' : (['h1', 'h2', 'h3'] as const)[level]! + return applyBlockType(view, next) +} + +function insertSnippet(view: EditorView, text: string, caretOffset: number): boolean { + const { from, to } = view.state.selection.main + view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + caretOffset } }) + return true +} + +export function runNoteEditorCommand(view: EditorView, command: EditorCommand): boolean { + // Search owns its focus. Refocusing the editor here would dismiss the native + // keyboard's query target immediately after the host opens Find. + if (command === 'open-search') return openSearchPanel(view) + if (command === 'close-search') return closeSearchPanel(view) + if (view.state.readOnly) return false + + let handled: boolean + switch (command) { + case 'undo': + handled = undo(view) + break + case 'redo': + handled = redo(view) + break + case 'indent': + handled = indentMore(view) + break + case 'outdent': + handled = indentLess(view) + break + case 'toggle-bold': + handled = toggleWrap(view, '**') + break + case 'toggle-italic': + handled = toggleWrap(view, '*') + break + case 'toggle-strikethrough': + handled = toggleWrap(view, '~~') + break + case 'toggle-highlight': + handled = toggleWrap(view, '==') + break + case 'toggle-inline-code': + handled = toggleWrap(view, '`') + break + case 'insert-link': + handled = wrapLink(view) + break + case 'insert-wikilink': + handled = insertSnippet(view, '[[]]', 2) + break + case 'insert-tag': + handled = insertSnippet(view, '#', 1) + break + case 'set-bullet-list': + handled = applyBlockType(view, 'bullet') + break + case 'set-task-list': + handled = applyBlockType(view, 'todo') + break + case 'cycle-heading': + handled = cycleHeading(view) + break + default: + return false + } + view.focus() + return handled +} diff --git a/packages/app-core/src/lib/editor-host.ts b/packages/app-core/src/lib/editor-host.ts new file mode 100644 index 00000000..30124593 --- /dev/null +++ b/packages/app-core/src/lib/editor-host.ts @@ -0,0 +1,168 @@ +import { Compartment, type Extension } from '@codemirror/state' +import { EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view' +import type { EditorBounds, EditorHostOptions, EditorHostRegistration } from '../editor' + +const hostCompartment = new Compartment() +const mounted = new Set() +let active: { options: EditorHostOptions } | null = null +const insetProperty = '--zen-editor-host-bottom-inset' +const nativeTyping = { + autocorrect: 'on', + autocapitalize: 'sentences', + spellcheck: 'true', + writingsuggestions: 'true' +} +const insetTheme = EditorView.theme({ + '&': { boxSizing: 'border-box', paddingBottom: `var(${insetProperty}, 0px)` } +}) + +function bounds(rect: DOMRect): EditorBounds { + const { top, bottom, left, right, width, height } = rect + return Object.freeze({ top, bottom, left, right, width, height }) +} + +function inset(value: number | undefined, height: number): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.min(value, height)) + : 0 +} + +class HostView { + scrollBottom = 0 + private destroyed = false + private reveal: (() => boolean) | null = null + + constructor(readonly view: EditorView) { + mounted.add(this) + if (active?.options.measureBottomInsets) this.refresh() + } + + update(update: ViewUpdate): void { + if (active?.options.measureBottomInsets && (update.geometryChanged || update.viewportChanged)) + this.refresh() + } + + refresh(): void { + if (this.destroyed) return + this.view.requestMeasure({ + key: this, + read: () => { + const owner = active + if (this.destroyed) return { owner, layout: 0, scroll: 0 } + const editor = bounds(this.view.dom.getBoundingClientRect()) + const scroll = bounds(this.view.scrollDOM.getBoundingClientRect()) + try { + const measured = owner?.options.measureBottomInsets?.(Object.freeze({ editor, scroll })) + return { + owner, + layout: inset(measured?.layout, editor.height), + scroll: inset(measured?.scroll, scroll.height) + } + } catch { + // Host overlays can disappear during disposal. Do not retain stale clearance. + return { owner, layout: 0, scroll: 0 } + } + }, + write: (measured) => { + if (this.destroyed || active !== measured.owner) return + this.scrollBottom = measured.scroll + const value = active?.options.measureBottomInsets ? `${measured.layout}px` : '' + if (this.view.dom.style.getPropertyValue(insetProperty) !== value) { + if (value) this.view.dom.style.setProperty(insetProperty, value) + else this.view.dom.style.removeProperty(insetProperty) + // Layout clearance changes the scroll viewport. Measure again before + // applying its additional margin or revealing the caret. + this.refresh() + return + } + const reveal = this.reveal + if (!reveal) return + // CodeMirror forbids dispatch during the measurement write phase. + queueMicrotask(() => { + if (this.destroyed || active !== measured.owner || this.reveal !== reveal) return + this.reveal = null + if (reveal()) + this.view.dispatch({ + effects: EditorView.scrollIntoView(this.view.state.selection.main.head, { + y: 'nearest' + }) + }) + }) + } + }) + } + + requestReveal(isCurrent: () => boolean): void { + this.reveal = isCurrent + this.refresh() + } + + reset(): void { + this.reveal = null + this.scrollBottom = 0 + this.view.dom.style.removeProperty(insetProperty) + } + + destroy(): void { + this.destroyed = true + this.reset() + mounted.delete(this) + } +} + +const hostPlugin = ViewPlugin.fromClass(HostView) + +function configuration(): Extension { + return [ + active?.options.nativeTyping ? EditorView.contentAttributes.of(nativeTyping) : [], + active?.options.measureBottomInsets + ? [ + insetTheme, + EditorView.scrollMargins.of((view) => ({ + bottom: view.plugin(hostPlugin)?.scrollBottom ?? 0 + })) + ] + : [] + ] +} + +function reconfigure(): void { + for (const host of mounted) { + host.reset() + host.view.dispatch({ effects: hostCompartment.reconfigure(configuration()) }) + if (active?.options.measureBottomInsets) host.refresh() + } +} + +/** Part of the editor's initial state, before publication or a first focus. */ +export function noteEditorHostExtension(): Extension { + return [hostCompartment.of(configuration()), hostPlugin] +} + +export function installNoteEditorHost(options: EditorHostOptions): EditorHostRegistration { + const owner = { + options: { + nativeTyping: options.nativeTyping, + measureBottomInsets: options.measureBottomInsets + } + } + active = owner + reconfigure() + return { + refresh: () => { + if (active === owner) for (const host of mounted) host.refresh() + }, + dispose: () => { + if (active !== owner) return + active = null + reconfigure() + } + } +} + +export function requestNoteEditorReveal(view: EditorView, isCurrent: () => boolean): boolean { + const host = view.plugin(hostPlugin) + if (!host) return false + host.requestReveal(isCurrent) + return true +} diff --git a/packages/app-core/src/lib/navigation-context.ts b/packages/app-core/src/lib/navigation-context.ts new file mode 100644 index 00000000..08f0afc8 --- /dev/null +++ b/packages/app-core/src/lib/navigation-context.ts @@ -0,0 +1,17 @@ +import { useStore } from '../store' +import { isWorkspaceTransitionPending, workspaceGeneration } from './workspace-transition' + +/** A relative path is meaningful only in the workspace where navigation began. */ +export function captureNavigationContext(): () => boolean { + const { vault, workspaceMode, remoteWorkspaceInfo } = useStore.getState() + const bridge = window.zen + const generation = workspaceGeneration() + const startedDuringTransition = isWorkspaceTransitionPending() + return () => { + const state = useStore.getState() + return !startedDuringTransition && !isWorkspaceTransitionPending() + && generation === workspaceGeneration() && state.vault === vault && window.zen === bridge + && state.workspaceMode === workspaceMode && state.remoteWorkspaceInfo?.baseUrl === remoteWorkspaceInfo?.baseUrl + && state.remoteWorkspaceInfo?.profileId === remoteWorkspaceInfo?.profileId + } +} diff --git a/packages/app-core/src/lib/note-editor-context.ts b/packages/app-core/src/lib/note-editor-context.ts new file mode 100644 index 00000000..3803a243 --- /dev/null +++ b/packages/app-core/src/lib/note-editor-context.ts @@ -0,0 +1,14 @@ +import type { EditorView } from '@codemirror/view' + +// A selected path can change before React updates the editor. Keep the view's +// actual path available to integrations without publishing the view itself. +const noteEditors = new WeakMap string | null; paneId: string }>() + +export function registerNoteEditor(view: EditorView, path: () => string | null, paneId: string): void { + noteEditors.set(view, { path, paneId }) +} + +export function noteEditorMatches(view: EditorView, path: string, paneId: string): boolean { + const registered = noteEditors.get(view) + return registered?.paneId === paneId && registered.path() === path +} diff --git a/packages/app-core/src/lib/note-lifecycle-actions.ts b/packages/app-core/src/lib/note-lifecycle-actions.ts new file mode 100644 index 00000000..5ed651a9 --- /dev/null +++ b/packages/app-core/src/lib/note-lifecycle-actions.ts @@ -0,0 +1,54 @@ +import { + requestArchiveNote, + requestNoteBatch, + requestEmptyTrash, + type NoteBatchAction, + requestDeleteNotePermanently, + requestTrashNote, + restoreNote, + type NoteActionResult, +} from "../notes"; +import { humanIpcError } from "./ipc-error"; +import { useToastStore } from "./toast"; + +/** Core UI uses the same guarded actions as an installed native host. */ +export async function runNoteLifecycleAction( + path: string, + action: "archive" | "trash" | "restore" | "delete", +): Promise { + const actions = { + archive: requestArchiveNote, + trash: requestTrashNote, + restore: restoreNote, + delete: requestDeleteNotePermanently, + }; + try { + // The public action captures the core vault, bridge, and folder layout. + return await actions[action]({ isCurrent: () => true }, path); + } catch (error) { + useToastStore + .getState() + .addToast( + humanIpcError(error, "Could not complete the note action."), + "error", + ); + return "failed"; + } +} + + +export async function runNoteBatchAction(paths: readonly string[], action: NoteBatchAction): Promise { + try { + const result = await requestNoteBatch({isCurrent:()=>true}, paths, action) + if (result.status === 'stale') useToastStore.getState().addToast('The workspace changed. Check completed note actions before retrying.', 'info') + return result.status === 'completed' + } catch (error) { + useToastStore.getState().addToast(humanIpcError(error,'Some notes could not be changed.'),'error') + return false + } +} + +export async function runEmptyTrash(): Promise { + try {await requestEmptyTrash({isCurrent:()=>true})} + catch(error){useToastStore.getState().addToast(humanIpcError(error,'Could not empty Trash.'),'error')} +} diff --git a/packages/app-core/src/lib/note-lifecycle-lock.ts b/packages/app-core/src/lib/note-lifecycle-lock.ts new file mode 100644 index 00000000..76aa9146 --- /dev/null +++ b/packages/app-core/src/lib/note-lifecycle-lock.ts @@ -0,0 +1,105 @@ +import { + Annotation, + Compartment, + EditorState, + Prec, + type Extension, +} from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; + +export const noteEditingSync = Annotation.define(); + +type Target = { vault: object | null; path: string | null }; +const locks = new WeakMap>(); +const listeners = new Set<() => void>(); +export function subscribeNoteEditingLocks(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} +function notifyLocks(): void { for (const listener of listeners) listener(); } +const contains = (scope: string | null, path: string | null) => + scope === null || (path !== null && (scope === path || (scope.endsWith("/") && path.startsWith(scope)))); +const editors = new Map< + EditorView, + { target: () => Target; refresh: () => void } +>(); + +export function isNoteEditingLocked( + vault: object | null, + path: string | null, +): boolean { + return !!(vault && path && [...(locks.get(vault) ?? [])].some(scope => contains(scope, path))); +} + +/** Hold only for operations that leave no editable destination in the vault. */ +export function lockNoteEditing(vault: object, path: string): () => void { + return acquireEditingLock(vault, path); +} + +/** Freeze every current and subsequently mounted editor in this vault. */ +export function lockVaultEditing(vault: object): () => void { + return acquireEditingLock(vault, null); +} + +function acquireEditingLock(vault: object, path: string | null): () => void { + for (const [view, editor] of editors) { + const target = editor.target(); + if (target.vault === vault && target.path && contains(path, target.path) && view.composing) + throw new Error("Finish entering text before changing this note or vault."); + } + const paths = locks.get(vault) ?? new Set(); + if ([...paths].some(scope => contains(scope, path) || contains(path, scope))) throw new Error("This note is already being deleted."); + paths.add(path); + locks.set(vault, paths); + notifyLocks(); + for (const editor of editors.values()) editor.refresh(); + return () => { + paths.delete(path); + notifyLocks(); + for (const editor of editors.values()) editor.refresh(); + }; +} + +export function refreshNoteEditingLock(view: EditorView): void { + editors.get(view)?.refresh(); +} + +export function noteEditingLockExtension(target: () => Target): Extension { + const compartment = new Compartment(); + const locked = () => { + const { vault, path } = target(); + return isNoteEditingLocked(vault, path); + }; + const configuration = () => + locked() + ? Prec.highest([ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + ]) + : []; + return [ + compartment.of(configuration()), + // Consult the live lock even before a view can apply its reconfiguration. + EditorState.changeFilter.of( + (transaction) => + transaction.annotation(noteEditingSync) === true || !locked(), + ), + ViewPlugin.define((view) => { + let previous = locked(); + editors.set(view, { + target, + refresh: () => { + const next = locked(); + if (next === previous) return; + previous = next; + view.dispatch({ effects: compartment.reconfigure(configuration()) }); + }, + }); + return { + destroy: () => { + editors.delete(view); + }, + }; + }), + ]; +} diff --git a/packages/app-core/src/lib/note-order.ts b/packages/app-core/src/lib/note-order.ts new file mode 100644 index 00000000..c2fa8eee --- /dev/null +++ b/packages/app-core/src/lib/note-order.ts @@ -0,0 +1,37 @@ +import { naturalCompare } from './natural-sort' + +export type NoteSortOrder = + | 'none' + | 'manual' + | 'updated-desc' + | 'updated-asc' + | 'created-desc' + | 'created-asc' + | 'name-asc' + | 'name-desc' + +interface SortableNote { + readonly title: string + readonly updatedAt: number + readonly createdAt: number +} + +/** Mobile Browse has no manual drag order; none/manual retain its recent-first fallback. */ +export function browseNoteComparator( + order: NoteSortOrder +): (a: SortableNote, b: SortableNote) => number { + switch (order) { + case 'name-asc': + return (a, b) => naturalCompare(a.title, b.title) + case 'name-desc': + return (a, b) => naturalCompare(b.title, a.title) + case 'updated-asc': + return (a, b) => a.updatedAt - b.updatedAt + case 'created-desc': + return (a, b) => b.createdAt - a.createdAt + case 'created-asc': + return (a, b) => a.createdAt - b.createdAt + default: + return (a, b) => b.updatedAt - a.updatedAt + } +} diff --git a/packages/app-core/src/lib/task-column-mutations.ts b/packages/app-core/src/lib/task-column-mutations.ts new file mode 100644 index 00000000..d6cfde82 --- /dev/null +++ b/packages/app-core/src/lib/task-column-mutations.ts @@ -0,0 +1,76 @@ +import type { KanbanGroupBy, TaskMutation } from '../store' +import { toIsoDateLocal, type VaultTask } from '@shared/tasks' + +/** Map a (groupBy, columnId) drop target to the task-line mutations + * that should land. Returns `null` when the drop has no defined + * semantics (e.g. when group-by is 'folder'). Returns `[]` when the + * task is already in the target column; caller can short-circuit. */ +export function dropMutationsFor( + groupBy: KanbanGroupBy, + columnId: string, + task: VaultTask, + today: Date +): TaskMutation[] | null { + if (groupBy === 'status') { + const todayIso = toIsoDateLocal(today) + switch (columnId) { + case 'today': + // "Live" columns; make sure neither @waiting, [x] nor [/] keep the + // task glued to a different bucket. + return [ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: false }, + { kind: 'set-in-progress', inProgress: false }, + { kind: 'set-due', due: todayIso } + ] + case 'upcoming': { + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + return [ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: false }, + { kind: 'set-in-progress', inProgress: false }, + { + kind: 'set-due', + due: task.due && task.due > todayIso ? task.due : toIsoDateLocal(tomorrow) + } + ] + } + case 'in-progress': + // Started work: `[/]`. The due date is left alone, so a card dragged + // back to Today or Upcoming keeps the date it had. + return [ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: false }, + { kind: 'set-in-progress', inProgress: true } + ] + case 'waiting': + // `[/]` survives underneath on purpose: clearing the wait returns the + // card to In progress, where it came from. + return [ + { kind: 'set-checked', checked: false }, + { kind: 'set-waiting', waiting: true } + ] + case 'done': + return [{ kind: 'set-checked', checked: true }] + default: + return null + } + } + if (groupBy === 'priority') { + if (columnId === 'high') return [{ kind: 'set-priority', priority: 'high' }] + if (columnId === 'med') return [{ kind: 'set-priority', priority: 'med' }] + if (columnId === 'low') return [{ kind: 'set-priority', priority: 'low' }] + if (columnId === 'none') return [{ kind: 'set-priority', priority: null }] + return null + } + if (groupBy.startsWith('field:')) { + // Drop sets the `@:` token; the No- column clears it. + const key = groupBy.slice('field:'.length) + return [{ kind: 'set-field', key, value: columnId === '__none__' ? null : columnId }] + } + // Folder grouping is read-only; moving the task across folders + // means moving the source note, which the user does explicitly via + // the sidebar. + return null +} diff --git a/packages/app-core/src/lib/wikilink-navigation.test.ts b/packages/app-core/src/lib/wikilink-navigation.test.ts index c6eaa875..f7e9b60c 100644 --- a/packages/app-core/src/lib/wikilink-navigation.test.ts +++ b/packages/app-core/src/lib/wikilink-navigation.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { isSameFileHeadingLink, wikilinkHeadingAnchor } from './wikilinks' diff --git a/packages/app-core/src/lib/wikilink-navigation.ts b/packages/app-core/src/lib/wikilink-navigation.ts index 23b58583..b0b54acb 100644 --- a/packages/app-core/src/lib/wikilink-navigation.ts +++ b/packages/app-core/src/lib/wikilink-navigation.ts @@ -1,3 +1,4 @@ +import { captureNavigationContext } from './navigation-context' import { useStore } from '../store' import { findBlockAnchor } from './block-anchors' import { parseOutline } from './outline' @@ -27,8 +28,11 @@ export function openDatabaseFromWikilink(target: string): boolean { * when the heading isn't found. Shared by the editor's wikilink click and the * preview pane so `[[Doc#Heading]]` lands on the heading. (#196) */ -export async function openWikilinkHeading(path: string, headingAnchor: string): Promise { +export async function openWikilinkHeading(path: string, headingAnchor: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false const body = await noteBody(path) + if (!isCurrent()) return false const needle = headingAnchor.trim().toLowerCase() const heading = parseOutline(body).find((h) => h.text.trim().toLowerCase() === needle) if (heading) { @@ -36,6 +40,7 @@ export async function openWikilinkHeading(path: string, headingAnchor: string): } else { await useStore.getState().selectNote(path) } + return isCurrent() && useStore.getState().selectedPath === path } /** @@ -43,13 +48,17 @@ export async function openWikilinkHeading(path: string, headingAnchor: string): * twin of {@link openWikilinkHeading}, with the same fallback: an id the note * no longer carries opens the note at the top rather than going nowhere. (#601) */ -export async function openWikilinkBlock(path: string, blockAnchor: string): Promise { +export async function openWikilinkBlock(path: string, blockAnchor: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false const block = findBlockAnchor(await noteBody(path), blockAnchor) + if (!isCurrent()) return false if (block) { await useStore.getState().openNoteAtOffset(path, block.from, { scrollMode: 'start' }) } else { await useStore.getState().selectNote(path) } + return isCurrent() && useStore.getState().selectedPath === path } /** @@ -60,7 +69,9 @@ export async function openWikilinkBlock(path: string, blockAnchor: string): Prom * quietly opened the note and stopped there for as long as they did: adding an * anchor kind meant remembering six call sites. (#601) */ -export async function openWikilinkTarget(path: string, target: string): Promise { +export async function openWikilinkTarget(path: string, target: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false const heading = wikilinkHeadingAnchor(target) if (heading) return openWikilinkHeading(path, heading) @@ -68,6 +79,7 @@ export async function openWikilinkTarget(path: string, target: string): Promise< if (block) return openWikilinkBlock(path, block) await useStore.getState().selectNote(path) + return isCurrent() && useStore.getState().selectedPath === path } /** The note's body from the store, falling back to a read, then to empty. */ diff --git a/packages/app-core/src/lib/wikilinks.test.ts b/packages/app-core/src/lib/wikilinks.test.ts index dc28c1f8..acc6a666 100644 --- a/packages/app-core/src/lib/wikilinks.test.ts +++ b/packages/app-core/src/lib/wikilinks.test.ts @@ -244,3 +244,20 @@ describe('extractMarkdownLinkHrefs (#70dark)', () => { expect(extractMarkdownLinkHrefs(body)).toEqual(['Note.md', 'https://example.com']) }) }) + +describe('resolveWikilinkTarget trims slash runs without regex backtracking', () => { + it('resolves an explicit path wrapped in slashes', () => { + expect(resolveWikilinkTarget(notes, '/projects/Spec/')?.path).toBe('inbox/projects/Spec.md') + expect(resolveWikilinkTarget(notes, '///projects/Spec///')?.path).toBe('inbox/projects/Spec.md') + }) + + it('resolves a path suffix with trailing slashes', () => { + expect(resolveWikilinkTarget(notes, 'projects/Spec/')?.path).toBe('inbox/projects/Spec.md') + expect(resolveWikilinkTarget(notes, 'projects/Spec///')?.path).toBe('inbox/projects/Spec.md') + }) + + it('treats a target made only of slashes as unresolved', () => { + expect(resolveWikilinkTarget(notes, '///')).toBeNull() + expect(resolveWikilinkTarget(notes, '/'.repeat(20000))).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/wikilinks.ts b/packages/app-core/src/lib/wikilinks.ts index b66f38d8..f96f0647 100644 --- a/packages/app-core/src/lib/wikilinks.ts +++ b/packages/app-core/src/lib/wikilinks.ts @@ -56,6 +56,17 @@ function normalizeForCompare(value: string): string { return value.trim().toLowerCase() } +// Trim leading and trailing slashes with a linear scan. The equivalent +// `/\/+$/` regex backtracks quadratically on a target made of many slashes, +// and wikilink targets come straight from note text. +function trimSlashes(value: string): string { + let start = 0 + let end = value.length + while (start < end && value.charCodeAt(start) === 47) start++ + while (end > start && value.charCodeAt(end - 1) === 47) end-- + return value.slice(start, end) +} + export function isPathLikeWikilinkTarget(target: string): boolean { const trimmed = target.trim() return trimmed.startsWith('/') || trimmed.includes('/') || /\.md$/i.test(trimmed) @@ -147,7 +158,7 @@ function resolveExplicitPath(notes: NoteRef[], target: string): NoteRef | null { const normalized = normalizeSlashes(target.trim()) if (!normalized) return null - const trimmed = stripMdExtension(normalized).replace(/^\/+/, '').replace(/\/+$/, '') + const trimmed = trimSlashes(stripMdExtension(normalized)) if (!trimmed) return null let relPath: string | null = null @@ -163,9 +174,7 @@ function resolveExplicitPath(notes: NoteRef[], target: string): NoteRef | null { } function resolvePathSuffix(notes: NoteRef[], target: string): NoteRef | null { - const trimmed = stripMdExtension(normalizeSlashes(target.trim())) - .replace(/^\/+/, '') - .replace(/\/+$/, '') + const trimmed = trimSlashes(stripMdExtension(normalizeSlashes(target.trim()))) if (!trimmed) return null const suffix = normalizeForCompare(`/${trimmed}.md`) diff --git a/packages/app-core/src/lib/workspace-relocation.ts b/packages/app-core/src/lib/workspace-relocation.ts new file mode 100644 index 00000000..9a1d3c9e --- /dev/null +++ b/packages/app-core/src/lib/workspace-relocation.ts @@ -0,0 +1,8 @@ +/** Host filesystem work runs only after pending writes drain and editing locks. + * Each callback must finish its own partial rollback before rejecting. */ +export interface LocalVaultRelocation { + move: () => Promise + rollback: () => Promise + /** Omit when relocating a vault that is not open. Tokens belong to the host. */ + reopen?: { source: string; destination: string } +} diff --git a/packages/app-core/src/lib/workspace-transition.ts b/packages/app-core/src/lib/workspace-transition.ts new file mode 100644 index 00000000..04f873d1 --- /dev/null +++ b/packages/app-core/src/lib/workspace-transition.ts @@ -0,0 +1,43 @@ +import { useToastStore } from './toast' +import { useStore } from '../store' +import { lockVaultEditing } from './note-lifecycle-lock' + +let pending = false +let generation = 0 +let writesBlocked = false + +export function isWorkspaceTransitionPending(): boolean { return pending } +export function workspaceGeneration(): number { return generation } +export function workspaceWritesBlocked(): boolean { return writesBlocked } + +/** Reserve before the first await, including connection prompts and save drains. */ +export async function runWorkspaceTransition(work: () => Promise, silentIfBusy = false, propagateError = false): Promise { + if (pending) { + if (propagateError) throw new Error('Wait for the current vault change to finish.') + if (!silentIfBusy) useToastStore.getState().addToast('Wait for the current vault change to finish.', 'info') + return + } + pending = true + generation += 1 + // These belong to navigation invalidated by this generation, even if a picker cancels. + useStore.setState({ workspaceTransitioning: true, loadingNote: false, pendingJumpLocation: null, databasesLoading: {} }) + let unlock: (() => void) | undefined + try { + // Let operations already dispatched finish in their original vault. Then + // lock input and drain once more before any host changes its active root. + await useStore.getState().flushDirtyNotes() + const vault = useStore.getState().vault + if (vault) unlock = lockVaultEditing(vault) + writesBlocked = true + await useStore.getState().flushDirtyNotes() + await work() + } catch (error) { + if (propagateError) throw error + useToastStore.getState().addToast(error instanceof Error ? error.message : String(error), 'error') + } finally { + writesBlocked = false + unlock?.() + pending = false + useStore.setState({ workspaceTransitioning: false }) + } +} diff --git a/packages/app-core/src/navigation.test.ts b/packages/app-core/src/navigation.test.ts new file mode 100644 index 00000000..bcd6e64f --- /dev/null +++ b/packages/app-core/src/navigation.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { findLeaf } from './lib/pane-layout' + +const original = new Map([ + ['one.md', '# First note\n\nOriginal body.\n'], + ['two.md', '# Second note\n\nDifferent body.\n'] +]) +let vault: Map +const disposers: Array<() => void> = [] + +function meta(path: string, body: string) { + return { + path, + title: path, + folder: 'inbox' as const, + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: body.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: body.slice(0, 40) + } +} + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + vault = new Map(original) + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getCapabilities: () => ({ supportsRemoteWorkspace: false }), + listNotes: async () => [...vault].map(([path, body]) => meta(path, body)), + listFolders: async () => [], + listAssets: async () => [], + listLocalVaults: async () => [], + hasAssetsDir: async () => false, + getRemoteWorkspaceInfo: async () => null, + scanTasks: async () => [], + scanTasksForPath: async () => [], + readNote: async (path: string) => { + const body = vault.get(path) + if (body === undefined) throw new Error(`Missing note: ${path}`) + return { ...meta(path, body), body } + }, + writeNote: async (path: string, body: string) => { + vault.set(path, body) + return meta(path, body) + } + } + }) +}) + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() +}) + +async function setup() { + const { useStore } = await import('./store') + const navigation = await import('./navigation') + useStore.setState({ + notes: [...vault].map(([path, body]) => meta(path, body)) + }) + return { useStore, ...navigation } +} + +describe('public shell navigation', () => { + it('shows Home without closing tabs or modifying note bytes', async () => { + const { useStore, openNote, goHome } = await setup() + await openNote('one.md') + await openNote('two.md') + goHome() + const state = useStore.getState() + expect(findLeaf(state.paneLayout, state.activePaneId)).toMatchObject({ + tabs: ['one.md', 'two.md'], + activeTab: null + }) + expect(state.selectedPath).toBeNull() + expect(state.activeNote).toBeNull() + expect(vault).toEqual(original) + }) + + it('saves only the edited note when returning Home', async () => { + const { useStore, openNote, goHome } = await setup() + await openNote('one.md') + useStore + .getState() + .updateNoteBody('one.md', '# First note\n\nEdited body.\n') + goHome() + await vi.waitFor(() => + expect(useStore.getState().noteDirty['one.md']).toBe(false) + ) + expect(vault.get('one.md')).toBe('# First note\n\nEdited body.\n') + expect(vault.get('two.md')).toBe(original.get('two.md')) + expect(useStore.getState().selectedPath).toBeNull() + }) + + it('keeps Home during a rescan and still allows deliberate navigation', async () => { + const { useStore, openNote, goHome, installHomeGuard } = await setup() + disposers.push(installHomeGuard()) + await openNote('one.md') + await openNote('two.md') + goHome() + await useStore.getState().refreshNotes() + expect(useStore.getState().selectedPath).toBeNull() + await openNote('two.md') + expect(useStore.getState().selectedPath).toBe('two.md') + expect(useStore.getState().activeNote?.body).toBe(original.get('two.md')) + }) + + it('removes the Home guard when its shell unmounts', async () => { + const { useStore, openNote, goHome, installHomeGuard } = await setup() + const dispose = installHomeGuard() + disposers.push(dispose) + await openNote('one.md') + goHome() + dispose() + await useStore.getState().refreshNotes() + expect(useStore.getState().selectedPath).toBe('one.md') + }) + + it('uses the existing note history for back and forward', async () => { + const { useStore, openNote, goBack, goForward } = await setup() + const { getShellSnapshot } = await import('./shell') + expect(getShellSnapshot()).toMatchObject({ + canGoBack: false, + canGoForward: false + }) + await openNote('one.md') + await openNote('two.md') + expect(getShellSnapshot()).toMatchObject({ + canGoBack: true, + canGoForward: false, + selectedNote: { path: 'two.md' } + }) + await goBack() + expect(useStore.getState().selectedPath).toBe('one.md') + expect(getShellSnapshot()).toMatchObject({ + canGoForward: true, + selectedNote: { path: 'one.md' } + }) + await goForward() + expect(useStore.getState().selectedPath).toBe('two.md') + expect(getShellSnapshot().canGoForward).toBe(false) + expect(vault).toEqual(original) + }) +}) + +describe('navigation across workspace changes', () => { + function gate() { + let resolve!: (value: T) => void + return { promise: new Promise(done => { resolve = done }), resolve: (value: T) => resolve(value) } + } + it('does not read the old relative target in a new vault after a pending save', async () => { + const s = await setup() + s.useStore.setState({ vault: { root: '/one', name: 'One' } }) + await s.openNote('one.md') + const save = gate() + s.useStore.setState({ noteDirty: { 'one.md': true }, persistNote: () => save.promise }) + const read = vi.spyOn(window.zen, 'readNote') + const pending = s.openNote('two.md') + s.useStore.setState({ vault: { root: '/two', name: 'Two' } }) + save.resolve() + await pending + expect(read).not.toHaveBeenCalled() + expect(s.useStore.getState().selectedPath).toBe('one.md') + }) + it.each(['openNoteInPane', 'focusTabInPane'] as const)('does not install an old read through %s', async method => { + const s = await setup(), read = gate & { body: string }>() + s.useStore.setState({ vault: { root: '/one', name: 'One' } }) + vi.spyOn(window.zen, 'readNote').mockReturnValue(read.promise) + const pending = s.useStore.getState()[method](s.useStore.getState().activePaneId, 'one.md') + s.useStore.setState({ vault: { root: '/two', name: 'Two' } }) + read.resolve({ ...meta('one.md', 'old'), body: 'old' }) + await pending + expect(s.useStore.getState().noteContents).toEqual({}) + expect(s.useStore.getState().selectedPath).toBeNull() + }) + it('rejects a task read that finishes after the vault changes', async () => { + const s = await setup(), read = gate & { body: string }>() + const tasks = await import('./tasks') + const task = { id: 'task', sourcePath: 'one.md', noteFolder: 'inbox', lineNumber: 0, taskIndex: 0 } as import('@bridge-contract/tasks').VaultTask + s.useStore.setState({ vault: { root: '/one', name: 'One' }, vaultTasks: [task] }) + vi.spyOn(window.zen, 'readNote').mockReturnValue(read.promise) + const pending = tasks.openTask('task') + s.useStore.setState({ vault: { root: '/two', name: 'Two' } }) + read.resolve({ ...meta('one.md', '- [ ] Old task'), body: '- [ ] Old task' }) + expect(await pending).toBe(false) + expect(s.useStore.getState().pendingJumpLocation).toBeNull() + expect(s.useStore.getState().selectedPath).toBeNull() + }) + it.each(['one#Heading', 'one#^block'])('rejects a stale anchored wikilink %s', async target => { + const s = await setup(), read = gate & { body: string }>() + s.useStore.setState({ vault: { root: '/one', name: 'One' }, notes: [{ ...meta('one.md', ''), title: 'one' }] }) + const spy = vi.spyOn(window.zen, 'readNote').mockReturnValue(read.promise) + const pending = s.openWikilink(target) + await vi.waitFor(() => expect(spy).toHaveBeenCalled()) + s.useStore.setState({ vault: { root: '/two', name: 'Two' } }) + read.resolve({ ...meta('one.md', ''), body: '# Heading\n\nBlock ^block\n' }) + expect(await pending).toBe(false) + expect(s.useStore.getState().selectedPath).toBeNull() + expect(s.useStore.getState().pendingJumpLocation).toBeNull() + }) +}) diff --git a/packages/app-core/src/navigation.ts b/packages/app-core/src/navigation.ts new file mode 100644 index 00000000..d664bdc9 --- /dev/null +++ b/packages/app-core/src/navigation.ts @@ -0,0 +1,128 @@ +import { captureNavigationContext } from './lib/navigation-context' +import { useStore } from './store' +import { findLeaf, updateLeaf } from './lib/pane-layout' +import { paneModesWithPathMode, type PaneMode } from './lib/pane-mode' + +/** Open a note or app-generated page path through the normal save and history flow. */ +export function openNote(path: string, options?: { mode?: PaneMode }): Promise { + if (!captureNavigationContext()()) return Promise.resolve() + if (options?.mode) { + const mode = options.mode + useStore.setState(state => ({ + paneModes: { ...state.paneModes, [state.activePaneId]: paneModesWithPathMode(state.paneModes[state.activePaneId] ?? {}, path, mode) }, + ...(state.keepViewModeAcrossNotes ? { paneStickyModes: { ...state.paneStickyModes, [state.activePaneId]: mode } } : {}) + })) + } + return useStore.getState().selectNote(path) +} + +export function goBack(): Promise { + return useStore.getState().jumpToPreviousNote() +} + +export function goForward(): Promise { + return useStore.getState().jumpToNextNote() +} + +/** Observe the current selection without exposing mutable application state. */ +export function useSelectedNotePath(): string | null { + return useStore((state) => state.selectedPath) +} + +/** Show Home, retaining open tabs and starting the normal save for pending edits. */ +export function goHome(): void { + if (!captureNavigationContext()()) return + const state = useStore.getState() + if (state.selectedPath && state.noteDirty[state.selectedPath]) { + void state.persistNote(state.selectedPath) + } + const leaf = findLeaf(state.paneLayout, state.activePaneId) + if (!leaf || leaf.activeTab === null) return + const next = updateLeaf(state.paneLayout, leaf.id, (pane) => ({ + ...pane, + activeTab: null + })) + if (!next) return + useStore.setState({ + paneLayout: next, + selectedPath: null, + activeNote: null, + activeDirty: false + }) +} + +/** + * Install once for the lifetime of a shell that offers Home alongside open tabs. + * Register before mounting React or adding other store subscribers. Call the + * returned disposer when the host shell is torn down. + * + * A rescan or vault mutation can promote the first tab while rewriting paths. + * Restore Home only for that transition. Deliberate note navigation does not + * replace the note index and must remain visible. + */ +export function installHomeGuard(): () => void { + return useStore.subscribe((state, previous) => { + if ( + state.notes === previous.notes || + state.paneLayout === previous.paneLayout + ) + return + const leaf = findLeaf(state.paneLayout, state.activePaneId) + const before = findLeaf(previous.paneLayout, state.activePaneId) + if ( + !leaf || + !before || + before.activeTab !== null || + before.tabs.length === 0 + ) + return + if (leaf.activeTab === null || leaf.activeTab !== leaf.tabs[0]) return + const next = updateLeaf(state.paneLayout, leaf.id, (pane) => ({ + ...pane, + activeTab: null + })) + if (!next) return + useStore.setState({ + paneLayout: next, + selectedPath: null, + activeNote: null, + activeDirty: false + }) + }) +} + +/** Follow note, heading, block, or database links without taking editor focus. */ +export async function openWikilink(target: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false + const { resolveWikilinkPath } = await import('./lib/wikilinks') + const { openWikilinkTarget } = await import('./lib/wikilink-navigation') + const { listDatabaseLinkTargets, resolveDatabaseWikilink } = await import('./lib/database-links') + if (!isCurrent()) return false + const state = useStore.getState() + const path = resolveWikilinkPath(state.notes, target, state.selectedPath) + if (path) return openWikilinkTarget(path, target) + const database = resolveDatabaseWikilink(listDatabaseLinkTargets(state.folders, state.vaultSettings), target) + if (!database) return false + await state.openDatabase(database.csvPath) + return isCurrent() && !!useStore.getState().databases[database.csvPath] + +} + +export function openTodayDailyNote(): Promise { + return captureNavigationContext()() ? useStore.getState().openTodayDailyNote() : Promise.resolve() +} + +export type AppPage = 'tasks' | 'quick-notes' | 'tags' | 'assets' | 'archive' | 'trash' +export function openAppPage(page: AppPage): Promise { + if (!captureNavigationContext()()) return Promise.resolve() + const state = useStore.getState() + switch (page) { + case 'tasks': return state.openTasksView() + case 'quick-notes': return state.openQuickNotesView() + case 'tags': return state.openTagView('') + case 'assets': return state.openAssetsView() + case 'archive': return state.openArchiveView() + case 'trash': return state.openTrashView() + } +} diff --git a/packages/app-core/src/note-actions.test.ts b/packages/app-core/src/note-actions.test.ts new file mode 100644 index 00000000..2d841e00 --- /dev/null +++ b/packages/app-core/src/note-actions.test.ts @@ -0,0 +1,1380 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeLeaf } from "./lib/pane-layout"; + +beforeEach(() => { + vi.resetModules(); + localStorage.clear(); +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +async function setup() { + const files = new Map([ + ["inbox/One.md", "# One\n\nOriginal.\n"], + ["inbox/Other.md", "See [[One]].\n"], + ]); + const metadata = (path: string) => ({ + path, + title: path.split("/").pop()!.replace(/\.md$/, ""), + folder: path.startsWith("archive/") + ? ("archive" as const) + : path.startsWith("trash/") + ? ("trash" as const) + : ("inbox" as const), + siblingOrder: 0, + createdAt: 1, + updatedAt: 1, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: "", + }); + const relocate = async (path: string, next: string) => { + if (!files.has(path)) throw new Error("Missing source"); + files.set(next, files.get(path)!); + files.delete(path); + return metadata(next); + }; + const bridge = { + getCapabilities: () => ({}), + listNotes: async () => [...files.keys()].map(metadata), + listFolders: async () => [ + { folder: "inbox" as const, subpath: "Work", siblingOrder: 0 }, + ], + hasAssetsDir: async () => false, + scanTasks: async () => [], + scanTasksForPath: async () => [], + getRemoteWorkspaceInfo: async () => null, + readNote: async (path: string) => ({ + ...metadata(path), + body: files.get(path)!, + }), + writeNote: vi.fn(async (path: string, body: string) => { + files.set(path, body); + return metadata(path); + }), + setVaultSettings: vi.fn(async (value) => value), + moveNote: vi.fn(async (path: string, folder: string, subpath: string) => + relocate(path, `${folder}/${subpath ? subpath + "/" : ""}One.md`), + ), + renameNote: vi.fn(async (path: string, title: string) => + relocate(path, `inbox/${title}.md`), + ), + archiveNote: vi.fn(async (path: string) => + relocate(path, "archive/One.md"), + ), + unarchiveNote: vi.fn(async (path: string) => + relocate(path, "inbox/One.md"), + ), + moveToTrash: vi.fn(async (path: string) => relocate(path, "trash/One.md")), + restoreFromTrash: vi.fn(async (path: string) => + relocate(path, "inbox/One.md"), + ), + deleteNote: vi.fn(async (path: string) => { + files.delete(path); + }), + }; + Object.defineProperty(window, "zen", { configurable: true, value: bridge }); + const { useStore } = await import("./store"); + const leaf = makeLeaf(["inbox/One.md", "inbox/Other.md"], "inbox/One.md"); + useStore.setState({ + vault: { root: "/test", name: "Test" }, + notes: [...files.keys()].map(metadata), + folders: await bridge.listFolders(), + paneLayout: leaf, + activePaneId: leaf.id, + selectedPath: "inbox/One.md", + noteContents: Object.fromEntries( + [...files].map(([path, body]) => [path, { ...metadata(path), body }]), + ), + noteDirty: {}, + syncTitleHeadingOnRename: false, + }); + return { useStore, files, bridge, relocate, metadata }; +} + +describe("note relocation saves", () => { + it("saves before moving and keeps edits made during the move at the canonical path", async () => { + const s = await setup(), + gate = deferred(); + s.useStore.getState().updateNoteBody("inbox/One.md", "Before move.\n"); + s.bridge.moveNote.mockImplementation(async (path) => { + expect(s.files.get(path)).toBe("Before move.\n"); + await gate.promise; + return s.relocate(path, "inbox/Work/One 2.md"); + }); + const moving = s.useStore + .getState() + .moveNote("inbox/One.md", "inbox", "Work"); + await vi.waitFor(() => expect(s.bridge.moveNote).toHaveBeenCalled()); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "During move: café 日本語. \n"); + gate.resolve(); + await moving; + expect(s.files.has("inbox/One.md")).toBe(false); + expect(s.files.get("inbox/Work/One 2.md")).toBe( + "During move: café 日本語. \n", + ); + expect(s.useStore.getState().selectedPath).toBe("inbox/Work/One 2.md"); + expect(s.useStore.getState().noteDirty["inbox/Work/One 2.md"]).toBe(false); + }); + + it("does not relocate a note if its save failed", async () => { + const s = await setup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + s.useStore.getState().updateNoteBody("inbox/One.md", "Keep unsaved.\n"); + await s.useStore.getState().moveNote("inbox/One.md", "inbox", "Work"); + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + expect(s.useStore.getState().noteContents["inbox/One.md"].body).toBe( + "Keep unsaved.\n", + ); + }); + + it("waits for a pending move before completing a vault-switch save", async () => { + const s = await setup(), + gate = deferred(); + s.bridge.moveNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "inbox/Work/One.md"); + }); + const moving = s.useStore + .getState() + .moveNote("inbox/One.md", "inbox", "Work"); + await vi.waitFor(() => expect(s.bridge.moveNote).toHaveBeenCalled()); + let flushed = false; + const flushing = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(flushed).toBe(false); + gate.resolve(); + await Promise.all([moving, flushing]); + expect(s.files.has("inbox/Work/One.md")).toBe(true); + }); +}); + +async function publicSetup() { + const s = await setup(); + const actions = await import("./notes"); + const confirms = await import("./lib/confirm-requests"); + const prompts = await import("./lib/prompt-requests"); + const answer = (value: string | null) => + prompts.settlePromptRequest(prompts.getPromptRequest()!, value); + return { + ...s, + ...actions, + ...prompts, + ...confirms, + confirm: (value: boolean) => + confirms.settleConfirmRequest(confirms.getConfirmRequest()!, value), + answer, + host: { isCurrent: () => true }, + }; +} +describe("public note move", () => { + it("prompts, saves, and moves through the public action", async () => { + const s = await publicSetup(); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Saved via public action.\n"); + const moving = s.requestMoveNote(s.host, "inbox/One.md"); + expect(s.getPromptRequest()?.options.initialValue).toBe("inbox"); + s.answer("inbox/Work"); + expect(await moving).toBe("completed"); + expect(s.files.get("inbox/Work/One.md")).toBe("Saved via public action.\n"); + }); + it("cancels unchanged targets and invalid destinations without writes", async () => { + const s = await publicSetup(); + for (const target of [ + null, + "inbox", + "inbox/../Other", + "inbox/.hidden", + "inbox/People.base", + "inbox/People.base/pages", + "trash", + "inbox/bad\0name", + ]) { + const moving = s.requestMoveNote(s.host, "inbox/One.md"); + s.answer(target); + expect(await moving).toBe("cancelled"); + } + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + }); + it("does not dispatch a dialog result after the host switches vaults", async () => { + const s = await publicSetup(); + let current = true; + const moving = s.requestMoveNote( + { isCurrent: () => current }, + "inbox/One.md", + ); + current = false; + s.answer("inbox/Work"); + expect(await moving).toBe("stale"); + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + }); + it("propagates host errors and permits the next attempt", async () => { + const s = await publicSetup(); + s.bridge.moveNote.mockRejectedValueOnce(new Error("permission denied")); + const moving = s.requestMoveNote(s.host, "inbox/One.md"); + s.answer("inbox/Work"); + await expect(moving).rejects.toThrow("permission denied"); + const retry = s.requestMoveNote(s.host, "inbox/One.md"); + s.answer("inbox/Work"); + expect(await retry).toBe("completed"); + }); + it("rejects missing notes, database records, and simultaneous prompts", async () => { + const s = await publicSetup(); + expect(await s.requestMoveNote(s.host, "missing.md")).toBe("unavailable"); + s.useStore.setState({ + notes: [ + ...s.useStore.getState().notes, + s.metadata("inbox/People.base/Record.md"), + ], + }); + expect(await s.requestMoveNote(s.host, "inbox/People.base/Record.md")).toBe( + "unavailable", + ); + const first = s.requestMoveNote(s.host, "inbox/One.md"); + expect(await s.requestMoveNote(s.host, "inbox/Other.md")).toBe( + "unavailable", + ); + s.answer(null); + expect(await first).toBe("cancelled"); + }); +}); + +describe("note move coordination", () => { + it("preserves exact scope, comments, references, task metadata, and manual order", async () => { + const s = await setup(); + const { parseTasksFromBody } = await import("@shared/tasks"); + const path = "inbox/One.md", + neighbor = "inbox/One.md.backup"; + s.files.set(neighbor, "Keep backup.\n"); + s.useStore.setState({ + manualNoteOrder: { inbox: [path, "inbox/Other.md"] }, + noteComments: { + [path]: [ + { + id: "comment", + notePath: path, + body: "Keep comment", + anchorStart: 0, + anchorEnd: 0, + anchorText: "", + resolvedAt: null, + createdAt: 1, + updatedAt: 1, + }, + ], + }, + noteRefs: { [path]: { path, pinned: true } as never }, + vaultTasks: parseTasksFromBody("- [ ] Task\n", { + path, + title: "One", + folder: "inbox", + }), + noteContents: { + ...s.useStore.getState().noteContents, + [neighbor]: { ...s.metadata(neighbor), body: "Keep backup.\n" }, + }, + noteDirty: { [neighbor]: false }, + }); + await s.useStore.getState().moveNote(path, "archive", "Work", () => true); + const current = s.useStore.getState(), + next = "archive/Work/One.md"; + expect(current.noteComments[next][0].notePath).toBe(next); + expect(current.noteRefs[next].path).toBe(next); + expect(current.vaultTasks[0]).toMatchObject({ + sourcePath: next, + noteFolder: "archive", + noteTitle: "One", + }); + expect(current.manualNoteOrder.inbox).not.toContain(path); + expect(current.manualNoteOrder["archive/Work"]).toContain(next); + expect(s.files.get(neighbor)).toBe("Keep backup.\n"); + }); + + it("reconciles a dispatched move but leaves the new vault UI untouched", async () => { + const s = await setup(), + gate = deferred(); + s.bridge.moveNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "inbox/Work/One.md"); + }); + const moving = s.useStore + .getState() + .moveNote("inbox/One.md", "inbox", "Work", () => true); + await vi.waitFor(() => expect(s.bridge.moveNote).toHaveBeenCalled()); + const nextVault = { root: "/other", name: "Other" }; + s.useStore.setState({ + vault: nextVault, + notes: [], + noteContents: {}, + noteDirty: {}, + selectedPath: null, + activeNote: null, + }); + gate.resolve(); + await moving; + expect(s.useStore.getState().vault).toBe(nextVault); + expect(s.useStore.getState().notes).toEqual([]); + expect(s.useStore.getState().selectedPath).toBeNull(); + }); + + it("rejects a move during a closed-note task write, then permits it when settled", async () => { + const s = await setup(), + gate = deferred(); + const { parseTasksFromBody } = await import("@shared/tasks"); + const path = "inbox/One.md"; + s.files.set(path, "- [ ] Task\n"); + const task = parseTasksFromBody(s.files.get(path)!, { + path, + title: "One", + folder: "inbox", + })[0]; + s.useStore.setState({ + noteContents: {}, + noteDirty: {}, + vaultTasks: [task], + }); + s.bridge.writeNote.mockImplementationOnce(async (path, body) => { + await gate.promise; + s.files.set(path, body); + return s.metadata(path); + }); + const writing = s.useStore.getState().toggleTaskFromList(task); + await vi.waitFor(() => expect(s.bridge.writeNote).toHaveBeenCalled()); + await expect( + s.useStore.getState().moveNote(path, "inbox", "Work", () => true), + ).rejects.toThrow("pending task changes"); + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + gate.resolve(); + await writing; + await s.useStore.getState().moveNote(path, "inbox", "Work", () => true); + expect(s.files.get("inbox/Work/One.md")).toBe("- [x] Task\n"); + expect(s.files.has(path)).toBe(false); + }); + + it("blocks task writes and retains buffers when a failed move cannot be rolled back", async () => { + const s = await setup(), + gate = deferred(); + const { parseTasksFromBody } = await import("@shared/tasks"); + const path = "inbox/One.md"; + s.bridge.moveNote.mockImplementation(async () => { + await gate.promise; + throw new Error("FOLDER_STATE_UNCERTAIN: rollback failed"); + }); + const moving = s.useStore + .getState() + .moveNote(path, "inbox", "Work", () => true); + const failed = expect(moving).rejects.toThrow("FOLDER_STATE_UNCERTAIN"); + await vi.waitFor(() => expect(s.bridge.moveNote).toHaveBeenCalled()); + const task = parseTasksFromBody("- [ ] Task\n", { + path, + title: "One", + folder: "inbox", + })[0]; + await s.useStore.getState().toggleTaskFromList(task); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); + s.useStore.getState().updateNoteBody(path, "Retain pending edit.\n"); + gate.resolve(); + await failed; + await s.useStore.getState().persistNote(path); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); + expect(s.useStore.getState().noteContents[path].body).toBe( + "Retain pending edit.\n", + ); + await expect(s.useStore.getState().flushDirtyNotes()).rejects.toThrow( + "FOLDER_STATE_UNCERTAIN", + ); + }); +}); + +it.each([false, true])( + "uses logical prompt paths with remapped primary folders (root: %s)", + async (root) => { + const s = await publicSetup(); + const path = root ? "Work/One.md" : "My Notes/Work/One.md"; + s.useStore.setState({ + notes: [s.metadata(path)], + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: root ? "root" : "inbox", + systemFolderPaths: { inbox: "My Notes", archive: "Old Notes" }, + }, + }); + const moving = s.requestMoveNote(s.host, path); + expect(s.getPromptRequest()?.options.initialValue).toBe("inbox/Work"); + s.answer("inbox/Work"); + expect(await moving).toBe("cancelled"); + }, +); + +it("finishes comment writes before moving their sidecar", async () => { + const s = await setup(), + gate = deferred(); + const writeComments = vi.fn(async () => { + await gate.promise; + return []; + }); + Object.assign(s.bridge, { writeNoteComments: writeComments }); + s.useStore.setState({ noteComments: { "inbox/One.md": [] } }); + const commenting = s.useStore.getState().addNoteComment({ + notePath: "inbox/One.md", + body: "Pending comment", + } as never); + await vi.waitFor(() => expect(writeComments).toHaveBeenCalled()); + const moving = s.useStore + .getState() + .moveNote("inbox/One.md", "inbox", "Work", () => true); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(s.bridge.moveNote).not.toHaveBeenCalled(); + gate.resolve(); + await Promise.all([commenting, moving]); + expect(s.bridge.moveNote).toHaveBeenCalledOnce(); +}); + +it("flushes an open-buffer task edit before its debounce when moving", async () => { + const s = await setup(); + const { parseTasksFromBody } = await import("@shared/tasks"); + const path = "inbox/One.md", + body = "- [ ] Task\n"; + s.files.set(path, body); + const task = parseTasksFromBody(body, { + path, + title: "One", + folder: "inbox", + })[0]; + s.useStore.setState({ + noteContents: { [path]: { ...s.metadata(path), body } }, + noteDirty: {}, + vaultTasks: [task], + }); + await s.useStore.getState().toggleTaskFromList(task); + expect(s.files.get(path)).toBe(body); + await s.useStore.getState().moveNote(path, "inbox", "Work", () => true); + expect(s.files.get("inbox/Work/One.md")).toBe("- [x] Task\n"); + expect(s.files.has(path)).toBe(false); +}); + +describe("public note rename", () => { + it("rewrites clean and late-edited inbound buffers using the canonical title", async () => { + const s = await publicSetup(), + gate = deferred(); + const clean = "inbox/Clean.md"; + s.files.set(clean, "Clean [[One|alias]].\n"); + const pane = makeLeaf( + ["inbox/One.md", "inbox/Other.md", clean], + "inbox/One.md", + ); + s.useStore.setState({ + paneLayout: pane, + activePaneId: pane.id, + notes: [...s.useStore.getState().notes, s.metadata(clean)], + noteContents: { + ...s.useStore.getState().noteContents, + [clean]: { ...s.metadata(clean), body: s.files.get(clean)! }, + }, + }); + s.useStore.getState().updateNoteBody("inbox/Other.md", "Before [[One]].\n"); + s.bridge.renameNote.mockImplementation(async (path) => { + expect(s.files.get("inbox/Other.md")).toBe("Before [[One]].\n"); + await gate.promise; + return s.relocate(path, "inbox/Renamed 2.md"); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + expect(s.getPromptRequest()?.options.initialValue).toBe("One"); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Late source: café 日本語. \n"); + s.useStore + .getState() + .updateNoteBody( + "inbox/Other.md", + "Late [[One#Heading|alias]] and `[[One]]`. \n", + ); + gate.resolve(); + expect(await renaming).toBe("completed"); + expect(s.files.has("inbox/One.md")).toBe(false); + expect(s.files.get("inbox/Renamed 2.md")).toBe( + "Late source: café 日本語. \n", + ); + expect(s.files.get("inbox/Other.md")).toBe( + "Late [[Renamed 2#Heading|alias]] and `[[One]]`. \n", + ); + expect(s.files.get(clean)).toBe("Clean [[Renamed 2|alias]].\n"); + expect(s.useStore.getState().noteContents[clean].body).toBe( + s.files.get(clean), + ); + expect(s.useStore.getState().selectedPath).toBe("inbox/Renamed 2.md"); + }); + + it("cancels empty, unchanged, and invalid titles without writing", async () => { + const s = await publicSetup(); + for (const title of [ + null, + "", + "One", + " One ", + "../Other", + "dir/Other", + "bad\\name", + ".hidden", + "Bad: title", + "Bad? title", + "bad\0name", + ]) { + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer(title); + expect(await renaming).toBe("cancelled"); + } + expect(s.bridge.renameNote).not.toHaveBeenCalled(); + }); + + it("rejects stale prompts and prevents overlap with a move prompt", async () => { + const s = await publicSetup(); + let current = true; + const renaming = s.requestRenameNote( + { isCurrent: () => current }, + "inbox/One.md", + ); + expect(await s.requestMoveNote(s.host, "inbox/Other.md")).toBe( + "unavailable", + ); + current = false; + s.answer("Renamed"); + expect(await renaming).toBe("stale"); + expect(s.bridge.renameNote).not.toHaveBeenCalled(); + }); + + it("blocks rename on a failed inbound save and releases the action for retry", async () => { + const s = await publicSetup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.useStore + .getState() + .updateNoteBody("inbox/Other.md", "Unsaved [[One]].\n"); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await expect(renaming).rejects.toThrow("unsaved changes"); + expect(s.bridge.renameNote).not.toHaveBeenCalled(); + s.bridge.writeNote.mockImplementation(async (path, body) => { + s.files.set(path, body); + return s.metadata(path); + }); + const retry = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + expect(await retry).toBe("completed"); + expect(s.files.get("inbox/Other.md")).toBe("Unsaved [[Renamed]].\n"); + }); + + it("keeps rewritten buffers dirty if persistence fails after the rename", async () => { + const s = await publicSetup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await expect(renaming).rejects.toThrow("unsaved changes"); + expect(s.useStore.getState().selectedPath).toBe("inbox/Renamed.md"); + expect(s.useStore.getState().noteDirty["inbox/Other.md"]).toBe(true); + expect(s.useStore.getState().noteContents["inbox/Other.md"].body).toBe( + "See [[Renamed]].\n", + ); + }); + + it("finishes a dispatched rename before draining saves for a vault switch", async () => { + const s = await publicSetup(), + gate = deferred(); + let current = true; + s.bridge.renameNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "inbox/Renamed.md"); + }); + const renaming = s.requestRenameNote( + { isCurrent: () => current }, + "inbox/One.md", + ); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + s.useStore.getState().updateNoteBody("inbox/Other.md", "During [[One]].\n"); + current = false; + let flushed = false; + const flushing = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(flushed).toBe(false); + gate.resolve(); + expect(await renaming).toBe("stale"); + await flushing; + expect(s.files.get("inbox/Other.md")).toBe("During [[Renamed]].\n"); + expect(s.files.has("inbox/One.md")).toBe(false); + }); + + it("does not reconcile a dispatched rename into a replaced vault", async () => { + const s = await publicSetup(), + gate = deferred(); + s.bridge.renameNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "inbox/Renamed.md"); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + s.useStore.setState({ + vault: { root: "/other", name: "Other" }, + notes: [], + noteContents: {}, + noteDirty: {}, + selectedPath: null, + activeNote: null, + }); + gate.resolve(); + expect(await renaming).toBe("stale"); + expect(s.useStore.getState().notes).toEqual([]); + expect(s.useStore.getState().selectedPath).toBeNull(); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); + expect(s.bridge.setVaultSettings).not.toHaveBeenCalled(); + }); +}); + +it("holds all task and note writes during rename and retains buffers on failed rollback", async () => { + const s = await publicSetup(), + gate = deferred(); + const { parseTasksFromBody } = await import("@shared/tasks"); + s.bridge.renameNote.mockImplementation(async () => { + await gate.promise; + throw new Error("FOLDER_STATE_UNCERTAIN: rollback failed"); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + const failed = expect(renaming).rejects.toThrow("FOLDER_STATE_UNCERTAIN"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + const task = parseTasksFromBody("- [ ] Task\n", { + path: "inbox/Other.md", + title: "Other", + folder: "inbox", + })[0]; + await s.useStore.getState().toggleTaskFromList(task); + s.useStore.getState().updateNoteBody("inbox/Other.md", "Keep [[One]].\n"); + await s.useStore.getState().persistNote("inbox/Other.md"); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); + gate.resolve(); + await failed; + expect(s.useStore.getState().noteContents["inbox/Other.md"].body).toBe( + "Keep [[One]].\n", + ); + await expect(s.useStore.getState().flushDirtyNotes()).rejects.toThrow( + "FOLDER_STATE_UNCERTAIN", + ); + expect(s.bridge.writeNote).not.toHaveBeenCalled(); +}); + +it("rewrites an edit that arrives during the post-rename refresh", async () => { + const s = await publicSetup(), + gate = deferred(); + const refresh = s.useStore.getState().refreshNotes; + const refreshing = vi.fn(async () => { + await gate.promise; + await refresh(); + }); + s.useStore.setState({ refreshNotes: refreshing }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await vi.waitFor(() => expect(refreshing).toHaveBeenCalled()); + s.useStore.getState().updateNoteBody("inbox/Other.md", "Late [[One]].\n"); + gate.resolve(); + expect(await renaming).toBe("completed"); + expect(s.files.get("inbox/Other.md")).toBe("Late [[Renamed]].\n"); +}); + +it("rejects rename during a closed-note tag rewrite and blocks a new tag rewrite during rename", async () => { + const s = await publicSetup(), + readGate = deferred(), + renameGate = deferred(); + s.files.set("inbox/Other.md", "#old [[One]]\n"); + s.useStore.setState({ + notes: s.useStore + .getState() + .notes.map((note) => + note.path === "inbox/Other.md" ? { ...note, tags: ["old"] } : note, + ), + noteContents: {}, + noteDirty: {}, + activeNote: null, + }); + const readNote = vi + .spyOn(s.bridge, "readNote") + .mockImplementationOnce(async (path) => { + await readGate.promise; + return { ...s.metadata(path), body: s.files.get(path)! }; + }); + const tagging = s.useStore.getState().renameTag("old", "new"); + await vi.waitFor(() => expect(readNote).toHaveBeenCalled()); + const rejected = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await expect(rejected).rejects.toThrow("pending note changes"); + expect(s.bridge.renameNote).not.toHaveBeenCalled(); + readGate.resolve(); + await tagging; + s.bridge.renameNote.mockImplementation(async (path) => { + await renameGate.promise; + return s.relocate(path, "inbox/Renamed.md"); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.renameNote).toHaveBeenCalled()); + readNote.mockClear(); + await s.useStore.getState().renameTag("new", "other"); + expect(readNote).not.toHaveBeenCalled(); + renameGate.resolve(); + expect(await renaming).toBe("completed"); +}); + +it("keeps the rename registered until final backlink saves finish", async () => { + const s = await publicSetup(), + gate = deferred(); + s.bridge.writeNote.mockImplementationOnce(async (path, body) => { + await gate.promise; + s.files.set(path, body); + return s.metadata(path); + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + await vi.waitFor(() => expect(s.bridge.writeNote).toHaveBeenCalled()); + const read = vi.spyOn(s.bridge, "readNote"); + s.useStore.setState({ + notes: s.useStore + .getState() + .notes.map((note) => ({ ...note, tags: ["tag"] })), + }); + await s.useStore.getState().renameTag("tag", "changed"); + expect(read).not.toHaveBeenCalled(); + let flushed = false; + const flushing = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(flushed).toBe(false); + gate.resolve(); + expect(await renaming).toBe("completed"); + await flushing; + expect(s.files.get("inbox/Other.md")).toBe("See [[Renamed]].\n"); +}); + +it("drains a closed-note writer before a vault-switch save completes", async () => { + const s = await setup(), + gate = deferred(); + s.files.set("inbox/Other.md", "#old [[One]]\n"); + s.useStore.setState({ + notes: s.useStore + .getState() + .notes.map((note) => ({ ...note, tags: ["old"] })), + noteContents: {}, + noteDirty: {}, + activeNote: null, + }); + const read = vi + .spyOn(s.bridge, "readNote") + .mockImplementationOnce(async (path) => { + await gate.promise; + return { ...s.metadata(path), body: s.files.get(path)! }; + }); + const tagging = s.useStore.getState().renameTag("old", "new"); + await vi.waitFor(() => expect(read).toHaveBeenCalled()); + let flushed = false; + const flushing = s.useStore + .getState() + .flushDirtyNotes() + .then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(flushed).toBe(false); + gate.resolve(); + await Promise.all([tagging, flushing]); + expect(s.files.get("inbox/Other.md")).toBe("#new [[One]]\n"); +}); + +it("refreshes visible task text after renamed backlink buffers are saved", async () => { + const s = await publicSetup(); + const { parseTasksFromBody, TASKS_TAB_PATH } = await import("@shared/tasks"); + s.files.set("inbox/Other.md", "- [ ] Read [[One]]\n"); + const pane = makeLeaf( + ["inbox/One.md", "inbox/Other.md", TASKS_TAB_PATH], + TASKS_TAB_PATH, + ); + const scanTasks = vi.fn(async () => + parseTasksFromBody( + s.files.get("inbox/Other.md")!, + s.metadata("inbox/Other.md"), + ), + ); + Object.assign(s.bridge, { scanTasks }); + s.useStore.setState({ + paneLayout: pane, + activePaneId: pane.id, + selectedPath: TASKS_TAB_PATH, + noteContents: { + "inbox/Other.md": { + ...s.metadata("inbox/Other.md"), + body: s.files.get("inbox/Other.md")!, + }, + }, + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + expect(await renaming).toBe("completed"); + expect(scanTasks).toHaveBeenCalled(); + expect(s.useStore.getState().vaultTasks[0].rawText).toContain("[[Renamed]]"); +}); + +it("updates open inbound links when a database record page is renamed", async () => { + const s = await setup(); + const csvPath = "inbox/Records.base/data.csv"; + Object.assign(s.bridge, { writeDatabaseSchema: vi.fn(async () => {}) }); + s.useStore.setState({ + databases: { + [csvPath]: { + version: 1, + idFieldId: "id", + path: csvPath, + title: "Records", + fields: [], + views: [], + activeViewId: "view", + rows: [{ id: "row", cells: { id: "row" } }], + pages: { row: "inbox/One.md" }, + }, + }, + }); + s.bridge.renameNote.mockImplementation(async (path) => + s.relocate(path, "inbox/Renamed.md"), + ); + await s.useStore.getState().renameRecordPage(csvPath, "row"); + expect(s.useStore.getState().databases[csvPath].pages?.row).toBe( + "inbox/Renamed.md", + ); + expect(s.files.get("inbox/Other.md")).toBe("See [[Renamed]].\n"); + await s.useStore.getState().flushDirtyNotes(); +}); + +it("refreshes already-mounted Home tasks after rename", async () => { + const s = await publicSetup(); + const { parseTasksFromBody } = await import("@shared/tasks"); + s.files.set("inbox/Other.md", "- [ ] Read [[One]]\n"); + const scanTasks = vi.fn(async () => + parseTasksFromBody( + s.files.get("inbox/Other.md")!, + s.metadata("inbox/Other.md"), + ), + ); + Object.assign(s.bridge, { scanTasks }); + const marker = document.createElement("div"); + marker.dataset.homeNav = "tasks"; + document.body.append(marker); + try { + s.useStore.setState({ + selectedPath: null, + activeNote: null, + vaultTasks: await scanTasks(), + noteContents: { + "inbox/Other.md": { + ...s.metadata("inbox/Other.md"), + body: s.files.get("inbox/Other.md")!, + }, + }, + }); + const renaming = s.requestRenameNote(s.host, "inbox/One.md"); + s.answer("Renamed"); + expect(await renaming).toBe("completed"); + expect(s.useStore.getState().vaultTasks[0].rawText).toContain( + "[[Renamed]]", + ); + } finally { + marker.remove(); + } +}); + +describe("note lifecycle saves", () => { + it.each(["archive", "trash"] as const)( + "saves edits made during %s before closing the note", + async (action) => { + const s = await setup(), + gate = deferred(); + const method = + action === "archive" ? s.bridge.archiveNote : s.bridge.moveToTrash; + method.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, `${action}/One 2.md`); + }); + const changing = s.useStore + .getState() + .changeNoteLifecycle("inbox/One.md", action, () => true); + await vi.waitFor(() => expect(method).toHaveBeenCalled()); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Late edit: café 日本語. \n"); + gate.resolve(); + await changing; + expect(s.files.get(`${action}/One 2.md`)).toBe( + "Late edit: café 日本語. \n", + ); + expect(s.files.has("inbox/One.md")).toBe(false); + expect( + s.useStore.getState().noteContents[`${action}/One 2.md`], + ).toBeUndefined(); + expect(s.useStore.getState().selectedPath).not.toBe(`${action}/One 2.md`); + }, + ); + + it("keeps the destination buffer open and dirty when saving after archive fails", async () => { + const s = await setup(), + gate = deferred(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.bridge.archiveNote.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "archive/One.md"); + }); + const changing = s.useStore + .getState() + .changeNoteLifecycle("inbox/One.md", "archive", () => true); + const failed = expect(changing).rejects.toThrow("unsaved changes"); + await vi.waitFor(() => expect(s.bridge.archiveNote).toHaveBeenCalled()); + s.useStore.getState().updateNoteBody("inbox/One.md", "Keep this draft.\n"); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + gate.resolve(); + await failed; + expect(s.useStore.getState().selectedPath).toBe("archive/One.md"); + expect(s.useStore.getState().noteContents["archive/One.md"].body).toBe( + "Keep this draft.\n", + ); + expect(s.useStore.getState().noteDirty["archive/One.md"]).toBe(true); + }); + + it.each(["archive", "trash"] as const)( + "restores a %s note to the canonical path and keeps its tab", + async (folder) => { + const s = await setup(); + const path = `${folder}/One.md`, + body = "Restore exact bytes. \n"; + s.files.set(path, body); + const leaf = makeLeaf([path], path); + s.useStore.setState({ + notes: [s.metadata(path)], + paneLayout: leaf, + activePaneId: leaf.id, + selectedPath: path, + noteContents: { [path]: { ...s.metadata(path), body } }, + noteDirty: {}, + }); + await s.useStore + .getState() + .changeNoteLifecycle(path, "restore", () => true); + expect(s.useStore.getState().selectedPath).toBe("inbox/One.md"); + expect(s.files.get("inbox/One.md")).toBe(body); + expect(s.files.has(path)).toBe(false); + }, + ); + + it("retains the source buffer when the host refuses a lifecycle move", async () => { + const s = await setup(); + s.bridge.archiveNote.mockRejectedValue(new Error("permission denied")); + await expect( + s.useStore + .getState() + .changeNoteLifecycle("inbox/One.md", "archive", () => true), + ).rejects.toThrow("permission denied"); + expect(s.useStore.getState().selectedPath).toBe("inbox/One.md"); + expect(s.files.has("inbox/One.md")).toBe(true); + }); +}); + +describe("irreversible note lifecycle", () => { + it.each(["delete", "system-trash"] as const)( + "freezes every editor for %s until it commits", + async (action) => { + const s = await setup(), + gate = deferred(); + const { EditorState } = await import("@codemirror/state"); + const { EditorView } = await import("@codemirror/view"); + const { noteEditingLockExtension } = + await import("./lib/note-lifecycle-lock"); + if (action === "system-trash") + s.useStore.setState({ + vault: { ...s.useStore.getState().vault!, temporary: true }, + }); + const views = [ + "inbox/One.md", + "inbox/One.md", + "inbox/One.md", + "inbox/Other.md", + ].map( + (path) => + new EditorView({ + state: EditorState.create({ + doc: s.files.get(path), + extensions: [ + noteEditingLockExtension(() => ({ + vault: s.useStore.getState().vault, + path, + })), + ], + }), + parent: document.body, + }), + ); + const method = + action === "delete" ? s.bridge.deleteNote : s.bridge.moveToTrash; + if (action === "delete") + s.bridge.deleteNote.mockImplementation(async (path) => { + await gate.promise; + s.files.delete(path); + }); + else + s.bridge.moveToTrash.mockImplementation(async (path) => { + await gate.promise; + s.files.delete(path); + return s.metadata(path); + }); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Save before deleting.\n"); + try { + const operation = s.useStore + .getState() + .changeNoteLifecycle( + "inbox/One.md", + action === "delete" ? "delete" : "trash", + () => true, + ); + for (const view of views.slice(0, 3)) { + expect(view.state.readOnly).toBe(true); + const before = view.state.doc.toString(); + view.dispatch({ changes: { from: 0, insert: "Rejected" } }); + expect(view.state.doc.toString()).toBe(before); + } + expect(views[3].state.readOnly).toBe(false); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Rejected late edit"); + await vi.waitFor(() => expect(method).toHaveBeenCalled()); + expect(s.files.get("inbox/One.md")).toBe("Save before deleting.\n"); + gate.resolve(); + await operation; + await s.useStore.getState().flushDirtyNotes(); + expect(s.files.has("inbox/One.md")).toBe(false); + expect( + s.useStore.getState().noteContents["inbox/One.md"], + ).toBeUndefined(); + for (const view of views) expect(view.state.readOnly).toBe(false); + } finally { + gate.resolve(); + views.forEach((view) => view.destroy()); + } + }, + ); + + it("unlocks after host failure and accepts the next edit", async () => { + const s = await setup(); + s.bridge.deleteNote.mockRejectedValue(new Error("denied")); + await expect( + s.useStore.getState().changeNoteLifecycle("inbox/One.md", "delete"), + ).rejects.toThrow("denied"); + s.useStore.getState().updateNoteBody("inbox/One.md", "Editable again.\n"); + await s.useStore.getState().flushDirtyNotes(); + expect(s.files.get("inbox/One.md")).toBe("Editable again.\n"); + expect(s.useStore.getState().selectedPath).toBe("inbox/One.md"); + }); + + it("does not delete after the initial save fails", async () => { + const s = await setup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + s.useStore.getState().updateNoteBody("inbox/One.md", "Keep draft.\n"); + s.bridge.writeNote.mockRejectedValue(new Error("disk full")); + await expect( + s.useStore.getState().changeNoteLifecycle("inbox/One.md", "delete"), + ).rejects.toThrow(); + expect(s.bridge.deleteNote).not.toHaveBeenCalled(); + expect(s.useStore.getState().noteContents["inbox/One.md"].body).toBe( + "Keep draft.\n", + ); + expect(s.useStore.getState().noteDirty["inbox/One.md"]).toBe(true); + s.bridge.writeNote.mockImplementation(async (path, body) => { + s.files.set(path, body); + return s.metadata(path); + }); + await s.useStore.getState().flushDirtyNotes(); + }); +}); + +describe("public note lifecycle", () => { + it("saves edits made while confirmation is open and restores exact bytes", async () => { + const s = await publicSetup(); + const trashing = s.requestTrashNote(s.host, "inbox/One.md"); + expect(s.getConfirmRequest()?.options.confirmLabel).toBe("Move to Trash"); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Confirmed draft café. \n"); + s.confirm(true); + expect(await trashing).toBe("completed"); + expect(s.files.get("trash/One.md")).toBe("Confirmed draft café. \n"); + expect(await s.restoreNote(s.host, "trash/One.md")).toBe("completed"); + expect(s.files.get("inbox/One.md")).toBe("Confirmed draft café. \n"); + }); + + it("cancels and rejects stale confirmations without touching the host", async () => { + const s = await publicSetup(); + let current = true; + const host = { isCurrent: () => current }; + const cancelled = s.requestTrashNote(host, "inbox/One.md"); + s.confirm(false); + expect(await cancelled).toBe("cancelled"); + const stale = s.requestTrashNote(host, "inbox/One.md"); + current = false; + s.confirm(true); + expect(await stale).toBe("stale"); + expect(s.bridge.moveToTrash).not.toHaveBeenCalled(); + }); + + it("only offers permanent deletion for trashed ordinary notes", async () => { + const s = await publicSetup(); + expect(await s.requestDeleteNotePermanently(s.host, "inbox/One.md")).toBe( + "unavailable", + ); + const trashing = s.requestTrashNote(s.host, "inbox/One.md"); + s.confirm(true); + await trashing; + const deleting = s.requestDeleteNotePermanently(s.host, "trash/One.md"); + expect(s.getConfirmRequest()?.options.danger).toBe(true); + s.confirm(true); + expect(await deleting).toBe("completed"); + expect(s.files.has("trash/One.md")).toBe(false); + }); + + it("archives and unarchives through the public boundary", async () => { + const s = await publicSetup(); + expect(await s.requestArchiveNote(s.host, "inbox/One.md")).toBe( + "completed", + ); + expect(await s.requestArchiveNote(s.host, "archive/One.md")).toBe( + "unavailable", + ); + expect(await s.restoreNote(s.host, "archive/One.md")).toBe("completed"); + }); + + it("keeps other editor restrictions when the deletion lock is removed", async () => { + const { EditorState } = await import("@codemirror/state"); + const { EditorView } = await import("@codemirror/view"); + const { lockNoteEditing, noteEditingLockExtension } = + await import("./lib/note-lifecycle-lock"); + const vault = {}, + path = "note.md"; + const view = new EditorView({ + state: EditorState.create({ + doc: "Body", + extensions: [ + noteEditingLockExtension(() => ({ vault, path })), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + ], + }), + parent: document.body, + }); + try { + expect(view.state.facet(EditorView.editable)).toBe(false); + const unlock = lockNoteEditing(vault, path); + unlock(); + expect(view.state.readOnly).toBe(true); + expect(view.state.facet(EditorView.editable)).toBe(false); + } finally { + view.destroy(); + } + }); +}); + +it("waits for an IME composition to finish before deleting", async () => { + const s = await setup(); + const { EditorState } = await import("@codemirror/state"); + const { EditorView } = await import("@codemirror/view"); + const { noteEditingLockExtension } = + await import("./lib/note-lifecycle-lock"); + const view = new EditorView({ + state: EditorState.create({ + doc: "Body", + extensions: [ + noteEditingLockExtension(() => ({ + vault: s.useStore.getState().vault, + path: "inbox/One.md", + })), + ], + }), + parent: document.body, + }); + Object.defineProperty(view, "composing", { get: () => true }); + try { + await expect( + s.useStore.getState().changeNoteLifecycle("inbox/One.md", "delete"), + ).rejects.toThrow("Finish entering text"); + expect(s.bridge.deleteNote).not.toHaveBeenCalled(); + expect(view.state.readOnly).toBe(false); + } finally { + view.destroy(); + } +}); + +it("does not claim system Trash succeeded if its token changes while saving", async () => { + const s = await setup(), + gate = deferred(); + let current = true; + const { useToastStore } = await import("./lib/toast"); + s.useStore.setState({ + vault: { ...s.useStore.getState().vault!, temporary: true }, + }); + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Saved before switching.\n"); + s.bridge.writeNote.mockImplementation(async (path, body) => { + await gate.promise; + s.files.set(path, body); + return s.metadata(path); + }); + const deleting = s.useStore + .getState() + .changeNoteLifecycle("inbox/One.md", "trash", () => current); + await vi.waitFor(() => expect(s.bridge.writeNote).toHaveBeenCalled()); + current = false; + gate.resolve(); + await deleting; + expect(s.bridge.moveToTrash).not.toHaveBeenCalled(); + expect(s.files.has("inbox/One.md")).toBe(true); + expect( + useToastStore + .getState() + .toasts.some((toast) => toast.message.includes("Moved to system Trash")), + ).toBe(false); +}); + +it("finishes saving a dispatched trash operation in its original vault after its host token changes", async () => { + const s = await publicSetup(), + gate = deferred(); + let current = true; + s.bridge.moveToTrash.mockImplementation(async (path) => { + await gate.promise; + return s.relocate(path, "trash/One 2.md"); + }); + const trashing = s.requestTrashNote( + { isCurrent: () => current }, + "inbox/One.md", + ); + s.confirm(true); + await vi.waitFor(() => expect(s.bridge.moveToTrash).toHaveBeenCalled()); + current = false; + s.useStore + .getState() + .updateNoteBody("inbox/One.md", "Late edit before vault switch.\n"); + gate.resolve(); + expect(await trashing).toBe("stale"); + expect(s.files.get("trash/One 2.md")).toBe( + "Late edit before vault switch.\n", + ); + expect(s.useStore.getState().noteContents["trash/One 2.md"]).toBeUndefined(); + expect(s.files.has("inbox/One.md")).toBe(false); +}); + +it('stops a bulk trash after failure and reports completed source paths', async () => { + const s = await publicSetup() + s.bridge.moveToTrash.mockImplementation(async path => { + if (path.endsWith('Other.md')) throw new Error('Second move refused') + return s.relocate(path, 'trash/One.md') + }) + s.useStore.getState().updateNoteBody('inbox/Other.md', 'Keep the second draft.\n') + const batch = s.requestNoteBatch(s.host, ['inbox/One.md','inbox/Other.md'], 'trash') + const failure = expect(batch).rejects.toMatchObject({name:'NoteBatchError',completed:['inbox/One.md'],unconfirmed:['inbox/Other.md']}) + s.confirm(true) + await failure + expect(s.files.has('trash/One.md')).toBe(true) + expect(s.files.get('inbox/Other.md')).toBe('Keep the second draft.\n') + expect(s.useStore.getState().noteContents['inbox/Other.md'].body).toBe('Keep the second draft.\n') +}) + +it('deduplicates a confirmed batch and cancels the whole selection together', async () => { + const s=await publicSetup() + const cancelled=s.requestNoteBatch(s.host,['inbox/One.md','inbox/Other.md'],'trash') + s.confirm(false) + expect((await cancelled).status).toBe('cancelled') + expect(s.bridge.moveToTrash).not.toHaveBeenCalled() + const batch=s.requestNoteBatch(s.host,['inbox/One.md','inbox/One.md'],'trash') + s.confirm(true) + expect(await batch).toEqual({status:'completed',completed:['inbox/One.md'],unconfirmed:[]}) + expect(s.bridge.moveToTrash).toHaveBeenCalledTimes(1) +}) + +it('stops a batch if its host token changes after dispatch',async()=>{ + const s=await publicSetup() + let current=true + s.bridge.archiveNote.mockImplementation(async path=>{current=false;return s.relocate(path,'archive/One.md')}) + const result=await s.requestNoteBatch({isCurrent:()=>current},['inbox/One.md','inbox/Other.md'],'archive') + expect(result.status).toBe('stale') + expect(s.bridge.archiveNote).toHaveBeenCalledTimes(1) + expect(s.files.has('inbox/Other.md')).toBe(true) + expect(s.files.has('archive/One.md')).toBe(true) +}) + +it('saves and freezes all notes in remapped Trash before emptying it',async()=>{ + const s=await publicSetup(),gate=deferred() + const emptyTrash=vi.fn(async()=>{expect(s.files.get('Bin/Nested/One.md')).toBe('Saved draft.\n');await gate.promise;for(const path of s.files.keys())if(path.startsWith('Bin/'))s.files.delete(path)}) + Object.assign(s.bridge,{emptyTrash}) + s.files.set('Bin/Nested/One.md','Original') + const note={...s.metadata('Bin/Nested/One.md'),folder:'trash' as const,body:'Original'} + s.useStore.setState({vaultSettings:{...s.useStore.getState().vaultSettings,systemFolderPaths:{trash:'Bin'}},notes:[...s.useStore.getState().notes,note],noteContents:{...s.useStore.getState().noteContents,[note.path]:note}}) + s.useStore.getState().updateNoteBody(note.path,'Saved draft.\n') + const emptying=s.requestEmptyTrash(s.host) + s.confirm(true) + await vi.waitFor(()=>expect(emptyTrash).toHaveBeenCalled()) + s.useStore.getState().updateNoteBody(note.path,'Rejected edit') + expect(s.useStore.getState().noteContents[note.path].body).toBe('Saved draft.\n') + s.useStore.getState().updateNoteBody('inbox/Other.md','Still editable.\n') + gate.resolve() + expect(await emptying).toBe('completed') + await s.useStore.getState().flushDirtyNotes() + expect(s.files.has(note.path)).toBe(false) + expect(s.useStore.getState().noteContents[note.path]).toBeUndefined() + expect(s.files.get('inbox/Other.md')).toBe('Still editable.\n') +}) + +it('keeps Trash editors available when emptying fails',async()=>{ + const s=await publicSetup() + Object.assign(s.bridge,{emptyTrash:vi.fn(async()=>{throw new Error('denied')})}) + s.files.set('trash/One.md','Keep me') + const note={...s.metadata('trash/One.md'),body:'Keep me'} + s.useStore.setState({notes:[note],noteContents:{[note.path]:note}}) + const emptying=s.requestEmptyTrash(s.host) + const rejected=expect(emptying).rejects.toThrow('denied') + s.confirm(true) + await rejected + s.useStore.getState().updateNoteBody(note.path,'Editable after failure') + await s.useStore.getState().flushDirtyNotes() + expect(s.files.get(note.path)).toBe('Editable after failure') +}) diff --git a/packages/app-core/src/notes.ts b/packages/app-core/src/notes.ts new file mode 100644 index 00000000..63b9c74f --- /dev/null +++ b/packages/app-core/src/notes.ts @@ -0,0 +1,337 @@ +import { isWorkspaceTransitionPending } from './lib/workspace-transition'; +import type { NoteMeta } from "@shared/ipc"; +import { formDirContaining } from "@shared/databases"; +import { useStore } from "./store"; +import { confirmApp, getConfirmRequest } from "./lib/confirm-requests"; +import { getPromptRequest, promptApp } from "./lib/prompt-requests"; +import { + buildMoveNotePrompt, + parseMoveNoteTarget, + validateMoveNoteTarget, +} from "./lib/move-note"; +import { noteFolderSubpath } from "./lib/vault-layout"; +import { + confirmDeletePermanently, + confirmMoveToTrash, +} from "./lib/confirm-trash"; + +export interface NoteActionHost { + /** Capture the native vault token before opening the prompt and compare it here. */ + isCurrent(): boolean; +} + +/** Operational errors reject. Dispatched work may complete in its original vault. */ +export type NoteActionResult = + | "completed" + | "cancelled" + | "stale" + | "unavailable"; + +let pending = false; + +function validateDestination(value: string): string | null { + const error = validateMoveNoteTarget(value); + if (error) return error; + const { subpath } = parseMoveNoteTarget(value); + if ( + /[\u0000-\u001f]/.test(value) || + subpath.split("/").some((part) => part.startsWith(".")) + ) + return "Choose a folder without hidden names or parent-directory segments."; + if (formDirContaining(subpath)) + return "Database record folders are not move destinations."; + return null; +} + +function captureNoteActionContext(host: NoteActionHost): () => boolean { + const state = useStore.getState(); + const vault = state.vault; + const bridge = window.zen; + const layout = (settings: typeof state.vaultSettings) => + JSON.stringify([settings.primaryNotesLocation, settings.systemFolderPaths]); + const originalLayout = layout(state.vaultSettings); + const isCurrent = () => { + try { + const current = useStore.getState(); + return ( + !isWorkspaceTransitionPending() && + current.vault === vault && + window.zen === bridge && + layout(current.vaultSettings) === originalLayout && + host.isCurrent() + ); + } catch { + return false; + } + }; + return isCurrent; +} + +async function requestNoteAction( + host: NoteActionHost, + path: string, + action: ( + state: ReturnType, + note: NoteMeta, + isCurrent: () => boolean, + ) => Promise, + allowed: (note: NoteMeta) => boolean = (note) => note.folder !== "trash", +): Promise { + if ( + pending || + getPromptRequest() || + getConfirmRequest() || + formDirContaining(path) + ) + return "unavailable"; + const state = useStore.getState(); + const note = state.notes.find((note) => note.path === path); + if (!state.vault || !note || !allowed(note)) return "unavailable"; + const isCurrent = captureNoteActionContext(host); + if (!isCurrent()) return "unavailable"; + pending = true; + try { + return await action(state, note, isCurrent); + } finally { + pending = false; + } +} + +/** Prompt to move an ordinary note using logical inbox/archive folder names. */ +export async function requestMoveNote( + host: NoteActionHost, + path: string, +): Promise { + return requestNoteAction(host, path, async (state, note, isCurrent) => { + const subpath = noteFolderSubpath(note, state.vaultSettings); + const initialValue = + note.folder === "archive" || note.folder === "inbox" + ? [note.folder, subpath].filter(Boolean).join("/") + : "inbox"; + const target = await promptApp({ + ...buildMoveNotePrompt( + note, + state.folders.filter((folder) => !formDirContaining(folder.subpath)), + ), + initialValue, + validate: validateDestination, + }); + if (!target || validateDestination(target)) return "cancelled"; + if ( + !isCurrent() || + !useStore.getState().notes.some((note) => note.path === path) + ) + return "stale"; + const destination = parseMoveNoteTarget(target); + if (destination.folder === note.folder && destination.subpath === subpath) + return "cancelled"; + await useStore + .getState() + .moveNote(path, destination.folder, destination.subpath, isCurrent); + return isCurrent() ? "completed" : "stale"; + }); +} + +function validateTitle(value: string): string | null { + if (!value.trim()) return "Enter a note title."; + if (/[/\\:*?"<>|\u0000-\u001f]/.test(value) || value.trim().startsWith(".")) + return "Choose a title without reserved filename characters, control characters, or a leading dot."; + return null; +} + +/** Rename a note and update inbound wikilinks, including cached editor buffers. */ +export async function requestRenameNote( + host: NoteActionHost, + path: string, +): Promise { + return requestNoteAction(host, path, async (_state, note, isCurrent) => { + const title = await promptApp({ + title: "Rename note", + initialValue: note.title, + okLabel: "Rename", + validate: validateTitle, + }); + if (title === null || validateTitle(title) || title.trim() === note.title) + return "cancelled"; + if ( + !isCurrent() || + !useStore.getState().notes.some((note) => note.path === path) + ) + return "stale"; + await useStore.getState().renameNote(path, title.trim(), isCurrent); + return isCurrent() ? "completed" : "stale"; + }); +} + +async function requestLifecycle( + host: NoteActionHost, + path: string, + action: "archive" | "trash" | "restore" | "delete", +): Promise { + const allowed = (note: NoteMeta) => { + if (action === "restore") + return note.folder === "archive" || note.folder === "trash"; + if (action === "delete") return note.folder === "trash"; + if (action === "archive") + return note.folder === "inbox" || note.folder === "quick"; + return note.folder !== "trash"; + }; + return requestNoteAction( + host, + path, + async (state, note, isCurrent) => { + const confirmed = + action === "archive" + ? await state.confirmArchiveNotes([path]) + : action === "trash" + ? await confirmMoveToTrash( + note.title, + state.vault?.temporary === true, + ) + : action === "delete" + ? await confirmDeletePermanently(note.title) + : true; + if (!confirmed) return "cancelled"; + const current = useStore + .getState() + .notes.find((note) => note.path === path); + if (!isCurrent() || !current || !allowed(current)) return "stale"; + await useStore.getState().changeNoteLifecycle(path, action, isCurrent); + return isCurrent() ? "completed" : "stale"; + }, + allowed, + ); +} + +/** Save and archive a note, confirming when it contains unfinished tasks. */ +export function requestArchiveNote( + host: NoteActionHost, + path: string, +): Promise { + return requestLifecycle(host, path, "archive"); +} + +/** Confirm and save before moving to vault Trash (system Trash in temporary sessions). */ +export function requestTrashNote( + host: NoteActionHost, + path: string, +): Promise { + return requestLifecycle(host, path, "trash"); +} + +/** Restore an archived or trashed note to the configured primary notes location. */ +export function restoreNote( + host: NoteActionHost, + path: string, +): Promise { + return requestLifecycle(host, path, "restore"); +} + +/** Confirm, save, and permanently delete a trashed note. */ +export function requestDeleteNotePermanently( + host: NoteActionHost, + path: string, +): Promise { + return requestLifecycle(host, path, "delete"); +} + +export type NoteBatchAction = 'archive' | 'trash' | 'restore' | 'delete' | 'move' +export interface NoteBatchResult { + readonly status: NoteActionResult + /** Source paths confirmed complete before any stale transition. */ + readonly completed: readonly string[] + /** A stale/failed current item may already have moved. Read the snapshot before retrying. */ + readonly unconfirmed: readonly string[] +} + +export class NoteBatchError extends Error { + readonly completed: readonly string[] + readonly unconfirmed: readonly string[] + constructor(completed: string[], unconfirmed: string[], readonly originalError: unknown) { + super(`${completed.length} note actions completed. ${originalError instanceof Error ? originalError.message : String(originalError)}`) + this.name = 'NoteBatchError' + this.completed = Object.freeze([...completed]) + this.unconfirmed = Object.freeze([...unconfirmed]) + } +} + +/** One confirmation, ordered saves, and immediate stop on failure or a stale host. */ +export async function requestNoteBatch( + host: NoteActionHost, + requestedPaths: readonly string[], + action: NoteBatchAction +): Promise { + const paths = [...new Set(requestedPaths)] + const completed: string[] = [] + const result = (status: NoteActionResult): NoteBatchResult => Object.freeze({ + status, completed: Object.freeze([...completed]), unconfirmed: Object.freeze(paths.slice(completed.length)) + }) + if (!paths.length || paths.some(path => formDirContaining(path))) return result('unavailable') + const allowed = (note: NoteMeta) => action === 'restore' + ? note.folder === 'archive' || note.folder === 'trash' + : action === 'delete' ? note.folder === 'trash' + : action === 'archive' ? note.folder === 'inbox' || note.folder === 'quick' + : note.folder !== 'trash' + try { + const status = await requestNoteAction(host, paths[0], async (state, first, isCurrent) => { + const valid = () => paths.every(path => { + const note = useStore.getState().notes.find(note => note.path === path) + return note && allowed(note) + }) + if (!valid()) return 'unavailable' + let destination: ReturnType | null = null + if (action === 'move') { + const target = await promptApp({ + ...buildMoveNotePrompt({ ...first, title: `${paths.length} notes` }, state.folders.filter(folder => !formDirContaining(folder.subpath))), + initialValue: [first.folder === 'archive' ? 'archive' : 'inbox', noteFolderSubpath(first, state.vaultSettings)].filter(Boolean).join('/'), + validate: validateDestination + }) + if (!target || validateDestination(target)) return 'cancelled' + destination = parseMoveNoteTarget(target) + } else if (action === 'archive') { + if (!(await state.confirmArchiveNotes(paths))) return 'cancelled' + } else if (action !== 'restore') { + const deleting = action === 'delete' + if (!(await confirmApp({ + title: deleting ? `Delete ${paths.length} notes permanently?` : `Move ${paths.length} notes to Trash?`, + description: deleting ? 'This cannot be undone.' : state.vault?.temporary + ? 'Restore these files using your system file manager.' : 'You can restore these notes from the Trash view.', + confirmLabel: deleting ? 'Delete permanently' : 'Move to Trash', + danger: deleting + }))) return 'cancelled' + } + if (!isCurrent() || !valid()) return 'stale' + for (const path of paths) { + const current = useStore.getState().notes.find(note => note.path === path) + if (!isCurrent() || !current || !allowed(current)) return 'stale' + if (destination) { + if (destination.folder !== current.folder || destination.subpath !== noteFolderSubpath(current, state.vaultSettings)) + await useStore.getState().moveNote(path, destination.folder, destination.subpath, isCurrent) + } else { + await useStore.getState().changeNoteLifecycle(path, action as Exclude, isCurrent) + } + if (!isCurrent()) return 'stale' + completed.push(path) + } + return 'completed' + }, allowed) + return result(status) + } catch (error) { + throw new NoteBatchError(completed, paths.slice(completed.length), error) + } +} + + +/** Permanently clear the configured Trash with one save/lock operation. */ +export async function requestEmptyTrash(host: NoteActionHost): Promise { + if (pending || getPromptRequest() || getConfirmRequest() || !useStore.getState().vault) return 'unavailable' + const isCurrent = captureNoteActionContext(host) + if (!isCurrent()) return 'unavailable' + pending = true + try { + if (!(await confirmApp({title:'Empty Trash permanently?',description:'All files in Trash will be deleted. This cannot be undone.',confirmLabel:'Empty trash',danger:true}))) return 'cancelled' + if (!isCurrent()) return 'stale' + await useStore.getState().emptyTrash(isCurrent) + return isCurrent() ? 'completed' : 'stale' + } finally {pending=false} +} diff --git a/packages/app-core/src/public-host-api.test.ts b/packages/app-core/src/public-host-api.test.ts new file mode 100644 index 00000000..375633c4 --- /dev/null +++ b/packages/app-core/src/public-host-api.test.ts @@ -0,0 +1,275 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { VaultTask } from '@bridge-contract/tasks' + +beforeEach(() => { vi.resetModules(); localStorage.clear() }) +async function setup() { + Object.defineProperty(window, 'zen', { configurable: true, value: { + getCapabilities: () => ({}), getAppInfo: () => ({ runtime: 'web' }), platformSync: () => 'linux' + } }) + const { useStore } = await import('./store') + return { useStore, tasks: await import('./tasks'), settings: await import('./settings'), workspace: await import('./workspace') } +} +function task(): VaultTask { + return { id: 'inbox/One.md#0', sourcePath: 'inbox/One.md', noteTitle: 'One', noteFolder: 'inbox', + lineNumber: 0, taskIndex: 0, rawText: '- [/] Write #work @status:ready', content: 'Write', + checked: false, forwarded: false, cancelled: false, inProgress: true, waiting: false, + tags: ['work'], fields: { status: 'ready' } } +} +describe('public host APIs', () => { + it('publishes frozen task data and ignores unrelated updates; disposal stops notifications', async () => { + const s = await setup(), original = task() + s.useStore.setState({ vaultTasks: [original] }) + const first = s.tasks.getTasksSnapshot(), listener = vi.fn() + const dispose = s.tasks.subscribeTasks(listener) + expect(() => (first.tasks[0].tags as string[]).push('wrong')).toThrow() + expect(() => Object.assign(first.tasks[0].fields!, { status: 'wrong' })).toThrow() + s.useStore.setState({ searchOpen: true }) + expect(s.tasks.getTasksSnapshot()).toBe(first) + s.useStore.setState({ tasksLoading: true }) + expect(listener).toHaveBeenCalledTimes(1) + dispose(); s.useStore.setState({ tasksLoading: false }) + expect(listener).toHaveBeenCalledTimes(1) + expect(original.tags).toEqual(['work']) + }) + it('moves the current task with desktop semantics and rejects stale host/grouping', async () => { + const s = await setup(), applyTaskMutation = vi.fn().mockResolvedValue(undefined), original = task() + s.useStore.setState({ vault: { root: '/test', name: 'Test' }, vaultTasks: [original], kanbanGroupBy: 'status', applyTaskMutation }) + expect(await s.tasks.moveTaskToColumn({ isCurrent: () => false }, original.id, 'status', 'today')).toBe(false) + expect(await s.tasks.moveTaskToColumn({ isCurrent: () => true }, original.id, 'priority', 'high')).toBe(false) + expect(applyTaskMutation).not.toHaveBeenCalled() + expect(await s.tasks.moveTaskToColumn({ isCurrent: () => true }, original.id, 'status', 'today')).toBe(true) + const changes = applyTaskMutation.mock.calls[0][1] + expect(changes).toContainEqual({ kind: 'set-in-progress', inProgress: false }) + expect(changes).toContainEqual({ kind: 'set-checked', checked: false }) + const today = new Date() + expect(changes).toContainEqual({ kind: 'set-due', due: `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}` }) + }) + it('exposes profile display fields without retaining credentials or mutable entries', async () => { + const s = await setup() + const profile = { id: 'one', name: 'Private server', baseUrl: 'https://example.test', vaultPath: null, + lastConnectedAt: null, hasCredential: true, authToken: 'never expose' } + s.useStore.setState({ remoteWorkspaceProfiles: [profile] }) + const publicProfile = s.workspace.getWorkspaceSnapshot().remoteProfiles[0] + expect(publicProfile).not.toHaveProperty('authToken') + expect(Object.isFrozen(publicProfile)).toBe(true) + expect(publicProfile).not.toBe(profile) + }) + it('clamps finite font gestures and publishes only changed settings', async () => { + const s = await setup(), setEditorFontSize = vi.fn() + s.useStore.setState({ setEditorFontSize }) + s.settings.setEditorFontSize(Number.NaN) + s.settings.setEditorFontSize(Infinity) + expect(setEditorFontSize).not.toHaveBeenCalled() + s.settings.setEditorFontSize(100); s.settings.setEditorFontSize(1) + expect(setEditorFontSize.mock.calls).toEqual([[28],[12]]) + const first = s.settings.getSettingsSnapshot() + s.useStore.setState({ sidebarOpen: false }) + expect(s.settings.getSettingsSnapshot()).toBe(first) + s.settings.setSettingsVisible(true) + expect(s.settings.getSettingsSnapshot().open).toBe(true) + }) + it('does not let a second host dialog replace an unresolved prompt', async () => { + await setup() + const dialogs = await import('./dialogs'), requests = await import('./lib/prompt-requests') + const pending = dialogs.prompt({ title: 'First' }), request = requests.getPromptRequest()! + expect(await dialogs.prompt({ title: 'Second' })).toBeNull() + expect(await dialogs.confirm({ title: 'Second' })).toBe(false) + expect(requests.getPromptRequest()).toBe(request) + requests.settlePromptRequest(request, 'answer') + expect(await pending).toBe('answer') + }) + it('rechecks command availability at invocation instead of retaining stale closures', async () => { + const s = await setup(), commands = await import('./commands') + expect(await commands.runAppCommand('not-a-command')).toBe(false) + const archiveActive = vi.fn() + s.useStore.setState({ archiveActive }) + expect(commands.getAppCommands().find(c => c.id === 'note.archive')?.available).toBe(false) + expect(await commands.runAppCommand('note.archive')).toBe(false) + expect(archiveActive).not.toHaveBeenCalled() + commands.showSearch() + expect(s.useStore.getState().searchOpen).toBe(true) + }) +}) + +describe('workspace transition reservation', () => { + it('reserves the entire save drain and bridge selection against competing transitions', async () => { + const s = await setup() + let release!: () => void + const drain = new Promise(resolve => { release = resolve }) + const open = vi.fn().mockResolvedValue(null), disconnect = vi.fn() + Object.assign(window.zen, { openLocalVault: open, disconnectRemoteWorkspace: disconnect }) + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, flushDirtyNotes: () => drain }) + const pending = s.workspace.openLocalVault('/first') + await s.workspace.openLocalVault('/second') + await s.workspace.disconnectRemoteWorkspace() + expect(open).not.toHaveBeenCalled() + expect(disconnect).not.toHaveBeenCalled() + release(); await pending + expect(open.mock.calls).toEqual([['/first']]) + await s.workspace.openLocalVault('/second') + expect(open.mock.calls).toEqual([['/first'], ['/second']]) + }) + it('does not dispatch navigation during a pending host switch or resume a read after cancellation', async () => { + const s = await setup(), navigation = await import('./navigation') + let release!: () => void + const selection = new Promise(resolve => { release = () => resolve(null) }) + const read = vi.fn() + Object.assign(window.zen, { openLocalVault: () => selection, readNote: read }) + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, flushDirtyNotes: async () => {} }) + const pending = s.workspace.openLocalVault('/next') + await navigation.openNote('inbox/One.md') + expect(read).not.toHaveBeenCalled() + release(); await pending + }) + it('previews font size in memory and persists once when the gesture completes', async () => { + const s = await setup(), persist = vi.fn() + s.useStore.setState({ setEditorFontSize: persist }) + s.settings.setEditorFontSize(17.5, { persist: false }) + s.settings.setEditorFontSize(30, { persist: false }) + expect(s.settings.getSettingsSnapshot().editorFontSize).toBe(28) + expect(persist).not.toHaveBeenCalled() + s.settings.setEditorFontSize(s.settings.getSettingsSnapshot().editorFontSize) + expect(persist.mock.calls).toEqual([[28]]) + }) +}) + +describe('workspace input safety', () => { + it('locks editor and database input through a cancelled picker, then releases it', async () => { + const s = await setup(), locks = await import('./lib/note-lifecycle-lock') + const vault = { root: '/current', name: 'Current' } + let release!: () => void + const picker = new Promise(resolve => { release = () => resolve(null) }) + Object.assign(window.zen, { openLocalVault: vi.fn(() => picker) }) + s.useStore.setState({ vault, flushDirtyNotes: async () => {} }) + const pending = s.workspace.openLocalVault('/next') + await vi.waitFor(() => expect(window.zen.openLocalVault).toHaveBeenCalled()) + expect(locks.isNoteEditingLocked(vault, 'inbox/Newly opened.md')).toBe(true) + expect(locks.isNoteEditingLocked(vault, 'inbox/Projects.base/data.csv')).toBe(true) + expect(s.workspace.getWorkspaceSnapshot().transitioning).toBe(true) + release(); await pending + expect(locks.isNoteEditingLocked(vault, 'inbox/Newly opened.md')).toBe(false) + expect(s.workspace.getWorkspaceSnapshot().transitioning).toBe(false) + }) + it('clears invalidated navigation markers even if the host picker cancels', async () => { + const s = await setup(), navigation = await import('./navigation') + let finishRead!: (value: unknown) => void + const read = new Promise(resolve => { finishRead = resolve }) + Object.assign(window.zen, { readNote: vi.fn(() => read), openLocalVault: async () => null }) + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, flushDirtyNotes: async () => {} }) + const pending = navigation.openNote('inbox/One.md') + expect(s.useStore.getState().loadingNote).toBe(true) + await s.workspace.openLocalVault('/next') + finishRead({ path: 'inbox/One.md', body: 'Old body' }) + await pending + expect(s.useStore.getState()).toMatchObject({ loadingNote: false, pendingJumpLocation: null, selectedPath: null }) + }) + it('leaves back, Home, daily creation and app pages alone while a transition is reserved', async () => { + const s = await setup(), navigation = await import('./navigation') + let release!: () => void + const picker = new Promise(resolve => { release = () => resolve(null) }) + Object.assign(window.zen, { openLocalVault: () => picker, readNote: vi.fn() }) + const daily = vi.fn(), tasks = vi.fn() + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, flushDirtyNotes: async () => {}, + selectedPath: 'inbox/Current.md', noteBackstack: [{ path: 'inbox/Old.md' } as never], + openTodayDailyNote: daily, openTasksView: tasks }) + const pending = s.workspace.openLocalVault('/next') + await navigation.goBack(); navigation.goHome() + await navigation.openTodayDailyNote(); await navigation.openAppPage('tasks') + expect(s.useStore.getState().selectedPath).toBe('inbox/Current.md') + expect(window.zen.readNote).not.toHaveBeenCalled() + expect(daily).not.toHaveBeenCalled(); expect(tasks).not.toHaveBeenCalled() + release(); await pending + }) +}) + +describe('comments during workspace selection', () => { + it('drains an existing comment write and rejects new comments while the host switches', async () => { + const s = await setup() + let finishWrite!: (value: unknown[]) => void, finishOpen!: () => void + const write = new Promise(resolve => { finishWrite = resolve }) + const opening = new Promise(resolve => { finishOpen = () => resolve(null) }) + Object.assign(window.zen, { writeNoteComments: vi.fn(() => write), openLocalVault: vi.fn(() => opening) }) + s.useStore.setState({ vault: { root: '/current', name: 'Current' }, noteComments: { 'inbox/One.md': [] } }) + const comment = s.useStore.getState().addNoteComment({ notePath: 'inbox/One.md', body: 'Keep this comment', anchor: null } as never) + const switching = s.workspace.openLocalVault('/next') + await Promise.resolve() + expect(window.zen.openLocalVault).not.toHaveBeenCalled() + finishWrite([]); await comment + await vi.waitFor(() => expect(window.zen.openLocalVault).toHaveBeenCalled()) + expect(await s.useStore.getState().addNoteComment({ notePath: 'inbox/One.md', body: 'Too late', anchor: null } as never)).toBeNull() + expect(window.zen.writeNoteComments).toHaveBeenCalledTimes(1) + finishOpen(); await switching + }) +}) + +describe('host vault relocation', () => { + it('reserves before draining saves and locks input until native relocation finishes', async () => { + const s = await setup(), locks = await import('./lib/note-lifecycle-lock') + const vault = { root: '/old', name: 'Old' }, events: string[] = [] + let finishDrain!: () => void, finishMove!: () => void + const drain = new Promise(resolve => { finishDrain = resolve }) + const moving = new Promise(resolve => { finishMove = resolve }) + s.useStore.setState({ vault, flushDirtyNotes: async () => { events.push('drain'); await drain } }) + const open = vi.fn().mockResolvedValue(null) + Object.assign(window.zen, { openLocalVault: open }) + const pending = s.workspace.relocateLocalVault({ + move: async () => { events.push('move'); await moving }, rollback: vi.fn() + }) + await s.workspace.openLocalVault('/other') + expect(open).not.toHaveBeenCalled() + expect(events).toEqual(['drain']) + finishDrain() + await vi.waitFor(() => expect(events).toContain('move')) + expect(events).toEqual(['drain', 'drain', 'move']) + expect(locks.isNoteEditingLocked(vault, 'inbox/One.md')).toBe(true) + await expect(s.workspace.relocateLocalVault({ move: vi.fn(), rollback: vi.fn() })).rejects.toThrow('Wait') + finishMove(); await pending + expect(locks.isNoteEditingLocked(vault, 'inbox/One.md')).toBe(false) + }) + it('rolls native storage back and restores the saved workspace when reopening fails', async () => { + const s = await setup(), events: string[] = [] + const vault = { root: '/old', name: 'Old' } + s.useStore.setState({ vault, selectedPath: 'inbox/Keep.md', noteContents: { 'inbox/Keep.md': { body: 'Exact café \n' } as never }, + flushDirtyNotes: async () => {}, refreshLocalVaults: async () => [] }) + Object.assign(window.zen, { openLocalVault: async (root: string) => { + events.push(`open:${root}`) + if (root === 'new-token') throw new Error('Provider unavailable') + return vault + } }) + await expect(s.workspace.relocateLocalVault({ + reopen: { source: 'old-token', destination: 'new-token' }, + move: async () => { events.push('move') }, rollback: async () => { events.push('rollback') } + })).rejects.toThrow('Provider unavailable') + expect(events).toEqual(['move', 'open:new-token', 'rollback', 'open:old-token']) + expect(s.useStore.getState().vault).toBe(vault) + expect(s.useStore.getState().selectedPath).toBe('inbox/Keep.md') + expect(s.useStore.getState().noteContents['inbox/Keep.md'].body).toBe('Exact café \n') + expect(s.workspace.getWorkspaceSnapshot().transitioning).toBe(false) + }) + it('stops active vault writers and surfaces both errors when native rollback fails', async () => { + const s = await setup() + s.useStore.setState({ vault: { root: '/old', name: 'Old' }, flushDirtyNotes: async () => {} }) + Object.assign(window.zen, { openLocalVault: async () => { throw new Error('reopen failed') } }) + await expect(s.workspace.relocateLocalVault({ + reopen: { source: 'old', destination: 'new' }, move: async () => {}, + rollback: async () => { throw new Error('rollback failed') } + })).rejects.toThrow('relocation and recovery failed') + expect(s.useStore.getState().vault).toBeNull() + expect(s.workspace.getWorkspaceSnapshot().restored).toBe(false) + expect(s.useStore.getState().workspaceSetupError).toContain('checking its storage location') + }) + it('keeps host generation invalidated after a cancelled or failed switch', async () => { + const s = await setup() + s.useStore.setState({ vault: { root: '/old', name: 'Old' }, flushDirtyNotes: async () => {} }) + const original = s.workspace.getWorkspaceSnapshot() + Object.assign(window.zen, { openLocalVault: async () => null }) + await s.workspace.openLocalVault('/cancelled') + const cancelled = s.workspace.getWorkspaceSnapshot() + expect(cancelled.generation).toBeGreaterThan(original.generation) + expect(cancelled.transitioning).toBe(false) + Object.assign(window.zen, { openLocalVault: async () => { throw new Error('failed') } }) + await s.workspace.openLocalVault('/failed') + expect(s.workspace.getWorkspaceSnapshot().generation).toBeGreaterThan(cancelled.generation) + }) +}) diff --git a/packages/app-core/src/settings.ts b/packages/app-core/src/settings.ts new file mode 100644 index 00000000..4889e31c --- /dev/null +++ b/packages/app-core/src/settings.ts @@ -0,0 +1,38 @@ +import { useSyncExternalStore } from 'react' +import { useStore } from './store' + +export interface SettingsSnapshot { + readonly themeId: string + readonly themeMode: 'light' | 'dark' | 'auto' + readonly open: boolean + readonly editorFontSize: number + readonly dailyNotesEnabled: boolean + readonly calendarAvailable: boolean +} +let snapshot: SettingsSnapshot | undefined +export function getSettingsSnapshot(): SettingsSnapshot { + const state = useStore.getState() + const next = { themeId: state.themeId, themeMode: state.themeMode, open: state.settingsOpen, editorFontSize: state.editorFontSize, + dailyNotesEnabled: state.vaultSettings.dailyNotes.enabled, + calendarAvailable: state.vaultSettings.dailyNotes.enabled || state.vaultSettings.weeklyNotes.enabled } + if (!snapshot || (Object.keys(next) as Array).some(key => snapshot![key] !== next[key])) + snapshot = Object.freeze(next) + return snapshot +} +export function subscribeSettings(listener: (next: SettingsSnapshot, previous: SettingsSnapshot) => void): () => void { + let previous = getSettingsSnapshot() + return useStore.subscribe(() => { + const next = getSettingsSnapshot() + if (next === previous) return + const before = previous; previous = next; listener(next, before) + }) +} +function subscribeReact(notify: () => void): () => void { return subscribeSettings(() => notify()) } +export function useSettingsSnapshot(): SettingsSnapshot { return useSyncExternalStore(subscribeReact, getSettingsSnapshot, getSettingsSnapshot) } +export function setSettingsVisible(open: boolean): void { useStore.getState().setSettingsOpen(open) } +export function setEditorFontSize(size: number, options?: { persist?: boolean }): void { + if (!Number.isFinite(size)) return + const clamped = Math.max(12, Math.min(28, Math.round(size))) + if (options?.persist === false) useStore.setState({ editorFontSize: clamped }) + else useStore.getState().setEditorFontSize(clamped) +} diff --git a/packages/app-core/src/shell.test.ts b/packages/app-core/src/shell.test.ts new file mode 100644 index 00000000..d756119b --- /dev/null +++ b/packages/app-core/src/shell.test.ts @@ -0,0 +1,326 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NoteMeta } from '@bridge-contract/ipc' + +const disposers: Array<() => void> = [] +const note = (path: string, extra: Partial = {}): NoteMeta => ({ + path, + title: path.split('/').pop()!.replace(/\.md$/, ''), + folder: 'inbox', + createdAt: 0, + updatedAt: 0, + siblingOrder: 0, + size: 10, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: 'Private preview', + ...extra +}) + +beforeEach(() => { + vi.resetModules() + localStorage.clear() + Object.defineProperty(window, 'zen', { + configurable: true, + value: { getCapabilities: () => ({}) } + }) +}) +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose() +}) + +async function setup(notes = [note('inbox/One.md')]) { + const { useStore } = await import('./store') + const shell = await import('./shell') + useStore.setState({ + notes, + vault: { root: '/test', name: 'Test' }, + workspaceRestored: true + }) + return { useStore, ...shell } +} + +describe('public shell snapshots', () => { + it('exposes frozen, copied identity metadata without bodies, credentials, or store internals', async () => { + const s = await setup() + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + const snapshot = s.getShellSnapshot() + expect(snapshot.selectedNote).toBe(snapshot.notes[0]) + expect(snapshot.notes[0]).toEqual({ + path: 'inbox/One.md', + title: 'One', + folder: 'inbox', + directory: '', + createdAt: 0, + updatedAt: 0 + }) + expect(snapshot).not.toHaveProperty('activeNote') + expect(snapshot).not.toHaveProperty('remoteWorkspaceProfiles') + expect(snapshot).not.toHaveProperty('paneLayout') + expect(snapshot).not.toHaveProperty('editorViewRef') + expect(snapshot.vault).not.toBe(s.useStore.getState().vault) + expect(snapshot.notes[0]).not.toBe(s.useStore.getState().notes[0]) + for (const value of [ + snapshot, + snapshot.notes, + snapshot.notes[0], + snapshot.vault + ]) + expect(Object.isFrozen(value)).toBe(true) + expect(() => + Object.assign(snapshot.notes[0], { title: 'Changed' }) + ).toThrow() + expect(s.useStore.getState().notes[0].title).toBe('One') + }) + + it('keeps snapshot identity across repeated reads and unrelated editor changes', async () => { + const s = await setup() + const before = s.getShellSnapshot() + s.useStore.setState({ editorFontSize: 25, activeDirty: true }) + expect(s.getShellSnapshot()).toBe(before) + expect(s.getShellSnapshot()).toBe(before) + const settings = s.useStore.getState().vaultSettings + s.useStore.setState({ + vaultSettings: { + ...settings, + dailyNotes: { + ...settings.dailyNotes, + enabled: !settings.dailyNotes.enabled + } + } + }) + expect(s.getShellSnapshot()).toBe(before) + }) + + it('copies changed metadata without mutating previously delivered snapshots', async () => { + const s = await setup() + const before = s.getShellSnapshot() + s.useStore.setState({ + notes: [note('inbox/One.md', { title: 'Renamed', updatedAt: 8 })] + }) + const after = s.getShellSnapshot() + expect(after.notes[0].title).toBe('Renamed') + expect(before.notes[0].title).toBe('One') + expect(after.vault).toBe(before.vault) + }) + + it('reports Home, virtual pages, and removed notes without inventing a selected note', async () => { + const s = await setup() + for (const selectedPath of [null, 'zen://help', 'inbox/Missing.md']) { + s.useStore.setState({ selectedPath }) + expect(s.getShellSnapshot()).toMatchObject({ + selectedPath, + selectedNote: null + }) + } + }) + + it('notifies only on public changes with coherent previous snapshots and supports disposal', async () => { + const s = await setup() + const before = s.getShellSnapshot() + const listener = vi.fn() + const dispose = s.subscribeShell(listener) + disposers.push(dispose) + expect(listener).not.toHaveBeenCalled() + s.useStore.setState({ activeDirty: true }) + expect(listener).not.toHaveBeenCalled() + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + expect(listener).toHaveBeenCalledExactlyOnceWith( + s.getShellSnapshot(), + before + ) + expect(s.getShellSnapshot().notes).toBe(before.notes) + dispose() + s.useStore.setState({ selectedPath: null }) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('tracks workspace replacement and restoration without exposing mutable vault objects', async () => { + const s = await setup() + const before = s.getShellSnapshot() + s.useStore.setState({ + vault: { root: '/other', name: 'Other', temporary: true }, + notes: [], + workspaceMode: 'remote', + workspaceRestored: false + }) + expect(s.getShellSnapshot()).toMatchObject({ + vault: { root: '/other', name: 'Other', temporary: true }, + notes: [], + workspaceMode: 'remote', + workspaceRestored: false + }) + expect(before.vault?.root).toBe('/test') + s.useStore.setState({ vault: null }) + expect(s.getShellSnapshot().vault).toBeNull() + }) + + it('observes the current state after an earlier subscriber corrects a transition', async () => { + const s = await setup() + disposers.push( + s.useStore.subscribe((state) => { + if (state.selectedPath === 'inbox/One.md') + s.useStore.setState({ selectedPath: null }) + }) + ) + const listener = vi.fn() + disposers.push(s.subscribeShell(listener)) + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + expect(listener).not.toHaveBeenCalled() + expect(s.getShellSnapshot().selectedPath).toBeNull() + }) + + it('keeps previous snapshots coherent when a listener causes another public change', async () => { + const s = await setup() + const transitions: Array<[string | null, string | null]> = [] + disposers.push( + s.subscribeShell((next, previous) => { + transitions.push([previous.selectedPath, next.selectedPath]) + if (next.selectedPath) s.useStore.setState({ selectedPath: null }) + }) + ) + s.useStore.setState({ selectedPath: 'inbox/One.md' }) + expect(transitions).toEqual([ + [null, 'inbox/One.md'], + ['inbox/One.md', null] + ]) + }) + + it('recomputes folder-relative directories when system folders or primary location change', async () => { + const s = await setup([ + note('Notes/Work/One.md'), + note('Saved/Two.md', { folder: 'archive' }) + ]) + const settings = s.useStore.getState().vaultSettings + s.useStore.setState({ + vaultSettings: { + ...settings, + systemFolderPaths: { + ...settings.systemFolderPaths, + inbox: 'Notes', + archive: 'Saved' + } + } + }) + expect(s.getShellSnapshot().notes.map((n) => n.directory)).toEqual([ + 'Work', + '' + ]) + s.useStore.setState({ + vaultSettings: { + ...s.useStore.getState().vaultSettings, + primaryNotesLocation: 'root' + } + }) + expect(s.getShellSnapshot().notes.map((n) => n.directory)).toEqual([ + 'Notes/Work', + '' + ]) + }) +}) + +describe('public Browse ordering', () => { + const names = (rows: readonly { title: string }[]) => rows.map((n) => n.title) + it.each([ + ['name-asc', ['Note 2', 'Note 10', 'Note 20']], + ['name-desc', ['Note 20', 'Note 10', 'Note 2']], + ['updated-asc', ['Note 10', 'Note 2', 'Note 20']], + ['updated-desc', ['Note 20', 'Note 2', 'Note 10']], + ['created-asc', ['Note 20', 'Note 10', 'Note 2']], + ['created-desc', ['Note 2', 'Note 10', 'Note 20']], + ['none', ['Note 20', 'Note 2', 'Note 10']], + ['manual', ['Note 20', 'Note 2', 'Note 10']] + ] as const)( + 'preserves mobile %s sorting', + async (noteSortOrder, expected) => { + const s = await setup([ + note('inbox/Note 10.md', { updatedAt: 1, createdAt: 2 }), + note('inbox/Note 2.md', { updatedAt: 2, createdAt: 3 }), + note('inbox/Note 20.md', { updatedAt: 3, createdAt: 1 }) + ]) + s.useStore.setState({ noteSortOrder }) + const snapshot = s.getShellSnapshot() + expect(names(s.getBrowseNotes(snapshot))).toEqual(expected) + expect(names(snapshot.notes)).toEqual(['Note 10', 'Note 2', 'Note 20']) + } + ) + + it('pins first while retaining sorted order within both groups and input order for ties', async () => { + const s = await setup( + ['C', 'A', 'B', 'D'].map((name) => note(`inbox/${name}.md`)) + ) + const pins = ['inbox/B.md', 'inbox/C.md', 'inbox/B.md', 'inbox/Gone.md'] + expect(names(s.getBrowseNotes(s.getShellSnapshot(), '', pins))).toEqual([ + 'C', + 'B', + 'A', + 'D' + ]) + s.useStore.setState({ noteSortOrder: 'name-asc' }) + const rows = s.getBrowseNotes(s.getShellSnapshot(), '', pins) + expect(names(rows)).toEqual(['B', 'C', 'A', 'D']) + expect(Object.isFrozen(rows)).toBe(true) + expect(pins).toHaveLength(4) + }) + + it('keeps navigation in the immediate primary folder and stops at both ends', async () => { + const s = await setup([ + note('inbox/Work/A.md'), + note('inbox/Work/B.md'), + note('inbox/Work/nested/C.md'), + note('inbox/D.md'), + note('archive/E.md', { folder: 'archive' }), + note('quick/F.md', { folder: 'quick' }) + ]) + const snapshot = s.getShellSnapshot() + expect(names(s.getBrowseNotes(snapshot, 'Work'))).toEqual(['A', 'B']) + expect(s.getAdjacentNotePath(snapshot, 'inbox/Work/A.md', 'next')).toBe( + 'inbox/Work/B.md' + ) + expect(s.getAdjacentNotePath(snapshot, 'inbox/Work/B.md', 'previous')).toBe( + 'inbox/Work/A.md' + ) + expect( + s.getAdjacentNotePath(snapshot, 'inbox/Work/A.md', 'previous') + ).toBeNull() + expect( + s.getAdjacentNotePath(snapshot, 'inbox/Work/B.md', 'next') + ).toBeNull() + for (const path of [ + 'archive/E.md', + 'quick/F.md', + 'zen://help', + 'missing.md' + ]) + expect(s.getAdjacentNotePath(snapshot, path, 'next')).toBeNull() + expect( + s.getAdjacentNotePath(snapshot, 'inbox/Work/B.md', 'next', [ + 'inbox/Work/B.md' + ]) + ).toBe('inbox/Work/A.md') + }) + + it('excludes database records at every depth while retaining ordinary similarly named folders', async () => { + const s = await setup([ + note('inbox/People.base/One.md'), + note('inbox/People.base/pages/Two.md'), + note('inbox/Work/PEOPLE.BASE/pages/Three.md'), + note('inbox/People.base-notes/Four.md') + ]) + const snapshot = s.getShellSnapshot() + for (const path of [ + 'People.base', + 'People.base/pages', + 'Work/PEOPLE.BASE/pages' + ]) + expect(s.getBrowseNotes(snapshot, path)).toEqual([]) + for (const n of snapshot.notes.slice(0, 3)) + expect(s.getAdjacentNotePath(snapshot, n.path, 'next')).toBeNull() + expect(names(s.getBrowseNotes(snapshot, 'People.base-notes'))).toEqual([ + 'Four' + ]) + }) +}) diff --git a/packages/app-core/src/shell.ts b/packages/app-core/src/shell.ts new file mode 100644 index 00000000..275ff31c --- /dev/null +++ b/packages/app-core/src/shell.ts @@ -0,0 +1,203 @@ +import { noteTagsForCount } from './lib/tags' +import { resolveTypstPreambleFolder } from './lib/typst-preamble' +import { useSyncExternalStore } from 'react' +import type { + NoteFolder, + NoteMeta, + VaultInfo, + WorkspaceMode +} from '@bridge-contract/ipc' +import { formDirContaining } from '@shared/databases' +import { resolveFolderPath } from '@shared/system-folder-paths' +import { useStore } from './store' +import { parentDirOf } from './lib/manual-order' +import { browseNoteComparator, type NoteSortOrder } from './lib/note-order' +import { notePathWithinFolder } from './lib/vault-layout' + +export type { NoteSortOrder } from './lib/note-order' + +export interface ShellNote { + readonly path: string + readonly title: string + readonly folder: NoteFolder + /** Parent directory relative to this note's logical folder; empty at its root. */ + readonly directory: string + readonly createdAt: number + readonly updatedAt: number +} + +export interface ShellSnapshot { + /** Display/change metadata. Persist native state under the host's stable vault token. */ + readonly vault: Readonly | null + readonly workspaceMode: WorkspaceMode + /** Workspace restoration state; native note-index readiness remains host-owned. */ + readonly workspaceRestored: boolean + readonly notes: readonly ShellNote[] + readonly selectedPath: string | null + /** Null for Home, virtual pages, or a path absent from the note index. */ + readonly selectedNote: ShellNote | null + readonly canGoBack: boolean + readonly canGoForward: boolean + readonly noteSortOrder: NoteSortOrder +} + +let notesSource: readonly NoteMeta[] | undefined +let notesLayout = '' +let notes: readonly ShellNote[] = Object.freeze([]) +let vault: ShellSnapshot['vault'] = null +let snapshot: ShellSnapshot | undefined + +/** Read frozen shell metadata, without note bodies, credentials, or mutable store values. */ +export function getShellSnapshot(): ShellSnapshot { + const state = useStore.getState() + const settings = state.vaultSettings + const layout = JSON.stringify([ + settings.primaryNotesLocation, + ...(['inbox', 'quick', 'archive', 'trash'] as const).map((folder) => + resolveFolderPath(folder, settings.systemFolderPaths) + ) + ]) + if (notesSource !== state.notes || notesLayout !== layout) { + notesSource = state.notes + notesLayout = layout + notes = Object.freeze( + state.notes.map((note) => + Object.freeze({ + path: note.path, + title: note.title, + folder: note.folder, + directory: parentDirOf( + notePathWithinFolder(note.path, note.folder, settings) + ), + createdAt: note.createdAt, + updatedAt: note.updatedAt + }) + ) + ) + } + if (!state.vault) vault = null + else if ( + vault?.root !== state.vault.root || + vault.name !== state.vault.name || + vault.temporary !== state.vault.temporary + ) { + vault = Object.freeze({ + root: state.vault.root, + name: state.vault.name, + temporary: state.vault.temporary + }) + } + const next: ShellSnapshot = { + vault, + notes, + workspaceMode: state.workspaceMode, + workspaceRestored: state.workspaceRestored && !state.workspaceTransitioning, + selectedPath: state.selectedPath, + selectedNote: + snapshot?.notes === notes && snapshot.selectedPath === state.selectedPath + ? snapshot.selectedNote + : (notes.find((note) => note.path === state.selectedPath) ?? null), + canGoBack: state.noteBackstack.length > 0, + canGoForward: state.noteForwardstack.length > 0, + noteSortOrder: state.noteSortOrder + } + if ( + !snapshot || + (Object.keys(next) as Array).some( + (key) => next[key] !== snapshot![key] + ) + ) { + snapshot = Object.freeze(next) + } + return snapshot +} + +/** Notify after a public snapshot changes. Does not emit an initial notification. */ +export function subscribeShell( + listener: (snapshot: ShellSnapshot, previous: ShellSnapshot) => void +): () => void { + let previous = getShellSnapshot() + return useStore.subscribe(() => { + // An earlier subscriber can synchronously correct a transition, such as the + // Home guard after a rescan. Read current state rather than a stale event. + const next = getShellSnapshot() + if (next === previous) return + const before = previous + previous = next + listener(next, before) + }) +} + +function subscribeReact(notify: () => void): () => void { + return subscribeShell(() => notify()) +} + +export function useShellSnapshot(): ShellSnapshot { + return useSyncExternalStore( + subscribeReact, + getShellSnapshot, + getShellSnapshot + ) +} + +/** Immediate primary-folder notes in mobile Browse order. Pins remain host-owned. */ +export function getBrowseNotes( + snapshot: Pick, + directory = '', + pinnedPaths: readonly string[] = [] +): readonly ShellNote[] { + if (formDirContaining(directory)) return Object.freeze([]) + const rows = snapshot.notes + .filter((note) => note.folder === 'inbox' && note.directory === directory) + .sort(browseNoteComparator(snapshot.noteSortOrder)) + const pins = new Set(pinnedPaths) + return Object.freeze([ + ...rows.filter((note) => pins.has(note.path)), + ...rows.filter((note) => !pins.has(note.path)) + ]) +} + +/** Find a Browse sibling without opening it or wrapping at either end. */ +export function getAdjacentNotePath( + snapshot: ShellSnapshot, + path: string, + direction: 'previous' | 'next', + pinnedPaths: readonly string[] = [] +): string | null { + const note = snapshot.notes.find((note) => note.path === path) + if (!note || note.folder !== 'inbox') return null + const rows = getBrowseNotes(snapshot, note.directory, pinnedPaths) + const index = rows.findIndex((note) => note.path === path) + return index < 0 + ? null + : (rows[index + (direction === 'next' ? 1 : -1)]?.path ?? null) +} + + +export interface TagPresenceSnapshot { + readonly vaultRoot: string | null + readonly hasTags: boolean +} +let tagNotes: unknown, tagActive: unknown, tagFolder: string | undefined +let tagPresence: TagPresenceSnapshot | undefined +/** Tag presence includes the live editor and excludes Typst preambles. */ +export function getTagPresenceSnapshot(): TagPresenceSnapshot { + const state = useStore.getState() + const folder = resolveTypstPreambleFolder(state.vaultSettings.typstPreambles?.folder) + if (tagNotes === state.notes && tagActive === state.activeNote && tagFolder === folder && tagPresence?.vaultRoot === (state.vault?.root ?? null)) return tagPresence! + tagNotes = state.notes; tagActive = state.activeNote; tagFolder = folder + const hasTags = state.notes.some(note => note.folder !== 'trash' && noteTagsForCount(note, state.activeNote, folder).length > 0) + const vaultRoot = state.vault?.root ?? null + if (!tagPresence || tagPresence.vaultRoot !== vaultRoot || tagPresence.hasTags !== hasTags) + tagPresence = Object.freeze({ vaultRoot, hasTags }) + return tagPresence +} +export function subscribeTagPresence(listener: (next: TagPresenceSnapshot) => void): () => void { + let previous = getTagPresenceSnapshot() + return useStore.subscribe(() => { + const next = getTagPresenceSnapshot() + if (next === previous) return + previous = next; listener(next) + }) +} +export function setNoteSortOrder(order: NoteSortOrder): void { useStore.getState().setNoteSortOrder(order) } diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 7cf9d9f3..6a2b6dc9 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -1698,15 +1698,17 @@ describe('deleteDatabaseRows (#391 — purge record-page schema mappings)', () = } it('purges the deleted row page mapping and trashes the note on confirm', async () => { - const moveToTrash = vi.fn().mockResolvedValue({}) + const moveToTrash = vi.fn().mockResolvedValue({ ...makeNote('', 'trash/r1.md'), folder: 'trash' }) installZen({ moveToTrash, + writeNote: vi.fn().mockImplementation(async (path) => makeNote('', path)), + setVaultSettings: vi.fn().mockImplementation(async (settings) => settings), writeDatabaseSchema: vi.fn().mockResolvedValue(undefined), writeDatabaseRows: vi.fn().mockResolvedValue(undefined) }) const { useStore } = await loadStore() const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') - useStore.setState({ databases: { [CSV]: makeDbDoc() } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, databases: { [CSV]: makeDbDoc() } }) const p = useStore.getState().deleteDatabaseRows(CSV, ['r1']) const req = getConfirmRequest() @@ -1722,15 +1724,17 @@ describe('deleteDatabaseRows (#391 — purge record-page schema mappings)', () = }) it('keeps the note on cancel but still purges the stale mapping', async () => { - const moveToTrash = vi.fn().mockResolvedValue({}) + const moveToTrash = vi.fn().mockResolvedValue({ ...makeNote('', 'trash/r1.md'), folder: 'trash' }) installZen({ moveToTrash, + writeNote: vi.fn().mockImplementation(async (path) => makeNote('', path)), + setVaultSettings: vi.fn().mockImplementation(async (settings) => settings), writeDatabaseSchema: vi.fn().mockResolvedValue(undefined), writeDatabaseRows: vi.fn().mockResolvedValue(undefined) }) const { useStore } = await loadStore() const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') - useStore.setState({ databases: { [CSV]: makeDbDoc() } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, databases: { [CSV]: makeDbDoc() } }) const p = useStore.getState().deleteDatabaseRows(CSV, ['r1']) settleConfirmRequest(getConfirmRequest()!, false) // "Keep note" @@ -1749,7 +1753,7 @@ describe('deleteDatabaseRows (#391 — purge record-page schema mappings)', () = }) const { useStore } = await loadStore() const { getConfirmRequest } = await import('./lib/confirm-requests') - useStore.setState({ databases: { [CSV]: makeDbDoc() } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, databases: { [CSV]: makeDbDoc() } }) await useStore.getState().deleteDatabaseRows(CSV, ['r2']) // r2 has no linked page expect(getConfirmRequest()).toBeNull() // no prompt @@ -2193,7 +2197,7 @@ describe('deleteActivePermanently (#712)', () => { const { useStore } = await loadStore() const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') const note = trashedNote() - useStore.setState({ + useStore.setState({ vault: { root: '/test', name: 'Test' }, notes: [note], selectedPath: TRASHED, activeNote: note, @@ -2219,7 +2223,7 @@ describe('deleteActivePermanently (#712)', () => { const { useStore } = await loadStore() const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') const note = trashedNote() - useStore.setState({ notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) const p = useStore.getState().deleteActivePermanently() settleConfirmRequest(getConfirmRequest()!, false) @@ -2237,7 +2241,7 @@ describe('deleteActivePermanently (#712)', () => { const { getConfirmRequest, settleConfirmRequest } = await import('./lib/confirm-requests') const { useToastStore } = await import('./lib/toast') const note = trashedNote() - useStore.setState({ notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, notes: [note], selectedPath: TRASHED, activeNote: note, noteContents: { [TRASHED]: note } }) const p = useStore.getState().deleteActivePermanently() settleConfirmRequest(getConfirmRequest()!, true) @@ -2252,7 +2256,7 @@ describe('deleteActivePermanently (#712)', () => { const deleteNote = vi.fn().mockResolvedValue(undefined) installZen({ deleteNote }) const { useStore } = await loadStore() - useStore.setState({ selectedPath: null, activeNote: null }) + useStore.setState({ vault: { root: '/test', name: 'Test' }, selectedPath: null, activeNote: null }) await useStore.getState().deleteActivePermanently() expect(deleteNote).not.toHaveBeenCalled() }) @@ -2451,3 +2455,27 @@ describe('ignored keys (#732)', () => { expect(useStore.getState().ignoredKeys).toEqual([]) }) }) + + +describe('file-task lifecycle coordination', () => { + it('trashes a file task outside the inline-task queue and keeps it on failure', async () => { + const source = makeNote('---\ntags: [task]\n---\nDraft.\n') + const moveToTrash=vi.fn().mockRejectedValueOnce(new Error('permission denied')).mockResolvedValue({...source,path:'trash/Note.md',folder:'trash'}) + installZen({moveToTrash,listNotes:vi.fn().mockResolvedValue([{...source,path:'trash/Note.md',folder:'trash'}])}) + const {useStore}=await loadStore() + const {getConfirmRequest,settleConfirmRequest}=await import('./lib/confirm-requests') + const task:VaultTask={...makeTask('Note',-1),id:'inbox/Note.md#file',taskIndex:-1,kind:'file',rawText:''} + useStore.setState({vault:{root:'/test',name:'Test'},notes:[source],noteContents:{[source.path]:source},vaultTasks:[task]}) + const failed=useStore.getState().deleteTaskFromList(task) + settleConfirmRequest(getConfirmRequest()!,true) + await failed + expect(moveToTrash).toHaveBeenCalledTimes(1) + expect(useStore.getState().vaultTasks).toEqual([task]) + expect(useStore.getState().noteContents[source.path]).toBeDefined() + const deleting=useStore.getState().deleteTaskFromList(task) + settleConfirmRequest(getConfirmRequest()!,true) + await deleting + expect(moveToTrash).toHaveBeenCalledTimes(2) + expect(useStore.getState().noteContents[source.path]).toBeUndefined() + }) +}) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index a1df05d7..0909267d 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -1,4 +1,10 @@ +import type { LocalVaultRelocation } from './lib/workspace-relocation' +import { captureNavigationContext } from './lib/navigation-context' +import { runWorkspaceTransition, workspaceGeneration, workspaceWritesBlocked, isWorkspaceTransitionPending } from './lib/workspace-transition' +import { isNoteEditingLocked, lockNoteEditing } from './lib/note-lifecycle-lock' import { create } from 'zustand' +import { rewriteWikilinksForRename } from '@shared/wikilink-rename' +import { useToastStore } from './lib/toast' import type { EditorView } from '@codemirror/view' import { editorCursorPosition, @@ -13,7 +19,6 @@ import { type HarperLintConfig, type HarperVaultState } from '@shared/harper-settings' -import { resolveFolderPath } from '@shared/system-folder-paths' import { normalizeTasksExcludedFolder } from '@shared/tasks-excluded-folders' import { cloudSyncPathKey } from '@zennotes/shared-domain/cloud-sync' import { useCloudSyncStatusStore } from './lib/cloud-auto-sync' @@ -50,7 +55,6 @@ import { TYPST_PREAMBLE_FOLDER, isTypstPreamblePath, preambleKeyFromTitle, - resolveTypstPreamble, resolveTypstPreambleFolder, type TypstPreambleNote } from './lib/typst-preamble' @@ -66,7 +70,10 @@ import { import type { DatabaseDoc, DatabaseSidecar } from '@shared/databases' import { databaseTabPath, + csvPathFromDatabaseTab, formTitleFromCsvPath, + formDirFromCsvPath, + formDirContaining, isDatabaseInternalPath, isDatabaseTabPath, isDatabaseCsvPath @@ -80,7 +87,7 @@ import { ATLAS_TAB_PATH, isAtlasTabPath } from '@shared/atlas-view' import { HELP_TAB_PATH, isHelpTabPath } from '@shared/help' import { ARCHIVE_TAB_PATH, isArchiveTabPath } from '@shared/archive' import { TRASH_TAB_PATH, isTrashTabPath } from '@shared/trash' -import { ASSETS_VIEW_TAB_PATH, isAssetsViewTabPath } from '@shared/assets-view' +import { ASSETS_VIEW_TAB_PATH } from '@shared/assets-view' import { QUICK_NOTES_TAB_PATH, isQuickNotesTabPath } from '@shared/quick-notes' import { isAssetTabPath, assetPathFromTab, assetTabPath } from './lib/asset-tabs' import { @@ -116,10 +123,9 @@ import { customCodeLanguageRegistry } from './lib/custom-code-languages' import { formatMarkdown } from './lib/format-markdown' import { confirmDeletePermanently, confirmMoveToTrash } from './lib/confirm-trash' import { humanIpcError } from './lib/ipc-error' -import { deleteNotePermanently, moveNoteToTrash } from './lib/trash-note' -import { confirmApp } from './lib/confirm-requests' +import { confirmApp, getConfirmRequest } from './lib/confirm-requests' import { pickServerDirectoryApp } from './lib/server-directory-picker-requests' -import { promptApp } from './lib/prompt-requests' +import { promptApp, getPromptRequest } from './lib/prompt-requests' import { buildNoteDestinationPrompt, buildTemplateDestinationPrompt, @@ -164,6 +170,7 @@ import { removeFolderIcons, normalizeVaultSettings, noteFolderSubpath, + vaultRelativeFolderPath, resolveCreateLocation, rewriteFavoriteNotePath, rewriteFavoritesForFolderRename, @@ -205,7 +212,6 @@ import { leafWithoutTab, makeLeaf, mapLeaves, - replaceLeaf, rewritePathsInTree, preserveLayoutIfPruneEmptiesNoteTabs, splitLeaf, @@ -239,15 +245,8 @@ import { normalizeApplicationSchemes } from '@shared/application-links' import { normalizeEditorTabSize } from './lib/editor-tab-size' import { recentNoteToggleTarget } from './lib/recent-note-toggle' -export type NoteSortOrder = - | 'none' - | 'manual' - | 'updated-desc' - | 'updated-asc' - | 'created-desc' - | 'created-asc' - | 'name-asc' - | 'name-desc' +import type { NoteSortOrder } from './lib/note-order' +export type { NoteSortOrder } from './lib/note-order' /** Which column the Assets view sorts by, and in which direction. Stored as one * `-` string so it maps onto a single portable pref, the same @@ -2159,21 +2158,6 @@ function noteHistoryAfterJump( } } -function rewriteNoteJumpHistory( - history: NoteJumpLocation[], - rewrite: (path: string) => string -): NoteJumpLocation[] { - const next: NoteJumpLocation[] = [] - for (const entry of history) { - const mapped = { ...entry, path: rewrite(entry.path) } - if (sameNoteJumpLocation(next[next.length - 1] ?? null, mapped)) continue - next.push(mapped) - } - return next.length > MAX_NOTE_JUMP_HISTORY - ? next.slice(next.length - MAX_NOTE_JUMP_HISTORY) - : next -} - /** * Rewrite every occurrence of `#oldTag` across all non-trash notes. * When `newTag` is null the hashtag is stripped (delete semantics); @@ -2737,15 +2721,13 @@ function hasTasksViewOpen(state: { paneLayout: PaneLayout }): boolean { } /** True when a surface backed by `vaultTasks` is on screen and therefore needs - * the shared task cache kept fresh on note edits. Covers the Tasks view and the - * calendar panel — the latter is per-pane local state exposed via a DOM marker - * (the same one VimNav reads for pane navigation), so editing a daily note with - * only the calendar open still refreshes its tasks. */ + * the shared task cache kept fresh on note edits. Tasks tabs are in the pane + * tree; Home and calendar expose the navigation markers also read by VimNav. */ function tasksSurfaceVisible(state: { paneLayout: PaneLayout }): boolean { if (hasTasksViewOpen(state)) return true return ( typeof document !== 'undefined' && - document.querySelector('[data-calendar-panel]') !== null + document.querySelector('[data-calendar-panel], [data-home-nav]') !== null ) } @@ -2861,6 +2843,7 @@ interface Store { query: string initialized: boolean workspaceRestored: boolean + workspaceTransitioning: boolean sidebarOpen: boolean noteListOpen: boolean zenMode: boolean @@ -3093,6 +3076,7 @@ interface Store { /** Hydrated CSV databases keyed by their vault-relative `.csv` path. */ databases: Record /** In-flight load flags keyed by `.csv` path. */ + databasesDeletingRows: Record databasesLoading: Record /** Tags currently selected in the Tags view. The view shows every non- @@ -3202,11 +3186,11 @@ interface Store { /** Load a database and open it as a tab in the active pane. */ openDatabase: (csvPath: string) => Promise /** Create a new empty database under `folder`/`subpath` and open it. */ - createDatabase: (folder: NoteFolder, subpath?: string, title?: string) => Promise + createDatabase: (folder: NoteFolder, subpath?: string, title?: string, isCurrent?: () => boolean) => Promise /** Create a database in the configured default databases location and open it. (#362) */ newDatabase: () => Promise /** Rename a database (its `.base` folder); rehomes the open grid tab. */ - renameDatabase: (csvPath: string, newTitle: string) => Promise + renameDatabase: (csvPath: string, newTitle: string, isCurrent?: () => boolean) => Promise /** Optimistically replace a database's rows and debounce-persist the CSV. */ updateDatabaseRows: (csvPath: string, next: DatabaseDoc) => void /** Delete rows AND purge their record-page mappings from the sidecar (a plain @@ -3331,7 +3315,7 @@ interface Store { updateActiveBody: (body: string) => void persistActive: () => Promise formatActiveNote: () => Promise - renameNote: (oldPath: string, nextTitle: string) => Promise + renameNote: (oldPath: string, nextTitle: string, hostIsCurrent?: () => boolean) => Promise renameActive: (nextTitle: string) => Promise createAndOpen: ( folder: NoteFolder, @@ -3377,6 +3361,8 @@ interface Store { /** Delete any note for good (confirm, delete, drop its tabs and buffers). * Resolves true when the file is gone. */ deleteNotePermanently: (path: string) => Promise + emptyTrash: (hostIsCurrent?: () => boolean) => Promise + changeNoteLifecycle: (path: string, action: 'archive' | 'trash' | 'restore' | 'delete', hostIsCurrent?: () => boolean) => Promise restoreActive: () => Promise archiveActive: () => Promise unarchiveActive: () => Promise @@ -3652,7 +3638,7 @@ interface Store { /** Update an open note's body (typed into any pane). Flags dirty. */ updateNoteBody: (path: string, body: string) => void /** Persist a specific note to disk. */ - persistNote: (path: string) => Promise + persistNote: (path: string, duringFolderMutation?: boolean) => Promise loadNoteComments: (path: string) => Promise addNoteComment: (input: NoteCommentInput) => Promise updateNoteComment: ( @@ -3673,13 +3659,14 @@ interface Store { renameTag: (oldTag: string, newTag: string) => Promise /** Remove `#tag` from every non-trash note. */ deleteTag: (tag: string) => Promise - createFolder: (folder: NoteFolder, subpath: string) => Promise + createFolder: (folder: NoteFolder, subpath: string, isCurrent?: () => boolean) => Promise renameFolder: ( folder: NoteFolder, oldSubpath: string, - newSubpath: string + newSubpath: string, + isCurrent?: () => boolean ) => Promise - deleteFolder: (folder: NoteFolder, subpath: string) => Promise + deleteFolder: (folder: NoteFolder, subpath: string, isCurrent?: () => boolean) => Promise duplicateFolder: (folder: NoteFolder, subpath: string) => Promise revealFolder: (folder: NoteFolder, subpath: string) => Promise revealAssetsDir: () => Promise @@ -3687,11 +3674,13 @@ interface Store { moveNote: ( relPath: string, targetFolder: NoteFolder, - targetSubpath: string + targetSubpath: string, + isCurrent?: () => boolean ) => Promise init: () => Promise openVaultPicker: () => Promise openLocalVault: (root: string) => Promise + relocateLocalVault: (operation: LocalVaultRelocation) => Promise closeVault: () => Promise connectRemoteWorkspace: () => Promise connectRemoteWorkspaceProfile: (id: string) => Promise @@ -3753,6 +3742,138 @@ function databaseToSidecar(doc: DatabaseDoc): DatabaseSidecar { } } +const databaseLoadVersions = new Map() +const databaseWriteQueues = new Map>() +const databaseCreations = new Map>() +const databaseRowActions = new Map>() +let pendingRowConfirmation = false +const folderMutations = new Map>() +const uncertainFolderMutations = new Map() +let noteIndexRequest = 0 +let assetIndexRequest = 0 +let taskIndexRevision = 0 +const inFlightNoteWrites = new Set>() +let pendingNoteRename: { + oldPath: string + nextPath: string + title: string + notesBefore: NoteMeta[] + isCurrent: () => boolean +} | null = null + +function rewriteRenamingBody(path: string, body: string, folder: NoteFolder): string { + const rename = pendingNoteRename + if ( + !rename || + !rename.isCurrent() || + path === rename.nextPath || + folder === 'trash' || + !path.toLowerCase().endsWith('.md') || + isObsidianExcalidrawPath(path) || + isObsidianExcalidrawMarkdown(body) + ) + return body + return rewriteWikilinksForRename(body, rename.notesBefore, rename.oldPath, rename.title).body +} + +/** Body writers outside the editor must settle before a file mutation starts. */ +function trackNoteWrite( + blocked: Result, + work: (...args: Args) => Promise +): (...args: Args) => Promise { + return async (...args) => { + if ( + workspaceWritesBlocked() || folderMutations.size > 0 || databaseRowActions.size > 0 || + [...uncertainFolderMutations.values()].includes(useStore.getState().vault) + ) { + useToastStore + .getState() + .addToast( + 'Wait for the file operation to finish, or reload the vault if it failed.', + 'info' + ) + return blocked + } + const running = work(...args) + inFlightNoteWrites.add(running) + try { + return await running + } finally { + inFlightNoteWrites.delete(running) + } + } +} + +const folderReadVersions = new Map() +const mutationContains = (scope: string, path: string): boolean => + scope === '' || scope.endsWith('/') ? path.startsWith(scope) : path === scope +const folderReadVersion = (path: string): number => + [...folderReadVersions].reduce( + (version, [prefix, value]) => (mutationContains(prefix, path) ? version + value : version), + 0 + ) +const folderMutationBlocks = (path: string): boolean => + [...folderMutations.keys()].some((prefix) => mutationContains(prefix, path)) || + [...uncertainFolderMutations].some( + ([prefix, vault]) => vault === useStore.getState().vault && mutationContains(prefix, path) + ) + +/** Register every task writer, including actions that write a closed note directly. */ +function trackTaskWrite( + work: (...args: Args) => Promise +): (...args: Args) => Promise { + return async (...args) => { + if ([...uncertainFolderMutations.values()].includes(useStore.getState().vault)) { + useToastStore.getState().addToast('Reload the vault before changing tasks after a failed file operation.', 'error') + return + } + if (workspaceWritesBlocked() || folderMutations.size > 0 || databaseRowActions.size > 0) { + useToastStore.getState().addToast('Wait for the file operation to finish before changing tasks.', 'info') + return + } + const running = work(...args) + inFlightTaskMutations.add(running) + try { await running } finally { inFlightTaskMutations.delete(running) } + } +} + +async function flushDatabaseWrite( + csvPath: string, + getDoc: () => DatabaseDoc | undefined +): Promise { + const isCurrent = captureFolderActionContext(useStore.getState) + const timer = databaseSaveTimers.get(csvPath) + if (timer) clearTimeout(timer) + databaseSaveTimers.delete(csvPath) + const previous = databaseWriteQueues.get(csvPath) + const write = async (): Promise => { + if (!isCurrent()) return + const kind = databaseWriteKind.get(csvPath) + const doc = getDoc() + if (!kind || !doc) return + databaseWriteKind.delete(csvPath) + try { + if (kind === 'schema') + await window.zen.writeDatabaseSchema(csvPath, databaseToSidecar(doc), doc.rows) + else await window.zen.writeDatabaseRows(csvPath, doc.rows) + lastDatabaseWriteAt.set(csvPath, Date.now()) + } catch (error) { + databaseWriteKind.set( + csvPath, + kind === 'schema' ? kind : (databaseWriteKind.get(csvPath) ?? kind) + ) + throw error + } + } + const run = previous ? previous.catch(() => {}).then(write) : write() + databaseWriteQueues.set(csvPath, run) + try { + await run + } finally { + if (databaseWriteQueues.get(csvPath) === run) databaseWriteQueues.delete(csvPath) + } +} + function scheduleDatabaseWrite( csvPath: string, kind: 'rows' | 'schema', @@ -3762,24 +3883,11 @@ function scheduleDatabaseWrite( databaseWriteKind.set(csvPath, kind === 'schema' || prev === 'schema' ? 'schema' : 'rows') const existing = databaseSaveTimers.get(csvPath) if (existing) clearTimeout(existing) - databaseSaveTimers.set( - csvPath, - setTimeout(() => { - databaseSaveTimers.delete(csvPath) - const writeKind = databaseWriteKind.get(csvPath) ?? 'rows' - databaseWriteKind.delete(csvPath) - const doc = getDoc() - if (!doc) return - const done = (): void => { - lastDatabaseWriteAt.set(csvPath, Date.now()) - } - const write = - writeKind === 'schema' - ? window.zen.writeDatabaseSchema(csvPath, databaseToSidecar(doc), doc.rows) - : window.zen.writeDatabaseRows(csvPath, doc.rows) - void write.catch((err) => console.error('database write failed', err)).finally(done) - }, DATABASE_SAVE_DEBOUNCE_MS) - ) + databaseSaveTimers.delete(csvPath) + if (folderMutationBlocks(csvPath) || databaseRowActions.has(csvPath)) return + databaseSaveTimers.set(csvPath, setTimeout(() => { + void flushDatabaseWrite(csvPath, getDoc).catch((err) => console.error('database write failed', err)) + }, DATABASE_SAVE_DEBOUNCE_MS)) } /** @@ -3917,43 +4025,282 @@ function activeFieldsFrom( } } -function renameNoteState( +const commentOperations = new Map>>() + +async function trackCommentOperation( + path: string, + fallback: T, + work: () => Promise +): Promise { + if (folderMutationBlocks(path) || workspaceWritesBlocked()) return fallback + const operations = commentOperations.get(path) ?? new Set>() + commentOperations.set(path, operations) + const pending = work() + operations.add(pending) + try { + return await pending + } finally { + operations.delete(pending) + if (operations.size === 0) commentOperations.delete(path) + } +} + +function captureFolderActionContext( + get: () => Store, + hostIsCurrent?: () => boolean +): () => boolean { + const vault = get().vault + const bridge = window.zen + const layout = JSON.stringify([ + get().vaultSettings.primaryNotesLocation, + get().vaultSettings.systemFolderPaths + ]) + return () => { + try { + return ( + get().vault === vault && + window.zen === bridge && + JSON.stringify([ + get().vaultSettings.primaryNotesLocation, + get().vaultSettings.systemFolderPaths + ]) === layout && + (hostIsCurrent?.() ?? true) + ) + } catch { + return false + } + } +} + +/** Keep saves and watcher echoes at the old paths until the host finishes moving them. */ +async function mutateFolderContents( + get: () => Store, + prefix: string, + isCurrent: () => boolean, + canReconcile: () => boolean, + mutate: () => Promise, + rowActionOwner?: string +): Promise { + if (workspaceWritesBlocked()) throw new Error('Wait for the vault change to finish') + if ([...databaseRowActions.keys()].some((owner) => owner !== rowActionOwner)) + throw new Error('Wait for database row deletion to finish before changing files') + if (inFlightNoteWrites.size > 0) + throw new Error('Wait for pending note changes to finish before changing files') + if ( + [...databaseCreations.keys()].some((other) => prefix.startsWith(other) || other.startsWith(prefix)) || + folderMutationBlocks(prefix) || + [...uncertainFolderMutations].some(([other, vault]) => vault === get().vault && other.startsWith(prefix)) || + [...folderMutations.keys()].some( + (other) => prefix.startsWith(other) || other.startsWith(prefix) + ) + ) + throw new Error('This folder already has an operation in progress') + let release!: () => void + const done = new Promise((resolve) => { + release = resolve + }) + folderMutations.set(prefix, done) + folderReadVersions.set(prefix, (folderReadVersions.get(prefix) ?? 0) + 1) + taskIndexRevision += 1 + useStore.setState({ tasksLoading: false }) + noteIndexRequest += 1 + assetIndexRequest += 1 + const notePaths = Object.keys(get().noteContents).filter((path) => mutationContains(prefix, path)) + const databasePaths = [ + ...new Set([ + ...Object.keys(get().databases), + ...Object.keys(get().databasesLoading), + ...databaseWriteQueues.keys(), + ...databaseWriteKind.keys() + ]) + ].filter((path) => mutationContains(prefix, path)) + for (const path of databasePaths) + databaseLoadVersions.set(path, (databaseLoadVersions.get(path) ?? 0) + 1) + useStore.setState((s) => ({ + databasesLoading: { + ...s.databasesLoading, + ...Object.fromEntries(databasePaths.map((path) => [path, false])) + } + })) + let nextPrefix: string | null = prefix + for (const path of notePaths) { + renamesInFlight.add(path) + noteContentVersions.set(path, (noteContentVersions.get(path) ?? 0) + 1) + } + try { + await Promise.all( + [...commentOperations] + .filter(([path]) => mutationContains(prefix, path)) + .flatMap(([, operations]) => [...operations]) + ) + if (!isCurrent()) return + await Promise.all(notePaths.map((path) => get().persistNote(path, true))) + if (!isCurrent()) return + if (notePaths.some((path) => get().noteDirty[path])) + throw new Error('Could not change this folder while notes still have unsaved changes') + await Promise.all( + databasePaths.map((path) => + flushDatabaseWrite(path, () => (isCurrent() ? get().databases[path] : undefined)) + ) + ) + if (!isCurrent()) return + nextPrefix = (await mutate()) ?? null + } catch (error) { + if (String(error).includes('FOLDER_STATE_UNCERTAIN:') && canReconcile()) { + uncertainFolderMutations.set(prefix, get().vault) + nextPrefix = null + } + throw error + } finally { + for (const path of notePaths) renamesInFlight.delete(path) + for (const path of databasePaths) { + const kind = databaseWriteKind.get(path) + databaseWriteKind.delete(path) + const timer = databaseSaveTimers.get(path) + if (timer) clearTimeout(timer) + databaseSaveTimers.delete(path) + if (canReconcile() && nextPrefix !== null && kind) { + const nextPath = nextPrefix + path.slice(prefix.length) + databaseWriteKind.set( + nextPath, + kind === 'schema' ? kind : (databaseWriteKind.get(nextPath) ?? kind) + ) + } + } + try { + if (canReconcile() && nextPrefix !== null) { + const targetPrefix = nextPrefix + await Promise.all( + Object.keys(get().noteDirty) + .filter((path) => mutationContains(targetPrefix, path) && get().noteDirty[path]) + .map((path) => get().persistNote(path, true)) + ) + } + } finally { + folderMutations.delete(prefix) + if (canReconcile() && nextPrefix !== null) { + const targetPrefix = nextPrefix + for (const path of Object.keys(get().databases)) { + const kind = databaseWriteKind.get(path) + if (mutationContains(targetPrefix, path) && kind) + scheduleDatabaseWrite(path, kind, () => get().databases[path]) + } + } + release() + } + } +} + +function rewriteFolderWorkspace( s: Store, - oldPath: string, - meta: NoteMeta + prefix: string, + nextPrefix: string | null ): Partial { - const rewrite = (p: string): string => (p === oldPath ? meta.path : p) - const nextLayout = rewritePathsInTree(s.paneLayout, rewrite) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents = { ...s.noteContents } - const dirty = { ...s.noteDirty } - const prevContent = contents[oldPath] - const prevDirty = dirty[oldPath] ?? false - if (oldPath !== meta.path) { - delete contents[oldPath] - delete dirty[oldPath] + const rewriteFile = (path: string): string | null => + mutationContains(prefix, path) + ? nextPrefix === null + ? null + : nextPrefix + path.slice(prefix.length) + : path + const rewrite = (path: string): string | null => { + const csv = csvPathFromDatabaseTab(path) + const asset = isAssetTabPath(path) ? assetPathFromTab(path) : null + const mapped = rewriteFile(csv ?? asset ?? path) + return mapped === null + ? null + : csv + ? databaseTabPath(mapped) + : asset + ? assetTabPath(mapped) + : mapped } - if (prevContent) { - contents[meta.path] = { ...prevContent, ...meta } + const remap = ( + entries: Record, + update: (value: T, path: string) => T + ): Record => { + const next: Record = {} + for (const [path, value] of Object.entries(entries)) { + const mapped = rewriteFile(path) + if (mapped !== null) next[mapped] = mapped === path ? value : update(value, mapped) + } + return next } - dirty[meta.path] = prevDirty + const contents = remap(s.noteContents, (content, path) => ({ ...content, path })) + const dirty = remap(s.noteDirty, (value) => value) + const ensured = ensureActivePane(rewritePathsInTree(s.paneLayout, rewrite), s.activePaneId) + const history = (entries: NoteJumpLocation[]) => + entries.flatMap((entry) => { + const path = rewrite(entry.path) + return path === null ? [] : [{ ...entry, path }] + }) + const pendingPath = s.pendingJumpLocation && rewrite(s.pendingJumpLocation.path) return { paneLayout: ensured.layout, activePaneId: ensured.activePaneId, noteContents: contents, noteDirty: dirty, - notes: replaceNoteMeta(s.notes, oldPath, meta), - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewrite), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewrite), - pendingJumpLocation: - s.pendingJumpLocation?.path === oldPath - ? { ...s.pendingJumpLocation, path: meta.path } - : s.pendingJumpLocation, - pendingTitleFocusPath: - s.pendingTitleFocusPath === oldPath ? meta.path : s.pendingTitleFocusPath, - pinnedRefPath: s.pinnedRefPath === oldPath ? meta.path : s.pinnedRefPath, - noteComments: rewriteNoteCommentsPath(s.noteComments, oldPath, meta.path), - activeCommentId: s.activeCommentId, + manualNoteOrder: Object.fromEntries( + Object.entries(s.manualNoteOrder).flatMap(([directory, paths]) => { + const mapped = rewriteFile(`${directory}/`) + return mapped + ? [ + [ + mapped.slice(0, -1), + paths.flatMap((path) => { + const next = rewriteFile(path) + return next ? [next] : [] + }) + ] + ] + : [] + }) + ), + paneModes: Object.fromEntries( + Object.entries(s.paneModes).map(([pane, modes]) => [pane, remap(modes, (mode) => mode)]) + ), + noteRefs: Object.fromEntries( + Object.entries(s.noteRefs).flatMap(([owner, ref]) => { + const nextOwner = rewriteFile(owner) + const path = rewriteFile(ref.path) + return nextOwner && path ? [[nextOwner, { ...ref, path }]] : [] + }) + ), + assetFiles: s.assetFiles.flatMap((asset) => { + const path = rewriteFile(asset.path) + return path ? [{ ...asset, path }] : [] + }), + vaultTasks: s.vaultTasks.flatMap((task) => { + const sourcePath = rewriteFile(task.sourcePath) + return sourcePath + ? [{ ...task, sourcePath, id: sourcePath + task.id.slice(task.sourcePath.length) }] + : [] + }), + tasksLoading: false, + noteComments: remap(s.noteComments, (comments, notePath) => + comments.map((comment) => ({ ...comment, notePath })) + ), + databases: remap(s.databases, (doc, path) => ({ + ...doc, + path, + title: formTitleFromCsvPath(path), + ...(doc.pages + ? { + pages: Object.fromEntries( + Object.entries(doc.pages).map(([id, page]) => [id, rewriteFile(page) ?? page]) + ) + } + : {}) + })), + databasesLoading: remap(s.databasesLoading, () => false), + noteBackstack: history(s.noteBackstack), + noteForwardstack: history(s.noteForwardstack), + closedTabStack: s.closedTabStack.flatMap((entry) => { + const path = rewrite(entry.path) + return path === null ? [] : [{ ...entry, path }] + }), + pendingJumpLocation: pendingPath ? { ...s.pendingJumpLocation!, path: pendingPath } : null, + pendingTitleFocusPath: s.pendingTitleFocusPath ? rewrite(s.pendingTitleFocusPath) : null, + pinnedRefPath: s.pinnedRefPath ? rewrite(s.pinnedRefPath) : null, ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) } } @@ -3974,10 +4321,12 @@ async function syncHeadingAfterRename( syncTitleHeadingOnRename: boolean noteContents: Record updateNoteBody: (path: string, body: string) => void - persistNote: (path: string) => Promise - } + persistNote: (path: string, duringFolderMutation?: boolean) => Promise + }, + isCurrent: () => boolean = () => true, + duringMutation = false ): Promise { - if (!get().syncTitleHeadingOnRename) return + if (!isCurrent() || !get().syncTitleHeadingOnRename) return // Markdown only, and never an Obsidian drawing: those are `.md` files whose // headings (`# Excalidraw Data`) are structure, not a title. if (!meta.path.toLowerCase().endsWith('.md')) return @@ -3989,14 +4338,15 @@ async function syncHeadingAfterRename( const next = retitleLeadingHeading(open.body, meta.title) if (next === open.body) return get().updateNoteBody(meta.path, next) - await get().persistNote(meta.path) + await get().persistNote(meta.path, duringMutation) return } - const content = await window.zen.readNote(meta.path) - if (isObsidianExcalidrawMarkdown(content.body)) return + const bridge = window.zen + const content = await bridge.readNote(meta.path) + if (!isCurrent() || isObsidianExcalidrawMarkdown(content.body)) return const next = retitleLeadingHeading(content.body, meta.title) if (next === content.body) return - await window.zen.writeNote(meta.path, next) + await bridge.writeNote(meta.path, next) } catch (err) { // The rename itself succeeded; a failed heading rewrite must not undo it. console.error('syncHeadingAfterRename failed', err) @@ -4107,19 +4457,6 @@ function withDateNotePatternHistory( } } -function rewriteNoteCommentsPath( - comments: Record, - oldPath: string, - nextPath: string -): Record { - if (oldPath === nextPath || !(oldPath in comments)) return comments - const { [oldPath]: moving, ...rest } = comments - return { - ...rest, - [nextPath]: moving.map((comment) => ({ ...comment, notePath: nextPath })) - } -} - /** Ensure `activePaneId` points at a real leaf. Falls back to first leaf. */ function ensureActivePane( layout: PaneLayout, @@ -4145,11 +4482,13 @@ function noteReadCacheKey( relPath: string ): string { return [ + workspaceGeneration(), state.workspaceMode, state.vault?.root ?? '', state.remoteWorkspaceInfo?.baseUrl ?? '', state.remoteWorkspaceInfo?.profileId ?? '', - relPath + relPath, + folderReadVersion(relPath) ].join('\0') } @@ -4159,11 +4498,17 @@ function clearNoteContentReadCaches(): void { } function readNoteContent(relPath: string, state: Store): Promise { + if (folderMutationBlocks(relPath)) return Promise.reject(new Error('Folder operation in progress')) + const version = folderReadVersion(relPath) const cacheKey = noteReadCacheKey(state, relPath) const pending = noteReadPromises.get(cacheKey) if (pending) return pending - const next = window.zen.readNote(relPath).finally(() => { + const next = window.zen.readNote(relPath).then((content) => { + if (folderMutationBlocks(relPath) || folderReadVersion(relPath) !== version) + throw new Error('Folder changed while loading this note') + return content + }).finally(() => { noteReadPromises.delete(cacheKey) }) noteReadPromises.set(cacheKey, next) @@ -4266,11 +4611,184 @@ function withoutNoteInWorkspace(s: Store, path: string): Partial { } export const useStore = create((set, get) => { + const mutateNoteImpl = async ( + path: string, + mutate: () => Promise, + hostIsCurrent?: () => boolean, + rename = false, + rowActionOwner?: string + ): Promise => { + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + if (!isCurrent()) return null + if (inFlightTaskMutations.size > 0 || taskMutationQueues.size > 0) + throw new Error('Wait for pending task changes to finish before changing this note') + let result: NoteMeta | null = null + // Rename can rewrite links anywhere in the vault, including buffers edited + // while the host is working. Hold those saves until their links are updated. + try { + await mutateFolderContents(get, rename ? '' : path, isCurrent, canReconcile, async () => { + const notesBefore = get().notes + result = await mutate() + if (!canReconcile()) return + if (rename && result && result.path !== path) + pendingNoteRename = { + oldPath: path, + nextPath: result.path, + title: result.title, + notesBefore, + isCurrent: canReconcile + } + const nextPath = result?.path ?? null + noteIndexRequest += 1 + taskIndexRevision += 1 + if (nextPath) folderReadVersions.set(nextPath, (folderReadVersions.get(nextPath) ?? 0) + 1) + set((s) => { + const rewritten = rewriteFolderWorkspace(s, path, nextPath) + const manualNoteOrder = { ...rewritten.manualNoteOrder } + const parent = parentDirOf(path) + const nextParent = nextPath ? parentDirOf(nextPath) : null + if (nextParent !== parent && s.manualNoteOrder[parent]?.includes(path)) { + manualNoteOrder[parent] = s.manualNoteOrder[parent].filter((value) => value !== path) + if (nextParent !== null) + manualNoteOrder[nextParent] = [ + ...(manualNoteOrder[nextParent] ?? []).filter((value) => value !== nextPath), + nextPath! + ] + } + const contents = rewritten.noteContents! + if (rename) { + for (const [owner, content] of Object.entries(contents)) { + const body = rewriteRenamingBody(owner, content.body, content.folder) + if (body !== content.body) { + contents[owner] = { ...content, body } + rewritten.noteDirty![owner] = true + } + } + } + if (result && contents[result.path]) + contents[result.path] = { ...contents[result.path], ...result } + return { + ...rewritten, + manualNoteOrder, + notes: result + ? replaceNoteMeta(s.notes, path, result) + : s.notes.filter((note) => note.path !== path), + vaultTasks: rewritten.vaultTasks!.map((task) => + result && task.sourcePath === result.path + ? { ...task, noteFolder: result.folder, noteTitle: result.title } + : task + ), + ...activeFieldsFrom( + rewritten.paneLayout!, + rewritten.activePaneId!, + contents, + rewritten.noteDirty! + ) + } + }) + savePrefs(collectPrefs(get())) + writeManualOrder(get().vault?.root ?? '', get().manualNoteOrder) + await get().applyFavorites( + nextPath && result?.folder !== 'trash' + ? rewriteFavoriteNotePath(get().vaultSettings.favorites, path, nextPath) + : get().vaultSettings.favorites.filter((favorite) => favorite !== path) + ) + if (rename && result) await syncHeadingAfterRename(result, get, canReconcile, true) + if (isCurrent()) await get().refreshNotes() + return rename ? '' : nextPath + }, rowActionOwner) + } finally { + if (rename && pendingNoteRename?.isCurrent === canReconcile) pendingNoteRename = null + } + if (isCurrent() && tasksSurfaceVisible(get())) await get().refreshTasks() + if (rename && isCurrent() && Object.values(get().noteDirty).some(Boolean)) + throw new Error( + 'The rename finished, but notes still have unsaved changes. Retry saving before leaving the vault.' + ) + const finalMeta = result as NoteMeta | null + if (!rename && canReconcile() && finalMeta && get().noteDirty[finalMeta.path]) + throw new Error('The note moved, but it still has unsaved changes. Save it before closing it.') + return result + } + + const renameFolderImpl = async ( + folder: NoteFolder, + oldSubpath: string, + oldPrefix: string, + rename: () => Promise<{ subpath: string; prefix: string }>, + hostIsCurrent?: () => boolean + ): Promise => { + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + if (!isCurrent()) return + await mutateFolderContents(get, oldPrefix, isCurrent, canReconcile, async () => { + const { subpath: newSubpath, prefix: newPrefix } = await rename() + if (!canReconcile()) return + noteIndexRequest += 1 + taskIndexRevision += 1 + assetIndexRequest += 1 + set((s) => ({ + ...rewriteFolderWorkspace(s, oldPrefix, newPrefix), + view: + s.view.kind === 'folder' && + s.view.folder === folder && + (s.view.subpath === oldSubpath || s.view.subpath.startsWith(`${oldSubpath}/`)) + ? { ...s.view, subpath: newSubpath + s.view.subpath.slice(oldSubpath.length) } + : s.view, + notes: s.notes.map((note) => + note.path.startsWith(oldPrefix) + ? { ...note, path: newPrefix + note.path.slice(oldPrefix.length) } + : note + ), + folders: s.folders.map((entry) => + entry.folder === folder && + (entry.subpath === oldSubpath || entry.subpath.startsWith(`${oldSubpath}/`)) + ? { ...entry, subpath: newSubpath + entry.subpath.slice(oldSubpath.length) } + : entry + ), + vaultSettings: { + ...s.vaultSettings, + folderIcons: rewriteFolderIconsForRename( + s.vaultSettings.folderIcons, + folder, + oldSubpath, + newSubpath + ), + folderColors: rewriteFolderColorsForRename( + s.vaultSettings.folderColors, + folder, + oldSubpath, + newSubpath + ) + } + })) + savePrefs(collectPrefs(get())) + writeManualOrder(get().vault?.root ?? '', get().manualNoteOrder) + await get().applyFavorites( + rewriteFavoritesForFolderRename( + get().vaultSettings.favorites, + folder, + oldSubpath, + newSubpath, + oldPrefix, + newPrefix + ) + ) + if (!isCurrent()) return newPrefix + await get().refreshNotes() + if (!isCurrent()) return newPrefix + return newPrefix + }) + } + const selectNoteImpl = async ( relPath: string | null, historyMode: 'push' | 'preserve' = 'push', opts?: { preview?: boolean } ): Promise => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false const startedAt = performance.now() const state = get() const activeLeaf = findLeaf(state.paneLayout, state.activePaneId) @@ -4317,6 +4835,7 @@ export const useStore = create((set, get) => { state.noteDirty[state.selectedPath] ) { await get().persistNote(state.selectedPath) + if (!isCurrent()) return false } const latest = get() const leafNow = findLeaf(latest.paneLayout, latest.activePaneId) @@ -4391,6 +4910,7 @@ export const useStore = create((set, get) => { state.noteDirty[state.selectedPath] ) { await get().persistNote(state.selectedPath) + if (!isCurrent()) return false } const latest = get() @@ -4404,6 +4924,7 @@ export const useStore = create((set, get) => { const readScopeKey = noteReadCacheKey(latest, relPath) const content = await readNoteContent(relPath, latest) const s = get() + if (!isCurrent()) return false if (noteReadCacheKey(s, relPath) !== readScopeKey) { set({ loadingNote: false }) return false @@ -4437,12 +4958,15 @@ export const useStore = create((set, get) => { path: relPath }) console.error('readNote failed', err) + if (!isCurrent()) return false set({ loadingNote: false, pendingJumpLocation: null }) return false } } const jumpThroughNoteHistory = async (direction: 'back' | 'forward'): Promise => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const state = get() const source = direction === 'back' ? [...state.noteBackstack] : [...state.noteForwardstack] @@ -4450,6 +4974,7 @@ export const useStore = create((set, get) => { if (state.selectedPath && state.noteDirty[state.selectedPath]) { await get().persistNote(state.selectedPath) + if (!isCurrent()) return } set({ loadingNote: true }) @@ -4484,7 +5009,9 @@ export const useStore = create((set, get) => { return } try { + const scope = noteReadCacheKey(get(), target.path) const content = await readNoteContent(target.path, get()) + if (!isCurrent() || noteReadCacheKey(get(), target.path) !== scope) return const latest = get() const leaf = findLeaf(latest.paneLayout, latest.activePaneId) if (!leaf) continue @@ -4510,6 +5037,7 @@ export const useStore = create((set, get) => { return } catch (err) { console.error(`jump ${direction} readNote failed`, err) + if (!isCurrent()) return } } @@ -4689,99 +5217,334 @@ export const useStore = create((set, get) => { } } - return { - vault: null, - workspaceMode: 'local', - remoteWorkspaceInfo: null, - remoteWorkspaceProfiles: [], - localVaults: [], - workspaceSetupError: null, - vaultSettings: DEFAULT_VAULT_SETTINGS, - rootContentHiddenByInboxMode: false, - rootContentBannerDismissed: false, - manualNoteOrder: {}, - notes: [], - typstPreambleNotes: [], - folders: [], - assetFiles: [], - assetUndoStack: [], - hasAssetsDir: false, - view: { kind: 'folder', folder: 'inbox', subpath: '' }, - selectedPath: null, - activeNote: null, - activeDirty: false, - noteBackstack: [], - noteForwardstack: [], - pendingJumpLocation: null, - loadingNote: false, - searchOpen: false, - vaultTextSearchOpen: false, - commandPaletteOpen: false, - commandPaletteInitialMode: 'main', - bufferPaletteOpen: false, - outlinePaletteOpen: false, - templatePaletteOpen: false, - embedDrawingPaletteOpen: false, - excalidrawPreviewVersion: 0, - templatePaletteMode: 'create', - templatePaletteTarget: null, - customTemplates: [], - workflowIndex: [], - query: '', - initialized: false, - workspaceRestored: false, - sidebarOpen: true, - noteListOpen: true, - zenMode: false, - zenRestoreState: null, - vimMode: loadPrefs().vimMode, - vimInsertEscape: loadPrefs().vimInsertEscape, - ignoredKeys: loadPrefs().ignoredKeys, - externalApplicationSchemes: loadPrefs().externalApplicationSchemes, - vimYankToClipboard: loadPrefs().vimYankToClipboard, - vimBlockImeInNormalMode: loadPrefs().vimBlockImeInNormalMode, - vimWrappedLineMotions: loadPrefs().vimWrappedLineMotions, - keymapOverrides: loadPrefs().keymapOverrides, - enabledOverrides: loadPrefs().enabledOverrides, - themeTweaks: loadPrefs().themeTweaks, - whichKeyHints: loadPrefs().whichKeyHints, - whichKeyHintMode: loadPrefs().whichKeyHintMode, - whichKeyHintTimeoutMs: loadPrefs().whichKeyHintTimeoutMs, - vaultTextSearchBackend: loadPrefs().vaultTextSearchBackend, - ripgrepBinaryPath: loadPrefs().ripgrepBinaryPath, - fzfBinaryPath: loadPrefs().fzfBinaryPath, - livePreview: loadPrefs().livePreview, - showHeadingLevelLabels: loadPrefs().showHeadingLevelLabels, - listIndentGuides: loadPrefs().listIndentGuides, - renderTablesInLivePreview: loadPrefs().renderTablesInLivePreview, - completedTaskStyle: loadPrefs().completedTaskStyle, - mathRenderer: loadPrefs().mathRenderer, - typstTagPreambles: loadPrefs().typstTagPreambles, - harperEnabled: loadPrefs().harperEnabled, - harperDialect: loadPrefs().harperDialect, - harperLintConfig: loadPrefs().harperLintConfig, - looseMathDelimiters: loadPrefs().looseMathDelimiters, - keepViewModeAcrossNotes: loadPrefs().keepViewModeAcrossNotes, - defaultPaneMode: loadPrefs().defaultPaneMode, - syncTitleHeadingOnRename: loadPrefs().syncTitleHeadingOnRename, - markdownSnippets: loadPrefs().markdownSnippets, - textReplacementsEnabled: loadPrefs().textReplacementsEnabled, - textReplacements: loadPrefs().textReplacements, - savedTaskFilters: loadPrefs().savedTaskFilters, - autoPairs: loadPrefs().autoPairs, - autoPairQuotesInProse: loadPrefs().autoPairQuotesInProse, - hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates, - tabsEnabled: loadPrefs().tabsEnabled, - wrapTabs: loadPrefs().wrapTabs, - settingsOpen: false, - workflowTutorialStep: null, - workflowRunRecord: null, - themeId: loadPrefs().themeId, - themeFamily: loadPrefs().themeFamily, - themeMode: loadPrefs().themeMode, - editorFontSize: loadPrefs().editorFontSize, - mathFontScale: loadPrefs().mathFontScale, - editorLineHeight: loadPrefs().editorLineHeight, + const initImpl = async (): Promise => { + if (get().initialized) return + const startedAt = performance.now() + set({ initialized: true }) + let initializedVault = false + try { + const remoteWorkspaceProfilesPromise = get().refreshRemoteWorkspaceProfiles() + const localVaultsPromise = get().refreshLocalVaults() + const [bootWorkspaceInfo, serverCapabilities] = await Promise.all([ + get().refreshWorkspaceContext(), + window.zen.getServerCapabilities().catch(() => null) + ]) + if (!(await ensureWebServerSession(serverCapabilities))) { + void remoteWorkspaceProfilesPromise + void localVaultsPromise + set({ + workspaceMode: workspaceModeFrom(bootWorkspaceInfo), + remoteWorkspaceInfo: bootWorkspaceInfo, + workspaceSetupError: null, + workspaceRestored: true, + vaultSettings: DEFAULT_VAULT_SETTINGS + }) + recordRendererPerf('store.init', performance.now() - startedAt, { + hasVault: false + }) + return + } + const vault = await window.zen.getCurrentVault() + // getCurrentVault is what connects a configured remote workspace, so + // the info fetched above predates the connection: its capabilities and + // bootError are still null, and keeping it would leave Settings + // believing the server advertises nothing (#723). Ask again now that + // the answer exists. + const remoteWorkspaceInfo = bootWorkspaceInfo + ? await get().refreshWorkspaceContext() + : bootWorkspaceInfo + void remoteWorkspaceProfilesPromise + void localVaultsPromise + if (vault) { + const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) + set({ + vault, + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + workspaceSetupError: null, + vaultSettings, + workspaceRestored: false + }) + await openVaultWorkspace(vault) + await prefetchInitialVisibleNotes(get()) + initializedVault = true + } else { + set({ + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + workspaceSetupError: null, + workspaceRestored: true, + vaultSettings: DEFAULT_VAULT_SETTINGS + }) + } + } catch (err) { + console.error('init failed', err) + set({ + workspaceMode: 'local', + remoteWorkspaceInfo: null, + workspaceSetupError: + window.zen.getAppInfo().runtime === 'web' ? describeWebServerSetupError(err) : null, + workspaceRestored: true, + vaultSettings: DEFAULT_VAULT_SETTINGS + }) + } + recordRendererPerf('store.init', performance.now() - startedAt, { + hasVault: initializedVault + }) + // Default focus to the sidebar so j/k navigation works immediately + if (get().sidebarOpen && !get().focusedPanel) { + set({ focusedPanel: 'sidebar' }) + } + // Restore the pinned reference note by loading its content — the + // path survived in prefs; `refreshNotes` has already confirmed it + // still exists and otherwise cleared `pinnedRefPath`. + const pinnedPath = get().pinnedRefPath + if (pinnedPath && !get().noteContents[pinnedPath]) { + try { + const content = await readNoteContent(pinnedPath, get()) + set((s) => ({ + noteContents: { ...s.noteContents, [pinnedPath]: content }, + noteDirty: { ...s.noteDirty, [pinnedPath]: false } + })) + } catch (err) { + console.error('pinned reference readNote failed', err) + set({ pinnedRefPath: null }) + savePrefs(collectPrefs(get())) + } + } + // `retryWorkspaceBoot` re-enters `init` on every successful reconnect, so + // the previous subscription has to go before a new one is made. Without + // this each reconnect left a live listener behind and one file change + // arrived as N changes, each running the full `applyChange`. + vaultChangeUnsubscribe?.() + vaultChangeUnsubscribe = window.zen.onVaultChange((ev) => { + void get().applyChange(ev) + }) + } + + const openLocalVaultImpl = async (root: string, requireVault = false): Promise => { + set({ workspaceSetupError: null }) + const vault = await window.zen.openLocalVault(root) + await get().refreshLocalVaults() + if (!vault) { + if (requireVault) throw new Error('The relocated vault could not be opened.') + return + } + + const remoteWorkspaceInfo = await get().refreshWorkspaceContext() + const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) + const fresh = makeLeaf() + set({ + vault, + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + workspaceSetupError: null, + vaultSettings, + notes: [], + folders: [], + hasAssetsDir: false, + assetFiles: [], + assetUndoStack: [], + closedTabStack: [], + workflowRunRecord: null, + workflowTutorialStep: null, + vaultTasks: [], + selectedTags: [], + view: { kind: 'folder', folder: 'inbox', subpath: '' }, + selectedPath: null, + activeNote: null, + activeDirty: false, + paneLayout: fresh, + activePaneId: fresh.id, + noteContents: {}, + noteDirty: {}, + loadingNote: false, + noteBackstack: [], + noteForwardstack: [], + pendingJumpLocation: null, + pinnedRefPath: null, + workspaceRestored: false + }) + savePrefs(collectPrefs(get())) + await openVaultWorkspace(vault) + } + + const disconnectRemoteWorkspaceImpl = async (): Promise => { + try { + await get().flushDirtyNotes() + const vault = await window.zen.disconnectRemoteWorkspace() + const remoteWorkspaceInfo = await get().refreshWorkspaceContext() + await get().refreshLocalVaults() + + if (!vault) { + const fresh = makeLeaf() + set({ + vault: null, + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + vaultSettings: DEFAULT_VAULT_SETTINGS, + notes: [], + folders: [], + hasAssetsDir: false, + assetFiles: [], + assetUndoStack: [], + closedTabStack: [], + workflowRunRecord: null, + workflowTutorialStep: null, + vaultTasks: [], + selectedTags: [], + view: { kind: 'folder', folder: 'inbox', subpath: '' }, + selectedPath: null, + activeNote: null, + activeDirty: false, + paneLayout: fresh, + activePaneId: fresh.id, + noteContents: {}, + noteDirty: {}, + loadingNote: false, + noteBackstack: [], + noteForwardstack: [], + pendingJumpLocation: null, + pinnedRefPath: null, + workspaceRestored: true + }) + savePrefs(collectPrefs(get())) + return + } + + const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) + const fresh = makeLeaf() + set({ + vault, + workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), + remoteWorkspaceInfo, + vaultSettings, + notes: [], + folders: [], + hasAssetsDir: false, + assetFiles: [], + assetUndoStack: [], + closedTabStack: [], + workflowRunRecord: null, + workflowTutorialStep: null, + vaultTasks: [], + selectedTags: [], + view: { kind: 'folder', folder: 'inbox', subpath: '' }, + selectedPath: null, + activeNote: null, + activeDirty: false, + paneLayout: fresh, + activePaneId: fresh.id, + noteContents: {}, + noteDirty: {}, + loadingNote: false, + noteBackstack: [], + noteForwardstack: [], + pendingJumpLocation: null, + pinnedRefPath: null, + workspaceRestored: false + }) + savePrefs(collectPrefs(get())) + await openVaultWorkspace(vault) + } catch (error) { + window.alert(error instanceof Error ? error.message : String(error)) + } + } + + return { + vault: null, + workspaceMode: 'local', + remoteWorkspaceInfo: null, + remoteWorkspaceProfiles: [], + localVaults: [], + workspaceSetupError: null, + vaultSettings: DEFAULT_VAULT_SETTINGS, + rootContentHiddenByInboxMode: false, + rootContentBannerDismissed: false, + manualNoteOrder: {}, + notes: [], + typstPreambleNotes: [], + folders: [], + assetFiles: [], + assetUndoStack: [], + hasAssetsDir: false, + view: { kind: 'folder', folder: 'inbox', subpath: '' }, + selectedPath: null, + activeNote: null, + activeDirty: false, + noteBackstack: [], + noteForwardstack: [], + pendingJumpLocation: null, + loadingNote: false, + searchOpen: false, + vaultTextSearchOpen: false, + commandPaletteOpen: false, + commandPaletteInitialMode: 'main', + bufferPaletteOpen: false, + outlinePaletteOpen: false, + templatePaletteOpen: false, + embedDrawingPaletteOpen: false, + excalidrawPreviewVersion: 0, + templatePaletteMode: 'create', + templatePaletteTarget: null, + customTemplates: [], + workflowIndex: [], + query: '', + initialized: false, + workspaceRestored: false, + workspaceTransitioning: false, + sidebarOpen: true, + noteListOpen: true, + zenMode: false, + zenRestoreState: null, + vimMode: loadPrefs().vimMode, + vimInsertEscape: loadPrefs().vimInsertEscape, + ignoredKeys: loadPrefs().ignoredKeys, + externalApplicationSchemes: loadPrefs().externalApplicationSchemes, + vimYankToClipboard: loadPrefs().vimYankToClipboard, + vimBlockImeInNormalMode: loadPrefs().vimBlockImeInNormalMode, + vimWrappedLineMotions: loadPrefs().vimWrappedLineMotions, + keymapOverrides: loadPrefs().keymapOverrides, + enabledOverrides: loadPrefs().enabledOverrides, + themeTweaks: loadPrefs().themeTweaks, + whichKeyHints: loadPrefs().whichKeyHints, + whichKeyHintMode: loadPrefs().whichKeyHintMode, + whichKeyHintTimeoutMs: loadPrefs().whichKeyHintTimeoutMs, + vaultTextSearchBackend: loadPrefs().vaultTextSearchBackend, + ripgrepBinaryPath: loadPrefs().ripgrepBinaryPath, + fzfBinaryPath: loadPrefs().fzfBinaryPath, + livePreview: loadPrefs().livePreview, + showHeadingLevelLabels: loadPrefs().showHeadingLevelLabels, + listIndentGuides: loadPrefs().listIndentGuides, + renderTablesInLivePreview: loadPrefs().renderTablesInLivePreview, + completedTaskStyle: loadPrefs().completedTaskStyle, + mathRenderer: loadPrefs().mathRenderer, + typstTagPreambles: loadPrefs().typstTagPreambles, + harperEnabled: loadPrefs().harperEnabled, + harperDialect: loadPrefs().harperDialect, + harperLintConfig: loadPrefs().harperLintConfig, + looseMathDelimiters: loadPrefs().looseMathDelimiters, + keepViewModeAcrossNotes: loadPrefs().keepViewModeAcrossNotes, + defaultPaneMode: loadPrefs().defaultPaneMode, + syncTitleHeadingOnRename: loadPrefs().syncTitleHeadingOnRename, + markdownSnippets: loadPrefs().markdownSnippets, + textReplacementsEnabled: loadPrefs().textReplacementsEnabled, + textReplacements: loadPrefs().textReplacements, + savedTaskFilters: loadPrefs().savedTaskFilters, + autoPairs: loadPrefs().autoPairs, + autoPairQuotesInProse: loadPrefs().autoPairQuotesInProse, + hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates, + tabsEnabled: loadPrefs().tabsEnabled, + wrapTabs: loadPrefs().wrapTabs, + settingsOpen: false, + workflowTutorialStep: null, + workflowRunRecord: null, + themeId: loadPrefs().themeId, + themeFamily: loadPrefs().themeFamily, + themeMode: loadPrefs().themeMode, + editorFontSize: loadPrefs().editorFontSize, + mathFontScale: loadPrefs().mathFontScale, + editorLineHeight: loadPrefs().editorLineHeight, editorTabSize: loadPrefs().editorTabSize, editorScrollOff: loadPrefs().editorScrollOff, timeFormat: loadPrefs().timeFormat, @@ -4850,6 +5613,7 @@ export const useStore = create((set, get) => { tasksCalendarSelectedDate: null, tasksCalendarMonthAnchor: null, databases: {}, + databasesDeletingRows: {}, databasesLoading: {}, selectedTags: [], tagMatchMode: 'all', @@ -4895,6 +5659,7 @@ export const useStore = create((set, get) => { } }, applyFavorites: async (nextFavorites) => { + const isCurrent = captureFolderActionContext(get) const current = get().vaultSettings if ( current.favorites.length === nextFavorites.length && @@ -4909,10 +5674,10 @@ export const useStore = create((set, get) => { const saved = normalizeVaultSettings( await window.zen.setVaultSettings({ ...get().vaultSettings, favorites: nextFavorites }) ) - set({ vaultSettings: saved }) + if (isCurrent()) set({ vaultSettings: saved }) } catch (err) { console.error('applyFavorites failed', err) - set({ vaultSettings: current }) // revert on failure + if (isCurrent()) set({ vaultSettings: current }) // revert on failure } }, toggleFavorite: async (key) => { @@ -5117,10 +5882,16 @@ export const useStore = create((set, get) => { set({ focusedPanel: 'editor' }) }, loadDatabase: async (csvPath) => { - if (get().databasesLoading[csvPath]) return + if (isWorkspaceTransitionPending()) return + if (get().databasesLoading[csvPath] || databaseRowActions.has(csvPath) || folderMutationBlocks(csvPath)) return + const contextIsCurrent = captureFolderActionContext(get) + const version = databaseLoadVersions.get(csvPath) ?? 0 + const generation = workspaceGeneration() + const isCurrent = () => generation === workspaceGeneration() && contextIsCurrent() && (databaseLoadVersions.get(csvPath) ?? 0) === version set((s) => ({ databasesLoading: { ...s.databasesLoading, [csvPath]: true } })) try { const doc = await window.zen.openDatabase(csvPath) + if (!isCurrent()) return if (!doc) { // The .csv is gone — drop it and close any stale tab rather than leave // a grid pointed at a deleted file (and re-requesting it on every render). @@ -5129,6 +5900,7 @@ export const useStore = create((set, get) => { } set((s) => ({ databases: { ...s.databases, [csvPath]: doc } })) } catch (err) { + if (!isCurrent()) return // Failing silently here is how "clicking a database does nothing" bug // reports happen (#499): the sidebar row looks live, the click dies in // the console. Whatever the cause (server unreachable, bad schema), @@ -5141,35 +5913,71 @@ export const useStore = create((set, get) => { } finally { set((s) => csvPath in s.databasesLoading - ? { databasesLoading: { ...s.databasesLoading, [csvPath]: false } } + && isCurrent() ? { databasesLoading: { ...s.databasesLoading, [csvPath]: false } } : {} ) } }, openDatabase: async (csvPath) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return await get().loadDatabase(csvPath) + if (!isCurrent()) return // The load may have failed/forgotten a now-missing database — don't open an // empty tab for it. if (!get().databases[csvPath]) return await get().openNoteInPane(get().activePaneId, databaseTabPath(csvPath)) + if (!isCurrent()) return ;(document.activeElement as HTMLElement | null)?.blur?.() set({ focusedPanel: 'editor' }) }, - createDatabase: async (folder, subpath = '', title) => { + createDatabase: async (folder, subpath = '', title, hostIsCurrent) => { + if (workspaceWritesBlocked()) return + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + if (!isCurrent()) return + const directory = vaultRelativeFolderPath(folder, subpath, get().vaultSettings) + const prefix = directory ? `${directory}/` : '' + let release: (() => void) | undefined try { + const busy = [ + ...folderMutations.keys(), + ...databaseCreations.keys(), + ...[...uncertainFolderMutations] + .filter(([, vault]) => vault === get().vault) + .map(([path]) => path) + ] + if (busy.some((path) => prefix.startsWith(path) || path.startsWith(prefix))) + throw new Error('This folder already has an operation in progress') + databaseCreations.set( + prefix, + new Promise((resolve) => { + release = resolve + }) + ) const doc = await window.zen.createDatabase(folder, subpath, title) + if (!isCurrent()) return set((s) => ({ databases: { ...s.databases, [doc.path]: doc } })) + await get().refreshNotes() + if (!isCurrent()) return await get().openNoteInPane(get().activePaneId, databaseTabPath(doc.path)) + if (!isCurrent()) return ;(document.activeElement as HTMLElement | null)?.blur?.() set({ focusedPanel: 'editor' }) } catch (err) { + if (hostIsCurrent) throw err console.error('createDatabase failed', err) - const { useToastStore } = await import('./lib/toast') - useToastStore - .getState() - .addToast(humanIpcError(err, 'Could not create database'), 'error') + if (isCurrent()) { + const { useToastStore } = await import('./lib/toast') + useToastStore.getState().addToast(humanIpcError(err, 'Could not create database'), 'error') + } + } finally { + if (release) { + databaseCreations.delete(prefix) + release() + } } }, + newDatabase: async () => { const s = get() const settings = normalizeVaultSettings(s.vaultSettings) @@ -5180,7 +5988,7 @@ export const useStore = create((set, get) => { ) await get().createDatabase(folder, subpath) }, - newTaskFile: async (opts) => { + newTaskFile: trackNoteWrite(null, async (opts) => { const title = ( await promptApp({ title: 'New task', @@ -5211,7 +6019,7 @@ export const useStore = create((set, get) => { console.error('newTaskFile failed', err) return null } - }, + }), newTaskFileInChosenFolder: async () => { const state = get() const entered = await promptApp(buildNoteDestinationPrompt('', state.folders)) @@ -5219,67 +6027,61 @@ export const useStore = create((set, get) => { const dest = parseTemplateDestination(entered) return get().newTaskFile({ folder: dest.folder, subpath: dest.subpath }) }, - renameDatabase: async (csvPath, newTitle) => { - if (typeof window.zen.renameDatabase !== 'function') return + renameDatabase: async (csvPath, newTitle, hostIsCurrent) => { try { - const newCsvPath = await window.zen.renameDatabase(csvPath, newTitle) - if (!newCsvPath || newCsvPath === csvPath) { - await get().refreshNotes() - return - } - // The `.base` folder moved, so the open grid tab's path changed. Rehome it - // in place (and the cached doc) instead of leaving a stale tab. - const oldTab = databaseTabPath(csvPath) - const newTab = databaseTabPath(newCsvPath) - set((s) => { - const rewrite = (p: string): string => (p === oldTab ? newTab : p) - const ensured = ensureActivePane(rewritePathsInTree(s.paneLayout, rewrite), s.activePaneId) - const databases = { ...s.databases } - const loading = { ...s.databasesLoading } - const prev = databases[csvPath] - if (prev) { - databases[newCsvPath] = { - ...prev, - path: newCsvPath, - title: formTitleFromCsvPath(newCsvPath) + if (newTitle.trim().startsWith('.')) throw new Error('Database names cannot start with a dot.') + if (typeof window.zen.renameDatabase !== 'function') + throw new Error('Database renaming is unavailable') + const directory = formDirFromCsvPath(csvPath) + if (!directory) throw new Error('Only database folders can be renamed') + const settings = get().vaultSettings + const folder = folderForVaultRelativePath(csvPath, settings) ?? 'inbox' + const oldSubpath = noteFolderSubpath({ path: csvPath, folder }, settings) + await renameFolderImpl( + folder, + oldSubpath, + `${directory}/`, + async () => { + const nextPath = await window.zen.renameDatabase(csvPath, newTitle) + const nextDirectory = formDirFromCsvPath(nextPath) + if (!nextDirectory) + throw new Error('FOLDER_STATE_UNCERTAIN: Database rename returned an invalid path') + return { + subpath: noteFolderSubpath({ path: nextPath, folder }, settings), + prefix: `${nextDirectory}/` } - delete databases[csvPath] - } - delete loading[csvPath] - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - databases, - databasesLoading: loading, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, s.noteContents, s.noteDirty) - } - }) - await get().refreshNotes() + }, + hostIsCurrent + ) } catch (err) { + if (hostIsCurrent) throw err console.error('renameDatabase failed', err) window.alert(err instanceof Error ? err.message : String(err)) } }, + updateDatabaseRows: (csvPath, next) => { + if (databaseRowActions.has(csvPath) || isNoteEditingLocked(get().vault, csvPath)) return set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) scheduleDatabaseWrite(csvPath, 'rows', () => get().databases[csvPath]) remirrorOpenRecordPages(csvPath, get) }, deleteDatabaseRows: async (csvPath, rowIds) => { + if (workspaceWritesBlocked()) return const doc = get().databases[csvPath] - if (!doc) return + const vault = get().vault + if (!doc || !vault || databaseRowActions.size > 0 || pendingRowConfirmation || getConfirmRequest() || getPromptRequest()) return + const isCurrent = captureFolderActionContext(get) + const bridge = window.zen const ids = [...new Set(rowIds)].filter((id) => doc.rows.some((r) => r.id === id)) if (ids.length === 0) return - - // Deleted rows that carry a linked record page — the ones worth asking about. - const attached = ids - .map((id) => doc.pages?.[id]) - .filter((p): p is string => typeof p === 'string' && p.length > 0) - + const mappings = new Map(ids.map((id) => [id, doc.pages?.[id]])) + const attached = [...mappings.values()].filter((path): path is string => !!path) let trashNotes = false if (attached.length > 0) { const many = attached.length > 1 - trashNotes = await confirmApp({ + pendingRowConfirmation = true + try { trashNotes = await confirmApp({ title: many ? `Delete ${ids.length} rows and their notes?` : 'Delete row and its linked note?', description: many ? `${attached.length} of these rows have a linked page note. Move those notes to Trash too, or keep them as standalone notes? The rows are deleted either way.` @@ -5287,55 +6089,133 @@ export const useStore = create((set, get) => { confirmLabel: many ? 'Delete rows + notes' : 'Delete row + note', cancelLabel: many ? 'Keep notes' : 'Keep note', danger: true - }) + }) } finally { pendingRowConfirmation = false } } - - // Re-read after the (async) prompt so a concurrent edit isn't clobbered. + if (!isCurrent()) return const latest = get().databases[csvPath] - if (!latest) return + if (!latest || ids.some((id) => !latest.rows.some(row => row.id === id) || latest.pages?.[id] !== mappings.get(id))) { + useToastStore.getState().addToast('The linked pages changed. Review the rows before deleting them.', 'info') + return + } + if (databaseRowActions.size || folderMutations.size || databaseCreations.size || inFlightNoteWrites.size || inFlightTaskMutations.size || taskMutationQueues.size || [...uncertainFolderMutations.values()].includes(vault)) { + useToastStore.getState().addToast('Wait for pending file and task changes before deleting rows.', 'info') + return + } const removeSet = new Set(ids) const nextPages = { ...(latest.pages ?? {}) } const nextFlags = { ...(latest.pageHasContent ?? {}) } - const prunedPaths: string[] = [] for (const id of ids) { - const pagePath = nextPages[id] - if (pagePath) { - prunedPaths.push(pagePath) - delete nextPages[id] - delete nextFlags[id] - } - } - const pagesChanged = prunedPaths.length > 0 + delete nextPages[id] + delete nextFlags[id] + } + const remainingPages = new Set(Object.values(nextPages)) + const directory = formDirFromCsvPath(csvPath) + // Only this database's exclusive pages can be changed automatically. A + // hand-edited foreign/shared mapping must not overwrite another record. + const pages = [...new Set(attached)].filter((path) => + directory && formDirContaining(path) === directory && !remainingPages.has(path) + ) const next: DatabaseDoc = { ...latest, - rows: latest.rows.filter((r) => !removeSet.has(r.id)), - ...(pagesChanged ? { pages: nextPages, pageHasContent: nextFlags } : {}) - } - set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) - // A pruned page mapping lives in the sidecar, so force a schema write; a - // plain 'rows' write only rewrites the CSV and would leave the stale entry. - scheduleDatabaseWrite(csvPath, pagesChanged ? 'schema' : 'rows', () => get().databases[csvPath]) - remirrorOpenRecordPages(csvPath, get) - - if (trashNotes) { - for (const pagePath of prunedPaths) { - await moveNoteToTrash(pagePath, { temporarySession: get().vault?.temporary === true }) + rows: latest.rows.filter((row) => !removeSet.has(row.id)), + pages: nextPages, + pageHasContent: nextFlags + } + let release!: () => void + const done = new Promise((resolve) => { release = resolve }) + databaseRowActions.set(csvPath, done) + set((s) => ({ databasesDeletingRows: { ...s.databasesDeletingRows, [csvPath]: true } })) + databaseLoadVersions.set(csvPath, (databaseLoadVersions.get(csvPath) ?? 0) + 1) + const unlock: Array<() => void> = [] + let committed = false + let moved = 0 + try { + for (const path of pages) unlock.push(lockNoteEditing(vault, path)) + await flushDatabaseWrite(csvPath, () => isCurrent() ? get().databases[csvPath] : undefined) + if (!isCurrent()) return + // Materialize properties while rows still exist. Preserve the freshest + // editor body, including an unsaved page that is open in another pane. + for (const path of pages) { + const pending = pathSaveQueues.get(path) + if (pending) await pending + if (!isCurrent()) return + const content = get().noteContents[path] ?? await bridge.readNote(path) + if (!isCurrent()) return + const row = latest.rows.find((row) => removeSet.has(row.id) && latest.pages?.[row.id] === path) + if (!row) continue + const body = composePageBody(latest, row, parseFrontmatter(content.body).body) + if (get().noteContents[path]) { + set((s) => { + const noteContents = { ...s.noteContents, [path]: { ...s.noteContents[path], body } } + const noteDirty = { ...s.noteDirty, [path]: true } + return { noteContents, noteDirty, ...activeFieldsFrom(s.paneLayout, s.activePaneId, noteContents, noteDirty) } + }) + await get().persistNote(path, true) + if (get().noteDirty[path]) throw new Error('A linked page could not be saved') + } else { + await bridge.writeNote(path, body) + } + if (!isCurrent()) return + } + set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) + databaseWriteKind.set(csvPath, 'schema') + try { + await flushDatabaseWrite(csvPath, () => isCurrent() ? get().databases[csvPath] : undefined) + } catch (error) { + // Keep the recoverable rows and schedule their full schema for retry. + if (isCurrent()) { + set((s) => ({ databases: { ...s.databases, [csvPath]: latest } })) + databaseWriteKind.set(csvPath, 'schema') + } + throw error + } + if (!isCurrent()) return + committed = true + if (trashNotes) { + for (const path of pages) { + if (!isCurrent()) return + const result = await mutateNoteImpl(path, async () => { + const meta = await bridge.moveToTrash(path) + return vault.temporary ? null : meta + }, isCurrent, false, csvPath) + moved += 1 + if (isCurrent() && result && !get().noteDirty[result.path]) + set((s) => withoutNoteInWorkspace(s, result.path)) + } + } + } catch (error) { + const message = committed + ? `Rows deleted; ${moved} linked pages moved. Remaining pages are saved as standalone notes.` + : 'Rows were kept because deletion could not finish.' + useToastStore.getState().addToast(`${message} ${humanIpcError(error, 'Could not finish deleting rows')}`, 'error') + } finally { + for (const restore of unlock) restore() + databaseRowActions.delete(csvPath) + if (isCurrent()) { + set((s) => ({ databasesDeletingRows: { ...s.databasesDeletingRows, [csvPath]: false } })) + const kind = databaseWriteKind.get(csvPath) + if (kind) scheduleDatabaseWrite(csvPath, kind, () => get().databases[csvPath]) } + release() } }, updateDatabaseSchema: (csvPath, next) => { + if (databaseRowActions.has(csvPath) || isNoteEditingLocked(get().vault, csvPath)) return set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) scheduleDatabaseWrite(csvPath, 'schema', () => get().databases[csvPath]) remirrorOpenRecordPages(csvPath, get) }, syncDatabaseFromDisk: async (csvPath) => { - if (!get().databases[csvPath]) return + if (!get().databases[csvPath] || databaseRowActions.has(csvPath) || folderMutationBlocks(csvPath)) return + const contextIsCurrent = captureFolderActionContext(get) + const version = databaseLoadVersions.get(csvPath) ?? 0 // Ignore the watcher echo of a write we just made. if (Date.now() - (lastDatabaseWriteAt.get(csvPath) ?? 0) < 1500) return // Don't clobber edits that are still mid-debounce. - if (databaseSaveTimers.has(csvPath)) return + if (databaseWriteKind.has(csvPath) || databaseWriteQueues.has(csvPath)) return try { const doc = await window.zen.openDatabase(csvPath) + if (!contextIsCurrent() || databaseRowActions.has(csvPath) || (databaseLoadVersions.get(csvPath) ?? 0) !== version || databaseWriteKind.has(csvPath) || databaseWriteQueues.has(csvPath)) return if (!doc) { await get().forgetDatabase(csvPath) return @@ -5367,7 +6247,7 @@ export const useStore = create((set, get) => { return { databases, databasesLoading } }) }, - openRecordPage: async (csvPath, rowId) => { + openRecordPage: trackNoteWrite(undefined, async (csvPath, rowId) => { const doc = get().databases[csvPath] if (!doc) return const row = doc.rows.find((r) => r.id === rowId) @@ -5405,15 +6285,17 @@ export const useStore = create((set, get) => { } } await get().selectNote(pagePath) - }, + }), renameRecordPage: async (csvPath, rowId) => { + const isCurrent = captureFolderActionContext(get) const doc = get().databases[csvPath] const pagePath = doc?.pages?.[rowId] if (!doc || !pagePath) return const row = doc.rows.find((r) => r.id === rowId) if (!row) return try { - const meta = await window.zen.renameNote(pagePath, recordTitle(doc, row)) + const meta = await mutateNoteImpl(pagePath, () => window.zen.renameNote(pagePath, recordTitle(doc, row)), isCurrent, true) + if (!meta || !isCurrent()) return if (meta.path !== pagePath) { get().updateDatabaseSchema(csvPath, { ...get().databases[csvPath]!, @@ -5453,19 +6335,28 @@ export const useStore = create((set, get) => { setTagMatchMode: (mode) => set({ tagMatchMode: mode }), refreshTasks: async () => { + if (folderMutations.size > 0) return + const isCurrent = captureFolderActionContext(get) + const revision = taskIndexRevision set({ tasksLoading: true }) try { const tasks = await window.zen.scanTasks() + if (!isCurrent() || revision !== taskIndexRevision) return set({ vaultTasks: tasks, tasksLoading: false }) } catch (err) { console.error('scanTasks failed', err) + if (!isCurrent() || revision !== taskIndexRevision) return set({ tasksLoading: false }) } }, rescanTasksForPath: async (relPath) => { + if (folderMutationBlocks(relPath)) return + const isCurrent = captureFolderActionContext(get) + const version = folderReadVersion(relPath) try { const fresh = await window.zen.scanTasksForPath(relPath) + if (!isCurrent() || folderMutationBlocks(relPath) || version !== folderReadVersion(relPath)) return set((s) => ({ vaultTasks: s.vaultTasks.filter((t) => t.sourcePath !== relPath).concat(fresh) })) @@ -5475,6 +6366,8 @@ export const useStore = create((set, get) => { }, openTaskAt: async (task) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const state = get() // Pull body — in-memory first, disk fallback. Used to resolve lineNumber @@ -5483,6 +6376,7 @@ export const useStore = create((set, get) => { if (!body) { try { const content = await window.zen.readNote(task.sourcePath) + if (!isCurrent()) return body = content.body } catch (err) { console.error('openTaskAt readNote failed', err) @@ -5507,6 +6401,7 @@ export const useStore = create((set, get) => { // tab's content area with the note (the Tasks tab itself stays in the // strip, so the user can hop back with a click). await get().openNoteInPane(state.activePaneId, task.sourcePath) + if (!isCurrent() || get().selectedPath !== task.sourcePath) return // Make sure the folder view is sensible in case the sidebar is visible. if (state.view.kind !== 'folder' || state.view.folder !== task.noteFolder) { set({ view: { kind: 'folder', folder: task.noteFolder, subpath: '' } }) @@ -5530,7 +6425,7 @@ export const useStore = create((set, get) => { requestEditorFocus() }, - toggleTaskFromList: async (task) => { + toggleTaskFromList: trackTaskWrite(async (task) => { const state = get() const path = task.sourcePath const openBuffer = state.noteContents[path] @@ -5570,9 +6465,9 @@ export const useStore = create((set, get) => { : t ) })) - }, + }), - cancelTaskFromList: async (task) => { + cancelTaskFromList: trackTaskWrite(async (task) => { const path = task.sourcePath const openBuffer = get().noteContents[path] const body = openBuffer?.body ?? (await window.zen.readNote(path)).body @@ -5604,9 +6499,9 @@ export const useStore = create((set, get) => { : t ) })) - }, + }), - startTaskFromList: async (task) => { + startTaskFromList: trackTaskWrite(async (task) => { const path = task.sourcePath const openBuffer = get().noteContents[path] const body = openBuffer?.body ?? (await window.zen.readNote(path)).body @@ -5638,9 +6533,9 @@ export const useStore = create((set, get) => { : t ) })) - }, + }), - applyTaskMutation: async (task, mutation) => { + applyTaskMutation: trackTaskWrite(async (task, mutation) => { const mutations: TaskMutation[] = Array.isArray(mutation) ? mutation : [mutation] if (mutations.length === 0) return @@ -5754,51 +6649,47 @@ export const useStore = create((set, get) => { } finally { inFlightTaskMutations.delete(running) } - }, + }), deleteTaskFromList: async (task) => { - const path = task.sourcePath - // A file-task *is* the note, so "delete" means trash the whole note (with a - // confirm, since it may hold body notes). Inline tasks just drop their line. + // File tasks use the note action before entering the inline-task write queue. if (task.kind === 'file') { - if (!(await confirmMoveToTrash(task.noteTitle))) return - set((s) => ({ vaultTasks: s.vaultTasks.filter((t) => t.sourcePath !== path) })) - if (await moveNoteToTrash(path, { temporarySession: get().vault?.temporary === true })) { - await get().refreshNotes() - } - else void get().refreshTasks() - return - } - const openBuffer = get().noteContents[path] - let body: string - try { - body = openBuffer?.body ?? (await window.zen.readNote(path)).body - } catch (err) { - console.error('deleteTaskFromList readNote failed', err) + await get().trashNote(task.sourcePath) return } - const nextBody = removeTaskAtIndex(body, task.taskIndex) - if (nextBody === body) return - // Optimistically drop it from the index so the row vanishes immediately. - set((s) => ({ - vaultTasks: s.vaultTasks.filter( - (t) => !(t.sourcePath === path && t.taskIndex === task.taskIndex) - ) - })) - if (openBuffer) { - get().updateNoteBody(path, nextBody) - } else { + await trackTaskWrite(async () => { + const path = task.sourcePath + const openBuffer = get().noteContents[path] + let body: string try { - await window.zen.writeNote(path, nextBody) - await get().rescanTasksForPath(path) + body = openBuffer?.body ?? (await window.zen.readNote(path)).body } catch (err) { - console.error('deleteTaskFromList writeNote failed', err) - void get().rescanTasksForPath(path) + console.error('deleteTaskFromList readNote failed', err) + return } - } + const nextBody = removeTaskAtIndex(body, task.taskIndex) + if (nextBody === body) return + // Optimistically drop it from the index so the row vanishes immediately. + set((s) => ({ + vaultTasks: s.vaultTasks.filter( + (t) => !(t.sourcePath === path && t.taskIndex === task.taskIndex) + ) + })) + if (openBuffer) { + get().updateNoteBody(path, nextBody) + } else { + try { + await window.zen.writeNote(path, nextBody) + await get().rescanTasksForPath(path) + } catch (err) { + console.error('deleteTaskFromList writeNote failed', err) + void get().rescanTasksForPath(path) + } + } + })() }, - moveTaskToDate: async (task, dateIso) => { + moveTaskToDate: trackTaskWrite(async (task, dateIso) => { const parsed = parseIsoDateLocal(dateIso) if (!parsed) return // A file-task isn't a line that can move into a daily note; rescheduling it @@ -5884,9 +6775,9 @@ export const useStore = create((set, get) => { ...tgtTasks ] })) - }, + }), - forwardTask: async (task, targetPath) => { + forwardTask: trackTaskWrite(async (task, targetPath) => { if (!targetPath || targetPath === task.sourcePath) return const targetMeta = get().notes.find((n) => n.path === targetPath) if (!targetMeta) return @@ -5962,7 +6853,7 @@ export const useStore = create((set, get) => { ...tgtTasks ] })) - }, + }), setTasksFilter: (q) => set({ tasksFilter: q, taskCursorIndex: 0 }), setTasksViewMode: (mode) => { @@ -6122,6 +7013,8 @@ export const useStore = create((set, get) => { }, openNoteAtOffset: async (relPath, offset, options) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const state = get() const anchor = Math.max(0, offset) const pendingJumpLocation = { @@ -6141,6 +7034,7 @@ export const useStore = create((set, get) => { ...noteHistoryAfterJump(state, relPath) }) await get().openNoteInPane(state.activePaneId, relPath) + if (!isCurrent()) return set((s) => { if (s.selectedPath === relPath) return { focusedPanel: 'editor' } if (s.pendingJumpLocation?.path === relPath) { @@ -6196,6 +7090,8 @@ export const useStore = create((set, get) => { }, refreshNotes: async () => { + const request = ++noteIndexRequest + const isCurrent = captureFolderActionContext(get) try { // Load this vault's manual note order once per vault (drives #224). const orderRoot = get().vault?.root ?? '' @@ -6209,6 +7105,7 @@ export const useStore = create((set, get) => { window.zen.listFolders(), window.zen.hasAssetsDir() ]) + if (!isCurrent() || request !== noteIndexRequest) return recordRendererPerf('store.refreshNotes.fetch', performance.now() - startedAt, { notes: notes.length, folders: folders.length, @@ -6299,38 +7196,40 @@ export const useStore = create((set, get) => { }, renameAsset: async (relPath, nextName) => { - // Renaming rewrites every note that references the asset on disk. Flush - // open buffers first so that rewrite cannot race a pending save and get - // overwritten by stale editor contents immediately afterwards (#785), the - // same guard `renameNote` uses for inbound wikilinks. - await get().flushDirtyNotes() - if (Object.values(get().noteDirty).some(Boolean)) { - throw new Error('Could not rename while notes still have unsaved changes') - } - const meta = await window.zen.renameAsset(relPath, nextName) - // Assets for the list; notes so `assetEmbeds` (usage) and excerpts follow - // the rewritten bodies. - await Promise.all([get().refreshAssets(), get().refreshNotes()]) - return meta + let result!: AssetMeta + // Link rewrites touch every referencing note. Reserve the vault while + // draining saves and refreshing buffers so neither typing nor a workspace + // switch can overwrite the rewritten links (#785). + await runWorkspaceTransition(async () => { + result = await window.zen.renameAsset(relPath, nextName) + await Promise.all([get().refreshAssets(), get().refreshNotes()]) + await Promise.all(Object.values(get().noteContents).map(({ path, folder }) => + get().applyChange({ kind: 'change', path, folder }) + )) + }, false, true) + return result }, moveAsset: async (relPath, targetDir) => { - // Same guard as renameAsset: the move rewrites referencing notes on disk, - // which must not race a pending save (#785). - await get().flushDirtyNotes() - if (Object.values(get().noteDirty).some(Boolean)) { - throw new Error('Could not move while notes still have unsaved changes') - } - const meta = await window.zen.moveAsset(relPath, targetDir) - await Promise.all([get().refreshAssets(), get().refreshNotes()]) - return meta + let result!: AssetMeta + await runWorkspaceTransition(async () => { + result = await window.zen.moveAsset(relPath, targetDir) + await Promise.all([get().refreshAssets(), get().refreshNotes()]) + await Promise.all(Object.values(get().noteContents).map(({ path, folder }) => + get().applyChange({ kind: 'change', path, folder }) + )) + }, false, true) + return result }, refreshAssets: async () => { + const request = ++assetIndexRequest + const isCurrent = captureFolderActionContext(get) try { const startedAt = performance.now() const [rawAssets, hasAssetsDirOnDisk] = await Promise.all([ window.zen.listAssets(), window.zen.hasAssetsDir() ]) + if (!isCurrent() || request !== assetIndexRequest) return // Hide database internals (sidecar + .bak backups) — they're not // standalone files the user manages. const assetFiles = rawAssets.filter((a) => !isDatabaseInternalPath(a.path)) @@ -6391,6 +7290,7 @@ export const useStore = create((set, get) => { }, applyChange: async (ev) => { + if (folderMutationBlocks(ev.path)) return // The live feed's unlink handling, shared with the resync path below: // a deleted note's tab closes wherever it is open. const closeUnlinkedNote = (notePath: string): void => { @@ -6649,8 +7549,10 @@ export const useStore = create((set, get) => { }, updateNoteBody: (path, body) => { + if (isNoteEditingLocked(get().vault, path)) return set((s) => { const existing = s.noteContents[path] + if (existing) body = rewriteRenamingBody(path, body, existing.folder) if (!existing || existing.body === body) return s const contents = { ...s.noteContents, [path]: { ...existing, body } } const dirty = { ...s.noteDirty, [path]: true } @@ -6668,6 +7570,7 @@ export const useStore = create((set, get) => { ...activeFieldsFrom(layout, s.activePaneId, contents, dirty) } }) + if (folderMutationBlocks(path)) return // Debounced disk write. const existing = pathSaveTimers.get(path) if (existing) clearTimeout(existing) @@ -6686,14 +7589,17 @@ export const useStore = create((set, get) => { await get().persistNote(path) }, - persistNote: async (path) => { + persistNote: async (path, duringFolderMutation = false) => { + const isCurrent = captureFolderActionContext(get) const pending = pathSaveTimers.get(path) if (pending) { clearTimeout(pending) pathSaveTimers.delete(path) } const performWrite = async (): Promise => { + if (!isCurrent()) return const s = get() + if (!duringFolderMutation && folderMutationBlocks(path)) return const content = s.noteContents[path] if (!content || !s.noteDirty[path]) return try { @@ -6702,6 +7608,7 @@ export const useStore = create((set, get) => { const writtenBody = content.body noteContentVersions.set(path, (noteContentVersions.get(path) ?? 0) + 1) const meta = await window.zen.writeNote(path, writtenBody) + if (!isCurrent()) return // Saving a Typst preamble note changes the definitions every note tagged // for it compiles against, so reload and repaint open panes. (#486) if ( @@ -6742,91 +7649,111 @@ export const useStore = create((set, get) => { }, loadNoteComments: async (path) => { - if (!path || isWorkspaceVirtualTabPath(path)) return [] - try { - const comments = await window.zen.readNoteComments(path) - set((s) => ({ - noteComments: { ...s.noteComments, [path]: comments } - })) - return comments - } catch (err) { - console.error('readNoteComments failed', err) - return get().noteComments[path] ?? [] - } + return trackCommentOperation(path, [], async () => { + const isCurrent = captureFolderActionContext(get) + if (!path || isWorkspaceVirtualTabPath(path)) return [] + try { + const comments = await window.zen.readNoteComments(path) + if (!isCurrent()) return [] + set((s) => ({ + noteComments: { ...s.noteComments, [path]: comments } + })) + return comments + } catch (err) { + console.error('readNoteComments failed', err) + return get().noteComments[path] ?? [] + } + }) }, addNoteComment: async (input) => { - const path = input.notePath - if (!path || isWorkspaceVirtualTabPath(path)) return null - const body = input.body.trim() - if (!body) return null - const now = Date.now() - const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) - const draft: NoteCommentInput = { - ...input, - notePath: path, - body, - createdAt: input.createdAt ?? now, - updatedAt: now, - resolvedAt: input.resolvedAt ?? null - } - try { - const comments = await window.zen.writeNoteComments(path, [...current, draft]) - const created = comments[comments.length - 1] ?? null - set((s) => ({ - noteComments: { ...s.noteComments, [path]: comments }, - activeCommentId: created?.id ?? s.activeCommentId - })) - return created - } catch (err) { - console.error('writeNoteComments failed', err) - return null - } + return trackCommentOperation(input.notePath, null, async () => { + const isCurrent = captureFolderActionContext(get) + const path = input.notePath + if (!path || isWorkspaceVirtualTabPath(path)) return null + const body = input.body.trim() + if (!body) return null + const now = Date.now() + const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) + const draft: NoteCommentInput = { + ...input, + notePath: path, + body, + createdAt: input.createdAt ?? now, + updatedAt: now, + resolvedAt: input.resolvedAt ?? null + } + try { + if (!isCurrent()) return null + const comments = await window.zen.writeNoteComments(path, [...current, draft]) + const created = comments[comments.length - 1] ?? null + if (!isCurrent()) return null + set((s) => ({ + noteComments: { ...s.noteComments, [path]: comments }, + activeCommentId: created?.id ?? s.activeCommentId + })) + return created + } catch (err) { + console.error('writeNoteComments failed', err) + return null + } + }) }, updateNoteComment: async (path, id, patch) => { - if (!path || !id) return - const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) - const now = Date.now() - const next = current.map((comment) => - comment.id === id - ? { - ...comment, - ...patch, - body: patch.body !== undefined ? patch.body.trim() : comment.body, - updatedAt: now - } - : comment - ) - try { - const comments = await window.zen.writeNoteComments(path, next) - set((s) => ({ - noteComments: { ...s.noteComments, [path]: comments }, - activeCommentId: - s.activeCommentId && comments.some((comment) => comment.id === s.activeCommentId) - ? s.activeCommentId - : null - })) - } catch (err) { - console.error('updateNoteComment failed', err) - } + return trackCommentOperation(path, undefined, async () => { + const isCurrent = captureFolderActionContext(get) + if (!path || !id) return + const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) + const now = Date.now() + const next = current.map((comment) => + comment.id === id + ? { + ...comment, + ...patch, + body: patch.body !== undefined ? patch.body.trim() : comment.body, + updatedAt: now + } + : comment + ) + try { + if (!isCurrent()) return undefined + const comments = await window.zen.writeNoteComments(path, next) + if (!isCurrent()) return undefined + set((s) => ({ + noteComments: { ...s.noteComments, [path]: comments }, + activeCommentId: + s.activeCommentId && comments.some((comment) => comment.id === s.activeCommentId) + ? s.activeCommentId + : null + })) + } catch (err) { + console.error('updateNoteComment failed', err) + } + }) }, deleteNoteComment: async (path, id) => { - if (!path || !id) return - const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) - const next = current.filter((comment) => comment.id !== id) - try { - const comments = await window.zen.writeNoteComments(path, next) - set((s) => ({ - noteComments: { ...s.noteComments, [path]: comments }, - activeCommentId: s.activeCommentId === id ? null : s.activeCommentId - })) - } catch (err) { - console.error('deleteNoteComment failed', err) - } + return trackCommentOperation(path, undefined, async () => { + const isCurrent = captureFolderActionContext(get) + if (!path || !id) return + const current = get().noteComments[path] ?? (await get().loadNoteComments(path)) + const next = current.filter((comment) => comment.id !== id) + try { + if (!isCurrent()) return undefined + const comments = await window.zen.writeNoteComments(path, next) + if (!isCurrent()) return undefined + set((s) => ({ + noteComments: { ...s.noteComments, [path]: comments }, + activeCommentId: s.activeCommentId === id ? null : s.activeCommentId + })) + } catch (err) { + console.error('deleteNoteComment failed', err) + } + }) }, + setActiveCommentId: (id) => set({ activeCommentId: id }), formatActiveNote: async () => { @@ -6845,32 +7772,12 @@ export const useStore = create((set, get) => { } }, - renameNote: async (oldPath, nextTitle) => { + renameNote: async (oldPath, nextTitle, hostIsCurrent) => { if (!oldPath) return try { - // Renaming rewrites every inbound wikilink on disk. Flush open buffers - // first so that rewrite cannot race a pending save and get overwritten - // by stale editor contents immediately afterwards. - await get().flushDirtyNotes() - if (Object.values(get().noteDirty).some(Boolean)) { - throw new Error('Could not rename while notes still have unsaved changes') - } - renamesInFlight.add(oldPath) - let meta: NoteMeta - try { - meta = await window.zen.renameNote(oldPath, nextTitle) - set((s) => renameNoteState(s, oldPath, meta)) - } finally { - renamesInFlight.delete(oldPath) - } - await get().applyFavorites( - rewriteFavoriteNotePath(get().vaultSettings.favorites, oldPath, meta.path) - ) - // Before the refresh so one listing picks up both the rename and the - // rewritten heading (excerpt, size). - await syncHeadingAfterRename(meta, get) - await get().refreshNotes() + await mutateNoteImpl(oldPath, () => window.zen.renameNote(oldPath, nextTitle), hostIsCurrent, true) } catch (err) { + if (hostIsCurrent) throw err console.error('renameNote failed', err) } }, @@ -6881,7 +7788,7 @@ export const useStore = create((set, get) => { await get().renameNote(oldPath, nextTitle) }, - createAndOpen: async (folder, subpath = '', options) => { + createAndOpen: trackNoteWrite(undefined, async (folder, subpath = '', options) => { try { const meta = await window.zen.createNote(folder, options?.title, subpath) rememberEditModeForCreatedNote(meta.path) @@ -6894,9 +7801,9 @@ export const useStore = create((set, get) => { } catch (err) { console.error('createNote failed', err) } - }, + }), - createDrawingAndOpen: async (folder, subpath = '') => { + createDrawingAndOpen: trackNoteWrite(undefined, async (folder, subpath = '') => { try { const meta = await window.zen.createExcalidraw(folder, subpath) await get().refreshNotes() @@ -6905,7 +7812,7 @@ export const useStore = create((set, get) => { } catch (err) { console.error('createExcalidraw failed', err) } - }, + }), insertEmbedAtCursor: (embed) => { const state = get() @@ -6920,7 +7827,7 @@ export const useStore = create((set, get) => { view.focus() }, - newDrawing: async () => { + newDrawing: trackNoteWrite(undefined, async () => { try { const s = get() const settings = normalizeVaultSettings(s.vaultSettings) @@ -6935,9 +7842,9 @@ export const useStore = create((set, get) => { } catch (err) { console.error('newDrawing failed', err) } - }, + }), - embedNewDrawing: async () => { + embedNewDrawing: trackNoteWrite(undefined, async () => { try { const s = get() const settings = normalizeVaultSettings(s.vaultSettings) @@ -6955,7 +7862,7 @@ export const useStore = create((set, get) => { } catch (err) { console.error('embedNewDrawing failed', err) } - }, + }), createNoteInCurrentFolder: async () => { const s = get() @@ -6985,7 +7892,7 @@ export const useStore = create((set, get) => { await get().createAndOpen(dest.folder, dest.subpath, { focusTitle: true }) }, - importDroppedMarkdownFiles: async (files) => { + importDroppedMarkdownFiles: trackNoteWrite(undefined, async (files) => { const createdPaths: string[] = [] for (const file of files) { try { @@ -7001,7 +7908,7 @@ export const useStore = create((set, get) => { if (createdPaths.length === 0) return await get().refreshNotes() for (const path of createdPaths) await get().openNoteInTab(path) - }, + }), closeActiveNote: async () => { const state = get() @@ -7039,113 +7946,125 @@ export const useStore = create((set, get) => { }, trashNote: async (path) => { - const state = get() - const title = state.notes.find((note) => note.path === path)?.title - if (!(await confirmMoveToTrash(title))) return false - if (!(await moveNoteToTrash(path, { temporarySession: state.vault?.temporary === true }))) { + const isCurrent = captureFolderActionContext(get) + const title = get().notes.find((note) => note.path === path)?.title + if (!(await confirmMoveToTrash(title, get().vault?.temporary === true)) || !isCurrent() || !get().notes.some(note => note.path === path)) return false + try { + await get().changeNoteLifecycle(path, 'trash', isCurrent) + return isCurrent() + } catch (error) { + useToastStore.getState().addToast(humanIpcError(error, 'Could not move the note to Trash.'), 'error') return false } - set((s) => withoutNoteInWorkspace(s, path)) - await get().refreshNotes() - return true }, deleteActivePermanently: async () => { const path = get().selectedPath - if (!path) return - await get().deleteNotePermanently(path) + if (path) await get().deleteNotePermanently(path) }, deleteNotePermanently: async (path) => { + const isCurrent = captureFolderActionContext(get) const title = get().notes.find((note) => note.path === path)?.title - if (!(await confirmDeletePermanently(title))) return false - if (!(await deleteNotePermanently(path))) return false - set((s) => withoutNoteInWorkspace(s, path)) - await get().refreshNotes() - return true + if (!(await confirmDeletePermanently(title)) || !isCurrent() || !get().notes.some(note => note.path === path)) return false + try { + await get().changeNoteLifecycle(path, 'delete', isCurrent) + return isCurrent() + } catch (error) { + useToastStore.getState().addToast(`Could not delete: ${humanIpcError(error, 'the note could not be deleted.')}`, 'error') + return false + } + }, + + emptyTrash: async (hostIsCurrent) => { + const vault = get().vault + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + if (!vault || !isCurrent()) return + if (inFlightTaskMutations.size || taskMutationQueues.size) + throw new Error('Wait for pending task changes before emptying Trash.') + const prefix = `${vaultRelativeFolderPath('trash', '', get().vaultSettings)}/` + const bridge = window.zen + const unlock = lockNoteEditing(vault, prefix) + try { + await mutateFolderContents(get, prefix, isCurrent, canReconcile, async () => { + await bridge.emptyTrash() + if (!canReconcile()) return null + set(s => ({ + ...rewriteFolderWorkspace(s, prefix, null), + notes: s.notes.filter(note => !note.path.startsWith(prefix)), + folders: s.folders.filter(folder => folder.folder !== 'trash'), + view: s.view.kind === 'folder' && s.view.folder === 'trash' + ? {kind:'folder',folder:'trash',subpath:''} : s.view + })) + savePrefs(collectPrefs(get())) + writeManualOrder(vault.root, get().manualNoteOrder) + await get().applyFavorites(get().vaultSettings.favorites.filter(path => !path.startsWith(prefix))) + if (isCurrent()) await get().refreshNotes() + return null + }) + } finally {unlock()} + }, + + changeNoteLifecycle: async (path, action, hostIsCurrent) => { + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + const source = get().notes.find(note => note.path === path) + if (!source || !isCurrent()) return null + const bridge = window.zen + const systemTrash = action === 'trash' && get().vault?.temporary === true + if (action === 'delete' || systemTrash) { + const unlock = lockNoteEditing(get().vault!, path) + let committed = false + try { + await mutateNoteImpl(path, async () => { + if (systemTrash) await bridge.moveToTrash(path) + else await bridge.deleteNote(path) + committed = true + return null + }, isCurrent) + if (systemTrash && committed && canReconcile()) { + useToastStore.getState().addToast('Moved to system Trash', 'info') + } + return null + } finally { + unlock() + } + } + const meta = await mutateNoteImpl(path, () => { + if (action === 'archive') return bridge.archiveNote(path) + if (action === 'trash') return bridge.moveToTrash(path) + return source.folder === 'archive' ? bridge.unarchiveNote(path) : bridge.restoreFromTrash(path) + }, isCurrent) + if (meta && canReconcile() && (action === 'archive' || action === 'trash')) { + if (get().noteDirty[meta.path]) throw new Error('The moved note still has unsaved changes.') + set(s => withoutNoteInWorkspace(s, meta.path)) + savePrefs(collectPrefs(get())) + } + return meta }, restoreActive: async () => { const path = get().selectedPath if (!path) return - const meta = await window.zen.restoreFromTrash(path) - await get().refreshNotes() - set((s) => { - const rewrite = (p: string): string => (p === path ? meta.path : p) - const nextLayout = rewritePathsInTree(s.paneLayout, rewrite) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents = { ...s.noteContents } - const dirty = { ...s.noteDirty } - const prevContent = contents[path] - if (path !== meta.path) { - delete contents[path] - delete dirty[path] - } - if (prevContent) { - contents[meta.path] = { ...prevContent, ...meta } - } - dirty[meta.path] = false - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewrite), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewrite), - pendingJumpLocation: - s.pendingJumpLocation?.path === path - ? { ...s.pendingJumpLocation, path: meta.path } - : s.pendingJumpLocation, - pinnedRefPath: s.pinnedRefPath === path ? meta.path : s.pinnedRefPath, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) + try { await get().changeNoteLifecycle(path, 'restore') } + catch (error) { useToastStore.getState().addToast(humanIpcError(error, 'Could not restore the note.'), 'error') } }, archiveActive: async () => { const path = get().selectedPath if (!path) return - if (!(await get().confirmArchiveNotes([path]))) return - await window.zen.archiveNote(path) - set((s) => withoutNoteInWorkspace(s, path)) - await get().refreshNotes() + const isCurrent = captureFolderActionContext(get) + if (!(await get().confirmArchiveNotes([path])) || !isCurrent()) return + try { await get().changeNoteLifecycle(path, 'archive', isCurrent) } + catch (error) { useToastStore.getState().addToast(humanIpcError(error, 'Could not archive the note.'), 'error') } }, unarchiveActive: async () => { const path = get().selectedPath if (!path) return - const meta = await window.zen.unarchiveNote(path) - await get().refreshNotes() - set((s) => { - const rewrite = (p: string): string => (p === path ? meta.path : p) - const nextLayout = rewritePathsInTree(s.paneLayout, rewrite) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents = { ...s.noteContents } - const dirty = { ...s.noteDirty } - const prevContent = contents[path] - if (path !== meta.path) { - delete contents[path] - delete dirty[path] - } - if (prevContent) { - contents[meta.path] = { ...prevContent, ...meta } - } - dirty[meta.path] = false - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewrite), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewrite), - pendingJumpLocation: - s.pendingJumpLocation?.path === path - ? { ...s.pendingJumpLocation, path: meta.path } - : s.pendingJumpLocation, - pinnedRefPath: s.pinnedRefPath === path ? meta.path : s.pinnedRefPath, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) + try { await get().changeNoteLifecycle(path, 'restore') } + catch (error) { useToastStore.getState().addToast(humanIpcError(error, 'Could not restore the note.'), 'error') } }, exportActiveNoteDocx: async () => { @@ -7759,7 +8678,7 @@ export const useStore = create((set, get) => { set({ manualNoteOrder: nextMap }) writeManualOrder(s.vault?.root ?? '', nextMap) }, - reorderTaskInNote: async (task, targetTask, position) => { + reorderTaskInNote: trackTaskWrite(async (task, targetTask, position) => { // Reorder is a within-note line move — tasks in different notes live in // different files, so cross-note moves aren't possible here. if (task.sourcePath !== targetTask.sourcePath || task.taskIndex === targetTask.taskIndex) { @@ -7798,7 +8717,7 @@ export const useStore = create((set, get) => { void get().rescanTasksForPath(path) } } - }, + }), setGroupByKind: (on) => { set({ groupByKind: on }) savePrefs(collectPrefs(get())) @@ -8020,7 +8939,7 @@ export const useStore = create((set, get) => { await get().openDailyNoteForDate(new Date()) }, - ensureDailyNoteForDate: async (date) => { + ensureDailyNoteForDate: trackNoteWrite(null, async (date) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.dailyNotes.enabled) return null @@ -8039,9 +8958,9 @@ export const useStore = create((set, get) => { console.error('ensureDailyNoteForDate failed', err) return null } - }, + }), - addTaskForDate: async (dateIso, text) => { + addTaskForDate: trackNoteWrite(undefined, async (dateIso, text) => { const content = text.trim() if (!content) return const parsed = parseIsoDateLocal(dateIso) @@ -8082,9 +9001,9 @@ export const useStore = create((set, get) => { console.error('addTaskForDate writeNote failed', err) } } - }, + }), - rolloverUnfinishedTasksIntoToday: async (opts) => { + rolloverUnfinishedTasksIntoToday: trackNoteWrite(0, async (opts) => { const force = opts?.force === true const settings = normalizeVaultSettings(get().vaultSettings) if (!settings.dailyNotes.enabled) return 0 @@ -8174,7 +9093,7 @@ export const useStore = create((set, get) => { } writeRolloverMarker(vaultRoot, todayIso) return movedLines.length - }, + }), openWeeklyNoteForDate: async (date) => { const state = get() @@ -8296,7 +9215,7 @@ export const useStore = create((set, get) => { await get().loadCustomTemplates() }, - createFromTemplate: async (template, opts) => { + createFromTemplate: trackNoteWrite(undefined, async (template, opts) => { try { // 1. Destination. An explicit folder (e.g. right-click on a folder) is // used directly; otherwise prompt, defaulting to the vault root so the @@ -8359,7 +9278,7 @@ export const useStore = create((set, get) => { } catch (err) { console.error('createFromTemplate failed', err) } - }, + }), saveActiveNoteAsTemplate: async () => { const active = get().activeNote @@ -8377,7 +9296,7 @@ export const useStore = create((set, get) => { await get().saveCustomTemplate({ slug: slugifyTemplateName(trimmed), raw }) }, - saveActiveNoteAs: async (newName: string) => { + saveActiveNoteAs: trackNoteWrite(undefined, async (newName: string) => { const active = get().activeNote const notePath = active?.path if (!active || !notePath) return @@ -8406,7 +9325,7 @@ export const useStore = create((set, get) => { } catch (err) { window.alert(err instanceof Error ? err.message : String(err)) } - }, + }), setWordWrap: (on) => { set({ wordWrap: on }) @@ -8521,6 +9440,8 @@ export const useStore = create((set, get) => { }, focusTabInPane: async (paneId, path) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const s = get() const leaf = findLeaf(s.paneLayout, paneId) if (!leaf) return @@ -8531,6 +9452,8 @@ export const useStore = create((set, get) => { if (s.noteDirty[s.selectedPath]) await get().persistNote(s.selectedPath) } + if (!isCurrent()) return + // Virtual Workflows tab. Same deal as Tasks below: `zen://workflows` is not // a file, so it must short-circuit before the disk read or readNote tries to // open `/zen:/workflows` and the tab never opens. @@ -8667,7 +9590,9 @@ export const useStore = create((set, get) => { if (needContent) { set({ loadingNote: paneId === s.activePaneId }) try { + const scope = noteReadCacheKey(s, path) const content = await readNoteContent(path, s) + if (!isCurrent() || noteReadCacheKey(get(), path) !== scope) return set((cur) => { const contents = { ...cur.noteContents, [path]: content } const dirty = { ...cur.noteDirty, [path]: false } @@ -8685,6 +9610,7 @@ export const useStore = create((set, get) => { }) } catch (err) { console.error('focusTabInPane readNote failed', err) + if (!isCurrent()) return set({ loadingNote: false }) } return @@ -8703,6 +9629,8 @@ export const useStore = create((set, get) => { }, openNoteInPane: async (paneId, path, insertIndex) => { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return const s = get() const leaf = findLeaf(s.paneLayout, paneId) if (!leaf) return @@ -8728,7 +9656,9 @@ export const useStore = create((set, get) => { } if (!s.noteContents[path]) { try { + const scope = noteReadCacheKey(s, path) const content = await readNoteContent(path, s) + if (!isCurrent() || noteReadCacheKey(get(), path) !== scope) return set((cur) => { const contents = { ...cur.noteContents, [path]: content } const dirty = { ...cur.noteDirty, [path]: false } @@ -9017,162 +9947,83 @@ export const useStore = create((set, get) => { clearPendingTitleFocus: () => set({ pendingTitleFocusPath: null }), clearPendingJumpLocation: () => set({ pendingJumpLocation: null }), - renameTag: async (oldTag, newTag) => { + renameTag: trackNoteWrite(undefined, async (oldTag, newTag) => { await rewriteTagAcrossVault(get, oldTag, newTag) - }, - deleteTag: async (tag) => { + }), + deleteTag: trackNoteWrite(undefined, async (tag) => { await rewriteTagAcrossVault(get, tag, null) - }, + }), - createFolder: async (folder, subpath) => { + createFolder: async (folder, subpath, hostIsCurrent) => { + if (workspaceWritesBlocked()) return + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + if (!isCurrent()) return + noteIndexRequest += 1 await window.zen.createFolder(folder, subpath) + if (!isCurrent()) return await get().refreshNotes() - set({ view: { kind: 'folder', folder, subpath } }) + if (isCurrent()) set({ view: { kind: 'folder', folder, subpath } }) }, - renameFolder: async (folder, oldSubpath, newSubpath) => { - await window.zen.renameFolder(folder, oldSubpath, newSubpath) - - const folderPath = resolveFolderPath(folder, get().vaultSettings.systemFolderPaths) - const oldPrefix = `${folderPath}/${oldSubpath}/` - const newPrefix = `${folderPath}/${newSubpath}/` - const rewritePath = (p: string): string => - p.toLowerCase().startsWith(oldPrefix.toLowerCase()) - ? newPrefix + p.slice(oldPrefix.length) - : p - - const notes = get().notes.map((n) => - n.path.toLowerCase().startsWith(oldPrefix.toLowerCase()) ? { ...n, path: rewritePath(n.path) } : n - ) - const folders = get().folders.map((f) => { - if (f.folder !== folder) return f - if (f.subpath === oldSubpath) return { ...f, subpath: newSubpath } - if (f.subpath.startsWith(`${oldSubpath}/`)) { - return { ...f, subpath: newSubpath + f.subpath.slice(oldSubpath.length) } - } - return f - }) - const nextFolderIcons = rewriteFolderIconsForRename( - get().vaultSettings.folderIcons, - folder, - oldSubpath, - newSubpath - ) - const nextFolderColors = rewriteFolderColorsForRename( - get().vaultSettings.folderColors, + renameFolder: async (folder, oldSubpath, requestedSubpath, hostIsCurrent) => { + const settings = get().vaultSettings + await renameFolderImpl( folder, oldSubpath, - newSubpath - ) - set((s) => { - const nextLayout = rewritePathsInTree(s.paneLayout, rewritePath) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents: Record = {} - const dirty: Record = {} - for (const [path, content] of Object.entries(s.noteContents)) { - const next = rewritePath(path) - contents[next] = path === next ? content : { ...content, path: next } - dirty[next] = s.noteDirty[path] ?? false - } - return { - notes, - folders, - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewritePath), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewritePath), - pendingJumpLocation: s.pendingJumpLocation - ? { ...s.pendingJumpLocation, path: rewritePath(s.pendingJumpLocation.path) } - : null, - pinnedRefPath: s.pinnedRefPath ? rewritePath(s.pinnedRefPath) : null, - vaultSettings: { - ...s.vaultSettings, - folderIcons: nextFolderIcons, - folderColors: nextFolderColors - }, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) - - // Repoint favorites at the renamed folder (its own key, descendant folder - // keys, and note favorites that lived under it) and persist. - await get().applyFavorites( - rewriteFavoritesForFolderRename( - get().vaultSettings.favorites, - folder, - oldSubpath, - newSubpath, - oldPrefix, - newPrefix - ) + `${vaultRelativeFolderPath(folder, oldSubpath, settings)}/`, + async () => { + const subpath = await window.zen.renameFolder(folder, oldSubpath, requestedSubpath) + return { subpath, prefix: `${vaultRelativeFolderPath(folder, subpath, settings)}/` } + }, + hostIsCurrent ) - - await get().refreshNotes() - - const v = get().view - if (v.kind === 'folder' && v.folder === folder) { - if (v.subpath === oldSubpath) { - set({ view: { ...v, subpath: newSubpath } }) - } else if (v.subpath.startsWith(`${oldSubpath}/`)) { - const tail = v.subpath.slice(oldSubpath.length + 1) - set({ view: { ...v, subpath: `${newSubpath}/${tail}` } }) - } - } }, - deleteFolder: async (folder, subpath) => { - await window.zen.deleteFolder(folder, subpath) - await get().refreshNotes() - const v = get().view - if ( - v.kind === 'folder' && - v.folder === folder && - (v.subpath === subpath || v.subpath.startsWith(`${subpath}/`)) - ) { - set({ view: { kind: 'folder', folder, subpath: '' } }) - } - const folderPath = resolveFolderPath(folder, get().vaultSettings.systemFolderPaths) - const prefix = `${folderPath}/${subpath}/` - const nextFolderIcons = removeFolderIcons(get().vaultSettings.folderIcons, folder, subpath) - const nextFolderColors = removeFolderColors(get().vaultSettings.folderColors, folder, subpath) - set((s) => { - const nextLayout = rewritePathsInTree(s.paneLayout, (p) => - p.startsWith(prefix) ? null : p - ) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents: Record = {} - const dirty: Record = {} - for (const [path, content] of Object.entries(s.noteContents)) { - if (!path.startsWith(prefix)) { - contents[path] = content - dirty[path] = s.noteDirty[path] ?? false - } - } - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - pendingJumpLocation: null, - pinnedRefPath: - s.pinnedRefPath && s.pinnedRefPath.startsWith(prefix) ? null : s.pinnedRefPath, + deleteFolder: async (folder, subpath, hostIsCurrent) => { + const isCurrent = captureFolderActionContext(get, hostIsCurrent) + const canReconcile = captureFolderActionContext(get) + if (!isCurrent()) return + const prefix = `${vaultRelativeFolderPath(folder, subpath, get().vaultSettings)}/` + await mutateFolderContents(get, prefix, isCurrent, canReconcile, async () => { + await window.zen.deleteFolder(folder, subpath) + if (!canReconcile()) return + noteIndexRequest += 1 + taskIndexRevision += 1 + assetIndexRequest += 1 + set((s) => ({ + ...rewriteFolderWorkspace(s, prefix, null), + notes: s.notes.filter((note) => !note.path.startsWith(prefix)), + folders: s.folders.filter( + (entry) => + entry.folder !== folder || + (entry.subpath !== subpath && !entry.subpath.startsWith(`${subpath}/`)) + ), + view: + s.view.kind === 'folder' && + s.view.folder === folder && + (s.view.subpath === subpath || s.view.subpath.startsWith(`${subpath}/`)) + ? { kind: 'folder', folder, subpath: '' } + : s.view, vaultSettings: { ...s.vaultSettings, - folderIcons: nextFolderIcons, - folderColors: nextFolderColors - }, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } + folderIcons: removeFolderIcons(s.vaultSettings.folderIcons, folder, subpath), + folderColors: removeFolderColors(s.vaultSettings.folderColors, folder, subpath) + } + })) + savePrefs(collectPrefs(get())) + writeManualOrder(get().vault?.root ?? '', get().manualNoteOrder) + await get().applyFavorites( + removeFavoritesForFolder(get().vaultSettings.favorites, folder, subpath, prefix) + ) + if (!isCurrent()) return null + await get().refreshNotes() + if (!isCurrent()) return null + return null }) - // Drop favorites for the deleted folder and the notes that lived under it. - await get().applyFavorites( - removeFavoritesForFolder(get().vaultSettings.favorites, folder, subpath, prefix) - ) }, - duplicateFolder: async (folder, subpath) => { + + duplicateFolder: trackNoteWrite(undefined, async (folder, subpath) => { const newSubpath = await window.zen.duplicateFolder(folder, subpath) await get().refreshNotes() set((s) => ({ @@ -9193,7 +10044,7 @@ export const useStore = create((set, get) => { ) } })) - }, + }), revealFolder: async (folder, subpath) => { await window.zen.revealFolder(folder, subpath) @@ -9203,44 +10054,11 @@ export const useStore = create((set, get) => { await window.zen.revealAssetsDir() }, - moveNote: async (relPath, targetFolder, targetSubpath) => { + moveNote: async (relPath, targetFolder, targetSubpath, hostIsCurrent) => { try { - const meta = await window.zen.moveNote(relPath, targetFolder, targetSubpath) - await get().refreshNotes() - set((s) => { - const rewrite = (p: string): string => (p === relPath ? meta.path : p) - const nextLayout = rewritePathsInTree(s.paneLayout, rewrite) - const ensured = ensureActivePane(nextLayout, s.activePaneId) - const contents = { ...s.noteContents } - const dirty = { ...s.noteDirty } - const prev = contents[relPath] - if (relPath !== meta.path) { - delete contents[relPath] - delete dirty[relPath] - } - if (prev) { - contents[meta.path] = { ...prev, ...meta } - dirty[meta.path] = s.noteDirty[relPath] ?? false - } - return { - paneLayout: ensured.layout, - activePaneId: ensured.activePaneId, - noteContents: contents, - noteDirty: dirty, - noteBackstack: rewriteNoteJumpHistory(s.noteBackstack, rewrite), - noteForwardstack: rewriteNoteJumpHistory(s.noteForwardstack, rewrite), - pendingJumpLocation: - s.pendingJumpLocation?.path === relPath - ? { ...s.pendingJumpLocation, path: meta.path } - : s.pendingJumpLocation, - pinnedRefPath: s.pinnedRefPath === relPath ? meta.path : s.pinnedRefPath, - ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) - } - }) - await get().applyFavorites( - rewriteFavoriteNotePath(get().vaultSettings.favorites, relPath, meta.path) - ) + await mutateNoteImpl(relPath, () => window.zen.moveNote(relPath, targetFolder, targetSubpath), hostIsCurrent) } catch (err) { + if (hostIsCurrent) throw err console.error('moveNote failed', err) } }, @@ -9295,112 +10113,9 @@ export const useStore = create((set, get) => { } }, - init: async () => { - if (get().initialized) return - const startedAt = performance.now() - set({ initialized: true }) - let initializedVault = false - try { - const remoteWorkspaceProfilesPromise = get().refreshRemoteWorkspaceProfiles() - const localVaultsPromise = get().refreshLocalVaults() - const [bootWorkspaceInfo, serverCapabilities] = await Promise.all([ - get().refreshWorkspaceContext(), - window.zen.getServerCapabilities().catch(() => null) - ]) - if (!(await ensureWebServerSession(serverCapabilities))) { - void remoteWorkspaceProfilesPromise - void localVaultsPromise - set({ - workspaceMode: workspaceModeFrom(bootWorkspaceInfo), - remoteWorkspaceInfo: bootWorkspaceInfo, - workspaceSetupError: null, - workspaceRestored: true, - vaultSettings: DEFAULT_VAULT_SETTINGS - }) - recordRendererPerf('store.init', performance.now() - startedAt, { - hasVault: false - }) - return - } - const vault = await window.zen.getCurrentVault() - // getCurrentVault is what connects a configured remote workspace, so - // the info fetched above predates the connection: its capabilities and - // bootError are still null, and keeping it would leave Settings - // believing the server advertises nothing (#723). Ask again now that - // the answer exists. - const remoteWorkspaceInfo = bootWorkspaceInfo - ? await get().refreshWorkspaceContext() - : bootWorkspaceInfo - void remoteWorkspaceProfilesPromise - void localVaultsPromise - if (vault) { - const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) - set({ - vault, - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - workspaceSetupError: null, - vaultSettings, - workspaceRestored: false - }) - await openVaultWorkspace(vault) - await prefetchInitialVisibleNotes(get()) - initializedVault = true - } else { - set({ - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - workspaceSetupError: null, - workspaceRestored: true, - vaultSettings: DEFAULT_VAULT_SETTINGS - }) - } - } catch (err) { - console.error('init failed', err) - set({ - workspaceMode: 'local', - remoteWorkspaceInfo: null, - workspaceSetupError: - window.zen.getAppInfo().runtime === 'web' ? describeWebServerSetupError(err) : null, - workspaceRestored: true, - vaultSettings: DEFAULT_VAULT_SETTINGS - }) - } - recordRendererPerf('store.init', performance.now() - startedAt, { - hasVault: initializedVault - }) - // Default focus to the sidebar so j/k navigation works immediately - if (get().sidebarOpen && !get().focusedPanel) { - set({ focusedPanel: 'sidebar' }) - } - // Restore the pinned reference note by loading its content — the - // path survived in prefs; `refreshNotes` has already confirmed it - // still exists and otherwise cleared `pinnedRefPath`. - const pinnedPath = get().pinnedRefPath - if (pinnedPath && !get().noteContents[pinnedPath]) { - try { - const content = await readNoteContent(pinnedPath, get()) - set((s) => ({ - noteContents: { ...s.noteContents, [pinnedPath]: content }, - noteDirty: { ...s.noteDirty, [pinnedPath]: false } - })) - } catch (err) { - console.error('pinned reference readNote failed', err) - set({ pinnedRefPath: null }) - savePrefs(collectPrefs(get())) - } - } - // `retryWorkspaceBoot` re-enters `init` on every successful reconnect, so - // the previous subscription has to go before a new one is made. Without - // this each reconnect left a live listener behind and one file change - // arrived as N changes, each running the full `applyChange`. - vaultChangeUnsubscribe?.() - vaultChangeUnsubscribe = window.zen.onVaultChange((ev) => { - void get().applyChange(ev) - }) - }, + init: () => get().initialized ? Promise.resolve() : runWorkspaceTransition(initImpl, true), - retryWorkspaceBoot: async () => { + retryWorkspaceBoot: () => runWorkspaceTransition(async () => { set({ workspaceSetupError: null }) try { const vault = await window.zen.retryWorkspaceBoot() @@ -9409,7 +10124,7 @@ export const useStore = create((set, get) => { // vault, settings, indexes and session restore land the normal way. // init() is once-guarded for real boots; this re-entry is the point. set({ initialized: false }) - await get().init() + await initImpl() return } // Still down. Refresh the info so the screen shows the latest reason. @@ -9418,9 +10133,9 @@ export const useStore = create((set, get) => { console.error('retryWorkspaceBoot failed', err) set({ workspaceSetupError: humanIpcError(err, 'Could not reach the server.') }) } - }, + }), - openVaultPicker: async () => { + openVaultPicker: () => runWorkspaceTransition(async () => { await get().flushDirtyNotes() set({ workspaceSetupError: null }) const capabilities = window.zen.getCapabilities() @@ -9497,66 +10212,42 @@ export const useStore = create((set, get) => { }) savePrefs(collectPrefs(get())) await openVaultWorkspace(vault) - }, + }), - openLocalVault: async (root: string) => { + openLocalVault: (root: string) => { const trimmed = root.trim() - if (!trimmed) return - // Only a no-op when we are already in this exact local vault. In remote - // mode vault.root holds the server-reported path, which for a localhost - // server equals the local vault's own path -- comparing against it here - // would wrongly block switching back from remote to local. - if (get().workspaceMode === 'local' && trimmed === get().vault?.root) return - try { - await get().flushDirtyNotes() - set({ workspaceSetupError: null }) - const vault = await window.zen.openLocalVault(trimmed) - await get().refreshLocalVaults() - if (!vault) return - - const remoteWorkspaceInfo = await get().refreshWorkspaceContext() - const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) - const fresh = makeLeaf() - set({ - vault, - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - workspaceSetupError: null, - vaultSettings, - notes: [], - folders: [], - hasAssetsDir: false, - assetFiles: [], - assetUndoStack: [], - closedTabStack: [], - workflowRunRecord: null, - workflowTutorialStep: null, - vaultTasks: [], - selectedTags: [], - view: { kind: 'folder', folder: 'inbox', subpath: '' }, - selectedPath: null, - activeNote: null, - activeDirty: false, - paneLayout: fresh, - activePaneId: fresh.id, - noteContents: {}, - noteDirty: {}, - loadingNote: false, - noteBackstack: [], - noteForwardstack: [], - pendingJumpLocation: null, - pinnedRefPath: null, - workspaceRestored: false - }) - savePrefs(collectPrefs(get())) - await openVaultWorkspace(vault) - } catch (err) { - console.error('openLocalVault failed', err) - window.alert(err instanceof Error ? err.message : String(err)) - } + if (!trimmed || (get().workspaceMode === 'local' && trimmed === get().vault?.root)) + return Promise.resolve() + return runWorkspaceTransition(() => openLocalVaultImpl(trimmed)) }, - closeVault: async () => { + relocateLocalVault: (operation) => runWorkspaceTransition(async () => { + const previous = get() + if (operation.reopen && (!previous.vault || previous.workspaceMode !== 'local')) + throw new Error('Open the local vault before relocating it.') + await operation.move() + if (!operation.reopen) return + try { + await openLocalVaultImpl(operation.reopen.destination, true) + } catch (error) { + try { + await operation.rollback() + const restored = await window.zen.openLocalVault(operation.reopen.source) + if (!restored) throw new Error('The original vault could not be reopened.') + set(previous) + savePrefs(collectPrefs(previous)) + } catch (rollbackError) { + // Keep writers stopped when the native storage location is uncertain. + set({ vault: null, workspaceRestored: false, workspaceSetupError: 'Vault relocation failed. Reopen the vault after checking its storage location.' }) + throw new AggregateError([error, rollbackError], 'Vault relocation and recovery failed. Your files have not been deleted.') + } + throw error + } + }, false, true), + + closeVault: () => { + if (!get().vault || get().workspaceMode === 'remote') return Promise.resolve() + return runWorkspaceTransition(async () => { const closingVault = get().vault if (!closingVault || get().workspaceMode === 'remote') return try { @@ -9652,9 +10343,10 @@ export const useStore = create((set, get) => { console.error('closeVault failed', err) window.alert(err instanceof Error ? err.message : String(err)) } + }) }, - connectRemoteWorkspace: async () => { + connectRemoteWorkspace: () => runWorkspaceTransition(async () => { try { await get().flushDirtyNotes() const capabilities = window.zen.getCapabilities() @@ -9787,9 +10479,9 @@ export const useStore = create((set, get) => { } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } - }, + }), - connectRemoteWorkspaceProfile: async (id: string) => { + connectRemoteWorkspaceProfile: (id: string) => runWorkspaceTransition(async () => { try { await get().flushDirtyNotes() const profile = get().remoteWorkspaceProfiles.find((entry) => entry.id === id) @@ -9867,9 +10559,11 @@ export const useStore = create((set, get) => { } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } - }, + }), - changeRemoteWorkspaceVaultPath: async () => { + changeRemoteWorkspaceVaultPath: () => { + if (get().workspaceMode !== 'remote') return Promise.resolve() + return runWorkspaceTransition(async () => { try { if (get().workspaceMode !== 'remote') return const remoteInfo = get().remoteWorkspaceInfo @@ -9949,89 +10643,10 @@ export const useStore = create((set, get) => { } catch (error) { window.alert(error instanceof Error ? error.message : String(error)) } + }) }, - disconnectRemoteWorkspace: async () => { - try { - await get().flushDirtyNotes() - const vault = await window.zen.disconnectRemoteWorkspace() - const remoteWorkspaceInfo = await get().refreshWorkspaceContext() - await get().refreshLocalVaults() - - if (!vault) { - const fresh = makeLeaf() - set({ - vault: null, - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - vaultSettings: DEFAULT_VAULT_SETTINGS, - notes: [], - folders: [], - hasAssetsDir: false, - assetFiles: [], - assetUndoStack: [], - closedTabStack: [], - workflowRunRecord: null, - workflowTutorialStep: null, - vaultTasks: [], - selectedTags: [], - view: { kind: 'folder', folder: 'inbox', subpath: '' }, - selectedPath: null, - activeNote: null, - activeDirty: false, - paneLayout: fresh, - activePaneId: fresh.id, - noteContents: {}, - noteDirty: {}, - loadingNote: false, - noteBackstack: [], - noteForwardstack: [], - pendingJumpLocation: null, - pinnedRefPath: null, - workspaceRestored: true - }) - savePrefs(collectPrefs(get())) - return - } - - const vaultSettings = normalizeVaultSettings(await window.zen.getVaultSettings()) - const fresh = makeLeaf() - set({ - vault, - workspaceMode: workspaceModeFrom(remoteWorkspaceInfo), - remoteWorkspaceInfo, - vaultSettings, - notes: [], - folders: [], - hasAssetsDir: false, - assetFiles: [], - assetUndoStack: [], - closedTabStack: [], - workflowRunRecord: null, - workflowTutorialStep: null, - vaultTasks: [], - selectedTags: [], - view: { kind: 'folder', folder: 'inbox', subpath: '' }, - selectedPath: null, - activeNote: null, - activeDirty: false, - paneLayout: fresh, - activePaneId: fresh.id, - noteContents: {}, - noteDirty: {}, - loadingNote: false, - noteBackstack: [], - noteForwardstack: [], - pendingJumpLocation: null, - pinnedRefPath: null, - workspaceRestored: false - }) - savePrefs(collectPrefs(get())) - await openVaultWorkspace(vault) - } catch (error) { - window.alert(error instanceof Error ? error.message : String(error)) - } - }, + disconnectRemoteWorkspace: () => runWorkspaceTransition(disconnectRemoteWorkspaceImpl), saveRemoteWorkspaceProfile: async (input) => { const profile = await window.zen.saveRemoteWorkspaceProfile(input) @@ -10039,7 +10654,7 @@ export const useStore = create((set, get) => { return profile }, - deleteRemoteWorkspaceProfile: async (id) => { + deleteRemoteWorkspaceProfile: (id) => runWorkspaceTransition(async () => { const wasRemote = get().workspaceMode === 'remote' await window.zen.deleteRemoteWorkspaceProfile(id) const [profiles] = await Promise.all([ @@ -10047,9 +10662,9 @@ export const useStore = create((set, get) => { get().refreshWorkspaceContext() ]) if (wasRemote && profiles.length === 0) { - await get().disconnectRemoteWorkspace() + await disconnectRemoteWorkspaceImpl() } - }, + }), persistWorkspace: () => { const state = get() @@ -10071,6 +10686,15 @@ export const useStore = create((set, get) => { }, flushDirtyNotes: async () => { + while (commentOperations.size > 0) + await Promise.all([...commentOperations.values()].flatMap(operations => [...operations])) + while (inFlightNoteWrites.size > 0) await Promise.all([...inFlightNoteWrites]) + await Promise.all([...databaseRowActions.values()]) + await Promise.all([...folderMutations.values(), ...databaseCreations.values()]) + if ([...uncertainFolderMutations.values()].includes(get().vault)) + throw new Error('FOLDER_STATE_UNCERTAIN: Reload the vault before saving or switching') + await Promise.all([...new Set([...databaseWriteKind.keys(), ...databaseWriteQueues.keys()])] + .map((path) => flushDatabaseWrite(path, () => get().databases[path]))) get().persistWorkspace() // Before the dirty sweep, not after: a queued task write on a note someone // has open lands in the buffer rather than on disk, so draining first is @@ -10080,6 +10704,8 @@ export const useStore = create((set, get) => { .filter(([, isDirty]) => isDirty) .map(([path]) => path) await Promise.all(dirtyPaths.map(async (path) => get().persistNote(path))) + if (Object.values(get().noteDirty).some(Boolean)) + throw new Error('Notes still have unsaved changes. Save them before leaving the vault.') } } }) diff --git a/packages/app-core/src/tasks.ts b/packages/app-core/src/tasks.ts new file mode 100644 index 00000000..a4ab6f6d --- /dev/null +++ b/packages/app-core/src/tasks.ts @@ -0,0 +1,89 @@ +import { captureNavigationContext } from './lib/navigation-context' +import { useSyncExternalStore } from 'react' +import type { VaultTask } from '@bridge-contract/tasks' +import { useStore, type KanbanGroupBy } from './store' +import { filterTasksForDisplay } from '@shared/tasks' +import { computeTasksRender } from './lib/tasks-filter' +import { dropMutationsFor } from './lib/task-column-mutations' + +export type { KanbanGroupBy } from './store' +export interface TaskActionHost { isCurrent(): boolean } +export type TaskSnapshot = Readonly> & { + readonly tags: readonly string[] + readonly fields?: Readonly> +} +export interface TasksSnapshot { + readonly tasks: readonly TaskSnapshot[] + readonly loading: boolean + readonly showArchived: boolean + readonly groupBy: KanbanGroupBy +} +let source: readonly VaultTask[] | undefined +let tasks: readonly TaskSnapshot[] = Object.freeze([]) +let snapshot: TasksSnapshot | undefined + +export function getTasksSnapshot(): TasksSnapshot { + const state = useStore.getState() + if (source !== state.vaultTasks) { + source = state.vaultTasks + tasks = Object.freeze(source.map(task => Object.freeze({ ...task, + tags: Object.freeze([...task.tags]), + ...(task.fields ? { fields: Object.freeze({ ...task.fields }) } : {}) + }))) + } + if (!snapshot || snapshot.tasks !== tasks || snapshot.loading !== state.tasksLoading || snapshot.groupBy !== state.kanbanGroupBy || snapshot.showArchived !== state.showArchivedTasks) + snapshot = Object.freeze({ tasks, loading: state.tasksLoading, showArchived: state.showArchivedTasks, groupBy: state.kanbanGroupBy }) + return snapshot +} +export function subscribeTasks(listener: (next: TasksSnapshot, previous: TasksSnapshot) => void): () => void { + let previous = getTasksSnapshot() + return useStore.subscribe(() => { + const next = getTasksSnapshot() + if (next === previous) return + const before = previous; previous = next; listener(next, before) + }) +} +function subscribeReact(notify: () => void): () => void { return subscribeTasks(() => notify()) } +export function useTasksSnapshot(): TasksSnapshot { + return useSyncExternalStore(subscribeReact, getTasksSnapshot, getTasksSnapshot) +} +export function refreshTasks(path?: string): Promise { + return path ? useStore.getState().rescanTasksForPath(path) : useStore.getState().refreshTasks() +} +export async function openTask(id: string): Promise { + const isCurrent = captureNavigationContext() + if (!isCurrent()) return false + const state = useStore.getState() + const task = state.vaultTasks.find(task => task.id === id) + if (!task) return false + await state.openTaskAt(task) + return isCurrent() && useStore.getState().selectedPath === task.sourcePath +} + +/** Dispatch through the same queued task writer as desktop. Errors use core's toast UI. */ +export async function moveTaskToColumn( + host: TaskActionHost, + taskId: string, + groupBy: KanbanGroupBy, + columnId: string +): Promise { + try { if (!host.isCurrent()) return false } catch { return false } + const state = useStore.getState() + if (!state.vault || state.kanbanGroupBy !== groupBy) return false + const task = state.vaultTasks.find(task => task.id === taskId) + if (!task) return false + const mutations = dropMutationsFor(groupBy, columnId, task, new Date()) + if (!mutations) return false + if (mutations.length) await state.applyTaskMutation(task, mutations) + return true +} + +/** Today/overdue groups use core's filtering and file order, including file tasks. */ +export function getTodayTasks(now = new Date()): { readonly tasks: readonly TaskSnapshot[]; readonly overdueCount: number } { + const state = useStore.getState() + const livePaths = new Set(state.notes.filter(note => note.folder !== 'trash').map(note => note.path)) + const live = filterTasksForDisplay(state.vaultTasks, state.showArchivedTasks).filter(task => livePaths.has(task.sourcePath)) + const render = computeTasksRender(live, '', now, { today: false, upcoming: false, waiting: false, forwarded: false, done: false, cancelled: false }) + const publicTasks = new Map(getTasksSnapshot().tasks.map(task => [task.id, task])) + return Object.freeze({ tasks: Object.freeze(render.groups.today.map(task => publicTasks.get(task.id)!)), overdueCount: render.groups.overdueCount ?? 0 }) +} diff --git a/packages/app-core/src/workspace.ts b/packages/app-core/src/workspace.ts new file mode 100644 index 00000000..3982f0fb --- /dev/null +++ b/packages/app-core/src/workspace.ts @@ -0,0 +1,94 @@ +import { workspaceGeneration } from './lib/workspace-transition' +import type { LocalVaultRelocation } from './lib/workspace-relocation' +export type { LocalVaultRelocation } from './lib/workspace-relocation' +import { useSyncExternalStore } from 'react' +import type { RemoteWorkspaceProfile, RemoteWorkspaceProfileInput, WorkspaceMode } from '@bridge-contract/ipc' +import { useStore } from './store' +import { findLeaf } from './lib/pane-layout' + +export interface WorkspaceSnapshot { + readonly mode: WorkspaceMode + readonly restored: boolean + readonly transitioning: boolean + /** Changes at transition start, including transitions that cancel or fail. */ + readonly generation: number + readonly remoteProfileId: string | null + readonly remoteProfiles: readonly Readonly[] + readonly folder: { readonly kind: 'folder'; readonly folder: 'inbox' | 'quick' | 'archive' | 'trash'; readonly subpath: string } | null +} +let profilesSource: readonly RemoteWorkspaceProfile[] | undefined +let profiles: WorkspaceSnapshot['remoteProfiles'] = Object.freeze([]) +let viewSource: unknown +let folder: WorkspaceSnapshot['folder'] = null +let snapshot: WorkspaceSnapshot | undefined +export function getWorkspaceSnapshot(): WorkspaceSnapshot { + const state = useStore.getState() + if (profilesSource !== state.remoteWorkspaceProfiles) { + profilesSource = state.remoteWorkspaceProfiles + profiles = Object.freeze(profilesSource.map(profile => Object.freeze({ + id: profile.id, name: profile.name, baseUrl: profile.baseUrl, hasCredential: profile.hasCredential, + vaultPath: profile.vaultPath, lastConnectedAt: profile.lastConnectedAt + }))) + } + if (viewSource !== state.view) { + viewSource = state.view + folder = state.view.kind === 'folder' ? Object.freeze({ ...state.view }) : null + } + const next = { mode: state.workspaceMode, restored: !!state.vault && state.workspaceRestored && !state.workspaceTransitioning, + transitioning: state.workspaceTransitioning, generation: workspaceGeneration(), + remoteProfileId: state.remoteWorkspaceInfo?.profileId ?? null, remoteProfiles: profiles, folder } + if (!snapshot || (Object.keys(next) as Array).some(key => snapshot![key] !== next[key])) + snapshot = Object.freeze(next) + return snapshot +} +export function subscribeWorkspace(listener: (next: WorkspaceSnapshot, previous: WorkspaceSnapshot) => void): () => void { + let previous = getWorkspaceSnapshot() + return useStore.subscribe(() => { + const next = getWorkspaceSnapshot() + if (next === previous) return + const before = previous; previous = next; listener(next, before) + }) +} +function subscribeReact(notify: () => void): () => void { return subscribeWorkspace(() => notify()) } +export function useWorkspaceSnapshot(): WorkspaceSnapshot { return useSyncExternalStore(subscribeReact, getWorkspaceSnapshot, getWorkspaceSnapshot) } + +/** Native storage identifiers are opaque to core. The host bridge resolves them. */ +export function openLocalVault(token: string): Promise { return useStore.getState().openLocalVault(token) } +/** Reserves the whole save, native relocation, reopen and rollback lifecycle. */ +export function relocateLocalVault(operation: LocalVaultRelocation): Promise { + return useStore.getState().relocateLocalVault(operation) +} +export function pickLocalVault(): Promise { return useStore.getState().openVaultPicker() } +export function closeVault(): Promise { return useStore.getState().closeVault() } +export function connectRemoteWorkspace(): Promise { return useStore.getState().connectRemoteWorkspace() } +export function connectRemoteProfile(id: string): Promise { return useStore.getState().connectRemoteWorkspaceProfile(id) } +export function changeRemoteVaultPath(): Promise { return useStore.getState().changeRemoteWorkspaceVaultPath() } +export function disconnectRemoteWorkspace(): Promise { return useStore.getState().disconnectRemoteWorkspace() } +export function deleteRemoteProfile(id: string): Promise { return useStore.getState().deleteRemoteWorkspaceProfile(id) } +export async function refreshRemoteProfiles(): Promise { await useStore.getState().refreshRemoteWorkspaceProfiles() } +export async function saveRemoteProfile(input: RemoteWorkspaceProfileInput): Promise> { + const profile = await useStore.getState().saveRemoteWorkspaceProfile({ ...input }) + return Object.freeze({ id: profile.id, name: profile.name, baseUrl: profile.baseUrl, hasCredential: profile.hasCredential, + vaultPath: profile.vaultPath, lastConnectedAt: profile.lastConnectedAt }) +} +export function flushWorkspace(): Promise { return useStore.getState().flushDirtyNotes() } +export function persistWorkspace(): void { useStore.getState().persistWorkspace() } +export function configureWorkspacePresentation(options: { + sidebarVisible?: boolean; noteListVisible?: boolean; automaticCalendar?: boolean +}): void { + useStore.setState({ + ...(options.sidebarVisible === undefined ? {} : { sidebarOpen: options.sidebarVisible }), + ...(options.noteListVisible === undefined ? {} : { noteListOpen: options.noteListVisible }), + ...(options.automaticCalendar === undefined ? {} : { autoCalendarPanel: options.automaticCalendar }) + }) +} +/** Read before restoration rewrites the legacy persisted layout. */ +export async function readPersistedHomeState(): Promise { + try { + const raw = await window.zen.readWorkspaceState() + if (!raw) return true + const saved = JSON.parse(raw) + const leaf = findLeaf(saved.paneLayout, saved.activePaneId) + return !leaf || leaf.activeTab === null + } catch { return true } +} diff --git a/packages/bridge-contract/fixtures/README.md b/packages/bridge-contract/fixtures/README.md new file mode 100644 index 00000000..c65088dd --- /dev/null +++ b/packages/bridge-contract/fixtures/README.md @@ -0,0 +1,43 @@ +# Vault behavior and self-hosted HTTP fixtures + +These JSON cases describe behavior shared by independent implementations. They +contain note bytes, operations, and expected results, with no runtime dependency. +The fixture schema has its own version; product versions do not change it. + +`self-hosted-http.json` defines the existing `self-hosted-http-v1` baseline for the +web bridge and TUI remote adapter: capabilities, bearer authentication, browser +session cookies, note fields, exact UTF-8 note bytes, and missing/directory errors. +Go runs it at both `/` and `/notes`. Writes retain the existing last-write-wins +behavior; this is not the revision-based Cloud save protocol. Additive fields are +compatible. Breaking route, authentication, status, or field changes need a new +protocol marker and an explicit consumer migration. + +The HTTP baseline exposed two Go gaps corrected during this migration: session +cookies now cover the configured URL prefix, and metadata includes `assetEmbeds` +as required by the bridge contract. Go invalidates old metadata caches so unchanged +notes receive the new field too. + +`task-roundtrip.json` covers due-date writes, in-progress state, fenced examples, +preserving unrelated Markdown bytes, and assigning the local calendar day near +midnight. `localNow` is `[year, month, day, hour, minute]`, with a one-based month, +interpreted in the executing host's local timezone. All `expectedAfter` fields +must match; implementations may expose additional metadata. + +TypeScript runs these through `shared-domain/src/task-roundtrip.test.ts`. Run the +timezone cases in both directions from UTC: + +```sh +TZ=America/Los_Angeles npm run test:run --workspace @zennotes/shared-domain -- task-roundtrip +TZ=Pacific/Auckland npm run test:run --workspace @zennotes/shared-domain -- task-roundtrip +``` + +The Go server runs the same cases in `internal/vault/task_roundtrip_contract_test.go`. +Its client sends the edited Markdown; the server verifies storage and parsing +before and after that write. The JSON and SHA-256 provenance are vendored in its +`testdata` directory so `go test ./...` needs no Node or sibling checkout. + +After changing a fixture, run `npm run sync:contract-fixtures`, then the TypeScript +and Go checks. CI runs `npm run check:contract-fixtures` to reject drift. The TUI +consumer and fixture artifact publication are still pending. Do not silently +rewrite expected results to match a divergent implementation; identify the +intended behavior first. diff --git a/packages/bridge-contract/fixtures/self-hosted-http.json b/packages/bridge-contract/fixtures/self-hosted-http.json new file mode 100644 index 00000000..d799b7b7 --- /dev/null +++ b/packages/bridge-contract/fixtures/self-hosted-http.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "protocol": "self-hosted-http-v1", + "mountPaths": [ + "", + "/notes" + ], + "note": { + "path": "inbox/Contract.md", + "body": "# Contract\n\nUnicode café 日本語. \n\n![[photo.png]]\n![](assets/document.pdf)\n", + "updatedBody": "# Contract\n\nUpdated café 日本語. \n\n![Photo]()\n\n", + "assetEmbeds": [ + "photo.png", + "assets/document.pdf" + ], + "updatedAssetEmbeds": [ + "assets/photo two.png" + ] + }, + "requiredNoteFields": [ + "path", + "title", + "folder", + "siblingOrder", + "createdAt", + "updatedAt", + "size", + "tags", + "wikilinks", + "assetEmbeds", + "hasAttachments", + "excerpt" + ], + "requiredCapabilities": [ + "version", + "platform", + "authRequired", + "supportsSessionLogin", + "browseRootsEnforced", + "supportsVaultSelection", + "supportsDirectoryBrowsing", + "supportsWatch", + "reportsMissingAsNotFound", + "supportsAssetOps", + "supportsWorkflows", + "supportsCustomTemplates" + ], + "errors": { + "unauthenticated": 401, + "challenge": "Bearer realm=\"ZenNotes\"", + "missingNote": 404, + "directoryAsNote": 400 + }, + "routePrefixes": [ + "/api", + "" + ] +} diff --git a/packages/bridge-contract/fixtures/task-roundtrip.json b/packages/bridge-contract/fixtures/task-roundtrip.json new file mode 100644 index 00000000..b3988cac --- /dev/null +++ b/packages/bridge-contract/fixtures/task-roundtrip.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "reschedule-in-progress-task-without-changing-other-content", + "note": { "path": "inbox/Release.md", "title": "Release", "folder": "inbox" }, + "body": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release due:2026-09-15 !high #release\n- [ ] Next item\n", + "taskIndex": 0, + "due": "2026-09-16", + "expectedBefore": { "due": "2026-09-15", "inProgress": true }, + "expectedBody": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release !high #release due:2026-09-16\n- [ ] Next item\n", + "expectedAfter": { "due": "2026-09-16", "inProgress": true, "checked": false, "priority": "high", "tags": ["release"] }, + "expectedTaskCount": 2 + }, + { + "id": "assign-local-today-near-midnight", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [ ] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 0, 15], + "expectedBefore": { "checked": false }, + "expectedBody": "# Today\n\n- [ ] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false }, + "expectedTaskCount": 1 + }, + { + "id": "assign-local-today-late-at-night", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [/] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 23, 45], + "expectedBefore": { "inProgress": true }, + "expectedBody": "# Today\n\n- [/] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false, "inProgress": true }, + "expectedTaskCount": 1 + } + ] +} diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index ed1abe1f..356f0ad2 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -8,12 +8,24 @@ "./cloud-sync": "./src/cloud-sync.ts", "./ipc": "./src/ipc.ts", "./templates": "./src/templates.ts", - "./workflows": "./src/workflows.ts" + "./workflows": "./src/workflows.ts", + "./tasks": "./src/tasks.ts", + "./databases": "./src/databases.ts", + "./mcp-clients": "./src/mcp-clients.ts", + "./custom-themes": "./src/custom-themes.ts", + "./overrides": "./src/overrides.ts", + "./application-links": "./src/application-links.ts", + "./custom-code-languages": "./src/custom-code-languages.ts", + "./app-config": "./src/app-config.ts", + "./platform": "./src/platform.ts" }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "build": "tsc --noEmit -p tsconfig.json", "test": "echo 'No bridge-contract tests yet'", "test:run": "echo 'No bridge-contract tests yet'" + }, + "devDependencies": { + "typescript": "^5.7.2" } } diff --git a/packages/bridge-contract/src/app-config.ts b/packages/bridge-contract/src/app-config.ts new file mode 100644 index 00000000..cc79cd96 --- /dev/null +++ b/packages/bridge-contract/src/app-config.ts @@ -0,0 +1,111 @@ +/** + * Preference keys (matching the renderer's `Prefs` shape) persisted to the + * portable config file. Keep this list in sync with `Prefs` in + * `packages/app-core/src/store.ts`; new portable settings should be added + * here AND given a TOML mapping in `apps/desktop/src/main/app-config.ts`. + */ +export const PORTABLE_PREF_KEYS = [ + // vim + 'vimMode', + 'vimInsertEscape', + 'vimYankToClipboard', + 'vimBlockImeInNormalMode', + 'vimWrappedLineMotions', + 'whichKeyHints', + 'whichKeyHintMode', + 'whichKeyHintTimeoutMs', + // keymaps (overrides only) + 'keymapOverrides', + 'ignoredKeys', + 'externalApplicationSchemes', + // search + 'vaultTextSearchBackend', + 'ripgrepBinaryPath', + 'fzfBinaryPath', + // editor + 'livePreview', + 'showHeadingLevelLabels', + 'listIndentGuides', + 'renderTablesInLivePreview', + 'completedTaskStyle', + 'mathRenderer', + 'typstTagPreambles', + 'harperEnabled', + 'harperDialect', + 'looseMathDelimiters', + 'keepViewModeAcrossNotes', + 'defaultPaneMode', + 'syncTitleHeadingOnRename', + 'markdownSnippets', + 'textReplacementsEnabled', + 'textReplacements', + 'autoPairs', + 'autoPairQuotesInProse', + 'hideBuiltinTemplates', + 'tabsEnabled', + 'wrapTabs', + 'editorFontSize', + 'mathFontScale', + 'editorLineHeight', + 'editorTabSize', + 'editorScrollOff', + 'timeFormat', + 'previewMaxWidth', + 'editorMaxWidth', + 'lineNumberMode', + 'lineNumberPosition', + 'viewSettingsScope', + 'wordWrap', + 'previewSmoothScroll', + 'pdfEmbedInEditMode', + 'pdfExportUseTheme', + // appearance + 'themeId', + 'themeFamily', + 'themeMode', + 'enabledOverrides', + 'themeTweaks', + 'darkSidebar', + 'showWindowTitleBar', + 'showSidebarChevrons', + 'contentAlign', + 'unifiedSidebar', + // typography + 'interfaceFont', + 'textFont', + 'monoFont', + // features + 'workflowsEnabled', + 'hiddenWorkflowPresets', + 'atlasEnabled', + // view + 'systemFolderLabels', + 'noteSortOrder', + 'assetSortOrder', + 'groupByKind', + 'nestedTags', + 'autoReveal', + 'quickNoteDateTitle', + 'quickNoteTitlePrefix', + 'autoCalendarPanel', + 'calendarWeekStart', + 'calendarShowWeekNumbers', + 'tasksViewMode', + 'showArchivedTasks', + 'kanbanGroupBy', + 'kanbanFolderRoot', + 'kanbanColumnTitles', + 'kanbanStatuses', + // tasks + 'savedTaskFilters' +] as const + +export type PortablePrefKey = (typeof PORTABLE_PREF_KEYS)[number] + +/** + * Transport shape for the portable config across the IPC boundary. Values are + * `unknown` on purpose , the file is user-editable plain text, so the renderer + * funnels everything through `normalizePrefs()` for validation rather than + * trusting compile-time types here. + */ +export type AppConfigPortable = Partial> diff --git a/packages/bridge-contract/src/application-links.ts b/packages/bridge-contract/src/application-links.ts new file mode 100644 index 00000000..dd881fd1 --- /dev/null +++ b/packages/bridge-contract/src/application-links.ts @@ -0,0 +1,5 @@ +export type ExternalUrlResult = { + ok: boolean + error?: 'scheme-disabled' | 'blocked' | 'open-failed' | 'desktop-only' + scheme?: string +} diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index eed9cb8f..3fad3396 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -1,3 +1,4 @@ +import type { ZenPlatform } from './platform.js' import type { AppUpdateState, AssetMeta, @@ -34,8 +35,8 @@ import type { VaultTextSearchCapabilities, VaultTextSearchMatch, VaultTextSearchToolPaths -} from './ipc' -import type { CustomTemplateFile, WriteTemplateInput } from './templates' +} from './ipc.js' +import type { CustomTemplateFile, WriteTemplateInput } from './templates.js' import type { CloudAccountConnectResult, CloudAccountStatus, @@ -58,7 +59,7 @@ import type { CloudSyncSettingsConflict, CloudSyncVault, CloudVaultLink -} from './cloud-sync' +} from './cloud-sync.js' import type { ApplyWorkflowInput, ExportWorkflowInput, @@ -68,29 +69,29 @@ import type { WorkflowRunSummary, WorkflowUndoResult, WriteWorkflowInput -} from './workflows' -import type { VaultTask } from '@zennotes/shared-domain/tasks' +} from './workflows.js' +import type { VaultTask } from './tasks.js' import type { DatabaseDoc, DatabaseSidecar, DatabaseSummary, DbRow -} from '@zennotes/shared-domain/databases' +} from './databases.js' import type { McpClientId, McpClientStatus, McpInstructionsPayload, McpServerRuntime -} from '@zennotes/shared-domain/mcp-clients' -import type { AppConfigPortable } from '@zennotes/shared-domain/app-config' -import type { ExternalUrlResult } from '@zennotes/shared-domain/application-links' -import type { CustomTheme } from '@zennotes/shared-domain/custom-themes' -import type { Override } from '@zennotes/shared-domain/overrides' +} from './mcp-clients.js' +import type { AppConfigPortable } from './app-config.js' +import type { ExternalUrlResult } from './application-links.js' +import type { CustomTheme } from './custom-themes.js' +import type { Override } from './overrides.js' import type { CustomCodeLanguage, CustomCodeLanguageInstallInput, CustomCodeLanguageUpdateInput -} from '@zennotes/shared-domain/custom-code-languages' +} from './custom-code-languages.js' export interface ZenCapabilities { supportsUpdater: boolean @@ -118,15 +119,17 @@ export interface ZenAppInfo { version: string description: string homepage?: string + /** Legacy renderer family. Use hostKind to distinguish native mobile shells. */ runtime: 'desktop' | 'web' + hostKind?: 'desktop' | 'browser' | 'ios' | 'android' } export interface ZenBridge { getCapabilities(): ZenCapabilities getAppInfo(): ZenAppInfo - platform(): Promise - platformSync(): NodeJS.Platform + platform(): Promise + platformSync(): ZenPlatform listSystemFonts(): Promise getAppIconDataUrl(): Promise zoomInApp(): Promise @@ -158,7 +161,7 @@ export interface ZenBridge { syncCloudVault(): Promise hasCloudVaultChanges?(): Promise /** Hosts with multiple workspace windows coordinate draft saves before sync. */ - onCloudSyncWindow?(handlers: import('./cloud-sync').CloudSyncWindowHandlers): () => void + onCloudSyncWindow?(handlers: import('./cloud-sync.js').CloudSyncWindowHandlers): () => void getCloudBootstrapConflict( conflict: CloudSyncBootstrapConflict ): Promise diff --git a/packages/bridge-contract/src/custom-code-languages.ts b/packages/bridge-contract/src/custom-code-languages.ts new file mode 100644 index 00000000..e2f78b41 --- /dev/null +++ b/packages/bridge-contract/src/custom-code-languages.ts @@ -0,0 +1,31 @@ +export interface CustomCodeLanguageManifest { + schemaVersion: 1; + id: string; + name: string; + aliases: string[]; + scopeName: string; + enabled: boolean; +} + +/** Renderer-ready language record returned by the host bridge. */ +export interface CustomCodeLanguage extends CustomCodeLanguageManifest { + grammar: string; + error?: string; +} + +export interface CustomCodeLanguageInstallInput { + fileName: string; + grammar: string; + id: string; + name: string; + aliases: string[]; + enabled?: boolean; + replace?: boolean; +} + +export interface CustomCodeLanguageUpdateInput { + id: string; + name?: string; + aliases?: string[]; + enabled?: boolean; +} diff --git a/packages/bridge-contract/src/custom-themes.ts b/packages/bridge-contract/src/custom-themes.ts new file mode 100644 index 00000000..1726e21d --- /dev/null +++ b/packages/bridge-contract/src/custom-themes.ts @@ -0,0 +1,34 @@ +export type CustomThemeMode = 'light' | 'dark' + +/** Which modes a theme provides; drives the mode toggle + auto resolution. */ +export type CustomThemeModes = 'light' | 'dark' | 'both' + +/** Parsed `manifest.json`. */ +export interface ThemeManifest { + /** Display name (falls back to the slug). */ + name: string + author?: string + version?: string + description?: string + /** Modes this theme styles. Default `both`. */ + modes: CustomThemeModes + /** Optional swatch hint for the Settings card (we can't cheaply render + * arbitrary CSS into a preview). */ + preview?: { light?: string; dark?: string } +} + +/** A loaded custom theme: its manifest fields + the raw `theme.css` to inject. */ +export interface CustomTheme { + /** Stable id from the folder name, e.g. `soft-paper`. */ + slug: string + name: string + author?: string + version?: string + description?: string + modes: CustomThemeModes + /** Raw `theme.css` text, injected verbatim when this theme is active. */ + css: string + preview?: { light?: string; dark?: string } + /** Set when the folder couldn't be used; surfaced in the UI. */ + error?: string +} diff --git a/packages/bridge-contract/src/databases.ts b/packages/bridge-contract/src/databases.ts new file mode 100644 index 00000000..3c70157d --- /dev/null +++ b/packages/bridge-contract/src/databases.ts @@ -0,0 +1,149 @@ +/** + * `note` / `noteMulti` cells store `[[wikilink]]` targets , `[[A]]`, or + * `[[A]] [[B]]` space-joined for multi (bracket-delimited, so titles with + * commas survive where multiSelect's comma-joined encoding cannot). Older + * builds neither validate nor migrate unknown types: they render such cells + * as plain text and round-trip the schema untouched, which is the intended + * degradation. (#500) + */ +export type FieldType = + | 'text' + | 'number' + | 'checkbox' + | 'date' + | 'select' + | 'multiSelect' + | 'note' + | 'noteMulti' + +export interface SelectOption { + id: string + /** The literal stored in the CSV cell. */ + value: string + /** Display override; defaults to `value`. */ + label?: string + /** Palette token name (not a raw hex), mapped to a chip color by the UI. */ + color?: string +} + +/** + * Where a select / multiSelect field discovers pickable values beyond its + * hand-added options: every note, a folder subtree (vault-relative path + * prefix), or a #tag. Discovery is a picker convenience only , a picked note + * still commits as a plain option through the normal path, so boards, + * filters, and older builds see ordinary select values. Absent = manual. (#500) + */ +export type SelectOptionsSource = + | { kind: 'notes' } + | { kind: 'folder'; path: string } + | { kind: 'tag'; tag: string } + +export interface DbField { + /** Stable uuid referenced by rows/views , NOT the CSV header. */ + id: string + /** The CSV column header (display + the header text written to disk). */ + name: string + type: FieldType + /** For `select` / `multiSelect`. */ + options?: SelectOption[] + /** For `select` / `multiSelect`: auto-discover options from notes. */ + optionsSource?: SelectOptionsSource + /** Table column width in px. */ + width?: number + /** Hidden in the Table view by default (e.g. the id field). */ + hidden?: boolean +} + +export type FilterOp = + | 'is' + | 'isNot' + | 'contains' + | 'notContains' + | 'isEmpty' + | 'isNotEmpty' + | 'gt' + | 'lt' + | 'before' + | 'after' + | 'checked' + | 'unchecked' + +export interface FilterRule { + fieldId: string + op: FilterOp + value?: string +} + +/** How a view's multiple filter conditions combine. `and` = match all (the + * default, backward-compatible), `or` = match any. (#394) */ +export type FilterConjunction = 'and' | 'or' + +export interface SortRule { + fieldId: string + direction: 'asc' | 'desc' +} + +export type DbViewType = 'table' | 'board' + +export interface DbView { + id: string + name: string + type: DbViewType + filters: FilterRule[] + /** How the `filters` combine , `and` (match all, default) or `or` (match + * any). Optional so existing views keep their AND behavior. (#394) */ + filterConjunction?: FilterConjunction + sorts: SortRule[] + // --- table --- + /** Ordered fieldIds (display order). */ + columnOrder?: string[] + hiddenFieldIds?: string[] + columnWidths?: Record + // --- board --- + /** Must reference a `select` field. */ + groupByFieldId?: string + /** Order of board columns; values are SelectOption.value (+ EMPTY_GROUP). */ + boardColumnOrder?: string[] + /** Per-card visible fields. */ + cardFieldIds?: string[] +} + +/** The sidecar JSON written to `.base/schema.json`. */ +export interface DatabaseSidecar { + version: 1 + /** Field whose cells hold the row UUID (its `name` is the CSV header). */ + idFieldId: string + /** Order == on-disk CSV column order. */ + fields: DbField[] + views: DbView[] + activeViewId: string + /** Row id → vault path of that record's "page" note (created on demand). */ + pages?: Record +} + +/** Cells are raw CSV strings keyed by DbField.id. */ +export interface DbRow { + /** == cells[idFieldId]. */ + id: string + cells: Record +} + +/** Fully-hydrated database handed to the renderer (sidecar + rows + identity). */ +export interface DatabaseDoc extends DatabaseSidecar { + /** Vault-relative POSIX path of the `data.csv` , identity / cache key. */ + path: string + /** Database name: the `.base` folder name (legacy: the `.csv` basename). */ + title: string + rows: DbRow[] + /** + * Row id → whether that record's linked page note has body content (beyond + * frontmatter + the title heading). Derived on read; not persisted. + */ + pageHasContent?: Record +} + +/** Lightweight listing entry for database discovery (sidebar / quick-open). */ +export interface DatabaseSummary { + path: string + title: string +} diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 60feb2fd..5c50df96 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -1,6 +1,8 @@ // Shared IPC channel names and types between main + renderer. // Keeping these in one file gives us a single source of truth. +import type { ZenPlatform } from './platform.js' + export const IPC = { WORKSPACE_GET_INFO: 'workspace:get-info', WORKSPACE_CONNECT_REMOTE: 'workspace:connect-remote', @@ -773,7 +775,7 @@ export interface LocalVaultEntry extends VaultInfo { export interface ServerCapabilities { version: string - platform: NodeJS.Platform + platform: ZenPlatform authRequired: boolean supportsSessionLogin: boolean browseRootsEnforced: boolean diff --git a/packages/bridge-contract/src/mcp-clients.ts b/packages/bridge-contract/src/mcp-clients.ts new file mode 100644 index 00000000..848ac99b --- /dev/null +++ b/packages/bridge-contract/src/mcp-clients.ts @@ -0,0 +1,48 @@ +export type McpClientId = 'claude-code' | 'claude-desktop' | 'codex' | 'opencode' + +/** Serialized state returned to the renderer for the settings UI. */ +export interface McpClientStatus { + id: McpClientId + /** Absolute path to the client's config file on this machine. */ + configPath: string + /** True if the config file currently contains a ZenNotes entry. */ + installed: boolean + /** Whether the installed entry matches what we would currently install + * (same command / args / env). False when the server path changed + * because the app moved, or when an older version installed a + * different shape. */ + upToDate: boolean + /** Human-readable diagnostic , surfaced beneath the row when the + * install state is ambiguous (file missing, permission error, etc). */ + note?: string +} + +export interface McpServerRuntime { + /** Absolute path to the Node binary that will run the server. */ + command: string + /** Arguments , typically `[mcpEntryPath]`. */ + args: string[] + /** Environment variables passed to the spawned server. */ + env: Record + /** Absolute path to the compiled MCP entry file. `null` when the + * build hasn\u2019t produced it yet (dev environment without a + * prior `npm run build`). */ + entryPath: string | null + /** Set when this build cannot run or install the MCP server at all (the + * web client). The settings page shows this sentence instead of the + * runtime details and the client list (#672). */ + unavailableReason?: string +} + +/** + * Shape returned when the renderer asks for the current server-side + * instructions. `defaultValue` is the compiled default; `current` is + * what the MCP server will actually send (either the user override + * or the default); `isCustom` flags whether an override is in place. + */ +export interface McpInstructionsPayload { + defaultValue: string + current: string + isCustom: boolean + filePath: string +} diff --git a/packages/bridge-contract/src/overrides.ts b/packages/bridge-contract/src/overrides.ts new file mode 100644 index 00000000..a01033e5 --- /dev/null +++ b/packages/bridge-contract/src/overrides.ts @@ -0,0 +1,19 @@ +/** + * CSS overrides , small user-authored `.css` files in + * `~/.config/zennotes/overrides/` that the user toggles on/off and that layer on + * top of *whichever* theme is active (built-in or custom). The enabled set is + * persisted as a portable config map (`[overrides]` in config.toml). + * + * To override a theme token from a override, target `:root[data-theme] { … }` , + * overrides are injected last, so that selector wins over both a built-in's + * `:root[data-theme="…"]` block and a custom theme's `:root {}`. + */ + +export interface Override { + /** Filename including `.css`, e.g. `punchy-accent.css`. Stable id. */ + name: string + /** Raw CSS text, injected verbatim when enabled. */ + css: string + /** Set when the file couldn't be read; surfaced in the UI. */ + error?: string +} diff --git a/packages/bridge-contract/src/platform.ts b/packages/bridge-contract/src/platform.ts new file mode 100644 index 00000000..14edd562 --- /dev/null +++ b/packages/bridge-contract/src/platform.ts @@ -0,0 +1,13 @@ +/** Operating-system identifiers returned by existing hosts, independent of Node types. */ +export type ZenPlatform = + | 'aix' + | 'android' + | 'darwin' + | 'freebsd' + | 'haiku' + | 'linux' + | 'openbsd' + | 'sunos' + | 'win32' + | 'cygwin' + | 'netbsd' diff --git a/packages/bridge-contract/src/tasks.ts b/packages/bridge-contract/src/tasks.ts new file mode 100644 index 00000000..3c3f171c --- /dev/null +++ b/packages/bridge-contract/src/tasks.ts @@ -0,0 +1,65 @@ +import type { NoteFolder } from './ipc.js' + +export type TaskPriority = 'high' | 'med' | 'low' + +export interface VaultTask { + /** Stable-ish id: `${sourcePath}#${taskIndex}`. Task index shifts only when + * tasks are added/removed above it in the same file, so this is stable + * across plain content edits. */ + id: string + /** Vault-relative POSIX path of the note containing this task. */ + sourcePath: string + /** File name without extension (for display). */ + noteTitle: string + /** Top-level vault folder the source note lives in. */ + noteFolder: NoteFolder + /** 0-based line number in the full file body (frontmatter included). */ + lineNumber: number + /** Must match `toggleTaskAtIndex` counting for round-trip edits. */ + taskIndex: number + /** Raw line as it appears on disk. */ + rawText: string + /** Display content (checkbox prefix + metadata tokens stripped). */ + content: string + checked: boolean + /** True for a `[>]` task forwarded to another note (#316). Mutually + * exclusive with `checked`; kept out of the today/upcoming/done buckets. */ + forwarded: boolean + /** True for a `[-]` task cancelled, intentionally abandoned (#450). Mutually + * exclusive with `checked`/`forwarded`; kept out of the active buckets and + * collected under its own group. */ + cancelled: boolean + /** True for a `[/]` task in progress: started, not finished (#512). Unlike + * the other non-empty state chars this one is still OPEN work, so it stays + * in Today/Upcoming, on the calendar, and on the board. It marks *how* an + * open task is going, not that it left the active set. */ + inProgress: boolean + /** ISO YYYY-MM-DD, validated via Date round-trip. */ + due?: string + /** True when `due` was *derived* from the containing daily note's date + * rather than written on the line. Lets UIs tell an implicit due apart + * from an explicit `due:` token. See `inferDailyTaskDueDates`. */ + dueInferred?: boolean + priority?: TaskPriority + /** True if `@waiting` appears anywhere on the line. */ + waiting: boolean + /** All inline `@key:value` fields on the line (lower-cased), e.g. + * `@status:review @sprint:24`. Any key can drive a Kanban group-by. Optional + * so hand-built task fixtures stay terse; the parser always sets it. (#354) */ + fields?: Record + /** Convenience accessor for `fields.status`, falling back to the note's + * `status:` frontmatter. The default Kanban custom field. (#354) */ + status?: string + /** Inline `#tags` found on the line. */ + tags: string[] + /** How this task is stored. `'file'` is a whole-note task (TaskNotes-style: + * a `.md` file tagged `#task`, metadata in frontmatter); `'inline'` (the + * default when absent) is a classic `- [ ]` checkbox line. File-tasks + * round-trip through frontmatter, not the checkbox, so mutators branch on + * this. */ + kind?: 'inline' | 'file' + /** ISO YYYY-MM-DD start/scheduled date (frontmatter `scheduled`). File-tasks. */ + scheduled?: string + /** ISO YYYY-MM-DD completion date (frontmatter `completedDate`). File-tasks. */ + completedDate?: string +} diff --git a/packages/bridge-contract/src/templates.ts b/packages/bridge-contract/src/templates.ts index 747cde83..d441b89c 100644 --- a/packages/bridge-contract/src/templates.ts +++ b/packages/bridge-contract/src/templates.ts @@ -1,7 +1,7 @@ // Shared note-template contract types. Lives in bridge-contract because both // the main process (custom-template CRUD over IPC) and the renderer (palette, // substitution) need this shape, and the IPC bridge return types reference it. -import type { NoteFolder } from './ipc' +import type { NoteFolder } from './ipc.js' export type TemplateCategory = 'Engineering' | 'Personal' | 'Custom' diff --git a/packages/bridge-contract/tsconfig.json b/packages/bridge-contract/tsconfig.json index 4106458d..b7d09dc1 100644 --- a/packages/bridge-contract/tsconfig.json +++ b/packages/bridge-contract/tsconfig.json @@ -2,6 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, + "rootDir": "src", + "noResolve": true, + "types": [], "lib": ["ES2022", "DOM"] }, "include": ["src/**/*"] diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 704fe806..ac470de2 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -17,6 +17,7 @@ "lz-string": "^1.5.0" }, "devDependencies": { + "typescript": "^5.7.2", "vitest": "^3.2.6" } } diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 336f4dca..7c344104 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -1,14 +1,11 @@ -// Portable application config — the subset of user preferences that travel -// between machines via a plain-text config file (config.toml). This is the -// single source of truth for *which* preference keys are portable; both the -// renderer (to extract/apply the subset) and the desktop main process (to -// read/write the file) import from here so the two never drift. -// -// Machine-local UI state (pane widths, collapsed folders, pinned reference, -// onboarding flag, last-opened vault, window geometry) is deliberately NOT -// listed here — it stays in localStorage / the runtime config so a synced -// dotfile doesn't churn on every drag and never carries machine-specific -// layout state. +import type { PortablePrefKey, AppConfigPortable } from '@zennotes/bridge-contract/app-config' +import { PORTABLE_PREF_KEYS } from '@zennotes/bridge-contract/app-config' +export type { PortablePrefKey, AppConfigPortable } from '@zennotes/bridge-contract/app-config' +export { PORTABLE_PREF_KEYS } from '@zennotes/bridge-contract/app-config' + +// Portable preferences are defined by the bridge contract and re-exported here +// for existing consumers. This module owns normalization, defaults, and selection. +// Machine-local layout and session state remain outside portable config. /** Bumped when the on-disk config layout changes in a way that needs a * migration. Written as `config_version` at the top of the file. */ @@ -51,118 +48,6 @@ export function defaultTimeFormat(): TimeFormat { return '24h' } -/** - * Preference keys (matching the renderer's `Prefs` shape) persisted to the - * portable config file. Keep this list in sync with `Prefs` in - * `packages/app-core/src/store.ts`; new portable settings should be added - * here AND given a TOML mapping in `apps/desktop/src/main/app-config.ts`. - */ -export const PORTABLE_PREF_KEYS = [ - // vim - 'vimMode', - 'vimInsertEscape', - 'vimYankToClipboard', - 'vimBlockImeInNormalMode', - 'vimWrappedLineMotions', - 'whichKeyHints', - 'whichKeyHintMode', - 'whichKeyHintTimeoutMs', - // keymaps (overrides only) - 'keymapOverrides', - 'ignoredKeys', - 'externalApplicationSchemes', - // search - 'vaultTextSearchBackend', - 'ripgrepBinaryPath', - 'fzfBinaryPath', - // editor - 'livePreview', - 'showHeadingLevelLabels', - 'listIndentGuides', - 'renderTablesInLivePreview', - 'completedTaskStyle', - 'mathRenderer', - 'typstTagPreambles', - 'harperEnabled', - 'harperDialect', - 'looseMathDelimiters', - 'keepViewModeAcrossNotes', - 'defaultPaneMode', - 'syncTitleHeadingOnRename', - 'markdownSnippets', - 'textReplacementsEnabled', - 'textReplacements', - 'autoPairs', - 'autoPairQuotesInProse', - 'hideBuiltinTemplates', - 'tabsEnabled', - 'wrapTabs', - 'editorFontSize', - 'mathFontScale', - 'editorLineHeight', - 'editorTabSize', - 'editorScrollOff', - 'timeFormat', - 'previewMaxWidth', - 'editorMaxWidth', - 'lineNumberMode', - 'lineNumberPosition', - 'viewSettingsScope', - 'wordWrap', - 'previewSmoothScroll', - 'pdfEmbedInEditMode', - 'pdfExportUseTheme', - // appearance - 'themeId', - 'themeFamily', - 'themeMode', - 'enabledOverrides', - 'themeTweaks', - 'darkSidebar', - 'showWindowTitleBar', - 'showSidebarChevrons', - 'contentAlign', - 'unifiedSidebar', - // typography - 'interfaceFont', - 'textFont', - 'monoFont', - // features - 'workflowsEnabled', - 'hiddenWorkflowPresets', - 'atlasEnabled', - // view - 'systemFolderLabels', - 'noteSortOrder', - 'assetSortOrder', - 'groupByKind', - 'nestedTags', - 'autoReveal', - 'quickNoteDateTitle', - 'quickNoteTitlePrefix', - 'autoCalendarPanel', - 'calendarWeekStart', - 'calendarShowWeekNumbers', - 'tasksViewMode', - 'showArchivedTasks', - 'kanbanGroupBy', - 'kanbanFolderRoot', - 'kanbanColumnTitles', - 'kanbanStatuses', - // tasks - 'savedTaskFilters' -] as const - -export type PortablePrefKey = (typeof PORTABLE_PREF_KEYS)[number] - -/** - * Transport shape for the portable config across the IPC boundary. Values are - * `unknown` on purpose — the file is user-editable plain text, so the renderer - * funnels everything through `normalizePrefs()` for validation rather than - * trusting compile-time types here. - */ -export type AppConfigPortable = Partial> - const PORTABLE_KEY_SET: ReadonlySet = new Set(PORTABLE_PREF_KEYS) /** True when `key` is one of the portable preference keys. */ diff --git a/packages/shared-domain/src/application-links.ts b/packages/shared-domain/src/application-links.ts index e7ef5649..18c9d13e 100644 --- a/packages/shared-domain/src/application-links.ts +++ b/packages/shared-domain/src/application-links.ts @@ -1,3 +1,5 @@ +export type { ExternalUrlResult } from '@zennotes/bridge-contract/application-links' + /** Application links are classified before note lookup, even when disabled. */ const SCHEME_RE = /^([a-z][a-z\d+.-]*):/i const STANDARD_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']) @@ -65,9 +67,3 @@ export function classifyApplicationLink(href: string): ApplicationLink | null { url.length === scheme.length + 1 } } - -export type ExternalUrlResult = { - ok: boolean - error?: 'scheme-disabled' | 'blocked' | 'open-failed' | 'desktop-only' - scheme?: string -} diff --git a/packages/shared-domain/src/custom-code-languages.ts b/packages/shared-domain/src/custom-code-languages.ts index 648352af..ad01e3fd 100644 --- a/packages/shared-domain/src/custom-code-languages.ts +++ b/packages/shared-domain/src/custom-code-languages.ts @@ -1,3 +1,11 @@ +import type { CustomCodeLanguageManifest } from '@zennotes/bridge-contract/custom-code-languages' +export type { + CustomCodeLanguageManifest, + CustomCodeLanguage, + CustomCodeLanguageInstallInput, + CustomCodeLanguageUpdateInput +} from '@zennotes/bridge-contract/custom-code-languages' + /** Shared contract and validation for user-installed TextMate code languages. */ export const CUSTOM_CODE_LANGUAGE_SCHEMA_VERSION = 1; @@ -280,38 +288,6 @@ export function isReservedCodeFenceTag(tag: string): boolean { return reservedTagSet.has(normalizeCodeFenceTag(tag)); } -export interface CustomCodeLanguageManifest { - schemaVersion: 1; - id: string; - name: string; - aliases: string[]; - scopeName: string; - enabled: boolean; -} - -/** Renderer-ready language record returned by the host bridge. */ -export interface CustomCodeLanguage extends CustomCodeLanguageManifest { - grammar: string; - error?: string; -} - -export interface CustomCodeLanguageInstallInput { - fileName: string; - grammar: string; - id: string; - name: string; - aliases: string[]; - enabled?: boolean; - replace?: boolean; -} - -export interface CustomCodeLanguageUpdateInput { - id: string; - name?: string; - aliases?: string[]; - enabled?: boolean; -} - export interface ParsedTextMateGrammar { raw: Record; name?: string; diff --git a/packages/shared-domain/src/custom-themes.ts b/packages/shared-domain/src/custom-themes.ts index 8b5250ba..2e657dc4 100644 --- a/packages/shared-domain/src/custom-themes.ts +++ b/packages/shared-domain/src/custom-themes.ts @@ -22,39 +22,18 @@ * starter + the migration), not live rendering. */ -export type CustomThemeMode = 'light' | 'dark' -/** Which modes a theme provides; drives the mode toggle + auto resolution. */ -export type CustomThemeModes = 'light' | 'dark' | 'both' - -/** Parsed `manifest.json`. */ -export interface ThemeManifest { - /** Display name (falls back to the slug). */ - name: string - author?: string - version?: string - description?: string - /** Modes this theme styles. Default `both`. */ - modes: CustomThemeModes - /** Optional swatch hint for the Settings card (we can't cheaply render - * arbitrary CSS into a preview). */ - preview?: { light?: string; dark?: string } -} - -/** A loaded custom theme: its manifest fields + the raw `theme.css` to inject. */ -export interface CustomTheme { - /** Stable id from the folder name, e.g. `soft-paper`. */ - slug: string - name: string - author?: string - version?: string - description?: string - modes: CustomThemeModes - /** Raw `theme.css` text, injected verbatim when this theme is active. */ - css: string - preview?: { light?: string; dark?: string } - /** Set when the folder couldn't be used; surfaced in the UI. */ - error?: string -} +import type { + CustomThemeMode, + CustomThemeModes, + ThemeManifest, + CustomTheme +} from '@zennotes/bridge-contract/custom-themes' +export type { + CustomThemeMode, + CustomThemeModes, + ThemeManifest, + CustomTheme +} from '@zennotes/bridge-contract/custom-themes' /** * The semantic palette of the old TOML format. Retained only as the input to diff --git a/packages/shared-domain/src/database-ops.test.ts b/packages/shared-domain/src/database-ops.test.ts index db0bd621..fba66e99 100644 --- a/packages/shared-domain/src/database-ops.test.ts +++ b/packages/shared-domain/src/database-ops.test.ts @@ -162,6 +162,30 @@ describe('createDatabaseOps', () => { expect(vault.files.has('inbox/Work/Old Name.base/data.csv')).toBe(false) }) + + it('returns the canonical directory supplied by the host after a rename', async () => { + const vault = memVault({ primaryNotesAtRoot: true }) + const rename = vault.io.renameFolder + vault.io.renameFolder = (folder, from) => rename(folder, from, 'Canonical.base') + const ops = createDatabaseOps(vault.io) + const doc = await ops.createDatabase('inbox', '', 'Original') + expect(await ops.renameDatabase(doc.path, 'Requested')).toBe('Canonical.base/data.csv') + expect(vault.files.has('Canonical.base/data.csv')).toBe(true) + }) + + + it.each(['People', 'people'])('does not overwrite a partial database folder when creating %s', async (title) => { + const vault = memVault() + vault.folders.push({ folder: 'inbox', subpath: 'People.base' }) + vault.files.set('inbox/People.base/schema.json', 'keep original schema') + vault.files.set('inbox/People.base/Record.md', 'keep record') + const created = await createDatabaseOps(vault.io).createDatabase('inbox', '', title) + expect(created.path).toBe(`inbox/${title} 2.base/data.csv`) + expect(vault.files.get('inbox/People.base/schema.json')).toBe('keep original schema') + expect(vault.files.get('inbox/People.base/Record.md')).toBe('keep record') + expect(vault.files.has('inbox/People.base/data.csv')).toBe(false) + }) + it('respects primaryNotesLocation root for inbox paths', async () => { const vault = memVault({ primaryNotesAtRoot: true }) const ops = createDatabaseOps(vault.io) @@ -218,13 +242,14 @@ describe('createDatabaseOps with remapped system folders', () => { // With archive remapped away, a directory literally named `archive/` is an // ordinary user folder inside the primary area. it('treats a literal archive/ as inbox content once archive has moved', async () => { - const vault = memVault({ systemFolderPaths: remapped }) + const vault = memVault({ primaryNotesAtRoot: true, systemFolderPaths: remapped }) const ops = createDatabaseOps(vault.io) vault.files.set('archive/Notes.base/data.csv', 'Title\nOne\n') vault.folders.push({ folder: 'inbox', subpath: 'archive/Notes.base' }) const renamed = await ops.renameDatabase('archive/Notes.base/data.csv', 'Renamed') expect(renamed).toBe('archive/Renamed.base/data.csv') + expect(vault.files.has(renamed)).toBe(true) }) }) diff --git a/packages/shared-domain/src/database-ops.ts b/packages/shared-domain/src/database-ops.ts index a1fbff9d..5f16da29 100644 --- a/packages/shared-domain/src/database-ops.ts +++ b/packages/shared-domain/src/database-ops.ts @@ -289,10 +289,11 @@ export function createDatabaseOps(io: DatabaseFileOps): DatabaseOps { const dirRel = vaultRelDir(folder, subpath, layout) const csvFor = (name: string): string => csvPathForFormDir(joinSub(dirRel, `${name}${FORM_DIR_SUFFIX}`)) - // Resolve a non-colliding .base under the directory. + // A partial database still owns its directory, even without data.csv. + const occupied = new Set((await io.listFolders()).map((entry) => vaultRelDir(entry.folder, entry.subpath, layout).toLowerCase())) let name = baseName let n = 2 - while ((await io.readFileTextOrNull(csvFor(name))) !== null) name = `${baseName} ${n++}` + while (occupied.has(formDirFromCsvPath(csvFor(name))!.toLowerCase()) || (await io.readFileTextOrNull(csvFor(name))) !== null) name = `${baseName} ${n++}` const csvPath = csvFor(name) const folderSub = joinSub(subpath, `${name}${FORM_DIR_SUFFIX}`) @@ -339,15 +340,16 @@ export function createDatabaseOps(io: DatabaseFileOps): DatabaseOps { parentRel ? `${parentRel}/${name}${FORM_DIR_SUFFIX}` : `${name}${FORM_DIR_SUFFIX}` let targetFormDir = makeFormDir(safeName) if (targetFormDir === oldFormDir) return csvPath + const layout = await io.vaultLayout() + const occupied = new Set((await io.listFolders()).map((entry) => vaultRelDir(entry.folder, entry.subpath, layout))) let n = 2 - while ((await io.readFileTextOrNull(csvPathForFormDir(targetFormDir))) !== null) { + while (occupied.has(targetFormDir) || (await io.readFileTextOrNull(csvPathForFormDir(targetFormDir))) !== null) { targetFormDir = makeFormDir(`${safeName} ${n++}`) } - const layout = await io.vaultLayout() const { folder, subpath: oldSub } = splitVaultPath(oldFormDir, layout) const { subpath: newSub } = splitVaultPath(targetFormDir, layout) - await io.renameFolder(folder, oldSub, newSub) - return csvPathForFormDir(targetFormDir) + const canonical = await io.renameFolder(folder, oldSub, newSub) + return csvPathForFormDir(vaultRelDir(folder, canonical, layout)) } async function listDatabases(): Promise { diff --git a/packages/shared-domain/src/databases.ts b/packages/shared-domain/src/databases.ts index 2c25612c..ca0b9bc6 100644 --- a/packages/shared-domain/src/databases.ts +++ b/packages/shared-domain/src/databases.ts @@ -1,3 +1,20 @@ +export type { + FieldType, + SelectOption, + SelectOptionsSource, + DbField, + FilterOp, + FilterRule, + FilterConjunction, + SortRule, + DbViewType, + DbView, + DatabaseSidecar, + DbRow, + DatabaseDoc, + DatabaseSummary +} from '@zennotes/bridge-contract/databases' + /** * CSV-backed "Databases" — a general data primitive (à la Notion / Obsidian * Bases). A `.csv` file in the vault is a database: rows are records, columns @@ -108,156 +125,6 @@ export function formTitleFromCsvPath(csvPath: string): string { return dir ? formTitleFromDir(dir) : csvPath } -/** - * `note` / `noteMulti` cells store `[[wikilink]]` targets — `[[A]]`, or - * `[[A]] [[B]]` space-joined for multi (bracket-delimited, so titles with - * commas survive where multiSelect's comma-joined encoding cannot). Older - * builds neither validate nor migrate unknown types: they render such cells - * as plain text and round-trip the schema untouched, which is the intended - * degradation. (#500) - */ -export type FieldType = - | 'text' - | 'number' - | 'checkbox' - | 'date' - | 'select' - | 'multiSelect' - | 'note' - | 'noteMulti' - -export interface SelectOption { - id: string - /** The literal stored in the CSV cell. */ - value: string - /** Display override; defaults to `value`. */ - label?: string - /** Palette token name (not a raw hex), mapped to a chip color by the UI. */ - color?: string -} - -/** - * Where a select / multiSelect field discovers pickable values beyond its - * hand-added options: every note, a folder subtree (vault-relative path - * prefix), or a #tag. Discovery is a picker convenience only — a picked note - * still commits as a plain option through the normal path, so boards, - * filters, and older builds see ordinary select values. Absent = manual. (#500) - */ -export type SelectOptionsSource = - | { kind: 'notes' } - | { kind: 'folder'; path: string } - | { kind: 'tag'; tag: string } - -export interface DbField { - /** Stable uuid referenced by rows/views — NOT the CSV header. */ - id: string - /** The CSV column header (display + the header text written to disk). */ - name: string - type: FieldType - /** For `select` / `multiSelect`. */ - options?: SelectOption[] - /** For `select` / `multiSelect`: auto-discover options from notes. */ - optionsSource?: SelectOptionsSource - /** Table column width in px. */ - width?: number - /** Hidden in the Table view by default (e.g. the id field). */ - hidden?: boolean -} - -export type FilterOp = - | 'is' - | 'isNot' - | 'contains' - | 'notContains' - | 'isEmpty' - | 'isNotEmpty' - | 'gt' - | 'lt' - | 'before' - | 'after' - | 'checked' - | 'unchecked' - -export interface FilterRule { - fieldId: string - op: FilterOp - value?: string -} - -/** How a view's multiple filter conditions combine. `and` = match all (the - * default, backward-compatible), `or` = match any. (#394) */ -export type FilterConjunction = 'and' | 'or' - -export interface SortRule { - fieldId: string - direction: 'asc' | 'desc' -} - -export type DbViewType = 'table' | 'board' - -export interface DbView { - id: string - name: string - type: DbViewType - filters: FilterRule[] - /** How the `filters` combine — `and` (match all, default) or `or` (match - * any). Optional so existing views keep their AND behavior. (#394) */ - filterConjunction?: FilterConjunction - sorts: SortRule[] - // --- table --- - /** Ordered fieldIds (display order). */ - columnOrder?: string[] - hiddenFieldIds?: string[] - columnWidths?: Record - // --- board --- - /** Must reference a `select` field. */ - groupByFieldId?: string - /** Order of board columns; values are SelectOption.value (+ EMPTY_GROUP). */ - boardColumnOrder?: string[] - /** Per-card visible fields. */ - cardFieldIds?: string[] -} - -/** The sidecar JSON written to `.base/schema.json`. */ -export interface DatabaseSidecar { - version: 1 - /** Field whose cells hold the row UUID (its `name` is the CSV header). */ - idFieldId: string - /** Order == on-disk CSV column order. */ - fields: DbField[] - views: DbView[] - activeViewId: string - /** Row id → vault path of that record's "page" note (created on demand). */ - pages?: Record -} - -/** Cells are raw CSV strings keyed by DbField.id. */ -export interface DbRow { - /** == cells[idFieldId]. */ - id: string - cells: Record -} - -/** Fully-hydrated database handed to the renderer (sidecar + rows + identity). */ -export interface DatabaseDoc extends DatabaseSidecar { - /** Vault-relative POSIX path of the `data.csv` — identity / cache key. */ - path: string - /** Database name: the `.base` folder name (legacy: the `.csv` basename). */ - title: string - rows: DbRow[] - /** - * Row id → whether that record's linked page note has body content (beyond - * frontmatter + the title heading). Derived on read; not persisted. - */ - pageHasContent?: Record -} - -/** Lightweight listing entry for database discovery (sidebar / quick-open). */ -export interface DatabaseSummary { - path: string - title: string -} - // --------------------------------------------------------------------------- // Virtual tab-path helpers (mirror lib/asset-tabs.ts). A database opens as a // virtual tab keyed by the real CSV path, so it never hits the markdown diff --git a/packages/shared-domain/src/demo-tour-data.ts b/packages/shared-domain/src/demo-tour-data.ts new file mode 100644 index 00000000..039aad83 --- /dev/null +++ b/packages/shared-domain/src/demo-tour-data.ts @@ -0,0 +1,82 @@ +export interface DemoTourTemplateFile { + path: string + body: string +} + +export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ + { + path: "inbox/demo/00 — Start Here.md", + body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n" + }, + { + path: "inbox/demo/01 — Markdown Basics.md", + body: "# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://lumarylabs.com)\n- Autolink: \n- Wikilink: [[07 — Wiki Links and Tags]]\n- Custom label: [[11 — Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 — Code Blocks]] for syntax highlighting, [[06 — Callouts and Footnotes]] for richer block styles, or back to [[00 — Start Here]].\n\n#demo #markdown\n" + }, + { + path: "inbox/demo/02 — Code Blocks.md", + body: "# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and renders them with syntax highlighting in preview and split view.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## What's next\n\nSee [[05 — Mermaid Diagrams]] for Mermaid fences, [[05b — Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 — Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n" + }, + { + path: "inbox/demo/03 — Tables and Task Lists.md", + body: "# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Mermaid | ✅ | Rendered inside preview and split view. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | File-based | Use any sync tool that watches folders. |\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 — Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday — design review\n - [ ] Tuesday — code-freeze prep\n - [x] Saturday — offline\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row to scan every live note across **Inbox**, **Quick Notes**, and **Archive**. From there you can:\n\n- filter by task content\n- filter by note title\n- filter by inline `#tags`\n- filter by priority markers like `!high`\n- press `Enter` or `o` to open the source note\n- press `Space` or `x` to toggle the selected task without leaving the list\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 — Callouts and Footnotes]])\n- [x] Math blocks (see [[04 — Math with KaTeX]])\n- [x] Mermaid (see [[05 — Mermaid Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b — Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Screenshots in the tour due:2026-04-25 !med #docs\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives.\n\n#demo #tasks #tables\n" + }, + { + path: "inbox/demo/04 — Math with KaTeX.md", + body: "# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown while preview and split mode give you readable math output.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode keeps the raw LaTeX visible.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## Prefer Typst? An alternative math engine\n\nZenNotes can also typeset math with **Typst** instead of KaTeX. Open **Settings ▸ Editor ▸ Math renderer** and pick **Typst**; it applies in both the live editor and the reading view.\n\nTypst reads the same `$…$` and `$$…$$` blocks as **Typst markup**, not LaTeX, so each note's math is written for whichever engine you pick. The formulas here are Typst syntax: with the Math renderer set to **Typst** they render; with **KaTeX** (the default) they show as errors until you switch.\n\nInline: $x^2 + y^2 = z^2$ and $sqrt(a^2 + b^2)$.\n\n$$\nintegral_0^1 x^2 dif x = 1/3\n$$\n\n$$\nsum_(n=1)^oo 1/n^2 = pi^2/6\n$$\n\n$$\nmat(1, 2; 3, 4) quad vec(a, b, c)\n$$\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b — Math Diagrams]].\n\n#demo #math #reference\n" + }, + { + path: "inbox/demo/05 — Mermaid Diagrams.md", + body: "# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b — Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- **Split** mode is usually the sweet spot: raw source on one side, rendered diagram on the other.\n- Diagrams are still searchable because the source fence lives in the note body.\n- If Mermaid syntax breaks, ZenNotes falls back to showing the source block, which makes failures debuggable instead of mysterious.\n\n## What's next\n\nStay in diagram mode with [[05b — Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n" + }, + { + path: "inbox/demo/05b — Math Diagrams.md", + body: "# Math diagrams — TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 — Mermaid Diagrams]]) and KaTeX (see [[04 — Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ — figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly — no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph — interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object — no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"` — useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser — so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot — quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math — `Math.PI`, `Math.sin(x)`, etc. — plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve — the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 — Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 — Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n" + }, + { + path: "inbox/demo/06 — Callouts and Footnotes.md", + body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" + }, + { + path: "inbox/demo/07 — Wiki Links and Tags.md", + body: "# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively.\n\n- Shortest form: [[01 — Markdown Basics]]\n- Custom display text: [[11 — Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] — opening it offers to create the note\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- PDFs can open directly into the reference pane\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 — Start Here]]\n A --> B[[01 — Markdown Basics]]\n A --> C[[02 — Code Blocks]]\n A --> D[[03 — Tables and Task Lists]]\n A --> E[[04 — Math with KaTeX]]\n A --> F[[05 — Mermaid Diagrams]]\n A --> G[[05b — Math Diagrams]]\n A --> H[[06 — Callouts and Footnotes]]\n A --> I[[07 — Wiki Links and Tags]]\n A --> J[[08 — Daily Notes]]\n A --> K[[09 — Vim Cheat Sheet]]\n A --> L[[10 — Ideas and Tasks]]\n A --> M[[11 — Workspace, Search, and Views]]\n A --> N[[12 — Settings and Keymaps]]\n A --> O[[13 — Commands, Help, and Demo Tour]]\n A --> P[[14 — Reference Pane and Floating Windows]]\n A --> Q[[15 — Search Backends and Fuzzy Workflows]]\n```\n\n#demo #reference #search #links\n" + }, + { + path: "inbox/demo/08 — Daily Notes.md", + body: "---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Agenda\n\n- [ ] Morning: triage [[10 — Ideas and Tasks]]\n- [ ] 10:00 — design review\n- [ ] 12:00 — lunch\n- [x] 14:00 — code-freeze prep\n- [ ] Evening: reading — Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Add a sample PDF so the reference-pane flow is demonstrated with a real file.\n- [ ] Add more screenshots for the search palette.\n- [ ] Refine the help text for view-specific ex prompts.\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 — Tables and Task Lists]]\n- [ ] Review [[12 — Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n" + }, + { + path: "inbox/demo/09 — Vim Cheat Sheet.md", + body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading at the cursor |\n| `zo` | Unfold the heading at the cursor |\n| `zM` | Fold all headings |\n| `zR` | Unfold all headings |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 — Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" + }, + { + path: "inbox/demo/10 — Ideas and Tasks.md", + body: "# Ideas and tasks — a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should attachment previews appear inline for PDFs by default?\n- [ ] Is the built-in text-search backend fast enough on large vaults when neither `fzf` nor `ripgrep` is available?\n- [ ] Do we expose tag renaming from the UI, or keep it intentionally file-grep first?\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which now opens as a dedicated main-pane list view.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n## Now\n\n- [ ] Add a sample PDF + image to the tour so [[06 — Callouts and Footnotes]] can illustrate attachments and reference-pane workflows.\n- [x] Document the Tasks tab behavior in [[03 — Tables and Task Lists]].\n- [ ] Collect feedback on [[09 — Vim Cheat Sheet]] now that keymaps are configurable.\n- [ ] Confirm the search backend badge is visible enough in the vault text search palette.\n\n## Shipped\n\n- [x] Vault text search can use **Auto**, **Built-in**, **ripgrep**, or **fzf**.\n- [x] Custom binary paths can be configured when `rg` or `fzf` live outside `PATH`.\n- [x] Settings now show the resolved runtime backend instead of only the requested one.\n- [x] Archive and Trash both behave as list-style built-in tabs instead of sidebar dump zones.\n\n## Cross-references\n\n- Tour index: [[00 — Start Here]]\n- Search and links: [[07 — Wiki Links and Tags]]\n- Workspace guide: [[11 — Workspace, Search, and Views]]\n- Settings and keymaps: [[12 — Settings and Keymaps]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n\n#demo #tasks #planning #workspace\n" + }, + { + path: "inbox/demo/11 — Workspace, Search, and Views.md", + body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n" + }, + { + path: "inbox/demo/12 — Settings and Keymaps.md", + body: "# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid anymore. Settings now cover both presentation and behavior.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location\n- inspect the app version\n- see the About section\n- find the Lumary Labs link\n- remember that Settings save automatically on this device\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 — Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n" + }, + { + path: "inbox/demo/13 — Commands, Help, and Demo Tour.md", + body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n" + }, + { + path: "inbox/demo/14 — Reference Pane and Floating Windows.md", + body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n" + }, + { + path: "inbox/demo/15 — Search Backends and Fuzzy Workflows.md", + body: "# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 — Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 — Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 — Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n" + }, +] + +export const DEMO_TOUR_ASSETS: DemoTourTemplateFile[] = [ + { + path: "zennotes-demo-card.svg", + body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n DEMO\n ZenNotes Demo\n Local files, keyboard-first flows, and markdown-friendly structure.\n \n \n \n \n \n \n \n SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES\n\n" + }, +] \ No newline at end of file diff --git a/packages/shared-domain/src/excalidraw.ts b/packages/shared-domain/src/excalidraw.ts index de013804..029fe50d 100644 --- a/packages/shared-domain/src/excalidraw.ts +++ b/packages/shared-domain/src/excalidraw.ts @@ -3,7 +3,9 @@ // Markdown notes and `.base` databases: listed in the sidebar with their own // icon, opened in a dedicated editor tab, and saved back as JSON. -import { decompressFromBase64 } from 'lz-string' +import LZString from 'lz-string' + +const { decompressFromBase64 } = LZString export const EXCALIDRAW_EXT = '.excalidraw' diff --git a/packages/shared-domain/src/mcp-clients.ts b/packages/shared-domain/src/mcp-clients.ts index 6573b06c..bddba8c7 100644 --- a/packages/shared-domain/src/mcp-clients.ts +++ b/packages/shared-domain/src/mcp-clients.ts @@ -6,7 +6,13 @@ * renderer (present the UI) rely on these constants. */ -export type McpClientId = 'claude-code' | 'claude-desktop' | 'codex' | 'opencode' +import type { McpClientId } from '@zennotes/bridge-contract/mcp-clients' +export type { + McpClientId, + McpClientStatus, + McpServerRuntime, + McpInstructionsPayload +} from '@zennotes/bridge-contract/mcp-clients' export interface McpClientDescriptor { id: McpClientId @@ -73,50 +79,3 @@ export function getMcpClientDescriptor(id: McpClientId): McpClientDescriptor { if (!found) throw new Error(`Unknown MCP client: ${id}`) return found } - -/** Serialized state returned to the renderer for the settings UI. */ -export interface McpClientStatus { - id: McpClientId - /** Absolute path to the client's config file on this machine. */ - configPath: string - /** True if the config file currently contains a ZenNotes entry. */ - installed: boolean - /** Whether the installed entry matches what we would currently install - * (same command / args / env). False when the server path changed - * because the app moved, or when an older version installed a - * different shape. */ - upToDate: boolean - /** Human-readable diagnostic — surfaced beneath the row when the - * install state is ambiguous (file missing, permission error, etc). */ - note?: string -} - -export interface McpServerRuntime { - /** Absolute path to the Node binary that will run the server. */ - command: string - /** Arguments — typically `[mcpEntryPath]`. */ - args: string[] - /** Environment variables passed to the spawned server. */ - env: Record - /** Absolute path to the compiled MCP entry file. `null` when the - * build hasn\u2019t produced it yet (dev environment without a - * prior `npm run build`). */ - entryPath: string | null - /** Set when this build cannot run or install the MCP server at all (the - * web client). The settings page shows this sentence instead of the - * runtime details and the client list (#672). */ - unavailableReason?: string -} - -/** - * Shape returned when the renderer asks for the current server-side - * instructions. `defaultValue` is the compiled default; `current` is - * what the MCP server will actually send (either the user override - * or the default); `isCustom` flags whether an override is in place. - */ -export interface McpInstructionsPayload { - defaultValue: string - current: string - isCustom: boolean - filePath: string -} diff --git a/packages/shared-domain/src/overrides.ts b/packages/shared-domain/src/overrides.ts index ceebde93..401cf04f 100644 --- a/packages/shared-domain/src/overrides.ts +++ b/packages/shared-domain/src/overrides.ts @@ -1,22 +1,4 @@ -/** - * CSS overrides — small user-authored `.css` files in - * `~/.config/zennotes/overrides/` that the user toggles on/off and that layer on - * top of *whichever* theme is active (built-in or custom). The enabled set is - * persisted as a portable config map (`[overrides]` in config.toml). - * - * To override a theme token from a override, target `:root[data-theme] { … }` — - * overrides are injected last, so that selector wins over both a built-in's - * `:root[data-theme="…"]` block and a custom theme's `:root {}`. - */ - -export interface Override { - /** Filename including `.css`, e.g. `punchy-accent.css`. Stable id. */ - name: string - /** Raw CSS text, injected verbatim when enabled. */ - css: string - /** Set when the file couldn't be read; surfaced in the UI. */ - error?: string -} +export type { Override } from '@zennotes/bridge-contract/overrides' /** * Whether a override is enabled, per the persisted `[overrides]` map. Only enabled diff --git a/packages/shared-domain/src/task-roundtrip.test.ts b/packages/shared-domain/src/task-roundtrip.test.ts new file mode 100644 index 00000000..6c2ac042 --- /dev/null +++ b/packages/shared-domain/src/task-roundtrip.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import fixtures from '../../bridge-contract/fixtures/task-roundtrip.json' +import { setTaskDueAtIndex } from './tasklists' +import { groupTasks, parseTasksFromBody, toIsoDateLocal, type ParseTasksContext } from './tasks' + +describe('shared task roundtrip fixtures', () => { + it.each(fixtures.cases)('$id', (fixture) => { + const note = fixture.note as ParseTasksContext + const before = parseTasksFromBody(fixture.body, note) + expect(before).toHaveLength(fixture.expectedTaskCount) + expect(before[fixture.taskIndex]).toMatchObject(fixture.expectedBefore) + + const localNow = fixture.localNow + ? new Date( + fixture.localNow[0], + fixture.localNow[1] - 1, + fixture.localNow[2], + fixture.localNow[3], + fixture.localNow[4] + ) + : undefined + const due = localNow ? toIsoDateLocal(localNow) : fixture.due! + const saved = setTaskDueAtIndex(fixture.body, fixture.taskIndex, due) + expect(saved).toBe(fixture.expectedBody) + + const after = parseTasksFromBody(saved, note) + expect(after).toHaveLength(fixture.expectedTaskCount) + expect(after[fixture.taskIndex]).toMatchObject(fixture.expectedAfter) + expect(after[fixture.taskIndex].id).toBe(before[fixture.taskIndex].id) + if (localNow) { + expect(groupTasks(after, localNow).today.map((task) => task.id)).toContain( + after[fixture.taskIndex].id + ) + } + }) +}) diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts index 2309e0e9..2f17bb6e 100644 --- a/packages/shared-domain/src/tasks.ts +++ b/packages/shared-domain/src/tasks.ts @@ -1,3 +1,4 @@ +import type { TaskPriority, VaultTask } from '@zennotes/bridge-contract/tasks' import { parseFrontmatterFields, unquote } from './frontmatter' import type { NoteFolder } from './ipc' import { FENCE_RE, TASK_LINE_RE } from './tasklists' @@ -18,69 +19,7 @@ export function isTasksTabPath(path: string | null | undefined): boolean { // Types // --------------------------------------------------------------------------- -export type TaskPriority = 'high' | 'med' | 'low' - -export interface VaultTask { - /** Stable-ish id: `${sourcePath}#${taskIndex}`. Task index shifts only when - * tasks are added/removed above it in the same file, so this is stable - * across plain content edits. */ - id: string - /** Vault-relative POSIX path of the note containing this task. */ - sourcePath: string - /** File name without extension (for display). */ - noteTitle: string - /** Top-level vault folder the source note lives in. */ - noteFolder: NoteFolder - /** 0-based line number in the full file body (frontmatter included). */ - lineNumber: number - /** Must match `toggleTaskAtIndex` counting for round-trip edits. */ - taskIndex: number - /** Raw line as it appears on disk. */ - rawText: string - /** Display content (checkbox prefix + metadata tokens stripped). */ - content: string - checked: boolean - /** True for a `[>]` task forwarded to another note (#316). Mutually - * exclusive with `checked`; kept out of the today/upcoming/done buckets. */ - forwarded: boolean - /** True for a `[-]` task cancelled — intentionally abandoned (#450). Mutually - * exclusive with `checked`/`forwarded`; kept out of the active buckets and - * collected under its own group. */ - cancelled: boolean - /** True for a `[/]` task in progress: started, not finished (#512). Unlike - * the other non-empty state chars this one is still OPEN work, so it stays - * in Today/Upcoming, on the calendar, and on the board. It marks *how* an - * open task is going, not that it left the active set. */ - inProgress: boolean - /** ISO YYYY-MM-DD, validated via Date round-trip. */ - due?: string - /** True when `due` was *derived* from the containing daily note's date - * rather than written on the line. Lets UIs tell an implicit due apart - * from an explicit `due:` token. See `inferDailyTaskDueDates`. */ - dueInferred?: boolean - priority?: TaskPriority - /** True if `@waiting` appears anywhere on the line. */ - waiting: boolean - /** All inline `@key:value` fields on the line (lower-cased), e.g. - * `@status:review @sprint:24`. Any key can drive a Kanban group-by. Optional - * so hand-built task fixtures stay terse; the parser always sets it. (#354) */ - fields?: Record - /** Convenience accessor for `fields.status`, falling back to the note's - * `status:` frontmatter. The default Kanban custom field. (#354) */ - status?: string - /** Inline `#tags` found on the line. */ - tags: string[] - /** How this task is stored. `'file'` is a whole-note task (TaskNotes-style: - * a `.md` file tagged `#task`, metadata in frontmatter); `'inline'` (the - * default when absent) is a classic `- [ ]` checkbox line. File-tasks - * round-trip through frontmatter, not the checkbox, so mutators branch on - * this. */ - kind?: 'inline' | 'file' - /** ISO YYYY-MM-DD start/scheduled date (frontmatter `scheduled`). File-tasks. */ - scheduled?: string - /** ISO YYYY-MM-DD completion date (frontmatter `completedDate`). File-tasks. */ - completedDate?: string -} +export type { TaskPriority, VaultTask } from '@zennotes/bridge-contract/tasks' export interface VaultTaskGroups { today: VaultTask[] diff --git a/packages/shared-domain/src/vault-relocation.test.ts b/packages/shared-domain/src/vault-relocation.test.ts new file mode 100644 index 00000000..9d245898 --- /dev/null +++ b/packages/shared-domain/src/vault-relocation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { relocateVaultEntries, type VaultRelocationIO } from './vault-relocation' + +function fixture() { + const files = new Map([['notes/One.md', 'Exact café 日本語. \n'], ['comments/One.json', '{unknown fields stay intact}']]) + const io: VaultRelocationIO = { + stat: async path => files.has(path) ? 'file' : null, + mkdir: async () => {}, + rename: async (from, to) => { + if (!files.has(from) || files.has(to)) throw new Error('Collision or missing source') + files.set(to, files.get(from)!); files.delete(from) + } + } + const entries = [{ from: 'notes/One.md', to: 'notes/Two.md', required: true }, { from: 'comments/One.json', to: 'comments/Two.json' }] + return { files, io, entries, original: new Map(files) } +} +describe('portable vault relocation', () => { + it('moves exact content and sidecar bytes together', async () => { + const s = fixture(); await relocateVaultEntries(s.io, s.entries) + expect([...s.files]).toEqual([['notes/Two.md', s.original.get('notes/One.md')], ['comments/Two.json', s.original.get('comments/One.json')]]) + }) + it('preflights orphan destination comments before moving content', async () => { + const s = fixture(); s.files.set('comments/Two.json', 'Do not overwrite') + await expect(relocateVaultEntries(s.io, s.entries)).rejects.toThrow('Destination already exists') + expect(s.files.get('notes/One.md')).toBe(s.original.get('notes/One.md')) + expect(s.files.get('comments/Two.json')).toBe('Do not overwrite') + }) + it('restores the note when its sidecar move fails', async () => { + const s = fixture(), rename = s.io.rename + s.io.rename = async (from, to) => { if (from === 'comments/One.json') throw new Error('Provider refused'); await rename(from, to) } + await expect(relocateVaultEntries(s.io, s.entries)).rejects.toThrow('Provider refused') + expect(s.files).toEqual(s.original) + }) + it('rolls both moves back when metadata cannot be committed', async () => { + const s = fixture() + await expect(relocateVaultEntries(s.io, s.entries, async () => { throw new Error('Settings failed') })).rejects.toThrow('Settings failed') + expect(s.files).toEqual(s.original) + }) + it('preserves a competing file at the old path and reports an uncertain rollback', async () => { + const s = fixture() + await expect(relocateVaultEntries(s.io, s.entries, async () => { + s.files.set('notes/One.md', 'Created by another client'); throw new Error('Commit failed') + })).rejects.toThrow('FOLDER_STATE_UNCERTAIN') + expect(s.files.get('notes/One.md')).toBe('Created by another client') + expect(s.files.get('notes/Two.md')).toBe(s.original.get('notes/One.md')) + }) + it('does not treat a provider error as absence and rejects traversal', async () => { + const s = fixture(); s.io.stat = async () => { throw new Error('Permission denied') } + await expect(relocateVaultEntries(s.io, s.entries)).rejects.toThrow('Permission denied') + expect(s.files).toEqual(s.original) + await expect(relocateVaultEntries(s.io, [{ from: '../outside', to: 'note.md' }])).rejects.toThrow('vault-relative') + }) +}) diff --git a/packages/shared-domain/src/vault-relocation.ts b/packages/shared-domain/src/vault-relocation.ts new file mode 100644 index 00000000..7d772a67 --- /dev/null +++ b/packages/shared-domain/src/vault-relocation.ts @@ -0,0 +1,62 @@ +/** Host-owned filesystem operations. All paths remain vault-relative. */ +export interface VaultRelocationIO { + stat(path: string): Promise<'file' | 'directory' | null> + mkdir(path: string): Promise + /** Must either complete or leave the source in place; uncertain failures must say so. */ + rename(from: string, to: string): Promise +} + +export interface VaultRelocation { + from: string + to: string + required?: boolean +} + +function validatePath(path: string): void { + if (!path || path.startsWith('/') || /[\\\u0000]/.test(path) + || path.split('/').some(part => !part || part === '.' || part === '..')) + throw new Error('Expected a vault-relative path') +} + +/** + * Relocate content and its parallel comments together. Preflight every target, + * including orphan comments, before changing anything. Restore earlier moves + * in reverse order if a later move or the metadata commit fails. + */ +export async function relocateVaultEntries( + io: VaultRelocationIO, + entries: readonly VaultRelocation[], + commit: () => Promise = async () => {} +): Promise { + const present: VaultRelocation[] = [] + for (const entry of entries) { + validatePath(entry.from); validatePath(entry.to) + if (entry.from === entry.to) continue + if (entry.to.startsWith(`${entry.from}/`)) throw new Error('Cannot move a folder into itself') + const source = await io.stat(entry.from) + if (entry.required && source === null) throw new Error(`Missing source: ${entry.from}`) + if (await io.stat(entry.to) !== null) throw new Error(`Destination already exists: ${entry.to}`) + if (source !== null) present.push(entry) + } + const moved: VaultRelocation[] = [] + try { + for (const entry of present) { + const parent = entry.to.slice(0, entry.to.lastIndexOf('/')) + if (entry.to.includes('/')) await io.mkdir(parent) + await io.rename(entry.from, entry.to) + moved.push(entry) + } + await commit() + } catch (error) { + const failures: unknown[] = [error] + for (const entry of moved.reverse()) { + try { + if (await io.stat(entry.from) !== null) throw new Error(`Rollback path is occupied: ${entry.from}`) + await io.rename(entry.to, entry.from) + } catch (rollbackError) { failures.push(rollbackError) } + } + if (failures.length > 1) throw new AggregateError(failures, + 'FOLDER_STATE_UNCERTAIN: Could not restore a failed file operation; reload the vault before editing') + throw error + } +} diff --git a/apps/desktop/src/main/wikilink-rename.test.ts b/packages/shared-domain/src/wikilink-rename.test.ts similarity index 100% rename from apps/desktop/src/main/wikilink-rename.test.ts rename to packages/shared-domain/src/wikilink-rename.test.ts diff --git a/packages/shared-domain/src/wikilink-rename.ts b/packages/shared-domain/src/wikilink-rename.ts new file mode 100644 index 00000000..afab0f58 --- /dev/null +++ b/packages/shared-domain/src/wikilink-rename.ts @@ -0,0 +1,145 @@ +/** + * Rewriting inbound `[[wikilinks]]` when a note is renamed. + * + * The wikilink *resolution* here mirrors + * `packages/app-core/src/lib/wikilinks.ts` (the renderer's source of truth): + * a target resolves by note title (case-insensitive) unless it looks like a + * path, in which case it resolves by explicit/suffix path match. This pure implementation is + * shared by native hosts without importing the renderer bundle. + * The Go server carries an equivalent port in `internal/vault`. + */ + +export interface RenameNoteRef { + path: string + title: string + folder: string +} + +const TOP_FOLDERS = ['inbox', 'quick', 'archive', 'trash'] + +function normalizeSlashes(value: string): string { + return value.replace(/\\/g, '/').replace(/\/+/g, '/') +} + +function stripMdExtension(value: string): string { + return value.replace(/\.md$/i, '') +} + +function normalizeForCompare(value: string): string { + return value.trim().toLowerCase() +} + +export function isPathLikeWikilinkTarget(target: string): boolean { + const trimmed = target.trim() + return trimmed.startsWith('/') || trimmed.includes('/') || /\.md$/i.test(trimmed) +} + +function resolveExplicitPath(notes: RenameNoteRef[], target: string): RenameNoteRef | null { + const normalized = normalizeSlashes(target.trim()) + if (!normalized) return null + const trimmed = stripMdExtension(normalized).replace(/^\/+/, '').replace(/\/+$/, '') + if (!trimmed) return null + + let relPath: string | null = null + if (normalized.startsWith('/')) { + relPath = `inbox/${trimmed}.md` + } else if (TOP_FOLDERS.some((folder) => trimmed.toLowerCase().startsWith(`${folder}/`))) { + relPath = `${trimmed}.md` + } + if (!relPath) return null + + const needle = normalizeForCompare(relPath) + return notes.find((note) => normalizeForCompare(note.path) === needle) ?? null +} + +function resolvePathSuffix(notes: RenameNoteRef[], target: string): RenameNoteRef | null { + const trimmed = stripMdExtension(normalizeSlashes(target.trim())) + .replace(/^\/+/, '') + .replace(/\/+$/, '') + if (!trimmed) return null + + const suffix = normalizeForCompare(`/${trimmed}.md`) + const exact = normalizeForCompare(`${trimmed}.md`) + const matches = notes.filter((note) => { + const path = normalizeForCompare(note.path) + return path === exact || path.endsWith(suffix) + }) + return matches.length === 1 ? matches[0] : null +} + +export function resolveWikilinkTarget( + notes: RenameNoteRef[], + target: string +): RenameNoteRef | null { + const visible = notes.filter((note) => note.folder !== 'trash') + if (isPathLikeWikilinkTarget(target)) { + return resolveExplicitPath(visible, target) ?? resolvePathSuffix(visible, target) + } + const needle = normalizeForCompare(stripMdExtension(target)) + return visible.find((note) => normalizeForCompare(note.title) === needle) ?? null +} + +/** Split `[[ ... ]]` inner text into target, `#heading`/`^block` anchor, and + * `|alias` , the anchor/alias keep their leading delimiter so the link can be + * reassembled verbatim. */ +function splitWikilinkContent(content: string): { + target: string + anchor: string + alias: string +} { + let rest = content + let alias = '' + const pipe = rest.indexOf('|') + if (pipe >= 0) { + alias = rest.slice(pipe) + rest = rest.slice(0, pipe) + } + let anchor = '' + const anchorIdx = rest.search(/[#^]/) + if (anchorIdx >= 0) { + anchor = rest.slice(anchorIdx) + rest = rest.slice(0, anchorIdx) + } + return { target: rest, anchor, alias } +} + +/** Replace a wikilink target's final segment (the renamed file's name) with the + * new title, preserving any directory prefix, leading slash, and `.md`. */ +function swapBasename(target: string, newTitle: string): string { + const slash = target.lastIndexOf('/') + const dir = slash >= 0 ? target.slice(0, slash + 1) : '' + const base = slash >= 0 ? target.slice(slash + 1) : target + const md = base.match(/\.md$/i) + return `${dir}${newTitle}${md ? md[0] : ''}` +} + +// Matches a fenced code block, inline code, or a (possibly embedded) wikilink. +// Code is matched first so links inside code spans/blocks are left untouched. +const TOKEN_RE = /(```[\s\S]*?```|`[^`\n]*`)|(!?)\[\[([^\]\n]+?)\]\]/g + +/** + * Rewrite every inbound `[[target]]` / `![[target]]` in `body` whose target + * resolves to the note at `oldPath`, pointing it at `newTitle` instead. Aliases, + * `#heading` / `^block` anchors, and embeds are preserved; code is skipped. + * + * `notes` must reflect the pre-rename vault (the renamed note still under its + * old title/path) so resolution matches what the links currently point to. + */ +export function rewriteWikilinksForRename( + body: string, + notes: RenameNoteRef[], + oldPath: string, + newTitle: string +): { body: string; changed: number } { + let changed = 0 + const next = body.replace(TOKEN_RE, (full, code, embed, content) => { + if (code !== undefined) return full + const { target, anchor, alias } = splitWikilinkContent(content as string) + if (resolveWikilinkTarget(notes, target)?.path !== oldPath) return full + const newTarget = swapBasename(target, newTitle) + if (newTarget === target) return full + changed++ + return `${embed}[[${newTarget}${anchor}${alias}]]` + }) + return { body: next, changed } +} diff --git a/packaging/nix/package-server.nix b/packaging/nix/package-server.nix index dced319a..28c235ad 100644 --- a/packaging/nix/package-server.nix +++ b/packaging/nix/package-server.nix @@ -43,6 +43,7 @@ buildGoModule (finalAttrs: { modRoot = "apps/server"; subPackages = [ "cmd/zennotes-server" ]; + tags = [ "embed_web" ]; ldflags = [ "-s" "-w" diff --git a/tooling/scripts/build-go-server.mjs b/tooling/scripts/build-go-server.mjs index c1fb5baa..07738f23 100644 --- a/tooling/scripts/build-go-server.mjs +++ b/tooling/scripts/build-go-server.mjs @@ -3,12 +3,13 @@ import { fileURLToPath } from 'node:url' import { spawn } from 'node:child_process' import { withGoEnv } from './go-env.mjs' +import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') const serverRoot = resolve(repoRoot, 'apps/server') -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' -const serverBinaryName = process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server' +const serverBinaryName = + process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server' function run(command, args, cwd = repoRoot, options = {}) { const shell = options.shell ?? false @@ -25,29 +26,45 @@ function run(command, args, cwd = repoRoot, options = {}) { resolvePromise() return } - rejectPromise(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}`)) + rejectPromise( + new Error( + `${command} ${args.join(' ')} exited with code ${code ?? 'unknown'}` + ) + ) }) child.on('error', rejectPromise) }) } -await run(npmCommand, ['run', 'sync-web', '--workspace', '@zennotes/server'], repoRoot, { - shell: process.platform === 'win32' +await withWebDistLock(async (lock) => { + const env = webDistLockEnv(lock) + await run( + process.execPath, + [resolve(repoRoot, 'tooling/scripts/sync-web-dist.mjs')], + repoRoot, + { env } + ) + await run( + 'go', + ['test', '-tags=embed_web', './web'], + serverRoot, + { env: withGoEnv(env) } + ) + await run( + 'go', + [ + 'build', + '-tags=embed_web', + '-trimpath', + '-ldflags=-s -w', + '-o', + resolve(serverRoot, 'bin', serverBinaryName), + './cmd/zennotes-server' + ], + serverRoot, + { + env: withGoEnv(env) + } + ) }) - -await run( - 'go', - [ - 'build', - '-trimpath', - '-ldflags=-s -w', - '-o', - resolve(serverRoot, 'bin', serverBinaryName), - './cmd/zennotes-server' - ], - serverRoot, - { - env: withGoEnv() - } -) diff --git a/tooling/scripts/collect-app-core-evidence.mjs b/tooling/scripts/collect-app-core-evidence.mjs new file mode 100644 index 00000000..489abe09 --- /dev/null +++ b/tooling/scripts/collect-app-core-evidence.mjs @@ -0,0 +1,36 @@ +import { cp, mkdir, readFile, readdir, stat } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Copy the browser harness evidence (screenshots, page dumps, request logs) +// out of the throwaway consumer directory so CI can upload it. The Chrome +// profile and the built consumer stay behind; they are large and reproducible. +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const target = resolve(process.argv[2] || join(root, 'dist/app-core-browser-evidence')) +const manifest = join(root, 'dist/shared-packages/app-core-consumer.json') +let consumer +try { + consumer = JSON.parse(await readFile(manifest, 'utf8')).consumer +} catch { + console.log('No consumer manifest; nothing to collect.') + process.exit(0) +} +let copied = 0 +for (const entry of await readdir(consumer, { withFileTypes: true }).catch(() => [])) { + if (!entry.isDirectory() || !entry.name.startsWith('browser-')) continue + const source = join(consumer, entry.name) + for (const file of await readdir(source)) { + if (!/\.(?:png|json|txt)$/.test(file)) continue + if (!(await stat(join(source, file))).isFile()) continue + await mkdir(join(target, entry.name), { recursive: true }) + await cp(join(source, file), join(target, entry.name, file)) + copied++ + } +} +const result = join(consumer, 'result.json') +if (await stat(result).then((info) => info.isFile()).catch(() => false)) { + await mkdir(target, { recursive: true }) + await cp(result, join(target, 'package-result.json')) + copied++ +} +console.log(`Collected ${copied} evidence files into ${target}`) diff --git a/tooling/scripts/pack-app-core.mjs b/tooling/scripts/pack-app-core.mjs new file mode 100644 index 00000000..4f3fe704 --- /dev/null +++ b/tooling/scripts/pack-app-core.mjs @@ -0,0 +1,123 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { copyFile, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { filesIn, packSharedPackage, resolvePublishedImports, runNpm, tsconfigPath } from './pack-shared-package.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const packageRoot = join(root, 'packages/app-core') +const require = createRequire(join(packageRoot, 'package.json')) + +export async function packAppCore() { + const contract = await packSharedPackage('bridge-contract') + const domain = await packSharedPackage('shared-domain') + const source = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) + const stage = await mkdtemp(join(tmpdir(), 'zennotes-app-core-package-')) + try { + const config = { + extends: tsconfigPath(join(packageRoot, 'tsconfig.json')), + compilerOptions: { + composite: false, declaration: true, noEmit: false, types: [], + rootDir: tsconfigPath(join(root, 'packages')), outDir: tsconfigPath(join(stage, 'emit')) + }, + include: [tsconfigPath(join(packageRoot, 'src/**/*.ts')), tsconfigPath(join(packageRoot, 'src/**/*.tsx'))], + exclude: [tsconfigPath(join(packageRoot, 'src/**/*.test.ts')), tsconfigPath(join(packageRoot, 'src/**/*.test.tsx'))] + } + await writeFile(join(stage, 'tsconfig.json'), JSON.stringify(config)) + execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '-p', join(stage, 'tsconfig.json')], { cwd: root, stdio: 'inherit' }) + const payload = join(stage, 'package') + await mkdir(payload) + // TypeScript follows source aliases while checking. Ship only app-core's + // emitted files; contract/domain are separately pinned dependencies. + await cp(join(stage, 'emit/app-core/src'), join(payload, 'dist'), { recursive: true }) + for (const file of await filesIn(join(packageRoot, 'src'))) { + if (/\.(?:ts|tsx)$/.test(file) && !file.endsWith('.d.ts')) continue + const target = join(payload, 'dist', relative(join(packageRoot, 'src'), file)) + await mkdir(dirname(target), { recursive: true }) + await cp(file, target) + } + await resolvePublishedImports(join(payload, 'dist'), require('typescript'), { + '@shared/': '@zennotes/shared-domain/', + '@bridge-contract/': '@zennotes/bridge-contract/' + }) + + const theme = require(join(packageRoot, 'build/tailwind-preset.cjs')) + const css = await require('postcss')([ + require('tailwindcss')({ ...theme, content: [join(payload, 'dist/**/*.js')] }), + require('autoprefixer')() + ]).process(await readFile(join(packageRoot, 'src/styles/index.css'), 'utf8'), { from: undefined }) + await writeFile(join(payload, 'dist/styles/index.css'), css.css) + await cp(join(packageRoot, 'build'), join(payload, 'build'), { recursive: true }) + await cp(join(root, 'LICENSE'), join(payload, 'LICENSE')) + await cp(join(packageRoot, 'README.md'), join(payload, 'README.md')) + + const exports = { + './main': { types: './dist/main.d.ts', import: './dist/main.js' }, + './navigation': { types: './dist/navigation.d.ts', import: './dist/navigation.js' }, + './notes': { types: './dist/notes.d.ts', import: './dist/notes.js' }, + './shell': { types: './dist/shell.d.ts', import: './dist/shell.js' }, + './browse': { types: './dist/browse.d.ts', import: './dist/browse.js' }, + './tasks': { types: './dist/tasks.d.ts', import: './dist/tasks.js' }, + './workspace': { types: './dist/workspace.d.ts', import: './dist/workspace.js' }, + './settings': { types: './dist/settings.d.ts', import: './dist/settings.js' }, + './commands': { types: './dist/commands.d.ts', import: './dist/commands.js' }, + './dialogs': { types: './dist/dialogs.d.ts', import: './dist/dialogs.js' }, + './host': { types: './dist/host.d.ts', import: './dist/host.js' }, + './editor': { types: './dist/editor.d.ts', import: './dist/editor.js' }, + './styles.css': './dist/styles/index.css', + './vite': { types: './build/vite.d.ts', import: './build/vite.mjs' } + } + const dependencies = { + ...source.dependencies, + [contract.name]: contract.version, + [domain.name]: domain.version + } + const peerDependencies = { vite: '^6.4.3 || ^7.0.0 || ^8.0.0' } + for (const name of ['react', 'react-dom', 'zustand', '@codemirror/state', '@codemirror/view', '@codemirror/language', '@lezer/common', '@lezer/highlight']) { + peerDependencies[name] = dependencies[name] + delete dependencies[name] + } + const metadata = { + name: source.name, type: 'module', license: 'MIT', exports, + files: ['dist', 'build', 'LICENSE'], dependencies, peerDependencies, + peerDependenciesMeta: { vite: { optional: true } } + } + const hash = createHash('sha256').update(JSON.stringify(metadata)) + for (const file of (await filesIn(payload)).sort()) { + const bytes = await readFile(file) + hash.update(`${relative(payload, file).split('\\').join('/')}\0${bytes.length}\0`) + hash.update(bytes) + } + const version = `${source.version}-core.h${hash.digest('hex').slice(0, 16)}` + await writeFile(join(payload, 'package.json'), JSON.stringify({ ...metadata, version }, null, 2) + '\n') + const packed = JSON.parse(runNpm(['pack', '--json', '--ignore-scripts'], { cwd: payload, encoding: 'utf8' }))[0] + const output = join(root, 'dist/shared-packages') + await mkdir(output, { recursive: true }) + const archive = join(output, packed.filename) + const bytes = await readFile(join(payload, packed.filename)) + try { await copyFile(join(payload, packed.filename), archive, constants.COPYFILE_EXCL) } + catch (error) { + if (error.code !== 'EEXIST') throw error + if (!bytes.equals(await readFile(archive))) throw new Error(`Package candidate already exists with different bytes: ${version}`) + } + const manifest = { + name: source.name, version, file: packed.filename, archive, + sha256: createHash('sha256').update(bytes).digest('hex'), integrity: packed.integrity, + sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), + workingTreeDirty: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim().length > 0, + sourceLockSha256: createHash('sha256').update(await readFile(join(root, 'package-lock.json'))).digest('hex'), + toolchain: { node: process.version, typescript: require('typescript/package.json').version, tailwind: require('tailwindcss/package.json').version }, + dependencies: [contract, domain] + } + await writeFile(`${archive}.json`, JSON.stringify(manifest, null, 2) + '\n') + return manifest + } finally { await rm(stage, { recursive: true, force: true }) } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stdout.write(JSON.stringify(await packAppCore(), null, 2) + '\n') +} diff --git a/tooling/scripts/pack-share-viewer.mjs b/tooling/scripts/pack-share-viewer.mjs new file mode 100644 index 00000000..f2929185 --- /dev/null +++ b/tooling/scripts/pack-share-viewer.mjs @@ -0,0 +1,23 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { packWebDistribution } from './pack-web-artifact.mjs' +import { runNpm } from './pack-shared-package.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +runNpm(['run', 'build', '--workspace', '@zennotes/share-viewer'], { cwd: root, stdio: 'inherit' }) +const product = JSON.parse(await readFile(join(root, 'apps/share-viewer/package.json'), 'utf8')) +const result = await packWebDistribution({ + target: 'viewer', distribution: join(root, 'apps/share-viewer/dist'), output: join(root, 'dist/viewer-artifacts'), + productVersion: product.version, + source: { + repository: 'https://github.com/ZenNotes/zennotes', + commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), + dirty: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim().length > 0, + lockfileSha256: createHash('sha256').update(await readFile(join(root, 'package-lock.json'))).digest('hex') + }, + toolchain: { node: process.version, npm: runNpm(['--version'], { encoding: 'utf8' }).trim() } +}) +console.log(JSON.stringify({ version: result.manifest.version, archive: result.archivePath, manifest: result.manifestPath }, null, 2)) diff --git a/tooling/scripts/pack-shared-package.mjs b/tooling/scripts/pack-shared-package.mjs new file mode 100644 index 00000000..cdaa34ce --- /dev/null +++ b/tooling/scripts/pack-shared-package.mjs @@ -0,0 +1,176 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { copyFile, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const packageNames = ['bridge-contract', 'shared-domain'] + +// tsc reads include and exclude as glob patterns, and globs only understand +// forward slashes, so a Windows path.join result matches nothing (TS18003). +export function tsconfigPath(path) { + return path.split(sep).join('/') +} + +export function runNpm(args, options) { + const cli = process.env.npm_execpath + if (cli) return execFileSync(process.execPath, [cli, ...args], options) + if (process.platform === 'win32') { + throw new Error('Run this script through npm run so the npm JavaScript CLI is available on Windows.') + } + return execFileSync('npm', args, options) +} + +export async function filesIn(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const groups = await Promise.all(entries.map((entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? filesIn(path) : [path] + })) + return groups.flat() +} + +async function isFile(path) { + try { return (await stat(path)).isFile() } catch (error) { + if (error.code === 'ENOENT') return false + throw error + } +} + +async function candidateSuffix() { + const inputs = ['LICENSE', 'tsconfig.base.json', 'package-lock.json', 'tooling/scripts/pack-shared-package.mjs'] + .map((path) => join(repoRoot, path)) + for (const name of packageNames) { + const root = join(repoRoot, 'packages', name) + inputs.push(join(root, 'package.json'), join(root, 'tsconfig.json'), ...await filesIn(join(root, 'src'))) + if (name === 'bridge-contract') inputs.push(...await filesIn(join(root, 'fixtures'))) + } + const hash = createHash('sha256') + hash.update(execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot })) + for (const file of inputs.sort()) { + const bytes = await readFile(file) + hash.update(`${relative(repoRoot, file).replace(/\\/g, '/')}\0${bytes.length}\0`) + hash.update(bytes) + } + return `boundaries.h${hash.digest('hex').slice(0, 16)}` +} + +// Source workspaces use extensionless imports. Published ESM and declarations +// need resolvable file extensions; rewrite only module specifiers, never note data. +export async function resolvePublishedImports(directory, ts, aliases = {}) { + for (const file of await filesIn(directory)) { + if (!file.endsWith('.js') && !file.endsWith('.ts')) continue + const source = await readFile(file, 'utf8') + const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true) + const literals = [] + function visit(node) { + let specifier + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) specifier = node.moduleSpecifier + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) specifier = node.argument.literal + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) specifier = node.arguments[0] + if (specifier && ts.isStringLiteral(specifier)) literals.push(specifier) + ts.forEachChild(node, visit) + } + visit(ast) + let rewritten = source + for (const literal of literals.sort((a, b) => b.pos - a.pos)) { + const alias = Object.keys(aliases).find((prefix) => literal.text.startsWith(prefix)) + if (alias) { + const replacement = aliases[alias] + literal.text.slice(alias.length) + rewritten = rewritten.slice(0, literal.getStart(ast) + 1) + replacement + rewritten.slice(literal.end - 1) + continue + } + if (!literal.text.startsWith('.')) continue + if (/\.(?:js|mjs|cjs|json)$/.test(literal.text)) continue + if (await isFile(resolve(dirname(file), literal.text.split('?')[0]))) continue + const target = resolve(dirname(file), literal.text) + const suffix = await isFile(`${target}.js`) ? '.js' + : await isFile(join(target, 'index.js')) ? '/index.js' : null + if (!suffix) throw new Error(`Unresolved published import ${literal.text} in ${file}`) + rewritten = rewritten.slice(0, literal.getStart(ast) + 1) + literal.text + suffix + rewritten.slice(literal.end - 1) + } + if (rewritten !== source) await writeFile(file, rewritten) + } +} + +export async function packSharedPackage(name, candidateVersion) { + if (!packageNames.includes(name)) throw new Error(`Unsupported shared package: ${name}`) + const packageRoot = join(repoRoot, 'packages', name) + const source = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) + const version = candidateVersion ?? `${source.version}-${await candidateSuffix()}` + const output = join(repoRoot, 'dist/shared-packages') + const stage = await mkdtemp(join(tmpdir(), `zennotes-${name}-package-`)) + const require = createRequire(join(packageRoot, 'package.json')) + try { + const config = { + extends: tsconfigPath(join(packageRoot, 'tsconfig.json')), + compilerOptions: { + composite: false, declaration: true, types: [], lib: ['ES2022', 'DOM'], + rootDir: tsconfigPath(join(packageRoot, 'src')), outDir: tsconfigPath(join(stage, 'dist')) + }, + include: [tsconfigPath(join(packageRoot, 'src/**/*.ts'))], + exclude: [tsconfigPath(join(packageRoot, 'src/**/*.test.ts'))] + } + await writeFile(join(stage, 'tsconfig.json'), JSON.stringify(config)) + execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '-p', join(stage, 'tsconfig.json')], { + cwd: repoRoot, stdio: 'inherit' + }) + await resolvePublishedImports(join(stage, 'dist'), require('typescript')) + const exports = Object.fromEntries(Object.entries(source.exports).map(([key, path]) => { + const entry = path.replace('./src/', './dist/').replace(/\.ts$/, '') + return [key, { types: `${entry}.d.ts`, import: `${entry}.js`, default: `${entry}.js` }] + })) + for (const [key, targets] of Object.entries(exports)) { + if (key.includes('*')) continue + for (const target of new Set(Object.values(targets))) { + if (!await isFile(join(stage, target))) throw new Error(`Missing export target ${key}: ${target}`) + } + } + const dependencies = Object.fromEntries(Object.entries(source.dependencies ?? {}).map(([key, value]) => [ + key, key.startsWith('@zennotes/') ? version : value + ])) + const files = ['dist', 'LICENSE'] + if (name === 'bridge-contract') { + await cp(join(packageRoot, 'fixtures'), join(stage, 'fixtures'), { recursive: true }) + files.push('fixtures') + } + await cp(join(repoRoot, 'LICENSE'), join(stage, 'LICENSE')) + await writeFile(join(stage, 'package.json'), JSON.stringify({ + name: source.name, version, type: 'module', license: 'MIT', exports, files, dependencies + }, null, 2) + '\n') + await mkdir(output, { recursive: true }) + const packed = JSON.parse(runNpm([ + 'pack', '--json', '--ignore-scripts' + ], { cwd: stage, encoding: 'utf8' }))[0] + const stagedArchive = join(stage, packed.filename) + const archive = join(output, packed.filename) + const bytes = await readFile(stagedArchive) + try { + await copyFile(stagedArchive, archive, constants.COPYFILE_EXCL) + } catch (error) { + if (error.code !== 'EEXIST') throw error + if (!bytes.equals(await readFile(archive))) { + throw new Error(`Candidate ${version} already exists with different bytes. Choose a new version.`) + } + } + const manifest = { + name: source.name, version, file: packed.filename, + sha256: createHash('sha256').update(bytes).digest('hex'), + integrity: packed.integrity, + sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim(), + workingTreeDirty: execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0 + } + await writeFile(`${archive}.json`, JSON.stringify(manifest, null, 2) + '\n') + return { archive, ...manifest } + } finally { + await rm(stage, { recursive: true, force: true }) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stdout.write(JSON.stringify(await packSharedPackage(process.argv[2], process.argv[3]), null, 2) + '\n') +} diff --git a/tooling/scripts/pack-web-artifact.mjs b/tooling/scripts/pack-web-artifact.mjs new file mode 100644 index 00000000..47450be5 --- /dev/null +++ b/tooling/scripts/pack-web-artifact.mjs @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { runNpm } from './pack-shared-package.mjs' +import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') + +async function inventory(directory, prefix = '') { + const files = [] + for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + )) { + const path = prefix + entry.name + if (entry.isDirectory()) + files.push(...(await inventory(join(directory, entry.name), `${path}/`))) + else if (entry.isFile()) { + const bytes = await readFile(join(directory, entry.name)) + files.push({ path, size: bytes.length, sha256: sha256(bytes) }) + } else throw new Error(`Browser artifacts must contain regular files: ${path}`) + } + return files +} + +async function writeImmutable(path, bytes) { + try { + await writeFile(path, bytes, { flag: 'wx' }) + } catch (error) { + if (error.code !== 'EEXIST') throw error + if (!bytes.equals(await readFile(path))) + throw new Error(`Artifact already exists with different bytes: ${path}`) + } +} + +export async function packWebDistribution({ + distribution, + output, + productVersion, + source, + toolchain, + license = join(root, 'LICENSE'), + target = 'web' +}) { + if ( + typeof source?.dirty !== 'boolean' || + source.repository !== 'https://github.com/ZenNotes/zennotes' || + !/^[a-f0-9]{40}$/.test(source.commit) + ) { + throw new Error( + 'Artifact source must include its repository, commit, and explicit dirty boolean' + ) + } + if (!['web', 'viewer'].includes(target)) throw new Error('Unknown frontend artifact target') + const viewer = target === 'viewer' + const stage = await mkdtemp(join(tmpdir(), 'zennotes-web-artifact-')) + try { + await cp(distribution, join(stage, 'dist'), { + recursive: true, + verbatimSymlinks: true + }) + const licenseBytes = await readFile(license) + // The viewer is installed as static files; retain its license in that tree. + if (viewer) await writeFile(join(stage, 'dist', 'LICENSE'), licenseBytes, { flag: 'wx' }) + const files = await inventory(join(stage, 'dist')) + const entrypoints = viewer ? ['share-viewer.js', 'share-viewer.css'] : ['index.html', 'sw.js', 'manifest.webmanifest'] + for (const path of entrypoints) { + if (!files.some((file) => file.path === path && file.size > 0)) + throw new Error(`Missing browser entrypoint: ${path}`) + } + const identity = { + schemaVersion: 1, + artifact: viewer ? 'zennotes-share-viewer' : 'zennotes-self-hosted-web', + protocol: viewer ? 'share-page-payload-v1' : 'self-hosted-http-v1', + source, + toolchain, + entrypoints, + files + } + const version = `${productVersion}-${target}.h${sha256(JSON.stringify({ ...identity, licenseSha256: sha256(licenseBytes), packFormat: 1 })).slice(0, 16)}` + await writeFile(join(stage, 'LICENSE'), licenseBytes) + await writeFile( + join(stage, 'package.json'), + JSON.stringify( + { + name: viewer ? '@zennotes/share-viewer-dist' : '@zennotes/self-hosted-web', + version, + private: true, + license: 'MIT', + files: ['dist', 'LICENSE'] + }, + null, + 2 + ) + '\n' + ) + const packed = JSON.parse( + runNpm(['pack', '--json', '--ignore-scripts'], { + cwd: stage, + encoding: 'utf8' + }) + )[0] + const bytes = await readFile(join(stage, packed.filename)) + const archive = { + file: packed.filename, + size: bytes.length, + sha256: sha256(bytes), + ...(!source.dirty + ? { + url: `https://github.com/ZenNotes/zennotes/releases/download/${target}-${version}/${packed.filename}` + } + : {}) + } + const manifest = { ...identity, version, archive } + await mkdir(output, { recursive: true }) + const archivePath = join(output, packed.filename) + const manifestPath = `${archivePath}.json` + await writeImmutable(archivePath, bytes) + await writeImmutable(manifestPath, Buffer.from(JSON.stringify(manifest, null, 2) + '\n')) + return { archivePath, manifestPath, manifest } + } finally { + await rm(stage, { recursive: true, force: true }) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await withWebDistLock(async (lock) => { + const source = { + repository: 'https://github.com/ZenNotes/zennotes', + commit: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: root, + encoding: 'utf8' + }).trim(), + dirty: + execFileSync('git', ['status', '--porcelain'], { + cwd: root, + encoding: 'utf8' + }).trim().length > 0, + lockfileSha256: sha256(await readFile(join(root, 'package-lock.json'))) + } + runNpm(['run', 'build', '--workspace', '@zennotes/web'], { + cwd: root, + env: webDistLockEnv(lock), + stdio: 'inherit' + }) + const product = JSON.parse(await readFile(join(root, 'apps/web/package.json'), 'utf8')) + const result = await packWebDistribution({ + distribution: join(root, 'apps/web/dist'), + output: join(root, 'dist/web-artifacts'), + productVersion: product.version, + source, + toolchain: { + node: process.version, + npm: runNpm(['--version'], { encoding: 'utf8' }).trim() + } + }) + process.stdout.write( + JSON.stringify( + { + version: result.manifest.version, + archive: result.archivePath, + manifest: result.manifestPath + }, + null, + 2 + ) + '\n' + ) + }) +} diff --git a/tooling/scripts/pack-web-artifact.test.mjs b/tooling/scripts/pack-web-artifact.test.mjs new file mode 100644 index 00000000..7c707138 --- /dev/null +++ b/tooling/scripts/pack-web-artifact.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { packWebDistribution } from './pack-web-artifact.mjs' + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'zennotes web artifact & test ')) + t.after(() => rm(root, { recursive: true, force: true })) + const distribution = join(root, 'dist') + await mkdir(distribution) + await writeFile(join(distribution, 'index.html'), '') + await writeFile(join(distribution, 'app.js'), 'console.log("first")') + await writeFile(join(distribution, 'sw.js'), '/* service worker */') + await writeFile(join(distribution, 'manifest.webmanifest'), '{}') + return { + distribution, + output: join(root, 'output'), + productVersion: '1.0.0', + source: { + repository: 'https://github.com/ZenNotes/zennotes', + commit: 'a'.repeat(40), + dirty: true + }, + toolchain: { node: process.version } + } +} + +test('identical browser bytes produce an identical archive; changed bytes produce a new pin', async (t) => { + const options = await fixture(t) + const first = await packWebDistribution(options) + const repeated = await packWebDistribution(options) + assert.deepEqual(first, repeated) + assert.equal(first.manifest.archive.url, undefined) + assert.deepEqual( + first.manifest.files.map((file) => file.path), + ['app.js', 'index.html', 'manifest.webmanifest', 'sw.js'] + ) + await writeFile(join(options.distribution, 'app.js'), 'console.log("second")') + const changed = await packWebDistribution(options) + assert.notEqual(changed.manifest.version, first.manifest.version) + assert.notEqual(changed.manifest.archive.sha256, first.manifest.archive.sha256) + assert.ok((await readFile(first.archivePath)).length > 0) +}) + +test('refuses source symlinks and missing browser entrypoints', async (t) => { + const options = await fixture(t) + await rm(join(options.distribution, 'sw.js')) + await assert.rejects(packWebDistribution(options), /Missing browser entrypoint/) + try { + await symlink('app.js', join(options.distribution, 'sw.js')) + } catch (error) { + if (error.code === 'EPERM') { + t.skip('symbolic links require OS permission') + return + } + throw error + } + await assert.rejects(packWebDistribution(options), /regular files/) +}) + +test('requires explicit source provenance before packing', async (t) => { + const options = await fixture(t) + delete options.source.dirty + await assert.rejects(packWebDistribution(options), /explicit dirty boolean/) +}) + +test('viewer archives have a distinct protocol, entrypoints and immutable identity', async (t) => { + const options = await fixture(t) + options.target = 'viewer' + await assert.rejects(packWebDistribution(options), /Missing browser entrypoint/) + await writeFile(join(options.distribution, 'share-viewer.js'), 'console.log("read only")') + await writeFile(join(options.distribution, 'share-viewer.css'), 'body { color: black }') + const first = await packWebDistribution(options) + assert.equal(first.manifest.artifact, 'zennotes-share-viewer') + assert.equal(first.manifest.protocol, 'share-page-payload-v1') + assert.deepEqual(first.manifest.entrypoints, ['share-viewer.js', 'share-viewer.css']) + assert.match(first.manifest.version, /^1\.0\.0-viewer\.h[a-f0-9]{16}$/) + assert.deepEqual(await packWebDistribution(options), first) + await writeFile(join(options.distribution, 'share-viewer.css'), 'body { color: blue }') + assert.notEqual((await packWebDistribution(options)).manifest.version, first.manifest.version) +}) + +// The producer must not follow input links while adding the static license. +test('viewer license symlinks cannot overwrite files outside staging', async (t) => { + const options = await fixture(t) + const target = join(options.distribution, '..', 'outside.txt') + await writeFile(target, 'unchanged') + try { await symlink(target, join(options.distribution, 'LICENSE')) } catch (error) { + if (error.code === 'EPERM') { t.skip('symbolic links require OS permission'); return } + throw error + } + await assert.rejects(packWebDistribution({ ...options, target: 'viewer' }), /EEXIST/) + assert.equal(await readFile(target, 'utf8'), 'unchanged') +}) diff --git a/tooling/scripts/perf-desktop-runtime.mjs b/tooling/scripts/perf-desktop-runtime.mjs index 0b51b994..cf7e5840 100644 --- a/tooling/scripts/perf-desktop-runtime.mjs +++ b/tooling/scripts/perf-desktop-runtime.mjs @@ -10,11 +10,10 @@ import { fileURLToPath } from 'node:url' import WebSocket from 'ws' -const require = createRequire(import.meta.url) -const electronPath = require('electron') - const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') +const requireDesktop = createRequire(resolve(repoRoot, 'apps/desktop/package.json')) +const electronPath = requireDesktop('electron') const desktopOutMain = resolve(repoRoot, 'apps/desktop/out/main/index.js') const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' @@ -301,6 +300,7 @@ function startDesktopRuntime({ debugPort, userDataRoot, disablePersistedMetaCach ELECTRON_DISABLE_SECURITY_WARNINGS: '1', ZEN_PERF: '1', ZENNOTES_USER_DATA_PATH: userDataRoot, + ZENNOTES_CONFIG_DIR: join(userDataRoot, 'config'), ...(disablePersistedMetaCache ? { ZEN_PERF_DISABLE_PERSISTED_META_CACHE: '1' } : {}) }, stdio: ['ignore', 'pipe', 'pipe'] @@ -694,6 +694,12 @@ async function main() { 'desktop workspace ready' ) + await waitForExpression(client, `(() => { + const skip = [...document.querySelectorAll('button')].find((button) => button.textContent.trim() === 'Skip setup'); + skip?.click(); + return Boolean(document.querySelector('[data-sidebar-type], [data-notelist-path]')); + })()`, 10000, 'desktop navigation after first-run setup') + const inboxExpansion = await evaluate( client, `(async () => { @@ -1078,6 +1084,18 @@ async function main() { printMetric('metadata cache wait', cacheWaitMs) } } + } catch (error) { + if (client && keepTempRoot) { + try { + const page = await evaluate(client, 'document.body.innerText') + await writeFile(join(tempRoot, 'failure-page.txt'), page) + const screenshot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(tempRoot, 'failure-page.png'), Buffer.from(screenshot.data, 'base64')) + } catch (diagnosticError) { + console.error('Could not capture failure diagnostics:', diagnosticError) + } + } + throw error } finally { client?.close() await stopChild(electron?.child) diff --git a/tooling/scripts/perf-web-runtime.mjs b/tooling/scripts/perf-web-runtime.mjs index 7611695a..cd6823c1 100644 --- a/tooling/scripts/perf-web-runtime.mjs +++ b/tooling/scripts/perf-web-runtime.mjs @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url' import WebSocket from 'ws' import { withGoEnv } from './go-env.mjs' +import { webDistLockEnv, withWebDistLock } from './web-dist-lock.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') @@ -21,6 +22,8 @@ const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' const noteCount = parsePositiveInt(process.env.ZEN_PERF_WEB_NOTES, 5000) const enforceBudgets = process.env.ZEN_PERF_ENFORCE === '1' const skipWebBuild = process.env.ZEN_PERF_SKIP_WEB_BUILD === '1' +const prebuiltServer = process.env.ZEN_PERF_WEB_SERVER_BINARY?.trim() + ? resolve(process.env.ZEN_PERF_WEB_SERVER_BINARY.trim()) : null const externalVaultRoot = externalVaultRootFromEnv('ZEN_PERF_WEB_VAULT_ROOT') const configuredTempRoot = process.env.ZEN_PERF_WEB_TEMP_ROOT?.trim() ? resolve(process.env.ZEN_PERF_WEB_TEMP_ROOT.trim()) @@ -256,7 +259,7 @@ function startGoServer({ vaultRoot, bind, serverBinary, configPath, disablePersi ...process.env, ZENNOTES_BIND: bind, ZENNOTES_CONFIG_PATH: configPath, - ZENNOTES_VAULT_PATH: vaultRoot, + ZENNOTES_DEFAULT_VAULT_PATH: vaultRoot, ZENNOTES_ALLOW_INSECURE_NOAUTH: '1', ...(disablePersistedMetaCache ? { ZEN_PERF_DISABLE_PERSISTED_META_CACHE: '1' } : {}) } @@ -444,19 +447,20 @@ async function waitForExpression(client, expression, timeoutMs, label) { throw new Error(`Timed out waiting for ${label}: ${lastError?.message ?? 'condition not met'}`) } -async function prepareWebDist() { +async function prepareWebDist(env) { if (!skipWebBuild || !(await fileExists(webDistIndex))) { await run(npmCommand, ['run', 'build:nocheck', '--workspace', '@zennotes/web'], { - shell: process.platform === 'win32' + shell: process.platform === 'win32', + env }) } - await run(process.execPath, [syncWebDistScript]) + await run(process.execPath, [syncWebDistScript], { env }) } -async function buildGoServer(outputPath) { - await run('go', ['build', '-trimpath', '-o', outputPath, './cmd/zennotes-server'], { +async function buildGoServer(outputPath, env) { + await run('go', ['build', '-tags=embed_web', '-trimpath', '-o', outputPath, './cmd/zennotes-server'], { cwd: serverRoot, - env: withGoEnv() + env: withGoEnv(env) }) } @@ -586,13 +590,11 @@ async function stopChild(child) { } async function main() { - await prepareWebDist() - const tempRoot = configuredTempRoot ?? await mkdtemp(join(tmpdir(), 'zennotes-web-perf-')) if (configuredTempRoot) await mkdir(tempRoot, { recursive: true }) const vaultRoot = externalVaultRoot ?? join(tempRoot, 'vault') const chromeProfile = join(tempRoot, 'chrome-profile') - const serverBinary = join( + const serverBinary = prebuiltServer ?? join( tempRoot, process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server' ) @@ -614,7 +616,12 @@ async function main() { } const seedMs = round(performance.now() - seedStartedAt) - await buildGoServer(serverBinary) + if (prebuiltServer) await access(serverBinary, constants.X_OK) + else await withWebDistLock(async (lock) => { + const env = webDistLockEnv(lock) + await prepareWebDist(env) + await buildGoServer(serverBinary, env) + }) server = startGoServer({ vaultRoot, bind: `127.0.0.1:${serverPort}`, @@ -708,6 +715,12 @@ async function main() { 'workspace ready' ) + await waitForExpression(client, `(() => { + const skip = [...document.querySelectorAll('button')].find((button) => button.textContent.trim() === 'Skip setup'); + skip?.click(); + return Boolean(document.querySelector('[data-sidebar-type], [data-notelist-path]')); + })()`, 10000, 'web navigation after first-run setup') + const inboxExpansion = await evaluate( client, `(async () => { diff --git a/tooling/scripts/prepare-boundary-release.mjs b/tooling/scripts/prepare-boundary-release.mjs new file mode 100644 index 00000000..ee6e7790 --- /dev/null +++ b/tooling/scripts/prepare-boundary-release.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { packAppCore } from './pack-app-core.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const target = process.argv[2] +assert.ok(['core', 'web', 'viewer'].includes(target), 'Expected core, web or viewer') +const local = process.argv.includes('--allow-dirty') +const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim().length > 0 +assert.ok(local || !dirty, 'Release preparation requires an approved clean source commit; use --allow-dirty only for a local rehearsal') +const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() +if (process.env.APPROVED_SOURCE) assert.equal(commit, process.env.APPROVED_SOURCE, 'Source differs from the approved commit') +let version, entries +if (target === 'core') { + const core = await packAppCore() + version = core.version + entries = [core, ...core.dependencies].map(manifest => ({ archive: manifest.archive, manifest, filename: manifest.file })) +} else { + const script = target === 'web' ? 'pack-web-artifact.mjs' : 'pack-share-viewer.mjs' + const log = execFileSync(process.execPath, [join(root, 'tooling/scripts', script)], { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }) + const result = JSON.parse(log.slice(log.lastIndexOf('\n{'))) + const manifest = JSON.parse(await readFile(result.manifest, 'utf8')) + version = result.version + entries = [{ archive: result.archive, manifest, filename: manifest.archive.file }] +} +const output = join(root, 'dist/boundary-release', `${target}-${version}`) +await mkdir(output, { recursive: true }) +// Runtime tests use absolute paths; public provenance must remain portable. +function portable(value) { + if (Array.isArray(value)) return value.map(portable) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.entries(value).filter(([key, val]) => !(key === 'archive' && typeof val === 'string')).map(([key, val]) => [key, portable(val)])) +} +const files = [] +for (const entry of entries) { + const bytes = await readFile(entry.archive) + assert.equal(createHash('sha256').update(bytes).digest('hex'), entry.manifest.sha256 ?? entry.manifest.archive.sha256) + assert.equal(entry.manifest.sourceCommit ?? entry.manifest.source.commit, commit) + assert.ok(local || !(entry.manifest.workingTreeDirty ?? entry.manifest.source?.dirty), 'Dirty artifacts cannot be released') + await copyFile(entry.archive, join(output, entry.filename)) + await writeFile(join(output, `${entry.filename}.json`), JSON.stringify(portable(entry.manifest), null, 2) + '\n') + files.push(entry.filename, `${entry.filename}.json`) +} +const release = { target, tag: `${target}-${version}`, sourceCommit: commit, localCandidate: local, files } +await writeFile(join(output, 'release.json'), JSON.stringify(release, null, 2) + '\n') +if (process.env.GITHUB_OUTPUT) await writeFile(process.env.GITHUB_OUTPUT, `directory=${output}\ntag=${release.tag}\n`, { flag: 'a' }) +console.log(JSON.stringify({ output, ...release }, null, 2)) diff --git a/tooling/scripts/rehearse-server-extraction.mjs b/tooling/scripts/rehearse-server-extraction.mjs new file mode 100644 index 00000000..908fcb41 --- /dev/null +++ b/tooling/scripts/rehearse-server-extraction.mjs @@ -0,0 +1,106 @@ +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { cp, mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const manifest = process.argv[2] && resolve(process.argv[2]) +if (!manifest || process.argv.length !== 3) + throw new Error('Usage: node tooling/scripts/rehearse-server-extraction.mjs ') +const pin = JSON.parse(await readFile(manifest, 'utf8')) +if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(pin.archive?.file ?? '')) + throw new Error('Expected a local web archive basename in the manifest') +const output = await mkdtemp(join(tmpdir(), 'zennotes-server-rehearsal-')) +const source = join(output, 'source') +const files = [] +const originalModule = 'github.com/ZenNotes/zennotes/apps/server' +const destinationModule = 'github.com/ZenNotes/znserver' +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') + +async function copySource(directory, relative = '') { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = relative + entry.name + if (['web/dist', 'bin', 'node_modules', '.git'].includes(path)) continue + if (entry.isDirectory()) await copySource(join(directory, entry.name), `${path}/`) + else if ( + entry.isFile() && + ((/\.(go|json)$/.test(path) && path !== 'package.json') || + ['go.mod', 'go.sum', 'README.md'].includes(path)) + ) { + const before = await readFile(join(directory, entry.name)) + const after = + path.endsWith('.go') || path === 'go.mod' + ? Buffer.from(before.toString('utf8').replaceAll(originalModule, destinationModule)) + : before + await mkdir(dirname(join(source, path)), { recursive: true }) + await writeFile(join(source, path), after) + files.push({ + path, + originalSha256: hash(before), + extractedSha256: hash(after) + }) + } + } +} + +await copySource(join(root, 'apps/server')) +await cp(join(root, 'LICENSE'), join(source, 'LICENSE')) +await cp(join(root, 'tooling/server-repository'), source, { recursive: true }) +const release = JSON.parse(await readFile(join(root, 'packaging/nix/release-data.json'), 'utf8')) +const serverPackage = JSON.parse(await readFile(join(root, 'apps/server/package.json'), 'utf8')) +await writeFile(join(source, 'release.json'), JSON.stringify({ version: serverPackage.version, vendorHash: release.vendorHash }, null, 2) + '\n') +await mkdir(join(source, 'web-artifact')) +await cp(manifest, join(source, 'web-artifact', 'manifest.json')) +await cp(join(dirname(manifest), pin.archive.file), join(source, 'web-artifact', pin.archive.file)) +await writeFile( + join(output, 'provenance.json'), + JSON.stringify( + { + kind: 'local-uncommitted-extraction-rehearsal', + originalModule, + destinationModule, + webArtifact: pin.version, + manifestSha256: hash(await readFile(join(source, 'web-artifact', 'manifest.json'))), + archiveSha256: hash(await readFile(join(source, 'web-artifact', pin.archive.file))), + files: files.sort((a, b) => a.path.localeCompare(b.path)) + }, + null, + 2 + ) + '\n' +) + +function go(args) { + const result = spawnSync('go', args, { + cwd: source, + stdio: 'inherit', + env: { ...process.env, GOWORK: 'off' } + }) + if (result.error) throw result.error + if (result.status !== 0) + throw new Error(`go ${args.join(' ')} failed; rehearsal retained at ${output}`) +} + +// All commands below run from the copied source with no frontend source or npm manifest. +go(['vet', './...']) +go(['test', './...']) +go(['build', '-trimpath', '-o', join(output, 'server-api'), './cmd/zennotes-server']) +go([ + 'run', + './cmd/prepare-web', + '-manifest', + 'web-artifact/manifest.json', + '-output', + 'web/dist', + ...(pin.source.dirty ? ['-allow-dirty'] : []) +]) +go(['test', '-tags=embed_web', './web']) +const binary = join( + output, + process.platform === 'win32' ? 'zennotes-server.exe' : 'zennotes-server' +) +go(['build', '-tags=embed_web', '-trimpath', '-o', binary, './cmd/zennotes-server']) +process.stdout.write( + JSON.stringify({ output, source, binary, webArtifact: pin.version }, null, 2) + '\n' +) diff --git a/tooling/scripts/run-go-server-dev.mjs b/tooling/scripts/run-go-server-dev.mjs index 3667c982..12908c9d 100644 --- a/tooling/scripts/run-go-server-dev.mjs +++ b/tooling/scripts/run-go-server-dev.mjs @@ -6,9 +6,13 @@ import { withGoEnv } from './go-env.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') -const serverRoot = resolve(repoRoot, 'apps/server') +const serverRoot = process.env.ZENNOTES_SERVER_DIR + ? resolve(process.env.ZENNOTES_SERVER_DIR) + : resolve(repoRoot, 'apps/server') +const binary = process.env.ZENNOTES_SERVER_BINARY +if (binary && process.env.ZENNOTES_SERVER_DIR) throw new Error('Choose ZENNOTES_SERVER_BINARY or ZENNOTES_SERVER_DIR') -const child = spawn('go', ['run', './cmd/zennotes-server'], { +const child = spawn(binary ? resolve(binary) : 'go', binary ? [] : ['run', './cmd/zennotes-server'], { cwd: serverRoot, env: withGoEnv({ ZENNOTES_DEV: '1' diff --git a/tooling/scripts/run-go-server-test.mjs b/tooling/scripts/run-go-server-test.mjs index 21a4db9e..31848a2c 100644 --- a/tooling/scripts/run-go-server-test.mjs +++ b/tooling/scripts/run-go-server-test.mjs @@ -7,7 +7,6 @@ import { withGoEnv } from './go-env.mjs' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '..', '..') const serverRoot = resolve(repoRoot, 'apps/server') -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' function run(command, args, cwd = repoRoot, options = {}) { const shell = options.shell ?? false @@ -31,10 +30,6 @@ function run(command, args, cwd = repoRoot, options = {}) { }) } -await run(npmCommand, ['run', 'prepare-web'], serverRoot, { - shell: process.platform === 'win32', -}) - await run('go', ['test', './...'], serverRoot, { env: withGoEnv(), }) diff --git a/tooling/scripts/sync-contract-fixtures.mjs b/tooling/scripts/sync-contract-fixtures.mjs new file mode 100644 index 00000000..36477944 --- /dev/null +++ b/tooling/scripts/sync-contract-fixtures.mjs @@ -0,0 +1,41 @@ +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const fixtures = [ + ['task-roundtrip.json', 'vault'], + ['self-hosted-http.json', 'httpserver'] +] +for (const [name, consumer] of fixtures) { + const source = `packages/bridge-contract/fixtures/${name}` + const target = resolve(root, `apps/server/internal/${consumer}/testdata/${name}`) + const bytes = await readFile(resolve(root, source)) + const provenance = + JSON.stringify( + { + sourceRepository: 'https://github.com/ZenNotes/zennotes', + sourcePath: source, + sha256: createHash('sha256').update(bytes).digest('hex') + }, + null, + 2 + ) + '\n' + + for (const [path, content] of [ + [target, bytes], + [`${target}.source.json`, Buffer.from(provenance)] + ]) { + if (process.argv.includes('--write')) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) + } else { + const current = await readFile(path) + if (!current.equals(content)) { + throw new Error(`Contract fixture differs: ${path}. Run npm run sync:contract-fixtures.`) + } + } + } +} +process.stdout.write('Go fixtures match the shared contract bytes.\n') diff --git a/tooling/scripts/test-app-core-browser.mjs b/tooling/scripts/test-app-core-browser.mjs new file mode 100644 index 00000000..14326ddc --- /dev/null +++ b/tooling/scripts/test-app-core-browser.mjs @@ -0,0 +1,794 @@ +import assert from 'node:assert/strict' +import { execFileSync, spawn } from 'node:child_process' +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import net from 'node:net' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { withGoEnv } from './go-env.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const manifest = resolve(process.argv[2] || join(root, 'dist/shared-packages/app-core-consumer.json')) +const evidence = JSON.parse(await readFile(manifest, 'utf8')) +const consumer = evidence.consumer +const run = await mkdtemp(join(consumer, 'browser-')) +console.log(`Browser evidence: ${run}`) +const vault = join(run, 'vault') +await mkdir(vault, { recursive: true }) +const require = createRequire(join(consumer, 'package.json')) +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)) +async function until(check, label, timeout = 30000) { + const deadline = Date.now() + timeout + let last + while (Date.now() < deadline) { + try { const value = await check(); if (value) return value } catch (error) { last = error } + await sleep(100) + } + throw new Error(`${label}: ${last?.message || 'timed out'}`) +} +// Public navigation drops calls made while the workspace is still restoring, +// so every scripted navigation after a page load waits for the shell's +// readiness signal, exactly as a host would. +const workspaceReady = () => until(() => client.evaluate('window.packageShell?.getShellSnapshot().workspaceRestored === true'), 'workspace restored') +async function port() { + return new Promise((done, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const number = server.address().port + server.close(() => done(number)) + }) + }) +} +class CDP { + nextId = 0 + pending = new Map() + listeners = new Map() + constructor(socket) { + this.socket = socket + socket.addEventListener('message', ({ data }) => { + const message = JSON.parse(data) + const pending = this.pending.get(message.id) + if (pending) { + this.pending.delete(message.id); clearTimeout(pending.timer) + if (message.error) pending.reject(new Error(message.error.message)) + else pending.done(message.result) + } else if (typeof message.method === 'string' && this.listeners.has(message.method)) { + this.listeners.get(message.method)(message.params) + } + }) + } + on(method, callback) { this.listeners.set(method, callback) } + send(method, params = {}) { + return new Promise((done, reject) => { + const id = ++this.nextId + const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)) }, 30000) + this.pending.set(id, { done, reject, timer }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + async evaluate(expression) { + const value = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }) + if (value.exceptionDetails) throw new Error(value.exceptionDetails.exception?.description || value.exceptionDetails.text) + return value.result?.value + } + close() { this.socket.close() } +} +const apiPort = await port(), uiPort = await port(), debugPort = await port() +const binary = join(run, process.platform === 'win32' ? 'server.exe' : 'server') +execFileSync('go', ['build', '-o', binary, './cmd/zennotes-server'], { cwd: join(root, 'apps/server'), env: withGoEnv(), stdio: 'inherit' }) +const children = [] +const logs = {} +function launch(name, command, args, options) { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...options }) + logs[name] = '' + child.stdout.on('data', (chunk) => { logs[name] += chunk }) + child.stderr.on('data', (chunk) => { logs[name] += chunk }) + children.push(child) + return child +} +let client +const errors = [], requests = [], failed = [], networkErrors = [] +const requestUrls = new Map() +const pendingAbsenceProbes = new Set() +const absenceProbes = new Map() +const token = 'isolated-package-test-only-token' +try { + launch('server', binary, [], { cwd: run, env: withGoEnv({ + ZENNOTES_BIND: `127.0.0.1:${apiPort}`, ZENNOTES_DEFAULT_VAULT_PATH: vault, + ZENNOTES_CONFIG_PATH: join(run, 'server.json'), ZENNOTES_BROWSE_ROOTS: vault, + ZENNOTES_AUTH_TOKEN: token, ZENNOTES_BASE_PATH: '' + }) }) + const api = `http://127.0.0.1:${apiPort}` + await until(async () => (await fetch(`${api}/api/healthz`, { signal: AbortSignal.timeout(1000) })).ok, 'API startup') + const path = 'inbox/Package test.md' + const original = '# Package test\n\nRead and edit this note.\n' + const lazyPath = 'inbox/Lazy features.md' + const lazyBody = '# Lazy features\n\n$$ x^2 + y^2 $$\n\n```mermaid\ngraph LR\n A[Packaged] --> B[Working]\n```\n' + const commandPath = 'inbox/Commands.md' + const hostPath = 'inbox/Host hooks.md' + const hostBody = Array.from({length:160}, (_,index) => `Host scroll line ${index + 1}`).join('\n') + const orderedPaths = ['inbox/Order/Note 2.md', 'inbox/Order/Note 10.md', 'inbox/Order/Note 20.md'] + await mkdir(join(vault, 'inbox/Order/People.base/pages'), { recursive: true }) + const databasePath = 'inbox/Browse demo/Customers.base/data.csv' + const schemaPath = 'inbox/Browse demo/Customers.base/schema.json' + const databaseBytes = 'id,Name\nrow-1,Example customer\n' + const schemaBytes = JSON.stringify({version:1,idFieldId:'id',fields:[{id:'id',name:'id',type:'text',hidden:true},{id:'name',name:'Name',type:'text'}],views:[{id:'table',name:'Table',type:'table',filters:[],sorts:[],columnOrder:['name']}],activeViewId:'table'}) + await mkdir(join(vault, 'inbox/Browse demo/Customers.base'), { recursive: true }) + await mkdir(join(vault, 'inbox/Browse demo/Empty'), { recursive: true }) + await writeFile(join(vault, databasePath), databaseBytes) + await writeFile(join(vault, schemaPath), schemaBytes) + const orderFixtures = [...orderedPaths.map(path => [path, `Original ${path}`]), ['inbox/Order/People.base/pages/Hidden.md', 'Database record']] + for (const [notePath, body] of [[path, original], [lazyPath, lazyBody], [commandPath, 'Format me'], [hostPath, hostBody], ['inbox/Browse demo/Read me.md', 'Opened through the public Browse model.'], ...orderFixtures]) { + const response = await fetch(`${api}/api/notes/write`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ path: notePath, body }) }) + assert.equal(response.status, 200) + } + launch('preview', process.execPath, [join(dirname(require.resolve('vite/package.json')), 'bin/vite.js'), 'preview', '--port', String(uiPort), '--strictPort'], { + cwd: consumer, env: { ...process.env, ZEN_CORE_SERVER: api } + }) + const url = `http://127.0.0.1:${uiPort}` + await until(async () => (await fetch(url, { signal: AbortSignal.timeout(1000) })).ok, 'built consumer startup') + const chrome = process.env.ZEN_CHROME_PATH || (process.platform === 'darwin' + ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' : 'google-chrome') + launch('chrome', chrome, [`--remote-debugging-port=${debugPort}`, `--user-data-dir=${join(run, 'chrome-profile')}`, + '--headless=new', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--disable-extensions', 'about:blank']) + const page = await until(async () => { + const pages = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`, { signal: AbortSignal.timeout(1000) })).json() + return pages.find((page) => page.type === 'page' && page.webSocketDebuggerUrl) + }, 'Chrome startup') + const socket = new WebSocket(page.webSocketDebuggerUrl) + await new Promise((done, reject) => { socket.addEventListener('open', done, { once: true }); socket.addEventListener('error', reject, { once: true }) }) + client = new CDP(socket) + await client.send('Page.enable'); await client.send('Runtime.enable'); await client.send('Network.enable') + await client.send('Emulation.setFocusEmulationEnabled', { enabled: true }) + await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false }) + client.on('Runtime.exceptionThrown', (event) => errors.push(event.exceptionDetails.exception?.description || event.exceptionDetails.text)) + client.on('Runtime.consoleAPICalled', event => { + const message = event.args.map(arg => arg.value ?? arg.description ?? '').join(' ') + if (event.type === 'error' || /Measure loop restarted|Viewport failed to stabilize/.test(message)) errors.push(message) + }) + client.on('Log.entryAdded', ({ entry }) => { + if (entry.level !== 'error') return + if (entry.source === 'network') networkErrors.push(entry) + else errors.push(entry.text) + }) + client.on('Network.requestWillBeSent', ({ requestId, request }) => { + requestUrls.set(requestId, request.url) + if (request.method === 'GET' && pendingAbsenceProbes.delete(request.url)) { + absenceProbes.set(requestId, {url:request.url}) + } + }) + client.on('Network.responseReceived', ({ requestId, response }) => { + requests.push(response.url) + const probe = absenceProbes.get(requestId) + if (probe) probe.status = response.status + if (response.status >= 400) failed.push({ requestId, url: response.url, status: response.status }) + }) + client.on('Network.loadingFailed', (event) => { + // Navigation intentionally cancels in-flight requests from the old document. + if (!event.canceled) failed.push({ url: requestUrls.get(event.requestId), error: event.errorText }) + }) + await client.send('Log.enable') + await client.send('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('zen:prefs:v2', JSON.stringify({vimMode:false,livePreview:false,mathRenderer:'typst'}))` }) + await client.send('Page.navigate', { url }) + await until(() => client.evaluate(`!!document.querySelector('input[placeholder="Enter the server auth token"]')`), 'login') + await client.evaluate(`document.querySelector('input[placeholder="Enter the server auth token"]').focus()`) + await client.send('Input.insertText', { text: token }) + await client.evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Sign In').click()`) + await until(() => client.evaluate(`(() => { [...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Skip setup')?.click(); return !!document.querySelector('[data-sidebar-type="folder"]') })()`), 'workspace') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(path)})`) + // The first note open fetches the editor, store, and Markdown chunks on a + // cold runner; allow the same window as the lazy renders below. + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Read and edit')`), 'editor', 60000) + assert.equal(await client.evaluate(`document.querySelector('[data-consumer-selection]').textContent`), path) + assert.equal(await client.evaluate(`getComputedStyle(document.querySelector('#root > *')).display`), 'flex', 'Compiled Tailwind styles did not load') + const beforeLazy = requests.filter((url) => /mermaid\.core|typst.*\.wasm|harper.*\.wasm/.test(url)) + assert.deepEqual(beforeLazy, [], 'Heavy features loaded before requested') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + const modifier = process.platform === 'darwin' ? 4 : 2 + async function shortcut(key, code) { + await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers: modifier }) + await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers: modifier }) + } + await shortcut('a', 'KeyA') + const saved = '# Package test\n\nSaved from the installed editor: café 日本語. \n\n- [ ] Preserve this task\n' + await client.send('Input.insertText', { text: saved }) + await until(async () => (await readFile(join(vault, path), 'utf8')) === saved, 'exact UTF-8 save') + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(lazyPath)})`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(lazyPath)}`), 'public hook updates') + await client.evaluate('window.packageNavigation.goBack()') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Saved from the installed editor')`), 'public back navigation') + await client.evaluate('window.packageNavigation.goForward()') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Lazy features')`), 'public forward navigation') + // CodeMirror updates its document before React commits the new toolbar's + // callbacks. Wait for the painted note before interacting with that toolbar. + await client.evaluate('new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))') + await until(() => client.evaluate(`(() => { const button = [...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Preview'); if (!button) return false; button.click(); return true })()`), 'Preview control') + await until(() => client.evaluate(`!!document.querySelector('[aria-label="Note preview"]')`), 'Preview mode') + await until(() => client.evaluate(`document.querySelector('.prose-zen .mermaid svg') && document.querySelector('.prose-zen .zen-typst-math svg')`), 'lazy Mermaid and Typst render', 60000) + const screenshot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'lazy-features.png'), Buffer.from(screenshot.data, 'base64')) + await client.evaluate('window.packageNavigation.goHome()') + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === 'Home'`), 'public Home navigation') + // Native hosts can omit Harper; the default web package must also prove the + // real worker/binary path works when the preference is enabled. + await client.send('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('zen:prefs:v2', JSON.stringify({vimMode:false,livePreview:false,harperEnabled:true}))` }) + await client.send('Page.navigate', { url: `${url}/?grammar=1` }) + await until(() => client.evaluate(`location.search === '?grammar=1' && !!window.packageNavigation`), 'reload') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(path)})`) + await until(() => client.evaluate(`!!document.querySelector('.cm-content')`), 'reloaded editor') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a', 'KeyA') + await client.send('Input.insertText', { text: '# Grammar\n\nThis is an mispelled sentense.\n' }) + await until(() => client.evaluate(`!!document.querySelector('.cm-harper-lint')`), 'Harper worker diagnostics', 60000) + const grammarShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'grammar.png'), Buffer.from(grammarShot.data, 'base64')) + + // Exercise the public attachment surface using the real host upload path. + // This server owns one immutable test vault, so its importer stays current. + await client.evaluate(`window.attachmentImporter = { + isCurrent: () => true, + importFile: async (notePath, file) => { + const [asset] = await window.zen.importFilesToNote(notePath, [window.zen.getPathForFile(file)]) + if (!asset) throw new Error('Host did not return an imported file') + return asset + }, + importPastedImage: input => window.zen.importPastedImage(input) + }`) + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a', 'KeyA') + const attachmentBody = '# Attachments\n\nKeep this note.\n' + await client.send('Input.insertText', { text: attachmentBody }) + await until(async () => (await readFile(join(vault, path), 'utf8')) === attachmentBody, 'attachment baseline save') + const attached = await client.evaluate(`window.packageEditor.attachFiles( + window.packageEditor.captureEditorInsertion(window.attachmentImporter), + [new File(['public attachment bytes'], 'public-attachment.txt', {type:'text/plain'})])`) + assert.equal(attached.status, 'inserted') + const attachedBody = attachmentBody + attached.assets[0].markdown + await until(async () => (await readFile(join(vault, path), 'utf8')) === attachedBody, 'exact attachment Markdown saved') + assert.equal(await readFile(join(vault, attached.assets[0].path), 'utf8'), 'public attachment bytes') + const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==' + const pasted = await client.evaluate(`window.packageEditor.insertPastedImage( + window.packageEditor.captureEditorInsertion(window.attachmentImporter), + {data:Uint8Array.from(atob(${JSON.stringify(pngBase64)}),c=>c.charCodeAt(0)),mimeType:'image/png',suggestedName:'public-paste.png'})`) + assert.equal(pasted.status, 'inserted') + const pastedBody = attachedBody + '\n\n' + pasted.assets[0].markdown + '\n' + await until(async () => (await readFile(join(vault, path), 'utf8')) === pastedBody, 'exact pasted image Markdown saved') + assert.deepEqual(await readFile(join(vault, pasted.assets[0].path)), Buffer.from(pngBase64, 'base64')) + const beforeRace = await readFile(join(vault, path), 'utf8') + const otherBeforeRace = await readFile(join(vault, lazyPath), 'utf8') + const uploadCount = () => requests.filter(url => url.endsWith('/api/assets/upload')).length + const beforeUploads = uploadCount() + await client.evaluate(`(() => { + window.attachmentSaved = false + const importer = {...window.attachmentImporter, importFile: async (notePath,file) => { + const asset = await window.attachmentImporter.importFile(notePath,file) + window.attachmentSaved = true + await new Promise(done => {window.finishAttachment = done}) + return asset + }} + const target = window.packageEditor.captureEditorInsertion(importer) + if (!target) throw new Error('No attachment target') + window.pendingAttachment = window.packageEditor.attachFiles(target, + [new File(['keep saved asset'],'slow-attachment.txt'),new File(['must not import'],'second-attachment.txt')]) + })()`) + await until(() => client.evaluate('window.attachmentSaved'), 'slow attachment saved') + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(lazyPath)})`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(lazyPath)}`), 'note switch during upload') + await client.evaluate('window.finishAttachment()') + const stale = await client.evaluate('window.pendingAttachment') + assert.equal(stale.status, 'saved-only') + assert.equal(stale.assets.length, 1) + assert.equal(uploadCount() - beforeUploads, 1, 'Import continued after the note changed') + assert.equal(await readFile(join(vault, stale.assets[0].path), 'utf8'), 'keep saved asset') + assert.equal(await readFile(join(vault, path), 'utf8'), beforeRace) + assert.equal(await readFile(join(vault, lazyPath), 'utf8'), otherBeforeRace) + const attachmentShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'attachment-race.png'), Buffer.from(attachmentShot.data, 'base64')) + + // Host buttons use only named public commands. Real mouse events move focus + // onto each button before its handler runs, as an external toolbar can do. + await client.send('Page.navigate', { url: `${url}/?commands=1` }) + await until(() => client.evaluate(`!!document.querySelector('[data-consumer-command]') && window.packageShell?.getShellSnapshot().workspaceRestored`), 'host command toolbar and restored workspace') + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(commandPath)})`) + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent === 'Format me'`), 'command note') + async function clickHostCommand(command) { + const point = await client.evaluate(`(() => { + const button = document.querySelector('[data-consumer-command="${command}"]') + const rect = button.getBoundingClientRect() + return {x:rect.x + rect.width/2, y:rect.y + rect.height/2} + })()`) + await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 }) + await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 }) + assert.deepEqual(await client.evaluate('window.lastHostCommand'), { command, handled: true }) + } + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a', 'KeyA') + assert.equal(await client.evaluate('window.packageEditor.hasEditorSelection()'), true) + await clickHostCommand('toggle-bold') + await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === '**Format me**', 'public bold save') + assert.equal(await client.evaluate(`document.activeElement.classList.contains('cm-content')`), true) + await clickHostCommand('undo') + await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === 'Format me', 'public undo save') + await clickHostCommand('redo') + await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === '**Format me**', 'public redo save') + await clickHostCommand('open-search') + assert.deepEqual(await client.evaluate(`(() => { + const field = document.querySelector('.cm-search [main-field]') + return {focused:document.activeElement === field, value:field?.value, selection:[field?.selectionStart,field?.selectionEnd]} + })()`), { focused: true, value: 'Format me', selection: [0, 9] }) + const commandsShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'commands.png'), Buffer.from(commandsShot.data, 'base64')) + // A native back handler does not take focus before closing the search panel. + assert.equal(await client.evaluate(`window.packageEditor.runEditorCommand('close-search')`), true) + assert.equal(await client.evaluate(`document.activeElement.classList.contains('cm-content')`), true) + assert.equal(await client.evaluate(`window.packageEditor.runEditorCommand('close-search')`), false) + await shortcut('a', 'KeyA') + await client.send('Input.insertText', { text: ' ' }) + assert.equal(await client.evaluate('window.packageEditor.hasEditorSelection()'), false) + await clickHostCommand('set-task-list') + await until(async () => (await readFile(join(vault, commandPath), 'utf8')) === ' - [ ] ', 'empty-line task marker save') + + await client.send('Page.navigate', { url: `${url}/?host=1` }) + await until(() => client.evaluate('!!window.packageHost'), 'host registration before mount') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(hostPath)})`) + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Host scroll line 1')`), 'host note') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + assert.deepEqual(await client.evaluate('window.firstEditorTyping'), ['on', 'sentences', 'true', 'true']) + await shortcut(process.platform === 'darwin' ? 'ArrowDown' : 'End', process.platform === 'darwin' ? 'ArrowDown' : 'End') + await until(() => client.evaluate(`window.getSelection()?.focusNode?.parentElement?.closest('.cm-line')?.textContent === 'Host scroll line 160'`), 'caret at note end') + const caretIsClear = () => client.evaluate(`(() => { + const cursor = document.querySelector('.cm-cursor')?.getBoundingClientRect() + const scroller = document.querySelector('.cm-scroller').getBoundingClientRect() + const toolbar = document.getElementById('host-selection-toolbar') ?? document.getElementById('host-keyboard-toolbar') + return !!cursor && cursor.height > 0 && cursor.top >= scroller.top && cursor.bottom <= Math.min(scroller.bottom, toolbar.getBoundingClientRect().top) - 4 + })()`) + await client.evaluate(`document.querySelector('.cm-scroller').scrollTop = 0; window.packageHost.refresh(); window.packageEditor.revealEditorCaret()`) + await until(caretIsClear, 'caret above host keyboard toolbar') + await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 700, deviceScaleFactor: 1, mobile: false }) + await client.evaluate(`document.getElementById('host-keyboard-toolbar').style.height = '96px'; window.packageHost.refresh(); window.packageEditor.revealEditorCaret()`) + await until(caretIsClear, 'caret above resized keyboard toolbar') + await client.evaluate(`(() => { + const selection = document.createElement('div') + selection.id = 'host-selection-toolbar' + selection.textContent = 'Host selection toolbar' + selection.style.cssText = 'position:fixed;bottom:96px;left:0;right:0;height:140px;background:#40585b;color:white;z-index:10000;pointer-events:none' + document.body.append(selection) + window.packageHost.refresh() + window.packageEditor.revealEditorCaret() + })()`) + await until(() => client.evaluate(`document.querySelector('.cm-scroller').getBoundingClientRect().bottom <= document.getElementById('host-selection-toolbar').getBoundingClientRect().top`), 'physical selection clearance') + await until(caretIsClear, 'caret above selection toolbar') + const hostShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'host-insets.png'), Buffer.from(hostShot.data, 'base64')) + await client.evaluate(`window.packageHost.dispose(); document.getElementById('host-selection-toolbar').remove(); document.getElementById('host-keyboard-toolbar').remove()`) + assert.deepEqual(await client.evaluate(`['autocorrect','autocapitalize','spellcheck','writingsuggestions'].map(name => document.querySelector('.cm-content').getAttribute(name))`), ['off', 'off', 'false', 'false']) + assert.equal(await client.evaluate(`document.querySelector('.cm-editor').style.getPropertyValue('--zen-editor-host-bottom-inset')`), '') + assert.equal(await readFile(join(vault, hostPath), 'utf8'), hostBody, 'Host configuration changed note bytes') + + await client.send('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('zen:prefs:v2', JSON.stringify({vimMode:false,livePreview:false,noteSortOrder:'name-asc'}))` }) + await client.send('Page.navigate', { url: `${url}/?shell=1` }) + await until(() => client.evaluate(`!!document.querySelector('[data-consumer-adjacent]')`), 'host note navigation') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(${JSON.stringify(orderedPaths[0])})`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]')?.textContent === 'Note 2'`), 'shell React snapshot') + const shellProof = await client.evaluate(`(() => { + const api = window.packageShell, snapshot = api.getShellSnapshot() + let immutable = false + try { Object.assign(snapshot.notes[0], {title:'Changed'}) } catch { immutable = true } + window.shellTransitions = [] + window.disposeShell = api.subscribeShell((next, previous) => window.shellTransitions.push([previous.selectedPath, next.selectedPath])) + return { + immutable, stable: snapshot === api.getShellSnapshot(), + selected: snapshot.selectedNote.path, + bodyExposed: snapshot.notes.some(note => 'body' in note), + order: api.getBrowseNotes(snapshot, 'Order').map(note => note.path), + pinned: api.getBrowseNotes(snapshot, 'Order', [${JSON.stringify(orderedPaths[2])}]).map(note => note.path), + hidden: api.getBrowseNotes(snapshot, 'Order/People.base/pages').length, + previous: api.getAdjacentNotePath(snapshot, snapshot.selectedPath, 'previous') + } + })()`) + assert.deepEqual(shellProof, { immutable: true, stable: true, selected: orderedPaths[0], bodyExposed: false, order: orderedPaths, pinned: [orderedPaths[2], ...orderedPaths.slice(0,2)], hidden: 0, previous: null }) + async function clickAdjacent(direction) { + const point = await client.evaluate(`(() => { + const rect = document.querySelector('[data-consumer-adjacent="${direction}"]').getBoundingClientRect() + return {x:rect.x + rect.width/2, y:rect.y + rect.height/2} + })()`) + await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 }) + await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 }) + } + await clickAdjacent('next') + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'Note 10'`), 'next Browse sibling') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Original inbox/Order/Note 10.md')`), 'adjacent editor ready') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a', 'KeyA') + await client.send('Input.insertText', { text: 'Edited via public sibling navigation: café.' }) + await clickAdjacent('next') + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'Note 20'`), 'second Browse sibling') + await until(async () => (await readFile(join(vault, orderedPaths[1]), 'utf8')) === 'Edited via public sibling navigation: café.', 'sibling navigation saves edited note') + await clickAdjacent('next') + assert.equal(await client.evaluate('window.packageShell.getShellSnapshot().selectedPath'), orderedPaths[2], 'Adjacent navigation wrapped') + await clickAdjacent('previous') + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'Note 10'`), 'previous Browse sibling') + const shellShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'shell.png'), Buffer.from(shellShot.data, 'base64')) + assert.ok((await client.evaluate('window.shellTransitions')).some(([previous, next]) => previous === orderedPaths[0] && next === orderedPaths[1])) + await client.evaluate('window.disposeShell(); window.shellTransitions = []; window.packageNavigation.goHome()') + await until(() => client.evaluate(`document.querySelector('[data-consumer-title]').textContent === 'No note'`), 'shell hook Home') + assert.deepEqual(await client.evaluate('window.shellTransitions'), [], 'Disposed shell subscriber still notified') + for (const [notePath, body] of orderFixtures) { + if (notePath !== orderedPaths[1]) assert.equal(await readFile(join(vault, notePath), 'utf8'), body, 'Sibling navigation changed another note') + } + + await client.send('Page.navigate', { url: `${url}/?browse=1` }) + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Browse demo"]')`), 'public Browse root') + async function clickBrowse(selector) { + const point = await client.evaluate(`(() => { + const rect = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect() + return {x:rect.x + rect.width/2, y:rect.y + rect.height/2} + })()`) + await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', clickCount: 1 }) + await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', clickCount: 1 }) + } + await clickBrowse('[data-browse-folder="Browse demo"]') + await until(() => client.evaluate(`!!document.querySelector('[data-browse-database="Browse demo/Customers.base"]')`), 'public Browse database row') + assert.deepEqual(await client.evaluate(`(() => { + const snapshot = window.packageBrowse.getBrowseSnapshot() + const rows = window.packageBrowse.getBrowseDirectory(snapshot, 'Browse demo') + return {folders:rows.folders.map(row => row.title), databases:rows.databases.map(row => row.title), notes:rows.notes.map(row => row.title), frozen:Object.isFrozen(snapshot.folders) && Object.isFrozen(rows.databases[0])} + })()`), { folders: ['Empty'], databases: ['Customers'], notes: ['Read me'], frozen: true }) + await clickBrowse('[data-browse-database="Browse demo/Customers.base"]') + await until(() => client.evaluate(`!!document.querySelector('[role="grid"]') && document.body.innerText.includes('Example customer')`), 'database opens from public Browse') + assert.equal(await readFile(join(vault, databasePath), 'utf8'), databaseBytes, 'Opening Browse database changed CSV bytes') + assert.equal(await readFile(join(vault, schemaPath), 'utf8'), schemaBytes, 'Opening Browse database changed schema bytes') + const browseShot = await client.send('Page.captureScreenshot', { format: 'png' }) + await writeFile(join(run, 'browse.png'), Buffer.from(browseShot.data, 'base64')) + await clickBrowse('[data-browse-note="inbox/Browse demo/Read me.md"]') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent === 'Opened through the public Browse model.'`), 'note opens from public Browse') + await client.evaluate(`window.browseChanges = []; window.disposeBrowse = window.packageBrowse.subscribeBrowse(next => window.browseChanges.push(next.folders.map(row => row.directory)))`) + await client.evaluate(`window.zen.createFolder('inbox', 'Browse demo/Later')`) + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Browse demo/Later"]')`), 'Browse hook updates after folder creation') + assert.ok((await client.evaluate('window.browseChanges')).length > 0, 'Browse subscriber missed folder update') + await client.evaluate(`window.disposeBrowse(); window.browseChanges = []; window.zen.createFolder('inbox', 'Browse demo/After disposal')`) + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Browse demo/After disposal"]')`), 'Browse hook stays live after other subscriber disposal') + assert.deepEqual(await client.evaluate('window.browseChanges'), [], 'Disposed Browse subscriber still notified') + async function dialogButton(label) { + const point = await client.evaluate(`(() => { + const button = [...document.querySelectorAll('[role="dialog"] button')].find(button => button.textContent.trim() === ${JSON.stringify(label)}) + const rect = button.getBoundingClientRect() + return {x:rect.x + rect.width/2, y:rect.y + rect.height/2} + })()`) + await client.send('Input.dispatchMouseEvent', {type:'mousePressed', ...point, button:'left', clickCount:1}) + await client.send('Input.dispatchMouseEvent', {type:'mouseReleased', ...point, button:'left', clickCount:1}) + } + async function answerFolderPrompt(name, label) { + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"] input')`), 'folder prompt') + await clickBrowse('[role="dialog"] input') + await until(() => client.evaluate(`document.activeElement === document.querySelector('[role="dialog"] input')`), 'prompt input focused') + await client.send('Input.dispatchKeyEvent', {type:'keyDown', key:'a', code:'KeyA', modifiers:modifier, commands:['selectAll']}) + await client.send('Input.dispatchKeyEvent', {type:'keyUp', key:'a', code:'KeyA', modifiers:modifier}) + await client.send('Input.insertText', { text: name }) + assert.equal(await client.evaluate(`document.querySelector('[role="dialog"] input').value`), name, 'Prompt text replacement') + await dialogButton(label) + await until(() => client.evaluate(`window.lastBrowseAction === 'completed'`), 'folder action completes') + } + await clickBrowse('[data-browse-create]') + await answerFolderPrompt('Action folder', 'Create') + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Browse demo/Action folder"]')`), 'created folder appears') + const actionNote = 'inbox/Browse demo/Action folder/Keep.md' + const actionBody = 'Folder action bytes: café 日本語. \n' + const writtenAction = await fetch(`${api}/api/notes/write`, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify({path:actionNote, body:actionBody})}) + assert.equal(writtenAction.status, 200) + const commentBody = { path: actionNote, comments: [{id:'folder-comment',body:'Keep this thread',createdAt:1,updatedAt:1}] } + const commentWrite = await fetch(`${api}/api/comments/write`, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify(commentBody)}) + assert.equal(commentWrite.status, 200) + await clickBrowse('[data-browse-database="Browse demo/Customers.base"]') + await until(() => client.evaluate(`!!document.querySelector('[role="grid"]')`), 'database active before parent rename') + await clickBrowse('[data-browse-parent]') + await clickBrowse('[data-browse-rename="Browse demo"]') + await answerFolderPrompt('Browse renamed', 'Rename') + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent.includes('Browse%20renamed')`), 'active database tab follows parent rename') + assert.equal(await readFile(join(vault, 'inbox/Browse renamed/Customers.base/data.csv'), 'utf8'), databaseBytes) + assert.equal(await readFile(join(vault, 'inbox/Browse renamed/Customers.base/schema.json'), 'utf8'), schemaBytes) + const renamedAction = 'inbox/Browse renamed/Action folder/Keep.md' + assert.equal(await readFile(join(vault, renamedAction), 'utf8'), actionBody) + const commentsAfterRename = await fetch(`${api}/api/comments/read?path=${encodeURIComponent(renamedAction)}`, {headers:{Authorization:`Bearer ${token}`}}).then(response => response.json()) + assert.ok(JSON.stringify(commentsAfterRename).includes('Keep this thread'), 'Folder rename lost comments') + await clickBrowse('[data-browse-folder="Browse renamed"]') + await clickBrowse('[data-browse-delete="Browse renamed/Action folder"]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`), 'folder deletion confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`), 'cancelled folder deletion') + assert.equal(await readFile(join(vault, renamedAction), 'utf8'), actionBody) + await clickBrowse('[data-browse-delete="Browse renamed/Customers.base"]') + await until(() => client.evaluate(`document.body.innerText.includes('All records will be permanently deleted')`), 'database deletion confirmation') + await dialogButton('Delete') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !document.querySelector('[data-browse-database="Browse renamed/Customers.base"]')`), 'database deletion') + assert.equal(await client.evaluate(`window.packageShell.getShellSnapshot().selectedPath?.includes('Customers.base') ?? false`), false, 'Deleted database tab remained active') + await assert.rejects(readFile(join(vault, 'inbox/Browse renamed/Customers.base/data.csv')), {code:'ENOENT'}) + await clickBrowse('[data-browse-delete="Browse renamed/Action folder"]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`), 'confirmed folder deletion') + await dialogButton('Delete') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !document.querySelector('[data-browse-folder="Browse renamed/Action folder"]')`), 'folder removed') + await assert.rejects(readFile(join(vault, renamedAction)), {code:'ENOENT'}) + assert.equal(await readFile(join(vault, 'inbox/Browse renamed/Read me.md'), 'utf8'), 'Opened through the public Browse model.') + const actionShot = await client.send('Page.captureScreenshot', {format:'png'}) + await writeFile(join(run, 'browse-actions.png'), Buffer.from(actionShot.data, 'base64')) + + + // Collision checks deliberately read an absent CSV. Admit only the first GET + // for each exact target, after proving absence on disk; all other failures stay + // fatal. A folder listing cannot replace this check because it hides .base internals. + async function expectAbsentCsv(directory) { + await assert.rejects(readFile(join(vault, directory, 'data.csv')), {code:'ENOENT'}) + pendingAbsenceProbes.add(`${url}/api/notes/read?path=${encodeURIComponent(directory + '/data.csv')}`) + } + const createdDir = 'inbox/Browse renamed/Untitled Database.base' + await expectAbsentCsv(createdDir) + await clickBrowse('[data-browse-create-database]') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-browse-database="Browse renamed/Untitled Database.base"]')`), 'created database in Browse') + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent.includes('Untitled%20Database.base') && !!document.querySelector('[role="grid"]')`), 'new database opens') + const createdCsv = await readFile(join(vault, createdDir, 'data.csv'), 'utf8') + const createdSchema = await readFile(join(vault, createdDir, 'schema.json'), 'utf8') + const recordPath = `${createdDir}/Record.md` + const recordBody = '# Record\n\nPreserve café 日本語. \n' + const recordWrite = await fetch(`${api}/api/notes/write`, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify({path:recordPath, body:recordBody})}) + assert.equal(recordWrite.status, 200) + const recordComment = await fetch(`${api}/api/comments/write`, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify({path:recordPath, comments:[{id:'record-comment',body:'Record discussion',createdAt:1,updatedAt:1}]})}) + assert.equal(recordComment.status, 200) + const projectsDir = 'inbox/Browse renamed/Projects.base' + await expectAbsentCsv(projectsDir) + await clickBrowse('[data-browse-rename-database="Browse renamed/Untitled Database.base"]') + await answerFolderPrompt('Projects', 'Rename') + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent.includes('Projects.base') && !!document.querySelector('[data-browse-database="Browse renamed/Projects.base"]')`), 'renamed database remains selected') + assert.equal(await readFile(join(vault, projectsDir, 'data.csv'), 'utf8'), createdCsv) + assert.equal(await readFile(join(vault, projectsDir, 'schema.json'), 'utf8'), createdSchema) + assert.equal(await readFile(join(vault, projectsDir, 'Record.md'), 'utf8'), recordBody) + const recordComments = await fetch(`${api}/api/comments/read?path=${encodeURIComponent(projectsDir + '/Record.md')}`, {headers:{Authorization:`Bearer ${token}`}}).then(response => response.json()) + assert.ok(JSON.stringify(recordComments).includes('Record discussion')) + await assert.rejects(readFile(join(vault, createdDir, 'data.csv')), {code:'ENOENT'}) + const databaseShot = await client.send('Page.captureScreenshot', {format:'png'}) + await writeFile(join(run, 'database-actions.png'), Buffer.from(databaseShot.data, 'base64')) + + // Move an actively edited note through the external host's public action. + const movingNote = 'inbox/Browse renamed/Read me.md' + await clickBrowse(`[data-browse-note="${movingNote}"]`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(movingNote)} && !!document.querySelector('.cm-content')`), 'note before move') + await client.evaluate(`document.querySelector('.cm-content').focus()`) + await shortcut('a','KeyA') + const movedBody = '# Read me\n\nMove this edited note: café 日本語. \n' + await client.send('Input.insertText', {text:movedBody}) + const movingComment = await fetch(`${api}/api/comments/write`, {method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},body:JSON.stringify({path:movingNote,comments:[{id:'moving-note-comment',body:'Move my discussion',createdAt:1,updatedAt:1}]})}) + assert.equal(movingComment.status,200) + await clickBrowse(`[data-note-move="${movingNote}"]`) + await answerFolderPrompt('inbox/Moved notes','Move') + const movedNote = 'inbox/Moved notes/Read me.md' + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(movedNote)}`), 'public moved note remains selected') + assert.equal(await readFile(join(vault,movedNote),'utf8'),movedBody) + await assert.rejects(readFile(join(vault,movingNote)),{code:'ENOENT'}) + const movedComments = await fetch(`${api}/api/comments/read?path=${encodeURIComponent(movedNote)}`,{headers:{Authorization:`Bearer ${token}`}}).then(response=>response.json()) + assert.ok(JSON.stringify(movedComments).includes('Move my discussion')) + const moveShot = await client.send('Page.captureScreenshot',{format:'png'}) + await writeFile(join(run,'note-move.png'),Buffer.from(moveShot.data,'base64')) + + // Keep an inbound note active so the rename must update its cached editor, + // then save another edit to prove it cannot restore the old link target. + const linkedNote = 'inbox/Moved notes/Links.md' + const linkedBody = 'See [[Read me#Heading|alias]] and `[[Read me]]`. \n' + const linkedWrite = await fetch(`${api}/api/notes/write`, {method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`},body:JSON.stringify({path:linkedNote,body:linkedBody})}) + assert.equal(linkedWrite.status,200) + await clickBrowse('[data-browse-parent]') + await until(() => client.evaluate(`!!document.querySelector('[data-browse-folder="Moved notes"]')`), 'moved folder in Browse') + await clickBrowse('[data-browse-folder="Moved notes"]') + await until(() => client.evaluate(`!!document.querySelector('[data-browse-note="${linkedNote}"]')`), 'inbound note in Browse') + await clickBrowse(`[data-browse-note="${linkedNote}"]`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(linkedNote)} && document.querySelector('.cm-content')?.textContent.includes('Read me')`), 'inbound editor before rename') + await clickBrowse(`[data-note-rename="${movedNote}"]`) + await answerFolderPrompt('Renamed guide','Rename') + const renamedNote = 'inbox/Moved notes/Renamed guide.md' + const rewrittenBody = linkedBody.replace('[[Read me#', '[[Renamed guide#') + await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Renamed guide')`), 'cached inbound editor updated') + assert.equal(await readFile(join(vault,linkedNote),'utf8'),rewrittenBody) + assert.equal(await readFile(join(vault,renamedNote),'utf8'),movedBody.replace('# Read me','# Renamed guide')) + await assert.rejects(readFile(join(vault,movedNote)),{code:'ENOENT'}) + const renamedComments = await fetch(`${api}/api/comments/read?path=${encodeURIComponent(renamedNote)}`,{headers:{Authorization:`Bearer ${token}`}}).then(response=>response.json()) + assert.ok(JSON.stringify(renamedComments).includes('Move my discussion')) + await clickBrowse('.cm-content > .cm-line:last-child') + await until(() => client.evaluate(`document.activeElement === document.querySelector('.cm-content') && document.querySelector('.cm-content').contains(getSelection()?.anchorNode)`), 'inbound editor caret after rename') + await shortcut('End','End') + await client.send('Input.insertText',{text:'After rename: café 日本語.'}) + assert.ok(await client.evaluate(`document.querySelector('.cm-content').textContent.includes('After rename: café 日本語.')`), 'Follow-up keystrokes did not reach the inbound editor') + await until(async () => (await readFile(join(vault, linkedNote),'utf8')).includes('After rename: café 日本語.'), 'follow-up edit saved') + await clickBrowse(`[data-browse-note="${renamedNote}"]`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(renamedNote)} && document.querySelector('.cm-content')?.textContent.includes('Renamed guide')`), 'renamed note opens') + const linkedAfterEdit = await readFile(join(vault,linkedNote),'utf8') + assert.ok(linkedAfterEdit.includes('[[Renamed guide#Heading|alias]]'), 'Saving the cached editor restored the old target') + assert.ok(linkedAfterEdit.includes('`[[Read me]]`'), 'Rename changed an inline-code link') + assert.ok(linkedAfterEdit.includes('After rename: café 日本語.'), 'Edit after rename was lost') + const renameShot = await client.send('Page.captureScreenshot',{format:'png'}) + await writeFile(join(run,'note-rename.png'),Buffer.from(renameShot.data,'base64')) + + // Exercise lifecycle through host-owned controls and real Go storage. + const lifecycleBody = movedBody.replace('# Read me','# Renamed guide') + await clickBrowse(`[data-note-archive="${renamedNote}"]`) + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-note-restore="archive/Moved notes/Renamed guide.md"]')`), 'archived note in public shell') + assert.equal(await readFile(join(vault,'archive/Moved notes/Renamed guide.md'),'utf8'),lifecycleBody) + assert.notEqual(await client.evaluate(`document.querySelector('[data-consumer-selection]').textContent`),'archive/Moved notes/Renamed guide.md','Archive closes the clean editor') + await assert.rejects(readFile(join(vault,renamedNote)),{code:'ENOENT'}) + await clickBrowse('[data-note-restore="archive/Moved notes/Renamed guide.md"]') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !document.querySelector('[data-note-restore="archive/Moved notes/Renamed guide.md"]')`),'restore archive') + const restoredNote='inbox/Moved notes/Renamed guide.md' + assert.equal(await readFile(join(vault,restoredNote),'utf8'),lifecycleBody) + await until(() => client.evaluate(`!!document.querySelector('[data-note-trash="${restoredNote}"]')`),'restored note in Browse') + await clickBrowse(`[data-browse-note="${restoredNote}"]`) + await until(() => client.evaluate(`document.querySelector('[data-consumer-selection]').textContent === ${JSON.stringify(restoredNote)}`),'restored note opens') + await clickBrowse(`[data-note-trash="${restoredNote}"]`) + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'trash confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`),'cancel trash') + assert.equal(await readFile(join(vault,restoredNote),'utf8'),lifecycleBody) + await clickBrowse(`[data-note-trash="${restoredNote}"]`) + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'confirmed trash') + await dialogButton('Move to Trash') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-note-restore="trash/Moved notes/Renamed guide.md"]')`),'trashed note in public shell') + assert.equal(await readFile(join(vault,'trash/Moved notes/Renamed guide.md'),'utf8'),lifecycleBody) + const trashedComments=await fetch(`${api}/api/comments/read?path=${encodeURIComponent('trash/Moved notes/Renamed guide.md')}`,{headers:{Authorization:`Bearer ${token}`}}).then(response=>response.json()) + assert.ok(JSON.stringify(trashedComments).includes('Move my discussion'),'Trash retained comments') + await clickBrowse('[data-note-restore="trash/Moved notes/Renamed guide.md"]') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-note-trash="${restoredNote}"]')`),'restore trash') + assert.equal(await readFile(join(vault,restoredNote),'utf8'),lifecycleBody) + await clickBrowse(`[data-note-trash="${restoredNote}"]`) + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'trash before permanent deletion') + await dialogButton('Move to Trash') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !!document.querySelector('[data-note-delete="trash/Moved notes/Renamed guide.md"]')`),'note ready for permanent deletion') + await clickBrowse('[data-note-delete="trash/Moved notes/Renamed guide.md"]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'permanent deletion confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`),'cancel permanent deletion') + assert.equal(await readFile(join(vault,'trash/Moved notes/Renamed guide.md'),'utf8'),lifecycleBody) + await clickBrowse('[data-note-delete="trash/Moved notes/Renamed guide.md"]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'confirm permanent deletion') + await dialogButton('Delete permanently') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed' && !document.querySelector('[data-note-delete="trash/Moved notes/Renamed guide.md"]')`),'permanently deleted note leaves public shell') + await assert.rejects(readFile(join(vault,'trash/Moved notes/Renamed guide.md')),{code:'ENOENT'}) + const deletedComments=await fetch(`${api}/api/comments/read?path=${encodeURIComponent('trash/Moved notes/Renamed guide.md')}`,{headers:{Authorization:`Bearer ${token}`}}).then(response=>response.json()) + assert.deepEqual(deletedComments,[]) + const lifecycleShot=await client.send('Page.captureScreenshot',{format:'png'}) + await writeFile(join(run,'note-lifecycle.png'),Buffer.from(lifecycleShot.data,'base64')) + + // The same public batch API backs mobile and Sidebar selections. + const batchPaths = ['inbox/Batch boundary/One.md', 'inbox/Batch boundary/Two.md'] + for (const [index, path] of batchPaths.entries()) { + const response = await fetch(`${api}/api/notes/write`, { method:'POST', headers:{'Content-Type':'application/json',Authorization:`Bearer ${token}`}, body:JSON.stringify({path,body:`# Batch ${index}\n\nExact café 日本語. \n`}) }) + assert.equal(response.status,200) + } + await until(() => client.evaluate(`window.packageShell.getShellSnapshot().notes.filter(note => note.path.startsWith('inbox/Batch boundary/')).length === 2`), 'batch notes indexed') + await clickBrowse('[data-batch-trash]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'batch confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`),'batch cancelled') + for (const path of batchPaths) assert.ok(await readFile(join(vault,path),'utf8')) + await clickBrowse('[data-batch-trash]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'batch confirmation again') + await dialogButton('Move to Trash') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed'`),'batch completed') + for (const [index,path] of batchPaths.entries()) { + await assert.rejects(readFile(join(vault,path)),{code:'ENOENT'}) + assert.equal(await readFile(join(vault,path.replace('inbox/','trash/')),'utf8'),`# Batch ${index}\n\nExact café 日本語. \n`) + } + await clickBrowse('[data-empty-trash]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'empty Trash confirmation') + await dialogButton('Cancel') + await until(() => client.evaluate(`window.lastBrowseAction === 'cancelled'`),'empty Trash cancelled') + for (const path of batchPaths) assert.ok(await readFile(join(vault,path.replace('inbox/','trash/')),'utf8')) + await clickBrowse('[data-empty-trash]') + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'empty Trash confirmation again') + await dialogButton('Empty trash') + await until(() => client.evaluate(`window.lastBrowseAction === 'completed'`),'empty Trash completed') + for (const path of batchPaths) await assert.rejects(readFile(join(vault,path.replace('inbox/','trash/'))),{code:'ENOENT'}) + + // Delete linked records through the actual grid context menu. + const rowsSchema = { + version: 1, idFieldId:'f_id', activeViewId:'table', + fields:[{id:'f_id',name:'ID',type:'text',hidden:true},{id:'f_name',name:'Name',type:'text'},{id:'f_status',name:'Status',type:'text'}], + views:[{id:'table',name:'Table',type:'table',filters:[],sorts:[],columnOrder:['f_name','f_status'],hiddenFieldIds:['f_id']}], + pages:{record1:`${projectsDir}/Record.md`,record2:`${projectsDir}/Two.md`} + } + const rowData = [{id:'record1',cells:{f_id:'record1',f_name:'Record',f_status:'Ready'}},{id:'record2',cells:{f_id:'record2',f_name:'Two',f_status:'Open'}}] + const secondPage = '# Two\n\nSecond exact page. \n' + await client.evaluate(`window.zen.writeNote(${JSON.stringify(projectsDir + '/Two.md')}, ${JSON.stringify(secondPage)})`) + await client.evaluate(`window.zen.writeDatabaseSchema(${JSON.stringify(projectsDir + '/data.csv')}, ${JSON.stringify(rowsSchema)}, ${JSON.stringify(rowData)})`) + await client.send('Page.navigate', { url: `${url}/?browse=1&rows=1` }) + await until(() => client.evaluate(`location.search.includes('rows=1') && window.packageBrowse?.getBrowseSnapshot().databases.some(row => row.title === 'Projects')`), 'seeded database indexed after reload') + await workspaceReady() + await client.evaluate(`window.packageNavigation.openNote(window.packageBrowse.getBrowseSnapshot().databases.find(row => row.title === 'Projects').path)`) + await until(() => client.evaluate(`document.querySelector('[role="grid"]')?.textContent.includes('Ready')`),'linked rows loaded') + async function deleteFirstRecord(choice) { + const point = await client.evaluate(`(() => { const rect=document.querySelector('[role="grid"] tbody tr td:nth-child(2)').getBoundingClientRect(); return {x:rect.x+rect.width/2,y:rect.y+rect.height/2} })()`) + await client.send('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'right',clickCount:1}) + await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'right',clickCount:1}) + await until(() => client.evaluate(`!![...document.querySelectorAll('[role="menu"] button')].find(button => button.textContent.trim() === 'Delete row')`),'row context menu') + const menuPoint = await client.evaluate(`(() => { const rect=[...document.querySelectorAll('[role="menu"] button')].find(button => button.textContent.trim() === 'Delete row').getBoundingClientRect(); return {x:rect.x+rect.width/2,y:rect.y+rect.height/2} })()`) + await client.send('Input.dispatchMouseEvent',{type:'mousePressed',...menuPoint,button:'left',clickCount:1}) + await client.send('Input.dispatchMouseEvent',{type:'mouseReleased',...menuPoint,button:'left',clickCount:1}) + await until(() => client.evaluate(`!!document.querySelector('[role="dialog"]')`),'linked page confirmation') + await dialogButton(choice) + } + await deleteFirstRecord('Keep note') + await until(async () => !(await readFile(join(vault,projectsDir,'data.csv'),'utf8')).includes('record1'),'first row detached') + assert.equal(await readFile(join(vault,projectsDir,'Record.md'),'utf8'),`---\nStatus: Ready\n---\n${recordBody}`) + assert.equal(JSON.parse(await readFile(join(vault,projectsDir,'schema.json'),'utf8')).pages.record1,undefined) + await deleteFirstRecord('Delete row + note') + await until(async () => !(await readFile(join(vault,projectsDir,'data.csv'),'utf8')).includes('record2'),'second row deleted') + await until(async () => { try { await readFile(join(vault,projectsDir,'Two.md')); return false } catch(error) { return error.code === 'ENOENT' } },'second page trashed') + assert.equal(await readFile(join(vault,projectsDir.replace('inbox/','trash/'),'Two.md'),'utf8'),`---\nStatus: Open\n---\n${secondPage}`) + const rowsShot = await client.send('Page.captureScreenshot',{format:'png'}) + await writeFile(join(run,'database-row-lifecycle.png'),Buffer.from(rowsShot.data,'base64')) + + assert.equal(pendingAbsenceProbes.size, 0, 'Expected collision probes were not sent') + assert.equal(absenceProbes.size, 2, 'Expected exactly two collision probes') + for (const probe of absenceProbes.values()) assert.equal(probe.status, 404, `Collision probe: ${probe.url}`) + const expectedAbsence = (requestId) => absenceProbes.get(requestId)?.status === 404 + for (const entry of networkErrors) { + if (!expectedAbsence(entry.networkRequestId) || entry.text !== 'Failed to load resource: the server responded with a status of 404 (Not Found)') errors.push(entry.text) + } + const unexpectedFailures = failed.filter(entry => entry.status !== 404 || !expectedAbsence(entry.requestId)) + assert.deepEqual(errors, [], 'Unexpected browser errors') + assert.deepEqual(unexpectedFailures, [], 'Unexpected failed application requests') + const result = { candidate: evidence.candidate, passed: ['installed editor loads', 'compiled styles', 'host React hook', 'exact UTF-8 save', 'public navigation', 'deferred heavy features', 'Mermaid SVG', 'Typst WASM and bundled fonts', 'Harper worker diagnostics', 'reload', 'public file attachment with exact saved bytes', 'public image paste with exact saved bytes', 'note switch stops insertion and remaining uploads', 'saved asset retained after note switch', 'host toolbar formatting with exact saved bytes', 'public undo and redo', 'Find field focus and native-back close', 'public selection query', 'empty-line list creation', 'no browser errors'], attachments: { attached, pasted, stale }, requests, errors, failed } + result.passed.push('native typing before first focus', 'caret above keyboard after viewport resize', 'physical selection toolbar clearance', 'host disposal restores defaults without note edits') + result.passed.push('immutable shell metadata and React hook', 'natural and pinned Browse order', 'hidden database records excluded', 'adjacent navigation saves exact bytes and stops at boundaries', 'shell subscription disposal') + result.passed.push('Browse folder/database React model', 'database and note navigation from public Browse preserves bytes', 'Browse live folder refresh and subscription disposal', 'public folder creation prompt', 'parent folder rename preserves active database and exact bytes', 'folder comments follow rename', 'confirmed database deletion closes its tab', 'folder deletion cancellation and confirmation') + result.passed.push('public database creation opens the canonical tab', 'public database rename preserves CSV, schema, records, and comments') + result.passed.push('public note move preserves active editor, exact saved bytes, and comments') + result.passed.push('public note rename preserves heading, exact bytes, and comments', 'cached inbound editor rewrites links and retains later edits') + result.passed.push('public archive and restore preserve exact bytes and canonical paths', 'public trash cancellation and confirmation preserve bytes and comments', 'public restore from trash', 'permanent deletion cancellation and confirmation remove content and comments') + result.passed.push('grid row deletion preserves standalone page properties and body', 'grid row deletion moves linked page after database save') + result.passed.push('public batch trash cancellation and exact bytes', 'public Empty Trash cancellation and deletion') + result.passed[result.passed.indexOf('no browser errors')] = 'no unexpected browser errors' + result.expectedAbsenceProbes = [...absenceProbes.values()] + result.networkErrors = networkErrors + result.failed = unexpectedFailures + await writeFile(join(run, 'result.json'), JSON.stringify(result, null, 2) + '\n') + console.log(`PASS: ${result.passed.join(', ')}\nEvidence: ${run}`) +} catch (error) { + // CI keeps only the console, so say what the page and the helpers saw + // before the evidence directory is uploaded or lost. + console.error(`Browser check failed: ${error.message}`) + console.error(`Page errors: ${JSON.stringify(errors, null, 2)}`) + console.error(`Failed requests: ${JSON.stringify(failed, null, 2)}`) + console.error(`Network log errors: ${JSON.stringify(networkErrors.map(entry => entry.text), null, 2)}`) + for (const [name, log] of Object.entries(logs)) { + const tail = log.split('\n').slice(-40).join('\n').trim() + if (tail) console.error(`--- ${name} output (tail) ---\n${tail}`) + } + if (client) { + const pageState = await client.evaluate(`({ + url: location.href, title: document.title, + editors: document.querySelectorAll('.cm-content').length, + dialogs: [...document.querySelectorAll('[role="dialog"]')].map(node => node.textContent.slice(0, 200)), + text: document.body.innerText.slice(0, 1500) + })`).catch((reason) => ({ unavailable: reason.message })) + console.error(`Page state: ${JSON.stringify(pageState, null, 2)}`) + await writeFile(join(run, 'failure.txt'), await client.evaluate('document.body.innerText').catch(() => '')) + const screenshot = await client.send('Page.captureScreenshot', { format: 'png' }).catch(() => null) + if (screenshot) await writeFile(join(run, 'failure.png'), Buffer.from(screenshot.data, 'base64')) + const geometry = await client.evaluate(`({ + url: location.href, + active: document.activeElement?.outerHTML.slice(0, 800), + firstEditorTyping: window.firstEditorTyping, + editors: [...document.querySelectorAll('.cm-content')].map(element => ({attributes:element.outerHTML.slice(0,400), bounds:element.getBoundingClientRect().toJSON()})), + scrollers: [...document.querySelectorAll('.cm-scroller')].map(element => ({bounds:element.getBoundingClientRect().toJSON(),scrollTop:element.scrollTop})) + })`).catch(() => null) + await writeFile(join(run, 'geometry.json'), JSON.stringify(geometry, null, 2)) + } + await writeFile(join(run, 'errors.json'), JSON.stringify({ errors, requests, failed, networkErrors, absenceProbes:[...absenceProbes.values()] }, null, 2)) + throw error +} finally { + client?.close() + for (const child of children.reverse()) child.kill() + for (const [name, log] of Object.entries(logs)) await writeFile(join(run, `${name}.log`), log) +} diff --git a/tooling/scripts/test-app-core-package.mjs b/tooling/scripts/test-app-core-package.mjs new file mode 100644 index 00000000..f89d410d --- /dev/null +++ b/tooling/scripts/test-app-core-package.mjs @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { cp, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { packAppCore } from './pack-app-core.mjs' +import { filesIn, runNpm } from './pack-shared-package.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const candidate = process.argv[2] + ? JSON.parse(await readFile(resolve(process.argv[2]), 'utf8')) + : await packAppCore() +// Keep the consumer for browser checks and diagnosis. It deliberately lives +// outside the checkout, and nested installation exposes undeclared dependencies. +const consumer = await mkdtemp(join(tmpdir(), 'zennotes core consumer & ')) +console.log(`Consumer: ${consumer}`) +for (const entry of [candidate, ...candidate.dependencies]) { + assert.equal(createHash('sha256').update(await readFile(entry.archive)).digest('hex'), entry.sha256, `Candidate checksum mismatch: ${entry.name}`) +} +const lock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8')) +const installedVersion = (name) => { + const version = lock.packages[`node_modules/${name}`]?.version + assert.ok(version, `Missing locked consumer dependency: ${name}`) + return version +} +const runtime = ['react', 'react-dom', 'zustand', '@codemirror/state', '@codemirror/view', '@codemirror/language', '@lezer/common', '@lezer/highlight'] +const development = ['typescript', 'vite', '@types/node', '@types/react', '@types/react-dom'] +await writeFile(join(consumer, 'package.json'), JSON.stringify({ + name: 'zennotes-isolated-core-consumer', private: true, type: 'module', version: '0.0.0', + description: 'Isolated package validation host', homepage: 'https://zennotes.org', + dependencies: Object.fromEntries([ + ...runtime.map((name) => [name, installedVersion(name)]), + ...[candidate, ...candidate.dependencies].map((entry) => [entry.name, `file:${entry.archive}`]) + ]), + devDependencies: Object.fromEntries(development.map((name) => [name, + name === 'vite' && process.env.ZEN_CORE_VITE_VERSION ? process.env.ZEN_CORE_VITE_VERSION : installedVersion(name) + ])) +}, null, 2) + '\n') +runNpm(['install', '--install-strategy=nested', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: consumer, stdio: 'inherit' }) +const require = createRequire(join(consumer, 'package.json')) +const core = createRequire(join(consumer, 'node_modules/@zennotes/app-core/dist/main.js')) +const installed = JSON.parse(await readFile(join(consumer, 'package-lock.json'), 'utf8')).packages +for (const name of runtime) { + assert.equal(core.resolve(name), require.resolve(name), `app-core has a second ${name} instance`) +} +// Drawing libraries own independent Zustand stores. Parser node properties, +// editor extensions, and React hooks must share runtime identity across packages. +for (const name of runtime.filter(name => name !== 'zustand')) { + const copies = Object.keys(installed).filter(path => path === `node_modules/${name}` || path.endsWith(`/node_modules/${name}`)) + assert.equal(copies.length, 1, `Installed multiple ${name} copies: ${copies.join(', ')}`) + for (const [path, entry] of Object.entries(installed)) { + if (!path || !(name in { ...entry.dependencies, ...entry.peerDependencies })) continue + const fromDependency = createRequire(join(consumer, path, 'package.json')) + assert.equal(fromDependency.resolve(name), require.resolve(name), `${path} has a second ${name} instance`) + } +} +for (const privatePath of ['store', 'dist/store.js', 'src/store.ts', 'lib/cm-format']) { + assert.throws(() => require.resolve(`@zennotes/app-core/${privatePath}`), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }) +} +await mkdir(join(consumer, 'src/bridge'), { recursive: true }) +// Exercise the current host adapter, with only public package imports. No source +// aliases or links back to the workspace are available to this build. +const bridge = (await readFile(join(root, 'apps/web/src/bridge/http-bridge.ts'), 'utf8')) + .replaceAll("from '@shared/", "from '@zennotes/shared-domain/") + .replace('supportsHarper: true', "supportsHarper: import.meta.env.VITE_ZEN_CORE_HARPER !== '0'") +await writeFile(join(consumer, 'src/bridge/http-bridge.ts'), bridge) +await cp(join(root, 'apps/web/src/env.d.ts'), join(consumer, 'src/env.d.ts')) +await writeFile(join(consumer, 'src/editor-types.ts'), ` +import { runEditorCommand, hasEditorSelection, type EditorCommand, type EditorInsertionTarget, type EditorViewport } from '@zennotes/app-core/editor' +import { getShellSnapshot, type ShellSnapshot } from '@zennotes/app-core/shell' +import { getBrowseSnapshot } from '@zennotes/app-core/browse' +import { requestNoteBatch, requestEmptyTrash, type NoteBatchResult, requestMoveNote, requestRenameNote, requestArchiveNote, requestTrashNote, restoreNote, requestDeleteNotePermanently, type NoteActionHost, type NoteActionResult } from '@zennotes/app-core/notes' +import { getTasksSnapshot, moveTaskToColumn, getTodayTasks } from '@zennotes/app-core/tasks' +import { getWorkspaceSnapshot, flushWorkspace } from '@zennotes/app-core/workspace' +import { getSettingsSnapshot, setEditorFontSize } from '@zennotes/app-core/settings' +import { getHostInfo } from '@zennotes/app-core/host' +import { getAppCommands, runAppCommand } from '@zennotes/app-core/commands' +import { prompt, confirm } from '@zennotes/app-core/dialogs' +const noteHost: NoteActionHost = { isCurrent: () => true } +const moveResult: Promise = requestMoveNote(noteHost, 'inbox/Note.md') +const renameResult: Promise = requestRenameNote(noteHost, 'inbox/Note.md') +const archiveResult: Promise = requestArchiveNote(noteHost, 'inbox/Note.md') +const trashResult: Promise = requestTrashNote(noteHost, 'inbox/Note.md') +const restoreResult: Promise = restoreNote(noteHost, 'trash/Note.md') +const deleteResult: Promise = requestDeleteNotePermanently(noteHost, 'trash/Note.md') +const batch: Promise = requestNoteBatch(noteHost, ['inbox/Note.md'], 'trash') +const empty: Promise = requestEmptyTrash(noteHost) +const taskGroup = getTasksSnapshot().groupBy +const workspace = getWorkspaceSnapshot() +const settings = getSettingsSnapshot() +const hostInfo = getHostInfo() +const descriptions = getAppCommands() +// @ts-expect-error Task snapshots cannot mutate store state. +getTasksSnapshot().tasks.push({}) +// @ts-expect-error Workspace snapshots contain no operations. +workspace.setState({}) +const command: EditorCommand = 'toggle-bold' +const handled: boolean = runEditorCommand(command) +const selected: boolean = hasEditorSelection() +// @ts-expect-error Only named semantic commands cross the public boundary. +runEditorCommand('dispatch') +// @ts-expect-error Hosts cannot inject arbitrary editor commands. +runEditorCommand(() => true) +// @ts-expect-error Hosts must capture a real target rather than manufacture one. +const forged: EditorInsertionTarget = {} +// @ts-expect-error The public target must not expose a CodeMirror view. +type PrivateView = EditorInsertionTarget['view'] +// @ts-expect-error Host geometry never exposes an editor DOM node. +type PrivateElement = EditorViewport['editor']['dom'] +function checkBounds(viewport: EditorViewport) { + // @ts-expect-error Measured geometry is an immutable snapshot. + viewport.editor.bottom = 100 +} +const snapshot: ShellSnapshot = getShellSnapshot() +// @ts-expect-error Hosts cannot mutate the published note index. +snapshot.notes.push({}) +// @ts-expect-error Metadata is immutable, including each note. +snapshot.notes[0].title = 'Changed' +// @ts-expect-error Body contents are not part of the shell boundary. +type PrivateBody = ShellSnapshot['notes'][number]['body'] +// @ts-expect-error Store operations do not leak through the snapshot. +snapshot.setState({}) +const browse = getBrowseSnapshot() +// @ts-expect-error Folder rows are immutable copies. +browse.folders[0].directory = 'Changed' +// @ts-expect-error Enabled date settings are read-only. +browse.dateDirectories.daily = 'Changed' +// @ts-expect-error The full settings object remains private. +browse.vaultSettings +`) +await writeFile(join(consumer, 'src/main.tsx'), ` +import { installBridge } from './bridge/http-bridge' +installBridge() +// Match native preference/bootstrap ordering before evaluating app-core. +const { renderZenNotesApp } = await import('@zennotes/app-core/main') +const navigation = await import('@zennotes/app-core/navigation') +navigation.installHomeGuard() +const editor = await import('@zennotes/app-core/editor') +const shell = await import('@zennotes/app-core/shell') +const browse = await import('@zennotes/app-core/browse') +const notes = await import('@zennotes/app-core/notes') +if (new URLSearchParams(location.search).has('host')) { + const toolbar = document.createElement('div') + toolbar.id = 'host-keyboard-toolbar' + toolbar.textContent = 'Host keyboard toolbar' + toolbar.style.cssText = 'position:fixed;bottom:0;left:0;right:0;height:80px;background:#524333;color:white;z-index:10000;pointer-events:none' + document.body.append(toolbar) + const overlap = (bounds: {top:number;bottom:number;left:number;right:number}, overlay: HTMLElement | null) => { + if (!overlay) return 0 + const bar = overlay.getBoundingClientRect() + if (bar.top >= bounds.bottom || bar.bottom <= bounds.top || bar.left >= bounds.right || bar.right <= bounds.left) return 0 + return Math.ceil(Math.min(bounds.bottom - bounds.top, bounds.bottom - bar.top + 8)) + } + const registration = editor.installEditorHost({ + nativeTyping: true, + measureBottomInsets: viewport => ({ + layout: overlap(viewport.editor, document.getElementById('host-selection-toolbar')), + scroll: overlap(viewport.scroll, document.getElementById('host-keyboard-toolbar')) + }) + }) + const focus = (event: FocusEvent) => { + if (!(event.target instanceof HTMLElement) || !event.target.classList.contains('cm-content')) return + Object.assign(window, { firstEditorTyping: ['autocorrect','autocapitalize','spellcheck','writingsuggestions'].map(name => (event.target as HTMLElement).getAttribute(name)) }) + document.removeEventListener('focus', focus, true) + } + document.addEventListener('focus', focus, true) + Object.assign(window, { packageHost: registration }) +} +;(window as unknown as { EXCALIDRAW_ASSET_PATH: string }).EXCALIDRAW_ASSET_PATH = '/excalidraw-assets/' +const root = document.getElementById('root')! +renderZenNotesApp(root) +// A host-rendered observer exercises the React hook from outside the package. +const { createRoot } = await import('react-dom/client') +const { useState } = await import('react') +function Browse() { + const [directory, setDirectory] = useState('') + const lifecycle = shell.useShellSnapshot() + const snapshot = browse.useBrowseSnapshot() + const rows = browse.getBrowseDirectory(snapshot, directory) + // This isolated fixture has one fixed vault; native hosts capture their actual vault token. + const host = { isCurrent: () => true } + const run = async (action: () => Promise) => { + Object.assign(window, { lastBrowseAction: null }) + const result = await action() + Object.assign(window, { lastBrowseAction: result }) + } + return
+ {directory || 'All notes'} + + + + + + {rows.folders.map(row =>
+ + + +
)} + {rows.databases.map(row =>
+ + +
)} + {rows.notes.map(row =>
+ + + + + +
)} + {lifecycle.notes.filter(note => note.folder === 'archive' || note.folder === 'trash').map(note =>
+ {note.title} + + {note.folder === 'trash' && } +
)} +
+} +function Selection() { + const path = navigation.useSelectedNotePath() + const snapshot = shell.useShellSnapshot() + return <> + {path ?? 'Home'} + {snapshot.selectedNote?.title ?? 'No note'} + {new URLSearchParams(location.search).has('browse') && } + {new URLSearchParams(location.search).has('shell') &&
+ {(['previous', 'next'] as const).map(direction => )} +
} + {new URLSearchParams(location.search).has('commands') &&
+ {(['toggle-bold', 'undo', 'redo', 'open-search', 'set-task-list'] as const).map(command => + + )} +
} + +} +createRoot(document.getElementById('selection')!).render() +Object.assign(window, { packageNavigation: navigation, packageEditor: editor, packageShell: shell, packageBrowse: browse }) +`) +await writeFile(join(consumer, 'index.html'), 'ZenNotes package consumer
') +await writeFile(join(consumer, 'vite.config.ts'), ` +import { defineConfig } from 'vite' +import { zenNotesAssets } from '@zennotes/app-core/vite' +export default defineConfig({ + base: './', + plugins: zenNotesAssets({ harper: process.env.ZEN_CORE_HARPER !== '0' }), + build: { target: 'es2022', manifest: true, outDir: process.env.ZEN_CORE_OUT_DIR || 'dist' }, + preview: { host: '127.0.0.1', proxy: { + '/api': { target: process.env.ZEN_CORE_SERVER, ws: true }, + '/assets-data': { target: process.env.ZEN_CORE_SERVER } + } } +}) +`) +await writeFile(join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx', + strict: true, noEmit: true, resolveJsonModule: true, esModuleInterop: true, + lib: ['ES2022', 'DOM', 'DOM.Iterable'], types: ['vite/client', 'node'] + }, include: ['src', 'vite.config.ts'] +}, null, 2)) +execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '--noEmit'], { cwd: consumer, stdio: 'inherit' }) +execFileSync(process.execPath, [join(dirname(require.resolve('vite/package.json')), 'bin/vite.js'), 'build'], { cwd: consumer, stdio: 'inherit' }) +const output = await filesIn(join(consumer, 'dist')) +assert.ok(output.some((file) => /harper.*\.wasm$/.test(file)), 'Harper WASM is missing') +assert.ok(output.some((file) => /typst.*\.wasm$/.test(file)), 'Typst WASM is missing') +assert.ok(output.some((file) => /excalidraw-assets\/fonts\/.+\.woff2$/.test(file)), 'Drawing fonts are missing') +assert.ok(output.some((file) => /KaTeX.+\.woff2$/.test(file)), 'Math fonts are missing') +execFileSync(process.execPath, [join(dirname(require.resolve('vite/package.json')), 'bin/vite.js'), 'build'], { + cwd: consumer, stdio: 'inherit', + env: { ...process.env, ZEN_CORE_HARPER: '0', VITE_ZEN_CORE_HARPER: '0', ZEN_CORE_OUT_DIR: 'dist-native-spelling' } +}) +assert.ok(!(await filesIn(join(consumer, 'dist-native-spelling'))).some((file) => /harper.*\.wasm$/.test(file)), 'Native-spelling build included Harper WASM') +const result = { consumer, candidate, passed: ['candidate checksums', 'nested install', 'singleton peers', 'private exports rejected', 'public typecheck', 'production build', 'font and WASM assets', 'native-spelling build omits Harper'] } +await writeFile(join(consumer, 'result.json'), JSON.stringify(result, null, 2) + '\n') +await writeFile(join(root, 'dist/shared-packages/app-core-consumer.json'), JSON.stringify(result, null, 2) + '\n') +console.log(`PASS: ${result.passed.join(', ')}\nEvidence: ${join(consumer, 'result.json')}`) diff --git a/tooling/scripts/test-shared-packages.mjs b/tooling/scripts/test-shared-packages.mjs new file mode 100644 index 00000000..d85e39d0 --- /dev/null +++ b/tooling/scripts/test-shared-packages.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { runNpm } from './pack-shared-package.mjs' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const require = createRequire(join(repoRoot, 'packages/bridge-contract/package.json')) +const consumer = await mkdtemp(join(tmpdir(), 'zennotes contract & domain consumer ')) + +try { + const packed = JSON.parse(execFileSync(process.execPath, [ + join(repoRoot, 'tooling/scripts/pack-shared-package.mjs'), 'bridge-contract' + ], { cwd: repoRoot, encoding: 'utf8' })) + const domain = JSON.parse(execFileSync(process.execPath, [ + join(repoRoot, 'tooling/scripts/pack-shared-package.mjs'), 'shared-domain' + ], { cwd: repoRoot, encoding: 'utf8' })) + await writeFile(join(consumer, 'package.json'), JSON.stringify({ + name: 'contract-consumer', private: true, type: 'module' + })) + // npm ci caches tarballs but never the registry metadata that resolving a + // dependency range needs, so a fully offline install fails on a fresh CI cache. + runNpm([ + 'install', packed.archive, domain.archive, '--ignore-scripts', '--prefer-offline', '--no-audit', '--no-fund' + ], { cwd: consumer, stdio: 'inherit' }) + const installedRoot = join(consumer, 'node_modules/@zennotes/bridge-contract') + const installed = JSON.parse(await readFile(join(installedRoot, 'package.json'), 'utf8')) + assert.equal(installed.version, packed.version) + assert.equal(installed.private, undefined) + assert.deepEqual(installed.dependencies ?? {}, {}) + const bridgeImports = Object.keys(installed.exports).map((path) => `${installed.name}/${path.slice(2)}`) + + const domainRoot = join(consumer, 'node_modules/@zennotes/shared-domain/dist') + const modules = (await readdir(domainRoot, { recursive: true })).filter((path) => path.endsWith('.js')) + const imports = [...bridgeImports, ...modules.map((path) => `@zennotes/shared-domain/${path.replace(/\\/g, '/').slice(0, -3)}`)] + await writeFile(join(consumer, 'runtime.mjs'), ` +import assert from 'node:assert/strict' +import { PORTABLE_PREF_KEYS } from '@zennotes/bridge-contract/app-config' +import { installZenBridge } from '@zennotes/bridge-contract/bridge' +import { IPC } from '@zennotes/bridge-contract/ipc' +assert.ok(PORTABLE_PREF_KEYS.includes('vimMode')) +assert.equal(typeof installZenBridge, 'function') +assert.ok(Object.keys(IPC).length > 0) +for (const name of ${JSON.stringify(imports)}) await import(name) +`) + execFileSync(process.execPath, ['runtime.mjs'], { cwd: consumer, stdio: 'inherit' }) + await writeFile(join(consumer, 'consumer.ts'), ` +import type { ZenBridge } from '@zennotes/bridge-contract/bridge' +import type { VaultTask } from '@zennotes/bridge-contract/tasks' +import type { AppConfigPortable } from '@zennotes/bridge-contract/app-config' +export const platform: Awaited> = 'darwin' +export const priority: VaultTask['priority'] = 'high' +export const prefs: AppConfigPortable = { vimMode: true } +${imports.map((name, index) => `import * as module${index} from '${name}'`).join('\n')} +`) + await writeFile(join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', + strict: true, types: [], lib: ['ES2022', 'DOM'], noEmit: true + }, + include: ['consumer.ts'] + })) + execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '-p', 'tsconfig.json'], { + cwd: consumer, stdio: 'inherit' + }) + process.stdout.write('Contract and domain packages install, import, and typecheck without workspace source.\n') +} finally { + await rm(consumer, { recursive: true, force: true }) +} diff --git a/tooling/scripts/web-dist-lock.mjs b/tooling/scripts/web-dist-lock.mjs index bd795991..cdf89c13 100644 --- a/tooling/scripts/web-dist-lock.mjs +++ b/tooling/scripts/web-dist-lock.mjs @@ -15,8 +15,8 @@ const repoRoot = resolve(scriptDir, '..', '..') // guards so a leftover lock is easy to spot and delete by hand. export const WEB_DIST_LOCK_DIR = resolve(repoRoot, 'apps/server/web/.web-dist.lock') const OWNER_FILE = resolve(WEB_DIST_LOCK_DIR, 'owner.json') -// A holder still running after this long is presumed wedged; a vite build plus -// a directory copy is a matter of seconds. +// Only an ownerless lock can expire by age. A cold Go build may legitimately +// hold the lock much longer while the compiler reads embedded browser assets. const STALE_MS = 10 * 60 * 1000 const POLL_MS = 50 // Handed to child processes so a locked script that shells out to another @@ -64,9 +64,7 @@ async function lockIsStale() { return false } } - // A recycled pid can make a dead holder look alive, so age is also checked. - if (!pidIsAlive(owner.pid)) return true - return Date.now() - (owner.startedAt ?? 0) > STALE_MS + return !pidIsAlive(owner.pid) } async function releaseLock(token) { diff --git a/tooling/scripts/web-dist-lock.test.mjs b/tooling/scripts/web-dist-lock.test.mjs new file mode 100644 index 00000000..437d5926 --- /dev/null +++ b/tooling/scripts/web-dist-lock.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { setTimeout } from 'node:timers/promises' +import test from 'node:test' + +test('a second producer waits for a live owner even after ten minutes', async () => { + const root = await mkdtemp(join(tmpdir(), 'zennotes-lock-test-')) + const script = join(root, 'tooling/scripts/web-dist-lock.mjs') + let pending + try { + await mkdir(dirname(script), { recursive: true }) + await copyFile(new URL('./web-dist-lock.mjs', import.meta.url), script) + const { WEB_DIST_LOCK_DIR, withWebDistLock } = await import(pathToFileURL(script).href) + await mkdir(WEB_DIST_LOCK_DIR, { recursive: true }) + await writeFile(join(WEB_DIST_LOCK_DIR, 'owner.json'), JSON.stringify({ + token: 'slow-compiler', pid: process.pid, startedAt: Date.now() - 60 * 60 * 1000 + })) + let acquired = false + pending = withWebDistLock(async () => { acquired = true }) + await setTimeout(150) + try { + assert.equal(acquired, false, 'a live compiler lost its lock based on age') + } finally { + await rm(WEB_DIST_LOCK_DIR, { recursive: true, force: true }) + await pending + } + assert.equal(acquired, true, 'the waiting producer did not acquire the released lock') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/tooling/server-repository/.dockerignore b/tooling/server-repository/.dockerignore new file mode 100644 index 00000000..9c1d413c --- /dev/null +++ b/tooling/server-repository/.dockerignore @@ -0,0 +1,4 @@ +.git +bin +web/dist +result diff --git a/tooling/server-repository/.github/workflows/ci.yml b/tooling/server-repository/.github/workflows/ci.yml new file mode 100644 index 00000000..1b974ced --- /dev/null +++ b/tooling/server-repository/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: Server boundary +on: [push, pull_request, workflow_dispatch] +permissions: + contents: read +jobs: + go: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: go vet ./... + - run: go test ./... + - run: go build -trimpath -o bin/server-api ./cmd/zennotes-server + - run: go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist + - run: go test -tags=embed_web ./web + - run: go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: false + nix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Build server with pinned Nix toolchain and browser artifact + run: docker run --rm -v "$PWD:/source:ro" nixos/nix@sha256:7a007c766426c1877758ddc5cb87a965ac131fc78c582ce0083d922d51ae945c sh -c 'cd /source && nix-build --no-out-link' diff --git a/tooling/server-repository/.github/workflows/docker-publish.yml b/tooling/server-repository/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..7b800f5b --- /dev/null +++ b/tooling/server-repository/.github/workflows/docker-publish.yml @@ -0,0 +1,60 @@ +name: Publish Docker image after channel cutover + +on: + workflow_dispatch: + inputs: + tag: + description: "Extra tag to publish (in addition to latest), e.g. 2.0.1" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE: adibhanna/zennotes + +jobs: + publish: + name: Build and push multi-arch image + runs-on: ubuntu-latest + environment: server-docker-publisher + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Derive image tags and labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/tooling/server-repository/.github/workflows/release.yml b/tooling/server-repository/.github/workflows/release.yml new file mode 100644 index 00000000..e108b636 --- /dev/null +++ b/tooling/server-repository/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Prepare server release +on: + workflow_dispatch: + inputs: + source_commit: + description: Reviewed full source commit SHA + type: string + required: true + tag: + description: New server release tag (vX.Y.Z) + type: string + required: true +permissions: + contents: read +jobs: + binaries: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_commit }} + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - env: + SOURCE_COMMIT: ${{ inputs.source_commit }} + TAG: ${{ inputs.tag }} + run: | + test "$(git rev-parse HEAD)" = "$SOURCE_COMMIT" + printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' + test "$TAG" = "v$(python3 -c 'import json; print(json.load(open("release.json"))["version"])')" + - run: go vet ./... && go test ./... + - run: go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist + - run: go test -tags=embed_web ./web + - name: Cross-compile binaries and checksums + run: | + mkdir release + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + os="${target%/*}" + arch="${target#*/}" + suffix=""; if [ "$os" = windows ]; then suffix=.exe; fi + CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -tags=embed_web -trimpath -ldflags="-s -w" -o "release/zennotes-server-$os-$arch$suffix" ./cmd/zennotes-server + done + cp LICENSE web-artifact/manifest.json release/ + cd release && sha256sum zennotes-server-* > SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + name: server-release + path: release/* + draft: + needs: binaries + runs-on: ubuntu-latest + environment: server-release + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: server-release + path: release + - env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: gh release create "$TAG" release/* --target "$SOURCE_COMMIT" --title "$TAG" --draft --notes "Self-hosted server with a pinned browser artifact. Complete candidate install and rollback checks before publication." diff --git a/tooling/server-repository/.gitignore b/tooling/server-repository/.gitignore new file mode 100644 index 00000000..6a1031cf --- /dev/null +++ b/tooling/server-repository/.gitignore @@ -0,0 +1,4 @@ +/bin/ +/web/dist/ +/web-artifact/*.tgz +/result diff --git a/tooling/server-repository/Dockerfile b/tooling/server-repository/Dockerfile new file mode 100644 index 00000000..bde5170d --- /dev/null +++ b/tooling/server-repository/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7 +FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS build +WORKDIR /source +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# Dirty source is an explicit local rehearsal option; release builds use false. +ARG ALLOW_DIRTY=false +RUN if [ "$ALLOW_DIRTY" = true ]; then go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist -allow-dirty; else go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist; fi +RUN go test -tags=embed_web ./web +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH go build -tags=embed_web -trimpath -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server +FROM scratch +LABEL org.opencontainers.image.title="ZenNotes" \ + org.opencontainers.image.description="Self-hosted ZenNotes server with a pinned browser artifact." \ + org.opencontainers.image.source="https://github.com/ZenNotes/znserver" +COPY --from=build /out/zennotes-server /zennotes-server +ENV ZENNOTES_BIND=0.0.0.0:7878 \ + ZENNOTES_CONFIG_PATH=/data/server.json \ + ZENNOTES_DEFAULT_VAULT_PATH=/workspace \ + ZENNOTES_BROWSE_ROOTS=/workspace +USER 65532:65532 +EXPOSE 7878 +VOLUME ["/workspace", "/data"] +ENTRYPOINT ["/zennotes-server"] diff --git a/tooling/server-repository/README.md b/tooling/server-repository/README.md new file mode 100644 index 00000000..7b91000a --- /dev/null +++ b/tooling/server-repository/README.md @@ -0,0 +1,54 @@ +# ZenNotes self-hosted server + +The Go server owns the self-hosted API, authentication, filesystem access, and +vault watching. The main ZenNotes repository owns the browser app and publishes +its immutable build. Laravel Cloud remains a separate service. + +## Build and verify + +Go 1.25 or later is sufficient for API development: + +```sh +go vet ./... +go test ./... +go run ./cmd/zennotes-server +``` + +Production bundles include the browser artifact pinned in +`web-artifact/manifest.json`: + +```sh +go run ./cmd/prepare-web -manifest web-artifact/manifest.json -output web/dist +go test -tags=embed_web ./web +go build -tags=embed_web -trimpath -o bin/zennotes-server ./cmd/zennotes-server +``` + +No Node install or sibling source checkout is needed. The importer checks +protocol, source, tar paths/types, size, archive SHA-256, and every file checksum. +The reviewed manifest is the trust anchor. Dirty local candidates require +`-allow-dirty`; release and CI paths deliberately omit that flag. + +## Distribution + +`docker build .` builds the same Go-only distribution. Preserve image ownership +and the existing `adibhanna/zennotes` image when the publisher cutover is approved. +Runtime defaults remain UID 65532, port 7878, `/workspace`, `/data/server.json`, and +the existing `ZENNOTES_*` variables. Existing authentication/base-path behavior +and note bytes are covered by HTTP fixtures under `internal/httpserver/testdata`. + +`nix-build` uses the pinned browser archive and Go vendor hash in `release.json`. +It never compiles frontend source. A local rehearsal can use +`nix-build --arg allowDirty true` with an adjacent candidate archive. + +To update the web app, review a new manifest, run Go/import/embed/HTTP tests, and +release it with the server source. Roll back by selecting the prior server image +or binary and restoring its pin; no vault migration is introduced. + +## Extraction gate + +This directory is prepared in a local rehearsal before publication. Do not enable +its Docker publisher while the main repository still publishes the same tags. +First approve and publish a clean browser artifact, extract approved history, +verify destination CI and candidate installation, then switch one channel at a +time. The main repository retains its source and previous release until a verified +destination release and rollback rehearsal exist. diff --git a/tooling/server-repository/default.nix b/tooling/server-repository/default.nix new file mode 100644 index 00000000..90cd74b2 --- /dev/null +++ b/tooling/server-repository/default.nix @@ -0,0 +1,21 @@ +{ pkgs ? import {}, allowDirty ? false }: +let + release = builtins.fromJSON (builtins.readFile ./release.json); + manifest = builtins.fromJSON (builtins.readFile ./web-artifact/manifest.json); + localArchive = ./web-artifact + "/${manifest.archive.file}"; + archive = if builtins.pathExists localArchive then localArchive else pkgs.fetchurl { + inherit (manifest.archive) url sha256; + }; +in pkgs.callPackage ./nix/package.nix { + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: pkgs.lib.cleanSourceFilter path type + && !(pkgs.lib.hasPrefix (toString ./. + "/web/dist") path) + && !(pkgs.lib.hasPrefix (toString ./. + "/web-artifact") path) + && builtins.baseNameOf path != "result"; + }; + inherit (release) version vendorHash; + inherit allowDirty; + webManifest = ./web-artifact/manifest.json; + webArchive = archive; +} diff --git a/tooling/server-repository/nix/package.nix b/tooling/server-repository/nix/package.nix new file mode 100644 index 00000000..66e845c7 --- /dev/null +++ b/tooling/server-repository/nix/package.nix @@ -0,0 +1,19 @@ +{ lib, buildGoModule, src, version, vendorHash, webManifest, webArchive, allowDirty ? false }: +buildGoModule { + pname = "zennotes-server"; + inherit src version vendorHash; + subPackages = [ "cmd/zennotes-server" ]; + tags = [ "embed_web" ]; + ldflags = [ "-s" "-w" ]; + postConfigure = '' + go run ./cmd/prepare-web -manifest ${webManifest} -archive ${webArchive} -output web/dist ${lib.optionalString allowDirty "-allow-dirty"} + go test -tags=embed_web ./web + ''; + meta = { + description = "A server API for hosting remote ZenNotes vaults"; + homepage = "https://zennotes.org/"; + license = lib.licenses.mit; + mainProgram = "zennotes-server"; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; +}