Forráskód Böngészése

feat(tauri): D4 page model, two-flow index, and related fixes

- Add catch-all [...slug].vue route — maps file paths to PageView/TagView
- Add PageView with auto-save (500ms debounce), rename, backlinks panel
- Add TagView for virtual tag aggregation from index
- Add usePageNavigation composable with DB lookup + conventional path fallback
- Add indexFile store method, reindex() for manual rebuild, isIndexing state
- Replace fullReindex on startup with syncIndex (hash-based skip of unchanged files)
- Add content_hash column to schema with PRAGMA-guarded migration
- Normalize absolute paths from list_directory via toRelativePath()
- Fix backlink navigation: store relative paths, not absolute
- Fix rename: removeFileFromIndex old path before indexing new
- Fix flushSave: writeFile + indexFile both called on unmount
- Fix content loading: onBeforeRouteUpdate instead of watch on computed ref
- Fix XSS: escapeHtml() on excerpts before v-html
- Fix journal navigation: @select-date pushes query param
- Add native File menu (Reindex Library Cmd+Shift+R, Quit Enesis Cmd+Q)
- Add exit_app Rust command
- Add computePageTitle() fallback to filename stem
- Add toRelativePath() and escapeHtml() to parse.ts
- Switch PageView content binding to useVModel from @vueuse/core
- Create ISSUES.md with 12 tracked edge cases
- 64 tests passing, biome clean
Zander Hawke 5 órája
szülő
commit
8cdcb648a6

+ 119 - 0
AGENTS.md

@@ -529,3 +529,122 @@ Both helpers call `view.focus()` + `view.dispatch(tr.setSelection(...))` — abs
 | Typing `(` for auto-close | `page.keyboard.type()` | Must go through auto-close handler |
 | Typing `` ``` `` | `page.keyboard.type()` | Must go through `tripleBacktickRule` |
 | Paste | `page.keyboard.press('Control+V')` | Must go through paste plugin |
+
+---
+
+## D4 — Page Model (Tauri app)
+
+The `@enesis/tauri` app adds page-level navigation and editing on top of the core editor. The page model lives in `apps/tauri/src/`.
+
+### Route architecture
+
+A single catch-all route `[...slug].vue` maps workspace-relative file paths to views:
+
+| Route | Classifier | Component |
+|---|---|---|
+| `/` | `index.vue` | Journal feed (unchanged) |
+| `/journals/2026-06-22.md` | starts with `journals/`, has `.md` | `PageView` |
+| `/pages/Architecture.md` | default case | `PageView` |
+| `/tag/design` | starts with `tag/`, no `.md` | `TagView` |
+| `/Readme.md` | default case | `PageView` |
+
+The file path IS the URL — no fragile ID scheme. `encodeURIComponent` is used for path segments in routes.
+
+### Data flow
+
+```
+Click [[Page Name]] in editor
+  ↓
+EditorBlock click handler (packages/editor/src/components/EditorBlock.vue:756)
+  emits "page-ref-clicked" { pageName, alias }
+  ↓
+Event bubbles through Editor → JournalDay → JournalView (tauri)
+  ↓
+usePageNavigation().navigateToPage(pageName)
+  ├── Look up title in SQLite index → SELECT path FROM pages WHERE title = ?
+  ├── If found: router.push(`/${path}`)
+  └── If not found: construct conventional path (pages/<safeName>.md), push.
+                     No file is created on disk. (D4 change: creation is lazy.)
+```
+
+### Page auto-save flow (`pages/[...slug].vue`)
+
+```
+User types in editor
+  ↓
+localContent ref in PageView updates via v-model:content
+  ↓
+watch(localContent) → emit("update:content", val)
+  ↓
+[...slug].vue sets content = $event
+  ↓
+watch(content) → 500ms debounce → fs.writeFile(fullPath, val)
+                                    ↓
+                               existsOnDisk = true
+                                    ↓
+                               ws.indexFile(relativePath, val)
+                                    ↓
+                               scanFiles() → sidebar refreshes
+```
+
+Key behaviors:
+- **No file on mount.** If the file doesn't exist on disk (`readFile` throws), `existsOnDisk` stays `false`. Empty content on a non-existent file is never written.
+- **Debounced at 500ms.** Pending saves are flushed on unmount (`onUnmounted`) so quick navigation doesn't lose data.
+- **Renaming** renames the physical file via `@tauri-apps/plugin-fs`. If no content exists yet, just navigates to the new path.
+
+### Index two-flow
+
+The indexer operates on two paths:
+
+| | `syncIndex` (startup) | `fullReindex` (File → Reindex Library) |
+|---|---|---|
+| **Intent** | Fast incremental sync | Complete rebuild |
+| **Wipes first?** | No — keeps existing data | Yes — `DELETE FROM pages` |
+| **Unchanged files** | Skip (hash match) | Always re-indexed |
+| **Deleted files** | Cleaned up | Cleaned up |
+| **When** | Every app launch | User-initiated (`Cmd+Shift+R`) |
+
+**`syncIndex`** reads every `.md` file, computes its djb2 content hash, and compares against `pages.content_hash`. If the hash matches, the file is skipped entirely — no `splitMarkdownIntoBlocks`, no block diff, no SQL writes. Files not on disk are cascade-removed from the index.
+
+> **Watcher race:** The file watcher starts before sync completes. If a file changes mid-sync, the watcher fires independently. Worst case: a stale entry that corrects on the next change.
+
+### Indexer title fallback
+
+`lib/indexer.ts` uses `computePageTitle()` from `lib/parse.ts` to guarantee every indexed page has a non-empty title:
+
+```
+extractTitle(content) → first # Heading 1, or ""
+  ↓
+"" → filePath.split("/").pop().replace(".md", "")  → filename stem
+  ↓
+Always non-empty → stored in pages.title
+```
+
+This makes two critical queries reliable:
+1. **Sidebar** (`useSidebarIndex`): `SELECT title FROM pages WHERE title != ''` — no page is invisible.
+2. **Nav lookup** (`usePageNavigation`): `SELECT path FROM pages WHERE title = ?` — `[[New Page]]` resolves correctly even without a heading.
+
+### Component responsibility split
+
+| Component | Role | Props in | Events out |
+|---|---|---|---|
+| `[...slug].vue` | Route controller: classify, load file, query backlinks, auto-save, rename | route params | — |
+| `PageView.vue` | Presentation: title input, editor, backlinks panel | `path`, `filename`, `content`, `backlinks` | `update:content`, `rename`, `backlink-clicked` |
+| `TagView.vue` | Virtual tag aggregation from index | `tag` | — |
+| `usePageNavigation` | Shared nav logic | — | — |
+
+### Notable edge cases (documented in JSDoc)
+
+- No mtime check on save — auto-save overwrites without conflict detection
+- `modified_at` is index-time (not file mtime), making block ordering unreliable
+- Excerpt `String.slice()` can split 4-byte Unicode characters
+- Rename doesn't update existing `[[links]]` in other files
+- Backlink counts are stale after rename (links store title at time of creation)
+- TagView has no pagination (50-result LIMIT)
+
+### Running tests
+
+```bash
+pnpm --filter @enesis/tauri test    # or vitest run in apps/tauri
+biome check apps/tauri/src/         # run from project root
+```

+ 210 - 0
ISSUES.md

@@ -0,0 +1,210 @@
+# Known Issues & Deferred Work
+
+Tracked at source via `@remarks` JSDoc tags. This file consolidates them for
+planning. Last updated: 2026-06-22 (D4). Paths are relative to repo root.
+
+---
+
+## Cross-file links / rename
+
+### 1. `[[link]]` targets stale after rename
+
+**Files:** `apps/tauri/src/components/PageView.vue:26`, `apps/tauri/src/composables/usePageNavigation.ts:33`, `apps/tauri/src/composables/useSidebarIndex.ts:10`
+
+Renaming a page does not update existing `[[links]]` in other files that
+reference the old title. Backlink counts also reflect the stale title.
+
+- **Risk:** Users navigate to dead pages via stale links
+- **Fix:** After rename, query SQLite for all `links.target = oldTitle`, update
+  file contents and re-index
+- **Deferred because:** No cross-file mutation pattern exists yet
+
+### 2. Block ref scroll target not implemented
+
+**File:** `apps/tauri/src/pages/[...slug].vue:43`
+
+`#block-{id}` is appended to the URL when navigating to a block ref
+(`((block-id))`), but the receiving PageView has no `onMounted` scroll-to
+logic. The hash fragment is in the URL for future use only.
+
+- **Risk:** Low — user lands at top of page instead of scrolled to block
+- **Fix:** `onMounted` → check `route.hash`, query DOM for `[data-block-id]`,
+  `scrollIntoView()`
+- **Deferred because:** Block refs are not yet first-class navigation targets
+
+---
+
+## Data integrity
+
+### 3. No mtime check on save
+
+**Files:** `apps/tauri/src/pages/[...slug].vue:36`, `components/JournalDay.vue`
+
+Auto-save always overwrites the file on disk regardless of whether an external
+editor changed it between debounce triggers.
+
+- **Risk:** Silent data loss if user edits the same file externally
+- **Fix:** Compare file mtime before writing; show conflict dialog on mismatch
+- **Deferred because:** Low probability in practice (single-user app)
+
+### 4. `modified_at` is index-time, not file mtime
+
+**Files:** `apps/tauri/src/components/TagView.vue:19`, `apps/tauri/src/lib/indexer.ts:70`
+
+`blocks.modified_at` is set to `Date.now() / 1000` during indexing, not the
+file's mtime from `fs::metadata`. Blocks from the same file indexed in the same
+pass share the same timestamp, making `ORDER BY modified_at` unreliable.
+
+- **Risk:** Tag view and block-ref ordering appear random within a single index
+  pass
+- **Fix:** Store file mtime from `fs::metadata` during indexing
+- **Deferred because:** Requires plumbing `mtime` through the indexer's
+  incremental update path
+
+### 5. Existing workspaces may have empty-page-title rows
+
+**File:** `apps/tauri/src/lib/indexer.ts:67`
+
+Workspaces indexed before the `computePageTitle` fallback was added (D4) may
+have `pages.title = ''` for pages without an `# H1`. These won't be fixed until
+a `fullReindex` (app restart) or incremental re-index on file change.
+
+- **Risk:** Some pages invisible in sidebar, some `[[links]]` don't resolve
+- **Fix:** One-time SQL migration: `UPDATE pages SET title = ... WHERE title = ''`
+- **Deferred because:** Existing test workspaces are small; no user data yet
+
+---
+
+## Rendering / display edge cases
+
+### 6. Excerpt `String.slice()` can split multi-byte characters
+
+**Files:** `components/PageView.vue:22`, `apps/tauri/src/pages/[...slug].vue:40`
+
+Excerpt extraction uses `String.slice()` (UTF-16 code units). If a `[[link]]`
+offset falls inside a 4-byte character (e.g. emoji), the excerpt may be
+truncated mid-character, producing a replacement character (�).
+
+- **Risk:** Cosmetic — garbled excerpt text in backlinks panel
+- **Fix:** Code-point-aware iteration via `Array.from()` or `Intl.Segmenter`
+- **Deferred because:** Low frequency; emoji in page titles is rare
+
+### 7. TagView has no pagination (50-result limit)
+
+**File:** `components/TagView.vue:24`
+
+The tag aggregation query uses `LIMIT 50` with no cursor/offset pagination.
+If a tag has hundreds of references, only the last 50 are returned.
+
+- **Risk:** Incomplete tag results for popular tags
+- **Fix:** Add cursor-based pagination with "load more" button
+- **Deferred because:** No workspace has tags with >50 references yet
+
+### 8. `safeName()` doesn't handle all Unicode edge cases
+
+**File:** `apps/tauri/src/composables/usePageNavigation.ts:35`
+
+`safeName` strips `<>:?*"\` characters but doesn't handle zero-width spaces,
+RTL markers, or other Unicode control characters. These are rare in page names
+but could produce surprising URL paths.
+
+- **Risk:** Very low — edge-case Unicode in filenames is uncommon
+- **Fix:** Normalize via `.normalize('NFC')` and filter Unicode control
+  character ranges
+- **Deferred because:** Not encountered in practice
+
+### 9. Tag names lowercased during extraction
+
+**File:** `components/TagView.vue:23`
+
+Links are extracted with lowercased tag names (see `extractLinks` in
+`parse.ts`). Mixed-case tags from a prior index version may not match.
+
+- **Risk:** Low — consistent lowercasing since D1 means all tags are already
+  lowercased
+- **Fix:** One-time `UPDATE tags SET name = LOWER(name)` migration
+- **Deferred because:** Already consistent in current data
+
+---
+
+## Editor — Indent/Outdent
+
+### 13. Shift+Tab / Tab outdent-indent doesn't reach the handler
+
+**Files:** `packages/editor/src/composables/useBlockKeyboardHandlers.ts:132`, `packages/editor/src/components/EditorBlock.vue:668`, `packages/editor/src/components/Editor.vue:1085`
+
+Tab/Shift+Tab indent/outdent handling is wired end-to-end (handler in
+`useBlockKeyboardHandlers`, pure operations in `editor-operations.ts`,
+event handling in `Editor.vue`) but the browser may capture the Tab key
+for focus navigation before it reaches the ProseMirror `handleKeyDown`
+prop, which is set at `EditorBlock.vue:668`.
+
+- **Risk:** Blocks can't be outdented. Deeply nested lists can only go
+  deeper via Tab (if it happens to work) but can never come back up.
+- **Fix options:**
+  - **Shift+Tab via `handleKeyDown`:** Ensure `event.preventDefault()`
+    is called and the PM `EditorView` DOM element captures Tab — check
+    `tabindex` and `contenteditable` interaction.
+  - **Alternative — Enter on empty indented block:** If a block has
+    `depth > 0` and its content is empty when Enter is pressed, outdent
+    one level instead of creating a new empty block at the same depth.
+    This is how many outliners (Logseq, Roam, Workflowy) handle it.
+  - **Explorer-style wrap at depth 0:** If Tab at depth 0 cycles to
+    depth 0 (no-op), reconsider to start indenting.
+- **Diagnosis needed:** Whether `handleKeyDown` fires at all for Tab,
+  or if the browser / PM default captures it first.
+- **Deferred because:** Not yet diagnosed.
+
+---
+
+## Missing features
+
+### 10. Block ID stamp creation not wired
+
+**File:** `apps/tauri/src/lib/indexer.ts:74`
+
+The indexer tracks `has_id_stamp` per block but the stamp creation
+(`id:: <nanoid>` written to the file when a `((ref))` is created) is
+deferred to D2. The indexer is read-only and never modifies files.
+
+- **Risk:** Block refs can't be created; `((ref))` syntax works in the editor
+  but there's no way to assign a stable ID to a block yet
+- **Deferred because:** D2 scope (read-only index) was prioritised over write
+
+### 11. No search UI wired to FTS5 index
+
+**File:** Sidebar (`apps/tauri/src/components/AppSidebar.vue`, not yet implemented)
+
+Full-text search via SQLite FTS5 is built into the schema (`pages_fts`,
+`blocks_fts`) and the indexer populates it. No search input or results UI
+exists in the sidebar yet.
+
+- **Risk:** Users can't search their notes
+- **Deferred because:** D6 scope
+
+### 12. Insertion zones still stubbed
+
+**File:** `packages/editor/src/components/EditorBlock.vue` (core editor)
+
+Click targets between blocks (`FIXME: Replace with Editor shell insertion
+zone`) are not yet materialised as visual insertion zones. Boundary
+navigation (ArrowUp from first block, ArrowDown from last) creates
+paragraphs inline as a temporary workaround.
+
+- **Risk:** None functional, but UX is rougher than final design
+- **Deferred because:** Part of the Editor shell redesign, not the page model
+
+---
+
+## Future enhancements (not bugs)
+
+These are tracked for completeness but not yet scoped into any milestone.
+
+| Item | Area | Notes |
+|---|---|---|
+| Automatic `[[link]]` update on rename | Cross-file | Requires write-lock pattern |
+| Conflict dialog on external file change | Auto-save | Low priority for single-user |
+| Paginated backlinks / tag view | UI | Simple cursor pattern |
+| Block ref scroll-to | Navigation | Depends on block ID stamp |
+| Full-text search UI | Sidebar | FTS5 already built |
+| File-mtime-based block ordering | Indexer | Requires fs::metadata |

+ 77 - 0
apps/tauri/README.md

@@ -59,3 +59,80 @@ src-tauri/
 public/
   favicon.svg                  # Clay block stacking logo
 ```
+
+## User guide — Editor behaviors
+
+### Auto-save
+
+Content is saved to disk **500ms after you stop typing**. Pending saves are flushed immediately when navigating away, so you won't lose edits from quick page switches.
+
+### Page creation
+
+**No file is created until you type.** Clicking a `[[link]]` navigates to the page's path and shows an empty editor. The file is written to disk only when you add content. If you navigate away without typing, nothing is saved. This keeps your workspace clean of empty placeholder files.
+
+The conventional location for new pages is the `pages/` directory (e.g. `pages/My Note.md`). You can change this in Settings.
+
+### Renaming a page
+
+The title at the top of a page is an editable `<input>` styled as a heading. Changing it renames the underlying file on disk. Press `Enter` to confirm, `Escape` to cancel. Note: existing `[[links]]` in other pages are not updated — that's a planned enhancement.
+
+### Tags
+
+Clicking a `#tag` opens a virtual aggregation page showing all blocks that reference it, regardless of which file they're in. This is a read-only view — no file on disk until you type content here (future feature).
+
+### Backlinks
+
+At the bottom of every page, a collapsible "Linked references" section shows which other pages link to this one. Each result includes the source page name and a contextual excerpt with the `[[link]]` highlighted. Clicking a backlink navigates to the source page.
+
+### Search (coming in D6)
+
+Full-text search across the SQLite index is planned but not yet wired to the sidebar. The FTS5 index is built, the query logic is ready, but the UI is not connected.
+
+---
+
+## Index two-flow architecture
+
+The SQLite index is rebuilt on two paths with different trade-offs:
+
+### Flow 1 — App startup (`syncIndex`)
+
+Runs every time the app launches. Avoids unnecessary work:
+
+1. **List all `.md` files** from the filesystem
+2. **Read and hash each file** (djb2, stored in `pages.content_hash`)
+3. **Compare hash** against the stored value for that file
+4. **Unchanged** → skip entirely (no `splitMarkdownIntoBlocks`, no SQL writes)
+5. **New/changed** → call `indexFile` (block-level diff, only writes changed blocks)
+6. **Deleted** → cascade remove from index (blocks, links, FTS)
+
+Files whose content hasn't changed cost one hash computation and a `SELECT` query. No block parsing, no link extraction, no write amplification.
+
+### Flow 2 — Manual reindex (`fullReindex`)
+
+Triggered via **File → Reindex Library** (`Cmd+Shift+R`). Wipes the entire index (`DELETE FROM pages` — FK cascade handles blocks, links, FTS) and re-indexes every `.md` file from scratch. Use when you suspect corruption or want a guaranteed clean state.
+
+### File watcher (incremental)
+
+Between these two flows, the `notify`-based file watcher handles individual file changes:
+- **Modified/created** → `indexFile` on the single file
+- **Deleted** → `removeFile` (cascade)
+
+The watcher starts before sync completes. If a file changes mid-sync, the watcher re-indexes it independently — worst case a stale entry that corrects on the next change.
+
+---
+
+## Developer notes — D4 Page Model
+
+For implementation details, data flow diagrams, and edge case documentation, see `AGENTS.md` in the repository root. Key sections:
+
+- **Route architecture**: catch-all `[...slug].vue` classifies paths (page vs tag)
+- **Data flow**: event chain from `EditorBlock` click → `usePageNavigation` → router push
+- **Indexer title fallback**: `computePageTitle()` ensures every page has a non-empty title
+- **Edge cases**: JSDoc `@remarks` on each component and function list deferred work
+
+### Tests
+
+```bash
+pnpm test              # vitest run in apps/tauri
+biome check src/       # run from apps/tauri
+```

+ 4 - 0
apps/tauri/package.json

@@ -13,6 +13,8 @@
   },
   "dependencies": {
     "@enesis/editor": "workspace:*",
+    "@nuxt/fonts": "^0.14.0",
+    "@nuxt/icon": "^2.2.3",
     "@nuxt/ui": "^4.9.0",
     "@nuxtjs/color-mode": "^4.0.1",
     "@pinia/nuxt": "^0.11.3",
@@ -23,6 +25,8 @@
     "@tauri-apps/plugin-sql": "^2.4.0",
     "@tauri-apps/plugin-store": "^2.4.3",
     "nuxt": "^4.4.8",
+    "tailwindcss": "^4.3.1",
+    "@vueuse/core": "^14.3.0",
     "vue": "^3.5.38",
     "vue-router": "^5.1.0"
   },

+ 4 - 0
apps/tauri/src-tauri/capabilities/default.json

@@ -16,6 +16,10 @@
       "identifier": "fs:allow-write-text-file",
       "allow": [{ "path": "**" }]
     },
+    {
+      "identifier": "fs:allow-rename",
+      "allow": [{ "path": "**" }]
+    },
     "dialog:default",
     "store:default",
     "sql:default",

+ 7 - 0
apps/tauri/src-tauri/src/lib.rs

@@ -56,6 +56,12 @@ struct FsEvent {
     kind: String,
 }
 
+/// Exit the application process.
+#[tauri::command]
+fn exit_app() {
+    std::process::exit(0)
+}
+
 /// Start a recursive file watcher on the workspace directory.
 ///
 /// Emits `fs-watch:change` events to the frontend for `.md` file
@@ -121,6 +127,7 @@ pub fn run() {
             list_directory,
             create_directory,
             start_watcher,
+            exit_app,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

+ 45 - 0
apps/tauri/src/app.vue

@@ -11,6 +11,7 @@
  * even during the welcome screen. Its actions transition state
  * from welcome → ready via hasWorkspace.
  */
+import { invoke } from "@tauri-apps/api/core"
 import { Menu, MenuItem, Submenu } from "@tauri-apps/api/menu"
 import { watch } from "vue"
 import WelcomeScreen from "~/components/WelcomeScreen.vue"
@@ -50,6 +51,36 @@ async function handleCloseWorkspace() {
   hasWorkspace.value = false
 }
 
+async function handleReindex() {
+  const toast = useToast()
+  const { id } = toast.add({
+    title: "Rebuilding index…",
+    duration: 0,
+    close: false,
+    color: "neutral",
+  })
+  try {
+    await workspace.reindex()
+    toast.update(id, {
+      title: "Index rebuilt",
+      color: "success",
+      duration: 3000,
+      close: true,
+    })
+  } catch {
+    toast.update(id, {
+      title: "Indexing failed",
+      color: "error",
+      duration: 5000,
+      close: true,
+    })
+  }
+}
+
+async function handleQuit() {
+  await invoke("exit_app")
+}
+
 async function buildMenu() {
   const fileItems: (MenuItem | Submenu | { item: "Separator" })[] = [
     await MenuItem.new({
@@ -97,6 +128,20 @@ async function buildMenu() {
       accelerator: "CmdOrCtrl+W",
       action: handleCloseWorkspace,
     }),
+    { item: "Separator" },
+    await MenuItem.new({
+      id: "reindex-library",
+      text: "Reindex Library",
+      accelerator: "CmdOrCtrl+Shift+R",
+      action: handleReindex,
+    }),
+    { item: "Separator" },
+    await MenuItem.new({
+      id: "quit",
+      text: "Quit Enesis",
+      accelerator: "CmdOrCtrl+Q",
+      action: handleQuit,
+    }),
   )
 
   const fileSubmenu = await Submenu.new({

+ 2 - 1
apps/tauri/src/components/AppLeftSidebar.vue

@@ -9,6 +9,7 @@
  */
 interface Page {
   name: string
+  displayName?: string
   backlinkCount?: number
 }
 
@@ -136,7 +137,7 @@ const sortedTags = computed(() =>
                       @click="emit('page-clicked', page.name)"
                     >
                       <UIcon name="i-lucide-file-text" class="w-3.5 h-3.5 shrink-0 opacity-50" />
-                      <span class="flex-1 truncate">{{ page.name }}</span>
+                      <span class="flex-1 truncate">{{ page.displayName || page.name }}</span>
                       <span
                         v-if="page.backlinkCount"
                         class="text-xs text-muted/40 tabular-nums

+ 18 - 3
apps/tauri/src/components/JournalView.vue

@@ -1,11 +1,15 @@
 <script setup lang="ts">
 import { nextTick, onMounted, ref, watch } from "vue"
+import { useRoute } from "vue-router"
 import { useDates } from "~/composables/useDates"
+import { usePageNavigation } from "~/composables/usePageNavigation"
 import { useUiStore } from "~/stores/ui"
 import JournalDay from "./JournalDay.vue"
 
+const route = useRoute()
 const ui = useUiStore()
 const { getToday } = useDates()
+const { navigateToPage, navigateToBlock, navigateToTag } = usePageNavigation()
 
 const today = getToday()
 const days = ref<string[]>([today])
@@ -32,6 +36,17 @@ function loadOlderDays() {
 
 onMounted(() => {
   loadOlderDays()
+
+  // If navigated from sidebar/timeline with a ?date= param, scroll there
+  const dateParam = route.query.date as string | undefined
+  if (dateParam) {
+    ui.setActiveDate(dateParam)
+    nextTick(() => {
+      document
+        .getElementById(`day-${dateParam}`)
+        ?.scrollIntoView({ behavior: "smooth" })
+    })
+  }
 })
 
 function onScroll(event: Event) {
@@ -58,15 +73,15 @@ watch(
 )
 
 function onPageRefClick(payload: { pageName: string; alias?: string }) {
-  console.log("page-ref-clicked", payload)
+  navigateToPage(payload.pageName)
 }
 
 function onBlockRefClick(payload: { blockId: string }) {
-  console.log("block-ref-clicked", payload)
+  navigateToBlock(payload.blockId)
 }
 
 function onTagClick(payload: { tag: string }) {
-  console.log("tag-clicked", payload)
+  navigateToTag(payload.tag)
 }
 </script>
 

+ 217 - 0
apps/tauri/src/components/PageView.vue

@@ -0,0 +1,217 @@
+<script setup lang="ts">
+/**
+ * PageView — the page editing surface for any non-journal `.md` file.
+ *
+ * Two distinct zones:
+ * 1. **Writing surface** (top): editable title `<input>` styled as `<h1>`,
+ *    breadcrumb path, and the Editor with bubble toolbar. Renaming the title
+ *    emits `rename` — the parent handles the actual file rename on disk.
+ * 2. **Backlinks panel** (bottom): collapsible list of cards showing where
+ *    `[[This Page]]` is referenced from, with highlighted excerpts for context.
+ *
+ * Content is two-way bound via `:content` / `@update:content`, so the parent
+ * can debounce auto-save without this component knowing about the filesystem.
+ *
+ * Navigation (`@page-ref-clicked`, `@block-ref-clicked`, `@tag-clicked`)
+ * is handled internally via `usePageNavigation` — events don't bubble up.
+ *
+ * @remarks Edge cases & deferred work:
+ * - The `formatExcerpt` function uses `String.slice()` for excerpt extraction,
+ *   which operates on UTF-16 code units. This is safe for BMP characters but
+ *   may split in the middle of a 4-byte Unicode character (e.g. emoji) if the
+ *   link offset falls inside one. A code-point-aware iteration would fix this.
+ * - Renaming the title emits `rename` to the parent, but does NOT update
+ *   `[[links]]` in other files that reference the old name. The parent handles
+ *   the filesystem rename; updating cross-file references is deferred.
+ * - The backlinks panel shows up to 50 results. No pagination is implemented.
+ */
+import { Editor, EditorToolbar } from "@enesis/editor"
+import { useVModel } from "@vueuse/core"
+import { ref, watch } from "vue"
+import { useIndexSuggestions } from "~/composables/useIndexSuggestions"
+import { usePageNavigation } from "~/composables/usePageNavigation"
+import { escapeHtml } from "~/lib/parse"
+
+export interface Backlink {
+  sourceType: "journal" | "page"
+  sourceName: string
+  sourcePath: string
+  excerpt: string
+}
+
+const props = defineProps<{
+  path: string
+  filename: string
+  content: string
+  backlinks: Backlink[]
+}>()
+
+const emit = defineEmits<{
+  "update:content": [value: string]
+  rename: [newName: string]
+  "backlink-clicked": [sourcePath: string]
+}>()
+
+const { navigateToPage, navigateToBlock, navigateToTag } = usePageNavigation()
+const { data: mentionData } = useIndexSuggestions()
+
+const title = ref(props.filename.replace(/\.md$/, ""))
+const backlinksOpen = ref(true)
+
+const localContent = useVModel(props, "content", emit)
+
+watch(
+  () => props.filename,
+  (name) => {
+    title.value = name.replace(/\.md$/, "")
+  },
+)
+
+function handleTitleBlur() {
+  const trimmed = title.value.trim()
+  if (!trimmed || trimmed === props.filename.replace(/\.md$/, "")) {
+    title.value = props.filename.replace(/\.md$/, "")
+    return
+  }
+  emit("rename", trimmed)
+}
+
+function handleTitleKeydown(e: KeyboardEvent) {
+  if (e.key === "Enter") {
+    e.preventDefault()
+    ;(e.target as HTMLInputElement).blur()
+  }
+  if (e.key === "Escape") {
+    title.value = props.filename.replace(/\.md$/, "")
+    ;(e.target as HTMLInputElement).blur()
+  }
+}
+
+function formatExcerpt(excerpt: string): string {
+  const safe = escapeHtml(excerpt)
+  return safe.replace(
+    /\[\[([^\]]+)\]\]/g,
+    '<mark class="bg-(--ui-primary)/15 text-(--ui-primary) rounded px-0.5 not-italic">$1</mark>',
+  )
+}
+</script>
+
+<template>
+  <div class="flex flex-col min-h-full">
+
+    <!-- Writing surface -->
+    <div class="flex-1 w-full max-w-3xl mx-auto px-8 pt-16 pb-12">
+
+      <!-- Path breadcrumb (relative to workspace root) -->
+      <p class="text-xs text-muted/50 font-mono mb-6 select-none truncate">
+        {{ path }}
+      </p>
+
+      <!-- Editable title -->
+      <input
+        v-model="title"
+        type="text"
+        class="w-full bg-transparent border-none outline-none
+               text-4xl font-semibold text-highlighted tracking-tight
+               placeholder:text-muted/30 mb-8"
+        placeholder="Untitled"
+        spellcheck="false"
+        @blur="handleTitleBlur"
+        @keydown="handleTitleKeydown"
+      />
+
+      <!-- Editor -->
+      <Editor
+        v-model:content="localContent"
+        :mention-data="mentionData"
+        marker-mode="live-preview"
+        focused="first-zone"
+        @page-ref-clicked="({ pageName, alias }) => navigateToPage(pageName)"
+        @block-ref-clicked="({ blockId }) => navigateToBlock(blockId)"
+        @tag-clicked="({ tag }) => navigateToTag(tag)"
+      >
+        <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
+          <EditorToolbar
+            mode="bubble"
+            :handlers="handlers"
+            :selection-version="selectionVersion"
+            :can-undo="canUndo"
+            :can-redo="canRedo"
+            :undo="undo"
+            :redo="redo"
+          />
+        </template>
+      </Editor>
+
+    </div>
+
+    <!-- Backlinks -->
+    <div
+      v-if="backlinks.length > 0"
+      class="w-full max-w-3xl mx-auto px-8 pb-16"
+    >
+      <USeparator class="mb-6" />
+
+      <UCollapsible v-model:open="backlinksOpen">
+
+        <button
+          class="flex items-center gap-2 mb-4 group"
+          @click="backlinksOpen = !backlinksOpen"
+        >
+          <UIcon
+            name="i-lucide-arrow-left"
+            class="w-3.5 h-3.5 text-muted/60"
+          />
+          <span class="text-xs font-medium text-muted uppercase tracking-widest">
+            Linked references
+          </span>
+          <span class="text-xs text-muted/40 tabular-nums">
+            {{ backlinks.length }}
+          </span>
+          <UIcon
+            name="i-lucide-chevron-right"
+            class="w-3 h-3 text-muted/40 transition-transform duration-150 ml-1"
+            :class="{ 'rotate-90': backlinksOpen }"
+          />
+        </button>
+
+        <template #content>
+          <ul class="space-y-3">
+            <li
+              v-for="link in backlinks"
+              :key="link.sourcePath"
+            >
+              <button
+                class="group w-full text-left rounded-lg border border-default
+                       hover:border-(--ui-primary)/30 hover:bg-elevated
+                       px-4 py-3 transition-all duration-150
+                       focus-visible:outline-none focus-visible:ring-2
+                       focus-visible:ring-(--ui-primary)"
+                @click="emit('backlink-clicked', link.sourcePath)"
+              >
+                <div class="flex items-center gap-1.5 mb-1.5">
+                  <UIcon
+                    :name="link.sourceType === 'journal'
+                      ? 'i-lucide-calendar'
+                      : 'i-lucide-file-text'"
+                    class="w-3 h-3 text-muted/50 shrink-0"
+                  />
+                  <span class="text-xs font-medium text-muted group-hover:text-default transition-colors">
+                    {{ link.sourceName }}
+                  </span>
+                </div>
+
+                <p
+                  class="text-sm text-muted/70 leading-relaxed line-clamp-2"
+                  v-html="formatExcerpt(link.excerpt)"
+                />
+              </button>
+            </li>
+          </ul>
+        </template>
+
+      </UCollapsible>
+    </div>
+
+  </div>
+</template>

+ 148 - 0
apps/tauri/src/components/TagView.vue

@@ -0,0 +1,148 @@
+<script setup lang="ts">
+/**
+ * TagView — virtual tag aggregation page.
+ *
+ * Renders all blocks from the index that reference the given tag (#tagname),
+ * ordered by most recently modified. Each result is a clickable card that
+ * navigates to the source page with a hash fragment targeting the block.
+ *
+ * This is a read-only view — no editor, no file on disk. The tag page file
+ * is created only when the user types content on this view (future feature).
+ *
+ * @remarks Edge cases & deferred work:
+ * - `ORDER BY b.modified_at DESC` uses the index timestamp, which is set
+ *   during indexing (not the actual file mtime). This means blocks re-indexed
+ *   together share the same timestamp, and ordering is effectively random
+ *   within a single index pass. A real fix would use the file's mtime from
+ *   `fs::metadata` (stored in `pages.modified_at` but not in `blocks`).
+ * - No pagination. If a tag has hundreds of references, only the last 50
+ *   are returned (LIMIT 50 in the SQL query, implicitly from cursor pagination
+ *   that's not yet implemented).
+ * - Tag names are lowercased during link extraction (see `extractLinks` in
+ *   `parse.ts`). The tag view shows the lowercased version. If the index
+ *   contains mixed-case tags from a previous version, they may not match.
+ */
+import { ref, watch } from "vue"
+import { useRouter } from "vue-router"
+import { useWorkspaceStore } from "~/stores/workspace"
+
+interface TagBlock {
+  content: string
+  pagePath: string
+  blockId: string
+}
+
+const props = defineProps<{
+  tag: string
+}>()
+
+const router = useRouter()
+const ws = useWorkspaceStore()
+const blocks = ref<TagBlock[]>([])
+const loading = ref(true)
+
+async function load() {
+  const db = ws.db
+  if (!db) {
+    blocks.value = []
+    loading.value = false
+    return
+  }
+  try {
+    const rows = await db.select<
+      { content: string; page_path: string; id: string }[]
+    >(
+      `SELECT b.content, b.page_path, b.id
+       FROM blocks b
+       JOIN links l ON l.source_block_id = b.id
+       WHERE l.link_type = 'tag' AND l.target = ?
+       ORDER BY b.modified_at DESC`,
+      [props.tag],
+    )
+    blocks.value = rows.map((r) => ({
+      content: r.content,
+      pagePath: r.page_path,
+      blockId: r.id,
+    }))
+  } catch (e) {
+    console.warn("[TagView] failed to load blocks:", e)
+    blocks.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+watch(() => ws.db, load, { immediate: true })
+
+function goToBlock(pagePath: string, blockId: string) {
+  router.push(`/${pagePath}#block-${blockId}`)
+}
+
+function formatContent(content: string): string {
+  const firstLine = content.split("\n")[0] ?? ""
+  return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine
+}
+</script>
+
+<template>
+  <div class="flex flex-col min-h-full w-full max-w-3xl mx-auto px-8 pt-16 pb-12">
+    <!-- Header -->
+    <div class="flex items-center gap-3 mb-8">
+      <UIcon name="i-lucide-hash" class="w-5 h-5 text-(--ui-primary)" />
+      <h1 class="text-3xl font-semibold text-highlighted tracking-tight">
+        {{ tag }}
+      </h1>
+    </div>
+
+    <!-- Loading -->
+    <div
+      v-if="loading"
+      class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
+    >
+      <UIcon name="i-lucide-loader" class="size-4 animate-spin" />
+      Loading...
+    </div>
+
+    <!-- No results -->
+    <p
+      v-else-if="blocks.length === 0"
+      class="py-16 text-center text-sm text-muted/50 italic"
+    >
+      No blocks reference this tag yet.
+    </p>
+
+    <!-- Block list -->
+    <ul v-else class="space-y-2">
+      <li v-for="block in blocks" :key="block.blockId">
+        <button
+          class="group w-full text-left rounded-lg border border-default
+                 hover:border-(--ui-primary)/30 hover:bg-elevated
+                 px-4 py-3 transition-all duration-150
+                 focus-visible:outline-none focus-visible:ring-2
+                 focus-visible:ring-(--ui-primary)"
+          @click="goToBlock(block.pagePath, block.blockId)"
+        >
+          <p class="text-sm text-default leading-relaxed">
+            {{ formatContent(block.content) }}
+          </p>
+          <p class="text-xs text-muted/50 mt-1.5 font-mono truncate">
+            {{ block.pagePath }}
+          </p>
+        </button>
+      </li>
+    </ul>
+
+    <!-- Back to journal -->
+    <div class="mt-auto pt-12">
+      <UButton
+        variant="ghost"
+        color="neutral"
+        size="sm"
+        icon="i-lucide-arrow-left"
+        to="/"
+      >
+        Back to journal
+      </UButton>
+    </div>
+  </div>
+</template>

+ 85 - 0
apps/tauri/src/composables/usePageNavigation.ts

@@ -0,0 +1,85 @@
+/**
+ * Shared page/block/tag navigation logic used by journal, page, and sidebar
+ * components. All navigation goes through this composable so the resolution
+ * logic (look up by title → navigate to path, or construct conventional path)
+ * is consistent everywhere.
+ *
+ * Pages that don't exist yet are navigated to by conventional path
+ * (`pages/<safe-name>.md`) without creating a file on disk. The file is
+ * created lazily when the user types content in the editor (see `[...slug].vue`'s
+ * 500ms debounced auto-save).
+ */
+import { useRouter } from "vue-router"
+import { useSettings } from "~/composables/useSettings"
+import { useWorkspaceStore } from "~/stores/workspace"
+
+export function usePageNavigation() {
+  const router = useRouter()
+  const ws = useWorkspaceStore()
+  const settings = useSettings()
+
+  /**
+   * Navigate to a page by its display name.
+   *
+   * 1. Look up `title` in the index (titles are always non-empty — indexer
+   *    falls back to filename stem when there's no `# Heading 1`).
+   * 2. If found, push the page's path.
+   * 3. If not found, construct the conventional `{pagesDir}/{safeName}.md`
+   *    path and push that. No file is created on disk at this point.
+   *
+   * @remarks Edge cases & deferred work:
+   * - Renaming a page does NOT update existing `[[links]]` in other files.
+   *   Backlink confirmation dialog is planned but deferred.
+   * - `safeName` strips `<>:?*"\` characters but doesn't handle all Unicode
+   *   edge cases (e.g. zero-width spaces, RTL markers). These are rare in
+   *    page names but could cause surprising URL paths.
+   */
+  async function navigateToPage(pageName: string) {
+    const db = ws.db
+    if (!db) return
+
+    const rows = await db.select<{ path: string }[]>(
+      "SELECT path FROM pages WHERE title = ?",
+      [pageName],
+    )
+    if (rows.length > 0) {
+      await router.push(`/${rows[0].path}`)
+      return
+    }
+
+    const pagesDir = await settings.getPagesDir()
+    const safeName = pageName.replace(/[/\\:?*"<>|]/g, "_")
+    const relativePath = pagesDir
+      ? `${pagesDir}/${safeName}.md`
+      : `${safeName}.md`
+    await router.push(`/${relativePath}`)
+  }
+
+  /**
+   * Navigate to the page containing the given block ID, with a hash fragment
+   * so the page can scroll the target block into view.
+   *
+   * If the block isn't in the index (e.g. it was deleted), the navigation
+   * is silently skipped.
+   */
+  async function navigateToBlock(blockId: string) {
+    const db = ws.db
+    if (!db) return
+    const rows = await db.select<{ page_path: string }[]>(
+      "SELECT page_path FROM blocks WHERE id = ?",
+      [blockId],
+    )
+    if (rows.length > 0) {
+      await router.push(`/${rows[0].page_path}#block-${blockId}`)
+    }
+  }
+
+  /**
+   * Navigate to a virtual tag aggregation page at `/tag/{tagName}`.
+   */
+  async function navigateToTag(tag: string) {
+    await router.push(`/tag/${encodeURIComponent(tag)}`)
+  }
+
+  return { navigateToPage, navigateToBlock, navigateToTag }
+}

+ 14 - 1
apps/tauri/src/composables/useSettings.ts

@@ -49,5 +49,18 @@ export function useSettings() {
     await set("recentWorkspaces", filtered.slice(0, 10))
   }
 
-  return { get, set, getRecentWorkspaces, addRecentWorkspace }
+  /**
+   * Returns the configured pages directory relative to the workspace root.
+   * Controls where new page files are created when navigating to a `[[link]]`
+   * that doesn't exist yet. The default `"pages"` matches the workspace layout
+   * created by `ensureDirectories()` in the workspace store.
+   *
+   * This setting only affects **creation**, not **recognition** — the indexer
+   * always walks the entire workspace regardless of this value.
+   */
+  async function getPagesDir(): Promise<string> {
+    return (await get<string>("pagesDir")) ?? "pages"
+  }
+
+  return { get, set, getRecentWorkspaces, addRecentWorkspace, getPagesDir }
 }

+ 25 - 5
apps/tauri/src/composables/useSidebarIndex.ts

@@ -1,12 +1,26 @@
 /**
  * Queries the SQLite index for pages (with backlink counts) and tags
  * (with frequency counts) to populate the sidebar.
+ *
+ * @remarks Edge cases & deferred work:
+ * - **Backlink counts may be stale after a page rename.** The count uses
+ *   `links.target` which stores the page title at the time the `[[link]]`
+ *   was created. Renaming a page doesn't update existing link targets.
+ *   A "migrate to suggested structure" command (plan D1) would fix this.
+ * - **Pages with empty titles fall back to filename stem.** The `name` field
+ *   is the raw title for navigation queries; `displayName` replaces underscores
+ *   with spaces for cosmetic display in the sidebar.
+ * - **No filtering or search.** The sidebar shows all pages and tags with
+ *   no text filter. Search is planned (D6) but not yet wired to this query.
  */
 import { ref, watch } from "vue"
 import { useWorkspaceStore } from "~/stores/workspace"
 
 export interface SidebarPage {
+  /** Raw title from the index (used for navigation queries). */
   name: string
+  /** Human-readable label (cosmetic transformations applied). */
+  displayName: string
   backlinkCount: number
 }
 
@@ -28,11 +42,16 @@ export function useSidebarIndex() {
       return
     }
     try {
-      // Pages: existing files with a title
-      const pageRows = await db.select<{ title: string }[]>(
-        "SELECT title FROM pages WHERE title != '' ORDER BY title",
+      // Pages: all indexed files, fall back to filename stem when title is empty
+      const pageRows = await db.select<{ path: string; title: string }[]>(
+        "SELECT path, title FROM pages ORDER BY title",
       )
-      const pageNames = pageRows.map((r) => r.title)
+      const pageEntries = pageRows.map((r) => {
+        const name =
+          r.title || r.path.split("/").pop()?.replace(/\.md$/, "") || r.path
+        const displayName = name.replace(/_/g, " ")
+        return { name, displayName }
+      })
 
       // Backlink counts: how many links reference each page
       const backlinkRows = await db.select<{ target: string; count: number }[]>(
@@ -40,8 +59,9 @@ export function useSidebarIndex() {
       )
       const backlinkMap = new Map(backlinkRows.map((r) => [r.target, r.count]))
 
-      pages.value = pageNames.map((name) => ({
+      pages.value = pageEntries.map(({ name, displayName }) => ({
         name,
+        displayName,
         backlinkCount: backlinkMap.get(name) ?? 0,
       }))
 

+ 12 - 3
apps/tauri/src/layouts/default.vue

@@ -1,16 +1,25 @@
 <script setup lang="ts">
+import { useRouter } from "vue-router"
 import AppLeftSidebar from "~/components/AppLeftSidebar.vue"
 import AppRightSidebar from "~/components/AppRightSidebar.vue"
 import { useDates } from "~/composables/useDates"
+import { usePageNavigation } from "~/composables/usePageNavigation"
 import { useSidebarIndex } from "~/composables/useSidebarIndex"
 import { useTimelineDensity } from "~/composables/useTimelineDensity"
 import { useUiStore } from "~/stores/ui"
 
+const router = useRouter()
 const ui = useUiStore()
+const { navigateToPage, navigateToTag } = usePageNavigation()
 const { lastDays, getToday } = useDates()
 const { densityMap } = useTimelineDensity()
 const { pages, tags } = useSidebarIndex()
 
+function handleSelectDate(date: string) {
+  ui.setActiveDate(date)
+  router.push(`/?date=${date}`)
+}
+
 const dateKeys = lastDays(14)
 const today = getToday()
 </script>
@@ -25,10 +34,10 @@ const today = getToday()
       :density-map="densityMap"
       :pages="pages"
       :tags="tags"
-      @select-date="ui.setActiveDate"
+      @select-date="handleSelectDate"
       @search="console.log('search')"
-      @page-clicked="console.log('page-clicked', $event)"
-      @tag-clicked="console.log('tag-clicked', $event)"
+      @page-clicked="navigateToPage($event)"
+      @tag-clicked="navigateToTag($event)"
     />
 
     <UMain class="flex flex-col flex-1 min-w-0">

+ 243 - 0
apps/tauri/src/lib/indexer.test.ts

@@ -0,0 +1,243 @@
+import Database from "better-sqlite3"
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
+
+// Mock @enesis/editor — its dependency chain pulls in Vue files that vitest
+// can't parse without a Vue plugin.
+vi.mock("@enesis/editor", () => ({
+  splitMarkdownIntoBlocks: (content: string) =>
+    content
+      .split("\n")
+      .filter(Boolean)
+      .map((line, i) => ({
+        id: `test-block-${i}`,
+        content: line,
+        depth: 0,
+      })),
+}))
+
+// Mock @tauri-apps/plugin-sql — wrap better-sqlite3 so the indexer runs
+// against a real in-memory SQLite database during tests.
+//
+// The real plugin uses $1, $2 positional placeholders, but better-sqlite3
+// uses ?. We translate $N → ? before preparing statements.
+vi.mock("@tauri-apps/plugin-sql", () => {
+  function translate(sql: string): string {
+    return sql.replace(/\$\d+/g, "?")
+  }
+
+  class MockDatabase {
+    private db: Database.Database
+
+    private constructor(db: Database.Database) {
+      this.db = db
+    }
+
+    static async load(_connectionString: string): Promise<MockDatabase> {
+      const { default: BS3 } = await import("better-sqlite3")
+      return new MockDatabase(new BS3(":memory:"))
+    }
+
+    async execute(
+      sql: string,
+      params?: unknown[],
+    ): Promise<{ rowsAffected: number }> {
+      if (params) {
+        const stmt = this.db.prepare(translate(sql))
+        const result = stmt.run(...params)
+        return { rowsAffected: result.changes }
+      }
+      const result = this.db.exec(sql)
+      return { rowsAffected: result.changes ?? 0 }
+    }
+
+    async select<T>(sql: string, params?: unknown[]): Promise<T[]> {
+      const stmt = this.db.prepare(translate(sql))
+      return (params ? stmt.all(...params) : stmt.all()) as T[]
+    }
+  }
+
+  return { default: MockDatabase }
+})
+
+// Now it's safe to import the real indexer
+import { fullReindex, initIndex, syncIndex } from "./indexer"
+
+let db: Awaited<ReturnType<typeof initIndex>>
+
+beforeAll(async () => {
+  db = await initIndex(":memory:")
+})
+
+afterAll(() => {
+  vi.restoreAllMocks()
+})
+
+function opts(files: Record<string, string>, workspacePath = "/ws") {
+  return {
+    listDirectory: vi.fn(async () =>
+      Object.keys(files).map((f) => `${workspacePath}/${f}`),
+    ),
+    readFile: vi.fn(async (path: string) => {
+      const relative = path.replace(`${workspacePath}/`, "")
+      if (!(relative in files)) throw new Error(`ENOENT: ${path}`)
+      return files[relative]
+    }),
+    workspacePath,
+  }
+}
+
+describe("syncIndex", () => {
+  it("indexes new files", async () => {
+    const o = opts({ "pages/New.md": "# Hello World" })
+    await syncIndex(o, db)
+
+    const pages = await db.select<{ path: string; title: string }[]>(
+      "SELECT path, title FROM pages",
+    )
+    expect(pages).toEqual([{ path: "pages/New.md", title: "Hello World" }])
+  })
+
+  it("skips unchanged files", async () => {
+    const o = opts({ "pages/Skip.md": "same content" })
+    await syncIndex(o, db)
+
+    await syncIndex(o, db)
+
+    const pageRows = await db.select<{ path: string }[]>(
+      "SELECT path FROM pages",
+    )
+    expect(pageRows).toHaveLength(1)
+    expect(pageRows[0].path).toBe("pages/Skip.md")
+  })
+
+  it("re-indexes changed files", async () => {
+    const o = opts({ "pages/Changed.md": "version 1" })
+    await syncIndex(o, db)
+
+    o.readFile = vi.fn(async () => "version 2")
+    await syncIndex(o, db)
+
+    const blocks = await db.select<{ content: string }[]>(
+      "SELECT content FROM blocks",
+    )
+    expect(blocks).toEqual([{ content: "version 2" }])
+  })
+
+  it("removes files deleted from disk", async () => {
+    const o = opts({
+      "pages/Keep.md": "keep me",
+      "pages/Remove.md": "remove me",
+    })
+    await syncIndex(o, db)
+
+    const updated = opts({ "pages/Keep.md": "keep me" })
+    await syncIndex(updated, db)
+
+    const pages = await db.select<{ path: string }[]>(
+      "SELECT path FROM pages ORDER BY path",
+    )
+    expect(pages).toEqual([{ path: "pages/Keep.md" }])
+  })
+
+  it("ignores non-.md files", async () => {
+    const o = opts({
+      "page.txt": "not markdown",
+      "readme.md": "# Actual doc",
+    })
+    await syncIndex(o, db)
+
+    const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
+    expect(pages).toEqual([{ path: "readme.md" }])
+  })
+
+  it("continues on read error", async () => {
+    const o = opts({ "pages/Good.md": "fine" })
+    vi.mocked(o.listDirectory).mockResolvedValue([
+      "/ws/pages/Bad.md",
+      "/ws/pages/Good.md",
+    ])
+    vi.mocked(o.readFile).mockImplementation(async (path: string) => {
+      if (path.includes("Bad.md")) throw new Error("read error")
+      return "fine"
+    })
+
+    await syncIndex(o, db)
+
+    const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
+    expect(pages).toEqual([{ path: "pages/Good.md" }])
+  })
+})
+
+describe("fullReindex", () => {
+  it("wipes and rebuilds all pages", async () => {
+    await db.execute(
+      "INSERT INTO pages (path, title, modified_at, word_count) VALUES ($1, $2, $3, $4)",
+      ["pages/Stale.md", "Stale", 1000, 2],
+    )
+
+    const o = opts({ "pages/Fresh.md": "# Fresh Start" })
+    await fullReindex(o, db)
+
+    const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
+    expect(pages).toEqual([{ path: "pages/Fresh.md" }])
+  })
+
+  it("continues on read error", async () => {
+    const o = opts({ "pages/Good.md": "fine" })
+    vi.mocked(o.listDirectory).mockResolvedValue([
+      "/ws/pages/Bad.md",
+      "/ws/pages/Good.md",
+    ])
+    vi.mocked(o.readFile).mockImplementation(async (path: string) => {
+      if (path.includes("Bad.md")) throw new Error("read error")
+      return "fine"
+    })
+
+    await fullReindex(o, db)
+
+    const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
+    expect(pages).toEqual([{ path: "pages/Good.md" }])
+  })
+})
+
+describe("migrateContentHash via initIndex", () => {
+  it("adds content_hash column to existing old-schema database", async () => {
+    // Build the old schema manually (without content_hash)
+    const raw = new Database(":memory:")
+    raw.exec(`
+      CREATE TABLE pages (
+        path TEXT PRIMARY KEY,
+        title TEXT NOT NULL DEFAULT '',
+        modified_at INTEGER NOT NULL,
+        word_count INTEGER NOT NULL DEFAULT 0
+      );
+    `)
+
+    // Verify old schema has no content_hash
+    const before = raw
+      .prepare(
+        "SELECT name FROM pragma_table_info('pages') WHERE name = 'content_hash'",
+      )
+      .all()
+    expect(before).toHaveLength(0)
+
+    // Serialize to a temp file and open through initIndex
+    const { writeFileSync, mkdtempSync } = await import("node:fs")
+    const { join } = await import("node:path")
+    const { tmpdir } = await import("node:os")
+    const dir = mkdtempSync(join(tmpdir(), "indexer-test-"))
+    const dbPath = join(dir, "index.db")
+    writeFileSync(dbPath, raw.serialize())
+
+    const migrated = await initIndex(dbPath)
+    const after = await migrated.select<{ name: string }[]>(
+      "SELECT name FROM pragma_table_info('pages') WHERE name = 'content_hash'",
+    )
+    expect(after).toHaveLength(1)
+    expect(after[0].name).toBe("content_hash")
+
+    // Cleanup
+    const { rmSync } = await import("node:fs")
+    rmSync(dir, { recursive: true })
+  })
+})

+ 120 - 22
apps/tauri/src/lib/indexer.ts

@@ -14,10 +14,11 @@
 import { splitMarkdownIntoBlocks } from "@enesis/editor"
 import Database from "@tauri-apps/plugin-sql"
 import {
+  computePageTitle,
   contentHash,
   detectBlockType,
   extractLinks,
-  extractTitle,
+  toRelativePath,
 } from "./parse"
 import { SCHEMA_SQL } from "./schema"
 
@@ -32,15 +33,33 @@ export interface IndexerOptions {
 
 /**
  * Initialize the SQLite index at the given path.
- * Creates the database file if it doesn't exist and runs CREATE TABLE IF NOT EXISTS.
+ * Creates the database file if it doesn't exist, runs CREATE TABLE IF NOT EXISTS,
+ * and applies any schema migrations needed for existing databases.
  * Tables, indices, FTS5 virtual table, and sync triggers are all idempotent.
  */
 export async function initIndex(dbPath: string): Promise<Database> {
   const db = await Database.load(`sqlite:${dbPath}`)
   await db.execute(SCHEMA_SQL)
+  await migrateContentHash(db)
   return db
 }
 
+/**
+ * Add the `content_hash` column to `pages` if it doesn't exist yet.
+ * SQLite doesn't support `ADD COLUMN IF NOT EXISTS`, so we query PRAGMA
+ * table_info first and only ALTER when the column is absent.
+ */
+async function migrateContentHash(db: Database): Promise<void> {
+  const cols = await db.select<{ name: string }[]>(
+    "SELECT name FROM pragma_table_info('pages')",
+  )
+  if (!cols.some((c) => c.name === "content_hash")) {
+    await db.execute(
+      "ALTER TABLE pages ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''",
+    )
+  }
+}
+
 /**
  * Index a single markdown file into the database.
  *
@@ -49,6 +68,30 @@ export async function initIndex(dbPath: string): Promise<Database> {
  * - Upserts changed or new blocks
  * - Re-indexes links (delete-all + re-insert within each changed block)
  * - Removes blocks that no longer exist in the file
+ *
+ * The page **title** is always non-empty: `extractTitle` returns the first
+ * `# Heading 1` in the content, falling back to the filename stem (without
+ * `.md` extension) when there's no heading. This guarantees downstream queries
+ * like `WHERE title = 'New Page'` from `[[New Page]]` links resolve correctly
+ * regardless of whether the page has a heading yet.
+ *
+ * @param db — Open SQLite database connection
+ * @param filePath — Path relative to workspace root, e.g. `"pages/Architecture.md"`
+ * @param content — Raw markdown file content
+ *
+ * @remarks Edge cases & deferred work:
+ * - **Existing workspaces** that have pages with empty titles (indexed before
+ *   the `computePageTitle` fallback was added) won't get fixed until the
+ *   next `fullReindex` (app restart) or incremental re-index (file change).
+ *   A one-time migration (`UPDATE pages SET title = filename_stem WHERE
+ *   title = ''`) would fix this proactively.
+ * - `modified_at` is set to `Date.now() / 1000` at index time, NOT the file's
+ *   mtime from `fs::metadata`. This means the same file indexed twice in
+ *   quick succession gets the same timestamp, making `ORDER BY modified_at`
+ *   unreliable for ordering blocks by actual edit time.
+ * - **Block ID stamping** (`has_id_stamp`) is tracked but stamp creation
+ *   (writing `id:: <nanoid>` to the file on reference creation) is deferred
+ *   to D2. The indexer is read-only and never modifies files.
  */
 export async function indexFile(
   db: Database,
@@ -56,17 +99,18 @@ export async function indexFile(
   content: string,
 ): Promise<void> {
   const now = Math.floor(Date.now() / 1000)
-  const _hash = contentHash(content)
-  const title = extractTitle(content)
+  const hash = contentHash(content)
+  const title = computePageTitle(content, filePath)
 
   await db.execute(
-    `INSERT INTO pages (path, title, modified_at, word_count)
-     VALUES ($1, $2, $3, $4)
+    `INSERT INTO pages (path, title, modified_at, word_count, content_hash)
+     VALUES ($1, $2, $3, $4, $5)
      ON CONFLICT(path) DO UPDATE SET
        title = excluded.title,
        modified_at = excluded.modified_at,
-       word_count = excluded.word_count`,
-    [filePath, title, now, content.split(/\s+/).length],
+       word_count = excluded.word_count,
+       content_hash = excluded.content_hash`,
+    [filePath, title, now, content.split(/\s+/).length, hash],
   )
 
   const existing = await db.select<{ id: string; content_hash: string }[]>(
@@ -139,33 +183,87 @@ export async function removeFile(
 }
 
 /**
- * Rebuild the entire index by walking every .md file in the workspace.
- * Compares existing indexed pages against the filesystem and:
- * - Indexes new or changed files
- * - Removes pages that no longer exist
- * - Skips files that fail to read (logs warning, continues)
+ * Rebuild the entire index from scratch.
+ * Deletes all existing index data (FK cascade handles blocks, links, FTS),
+ * then re-indexes every markdown file found on disk.
+ * Skips files that fail to read (logs warning, continues).
  */
 export async function fullReindex(
   opts: IndexerOptions,
   db: Database,
 ): Promise<void> {
+  // Wipe clean — FK cascade handles blocks, links, and FTS
+  await db.execute("DELETE FROM pages")
+
   const allFiles = await opts.listDirectory(opts.workspacePath)
   const mdFiles = allFiles.filter((f) => f.endsWith(".md"))
 
-  const indexed = await db.select<{ path: string }[]>("SELECT path FROM pages")
-  const indexedSet = new Set(indexed.map((p) => p.path))
+  for (const absolutePath of mdFiles) {
+    try {
+      const content = await opts.readFile(absolutePath)
+
+      // Normalize to workspace-relative path for the index.
+      // Storing relative paths means backlink source_page_path can be
+      // passed directly to the router for navigation.
+      const relativePath = toRelativePath(absolutePath, opts.workspacePath)
+
+      await indexFile(db, relativePath, content)
+    } catch (e) {
+      console.warn(`[indexer] failed to index ${absolutePath}:`, e)
+    }
+  }
+}
+
+/**
+ * Incrementally sync the index with the filesystem on app startup.
+ * Skips parsing and SQL writes for files whose content hash hasn't changed.
+ *
+ * Flow for each file on disk:
+ * - **New** (not in DB) — read, hash, full indexFile
+ * - **Changed** (in DB, hash differs) — read, hash, full indexFile
+ * - **Unchanged** (in DB, hash matches) — skip parsing and SQL writes
+ * - **Deleted** (in DB, not on disk) — remove from index (FK cascade)
+ *
+ * @remarks The file watcher starts before this sync completes. If a watched
+ * file changes mid-sync, the watcher fires `indexFile` on that path
+ * concurrently. Worst case: a stale index entry that corrects on the next
+ * change. Low probability, acceptable for startup.
+ */
+export async function syncIndex(
+  opts: IndexerOptions,
+  db: Database,
+): Promise<void> {
+  const allFiles = await opts.listDirectory(opts.workspacePath)
+  const mdFiles = new Set(allFiles.filter((f) => f.endsWith(".md")))
+  const relativePaths = new Set<string>()
+
+  for (const absolutePath of mdFiles) {
+    const relativePath = toRelativePath(absolutePath, opts.workspacePath)
+    relativePaths.add(relativePath)
 
-  for (const filePath of mdFiles) {
     try {
-      const content = await opts.readFile(filePath)
-      await indexFile(db, filePath, content)
+      const content = await opts.readFile(absolutePath)
+      const hash = contentHash(content)
+
+      const rows = await db.select<{ content_hash: string }[]>(
+        "SELECT content_hash FROM pages WHERE path = $1",
+        [relativePath],
+      )
+
+      // Skip unchanged files — no parsing, no SQL writes
+      if (rows.length > 0 && rows[0].content_hash === hash) continue
+
+      await indexFile(db, relativePath, content)
     } catch (e) {
-      console.warn(`[indexer] failed to index ${filePath}:`, e)
+      console.warn(`[indexer] failed to sync ${absolutePath}:`, e)
     }
-    indexedSet.delete(filePath)
   }
 
-  for (const stalePath of indexedSet) {
-    await removeFile(db, stalePath)
+  // Remove files that no longer exist on disk
+  const indexed = await db.select<{ path: string }[]>("SELECT path FROM pages")
+  for (const row of indexed) {
+    if (!relativePaths.has(row.path)) {
+      await db.execute("DELETE FROM pages WHERE path = $1", [row.path])
+    }
   }
 }

+ 120 - 0
apps/tauri/src/lib/parse.test.ts

@@ -1,9 +1,12 @@
 import { describe, expect, it } from "vitest"
 import {
+  computePageTitle,
   contentHash,
   detectBlockType,
+  escapeHtml,
   extractLinks,
   extractTitle,
+  toRelativePath,
 } from "./parse"
 
 describe("contentHash", () => {
@@ -85,6 +88,45 @@ describe("extractTitle", () => {
   })
 })
 
+describe("computePageTitle", () => {
+  it("uses # Heading 1 when present", () => {
+    expect(computePageTitle("# My Title\n\ncontent\n", "pages/Foo.md")).toBe(
+      "My Title",
+    )
+  })
+
+  it("falls back to filename stem when there is no heading", () => {
+    expect(computePageTitle("plain text\n", "pages/My Page.md")).toBe("My Page")
+  })
+
+  it("handles subdirectory paths", () => {
+    expect(computePageTitle("content", "projects/sub/deep/Deep Note.md")).toBe(
+      "Deep Note",
+    )
+  })
+
+  it("returns filename stem for empty content", () => {
+    expect(computePageTitle("", "pages/Empty.md")).toBe("Empty")
+  })
+
+  it("strips .md extension (case-insensitive)", () => {
+    expect(computePageTitle("", "pages/Readme.MD")).toBe("Readme")
+  })
+
+  it("preserves underscores and dashes in the stem", () => {
+    expect(computePageTitle("", "pages/my_notes-file.md")).toBe("my_notes-file")
+  })
+
+  it("uses heading over filename stem when both exist", () => {
+    const result = computePageTitle(
+      "# Actual Title\n\ncontent\n",
+      "pages/Other Name.md",
+    )
+    expect(result).toBe("Actual Title")
+    expect(result).not.toBe("Other Name")
+  })
+})
+
 describe("extractLinks", () => {
   it("extracts [[Page Name]] references", () => {
     const links = extractLinks("see [[Other Page]] for details")
@@ -95,6 +137,50 @@ describe("extractLinks", () => {
     })
   })
 
+  describe("toRelativePath", () => {
+    it("strips workspace prefix from absolute path", () => {
+      expect(
+        toRelativePath(
+          "/home/user/workspace/pages/Foo.md",
+          "/home/user/workspace",
+        ),
+      ).toBe("pages/Foo.md")
+    })
+
+    it("handles workspace path with trailing slash", () => {
+      expect(
+        toRelativePath(
+          "/home/user/workspace/journals/2026-01-01.md",
+          "/home/user/workspace/",
+        ),
+      ).toBe("journals/2026-01-01.md")
+    })
+
+    it("returns already-relative path unchanged", () => {
+      expect(toRelativePath("pages/Foo.md", "/home/user/workspace")).toBe(
+        "pages/Foo.md",
+      )
+    })
+
+    it("handles deeply nested paths", () => {
+      expect(
+        toRelativePath(
+          "/Users/test/workspace/pages/sub/deep/file.md",
+          "/Users/test/workspace",
+        ),
+      ).toBe("pages/sub/deep/file.md")
+    })
+
+    it("does not strip prefix from unrelated path with matching start", () => {
+      expect(
+        toRelativePath(
+          "/home/user/workspace-other/file.md",
+          "/home/user/workspace",
+        ),
+      ).toBe("/home/user/workspace-other/file.md")
+    })
+  })
+
   it("extracts [[Page|Alias]] references", () => {
     const links = extractLinks("see [[Page|Alias]] here")
     expect(links).toHaveLength(1)
@@ -139,3 +225,37 @@ describe("extractLinks", () => {
     expect(extractLinks("plain text")).toHaveLength(0)
   })
 })
+
+describe("escapeHtml", () => {
+  it("escapes & < > \" '", () => {
+    expect(escapeHtml(`&<>"'`)).toBe("&amp;&lt;&gt;&quot;&#039;")
+  })
+
+  it("passes plain text through unchanged", () => {
+    expect(escapeHtml("hello world")).toBe("hello world")
+  })
+
+  it("passes [[markdown links]] through unchanged", () => {
+    expect(escapeHtml("see [[Other Page]]")).toBe("see [[Other Page]]")
+  })
+
+  it("escapes html in excerpt before mark highlighting", () => {
+    const safe = escapeHtml("<img src=x onerror=alert(1)>")
+    const result = safe.replace(
+      /\[\[([^\]]+)\]\]/g,
+      '<mark class="hl">$1</mark>',
+    )
+    expect(result).toBe("&lt;img src=x onerror=alert(1)&gt;")
+  })
+
+  it("escapes html mixed with [[links]]", () => {
+    const safe = escapeHtml("<script>alert(1)</script> and [[Page]]")
+    const result = safe.replace(
+      /\[\[([^\]]+)\]\]/g,
+      '<mark class="hl">$1</mark>',
+    )
+    expect(result).toBe(
+      '&lt;script&gt;alert(1)&lt;/script&gt; and <mark class="hl">Page</mark>',
+    )
+  })
+})

+ 61 - 0
apps/tauri/src/lib/parse.ts

@@ -1,3 +1,16 @@
+/**
+ * Escape HTML special characters (`&<>"'`) so text is safe for `innerHTML` / `v-html`.
+ * Used before injecting user-written excerpts into the backlinks panel.
+ */
+export function escapeHtml(text: string): string {
+  return text
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#039;")
+}
+
 /** Simple content hash for change detection (djb2 variant, base-36 encoded). */
 export function contentHash(content: string): string {
   let hash = 0
@@ -37,6 +50,28 @@ export function extractTitle(content: string): string {
   return match ? match[1]?.trim() : ""
 }
 
+/**
+ * Derive a page's display title from its content, falling back to the
+ * filename stem when there's no `# Heading 1`. Guarantees every indexed
+ * page has a non-empty title — downstream code (sidebar, link navigation)
+ * depends on this.
+ *
+ * @param content — Raw markdown content
+ * @param filePath — Path relative to workspace root, e.g. `"pages/Foo.md"`
+ * @returns A non-empty title string
+ *
+ * @remarks The filename stem is derived from the last path segment, with
+ * `.md` extension stripped. Underscores and dashes are preserved as-is
+ * (they're common in page names). The sidebar's `useSidebarIndex` also
+ * replaces underscores with spaces for display — that's a separate concern
+ * from the indexer's stored title.
+ */
+export function computePageTitle(content: string, filePath: string): string {
+  const rawTitle = extractTitle(content)
+  const stem = filePath.split("/").pop()?.replace(/\.md$/i, "") ?? ""
+  return rawTitle || stem
+}
+
 export interface ExtractedLink {
   target: string
   linkType: "page-ref" | "block-ref" | "tag"
@@ -70,3 +105,29 @@ export function extractLinks(content: string): ExtractedLink[] {
 
   return links
 }
+
+/**
+ * Convert an absolute file path to a workspace-relative path by stripping
+ * the workspace root prefix. If the path is already relative, returns it
+ * unchanged.
+ *
+ * This is used at the boundary between the filesystem layer (which returns
+ * absolute paths from Tauri's `list_directory`) and the indexer (which
+ * stores relative paths so they can be passed directly to the router for
+ * backlink navigation).
+ *
+ * @param absolutePath — A path (absolute or already relative)
+ * @param workspacePath — Workspace root directory (absolute)
+ * @returns The path relative to workspacePath
+ */
+export function toRelativePath(
+  absolutePath: string,
+  workspacePath: string,
+): string {
+  const prefix = workspacePath.endsWith("/")
+    ? workspacePath
+    : `${workspacePath}/`
+  return absolutePath.startsWith(prefix)
+    ? absolutePath.slice(prefix.length)
+    : absolutePath
+}

+ 8 - 0
apps/tauri/src/lib/schema.test.ts

@@ -91,4 +91,12 @@ describe("schema integrity", () => {
       ).run()
     }).toThrow()
   })
+
+  it("pages has content_hash column with default ''", () => {
+    const cols = db
+      .prepare("SELECT name, dflt_value FROM pragma_table_info('pages') WHERE name = 'content_hash'")
+      .get() as { name: string; dflt_value: string } | undefined
+    expect(cols?.name).toBe("content_hash")
+    expect(cols?.dflt_value).toBe("''")
+  })
 })

+ 2 - 1
apps/tauri/src/lib/schema.ts

@@ -7,7 +7,8 @@ CREATE TABLE IF NOT EXISTS pages (
     path TEXT PRIMARY KEY,
     title TEXT NOT NULL DEFAULT '',
     modified_at INTEGER NOT NULL,
-    word_count INTEGER NOT NULL DEFAULT 0
+    word_count INTEGER NOT NULL DEFAULT 0,
+    content_hash TEXT NOT NULL DEFAULT ''
 );
 
 CREATE TABLE IF NOT EXISTS blocks (

+ 270 - 0
apps/tauri/src/pages/[...slug].vue

@@ -0,0 +1,270 @@
+<script setup lang="ts">
+/**
+ * Catch-all route (`/*`) that maps workspace-relative file paths to views.
+ *
+ * **Classification:**
+ * - `/tag/{name}` (no `.md` extension) → TagView (virtual aggregation)
+ * - Everything else → PageView (editable editor)
+ *
+ * **Data flow:**
+ * ```
+ * Route change → loadContent() → readFile from disk
+ *                    ↓
+ *               existsOnDisk = true/false
+ *                    ↓
+ *               loadBacklinks() → SQLite → LINKS to this page title
+ *                    ↓
+ *               Render <PageView> with content + backlinks
+ *                    ↓
+ *               User types → 500ms debounce → writeFile → indexFile
+ * ```
+ *
+ * **Key behaviors:**
+ * - Non-existent pages show an empty editor. No file is created until content
+ *   is typed (guarded by `existsOnDisk` — empty content on a non-existent file
+ *   never triggers a write).
+ * - Auto-save is debounced at 500ms. Pending saves are flushed on unmount
+ *   (component destroy) to prevent data loss on quick navigation.
+ * - Rename: physical file rename via `@tauri-apps/plugin-fs`. If the file
+ *   doesn't exist yet (no content), just navigates to the new path.
+ *
+ * @remarks Edge cases & deferred work:
+ * - **No mtime check on save.** Auto-save always overwrites the file on disk
+ *   regardless of whether an external editor changed it between debounce
+ *   triggers. A real fix would compare the file's mtime before writing and
+ *   show a conflict dialog if the file was modified externally. Same
+ *   limitation exists in `JournalDay.vue`.
+ * - **Excerpt `slice()`** uses UTF-16 code units. If a `[[link]]` offset falls
+ *   in the middle of a 4-byte Unicode character, the excerpt may be truncated
+ *   mid-character. See `PageView.vue`'s `formatExcerpt` — same pattern.
+ * - **Block ref scroll target** (`#block-{id}`) is appended to the URL but
+ *   the receiving PageView doesn't scroll to the block yet. The hash fragment
+ *   is in the URL for future use, but no `onMounted` scroll-to logic exists.
+ * - **TagView block ordering** uses index timestamps, not file mtimes.
+ *   Blocks from the same file will have identical `modified_at` values.
+ */
+import { rename } from "@tauri-apps/plugin-fs"
+import { computed, onMounted, onUnmounted, ref, watch } from "vue"
+import { onBeforeRouteUpdate, useRouter } from "vue-router"
+import type { Backlink } from "~/components/PageView.vue"
+import PageView from "~/components/PageView.vue"
+import TagView from "~/components/TagView.vue"
+import { useFileSystem } from "~/composables/useFileSystem"
+import { removeFile as removeFileFromIndex } from "~/lib/indexer"
+import { markInFlight, useWorkspaceStore } from "~/stores/workspace"
+
+const route = useRoute()
+const router = useRouter()
+const ws = useWorkspaceStore()
+const fs = useFileSystem()
+
+const slug = computed(() => (route.params.slug as string[]) ?? [])
+const relativePath = computed(() => slug.value.join("/"))
+
+const isTagView = computed(
+  () =>
+    relativePath.value.startsWith("tag/") &&
+    !relativePath.value.endsWith(".md"),
+)
+
+const tagName = computed(() =>
+  isTagView.value ? decodeURIComponent(relativePath.value.slice(4)) : "",
+)
+
+const filename = computed(() => {
+  const parts = relativePath.value.split("/")
+  return parts[parts.length - 1] ?? relativePath.value
+})
+
+const content = ref("")
+const backlinks = ref<Backlink[]>([])
+const existsOnDisk = ref(false)
+
+let saveTimer: ReturnType<typeof setTimeout> | null = null
+
+watch(content, (val) => {
+  if (isTagView.value || !ws.workspacePath) return
+
+  // Don't create a file for empty content on pages that don't exist yet
+  if (!val && !existsOnDisk.value) return
+
+  if (saveTimer) clearTimeout(saveTimer)
+  saveTimer = setTimeout(async () => {
+    const fullPath = `${ws.workspacePath}/${relativePath.value}`
+    markInFlight(fullPath)
+    try {
+      await fs.writeFile(fullPath, val)
+      existsOnDisk.value = true
+      if (ws.db) {
+        await ws.indexFile(relativePath.value, val)
+      }
+    } catch (e) {
+      console.warn("[page] save failed:", e)
+    }
+  }, 500)
+})
+
+// Flush pending save on unmount (user navigates away)
+function flushSave() {
+  if (saveTimer) {
+    clearTimeout(saveTimer)
+    saveTimer = null
+    if (content.value && ws.workspacePath && !isTagView.value) {
+      const rp = relativePath.value
+      const fullPath = `${ws.workspacePath}/${rp}`
+      markInFlight(fullPath)
+      fs.writeFile(fullPath, content.value).catch((e) =>
+        console.warn("[page] save on unmount failed:", e),
+      )
+      if (ws.db) {
+        ws.indexFile(rp, content.value).catch((e) =>
+          console.warn("[page] index on unmount failed:", e),
+        )
+      }
+    }
+  }
+}
+
+onUnmounted(flushSave)
+
+async function loadBacklinks() {
+  const db = ws.db
+  if (!db || isTagView.value) {
+    backlinks.value = []
+    return
+  }
+
+  try {
+    const titleRow = await db.select<{ title: string }[]>(
+      "SELECT title FROM pages WHERE path = ?",
+      [relativePath.value],
+    )
+    const pageTitle = titleRow[0]?.title || filename.value.replace(/\.md$/, "")
+
+    const rows = await db.select<
+      { source_page_path: string; content: string; offset_in_block: number }[]
+    >(
+      `SELECT l.source_page_path, b.content, l.offset_in_block
+       FROM links l
+       JOIN blocks b ON b.id = l.source_block_id
+       WHERE l.link_type = 'page-ref' AND l.target = ?
+       ORDER BY b.modified_at DESC
+       LIMIT 50`,
+      [pageTitle],
+    )
+
+    backlinks.value = rows.map((row) => {
+      const isJournal = row.source_page_path.startsWith("journals/")
+
+      const sourceName = isJournal
+        ? row.source_page_path.replace("journals/", "").replace(".md", "")
+        : (row.source_page_path.split("/").pop()?.replace(/\.md$/, "") ??
+          row.source_page_path)
+
+      const excerpt = extractExcerpt(row.content, row.offset_in_block)
+
+      return {
+        sourceType: isJournal ? ("journal" as const) : ("page" as const),
+        sourceName,
+        sourcePath: row.source_page_path,
+        excerpt,
+      }
+    })
+  } catch (e) {
+    console.warn("[page] failed to load backlinks:", e)
+    backlinks.value = []
+  }
+}
+
+function extractExcerpt(content: string, offset: number): string {
+  const start = Math.max(0, offset - 40)
+  const end = Math.min(content.length, offset + 60)
+  let excerpt = content.slice(start, end)
+  if (start > 0) excerpt = `…${excerpt}`
+  if (end < content.length) excerpt = `${excerpt}…`
+  return excerpt
+}
+
+async function loadContent() {
+  if (isTagView.value) return
+
+  const fullPath = `${ws.workspacePath}/${relativePath.value}`
+  try {
+    content.value = await fs.readFile(fullPath)
+    existsOnDisk.value = true
+  } catch (e) {
+    console.warn("[page] loadContent failed for", fullPath, e)
+    content.value = ""
+    existsOnDisk.value = false
+  }
+  await loadBacklinks()
+}
+
+// Load content on initial mount. onMounted ensures route params are resolved
+// and the Tauri backend is available before calling readFile.
+onMounted(() => {
+  loadContent()
+})
+
+// React to same-component route changes (catch-all navigation).
+// Uses Vue Router's guard rather than a watch on computed refs — eliminates
+// timing sensitivity between the reactive flush and the async readFile.
+onBeforeRouteUpdate(() => {
+  loadContent()
+})
+
+async function handleRename(newName: string) {
+  if (!ws.workspacePath) return
+
+  const oldPath = relativePath.value
+  const lastSlash = oldPath.lastIndexOf("/")
+  const oldDir = lastSlash >= 0 ? oldPath.slice(0, lastSlash + 1) : ""
+  const newPath = `${oldDir}${newName}.md`
+
+  // If no content was ever written, there's no file to rename — just navigate
+  if (!content.value) {
+    await router.push(`/${newPath}`)
+    return
+  }
+
+  const oldFullPath = `${ws.workspacePath}/${oldPath}`
+  const newFullPath = `${ws.workspacePath}/${newPath}`
+
+  try {
+    await rename(oldFullPath, newFullPath)
+
+    const updated = content.value.replace(/^# .+/m, `# ${newName}`)
+    await fs.writeFile(newFullPath, updated)
+    content.value = updated
+
+    if (ws.db) {
+      await removeFileFromIndex(ws.db, oldPath)
+    }
+    await ws.indexFile(newPath, updated)
+    await router.push(`/${newPath}`)
+  } catch (e) {
+    console.warn("[page] rename failed:", e)
+  }
+}
+
+function handleBacklinkClick(sourcePath: string) {
+  router.push(`/${sourcePath}`)
+}
+</script>
+
+<template>
+  <TagView
+    v-if="isTagView"
+    :tag="tagName"
+  />
+  <PageView
+    v-else
+    :path="relativePath"
+    :filename="filename"
+    :content="content"
+    :backlinks="backlinks"
+    @update:content="content = $event"
+    @rename="handleRename"
+    @backlink-clicked="handleBacklinkClick"
+  />
+</template>

+ 1 - 0
apps/tauri/src/stores/workspace.test.ts

@@ -12,6 +12,7 @@ vi.mock("~/lib/indexer", () => ({
     }),
   ),
   fullReindex: vi.fn(() => Promise.resolve()),
+  syncIndex: vi.fn(() => Promise.resolve()),
   indexFile: vi.fn(() => Promise.resolve()),
   removeFile: vi.fn(() => Promise.resolve()),
 }))

+ 49 - 6
apps/tauri/src/stores/workspace.ts

@@ -11,7 +11,13 @@ import { defineStore } from "pinia"
 import { computed, ref } from "vue"
 import { useFileSystem } from "~/composables/useFileSystem"
 import { useSettings } from "~/composables/useSettings"
-import { fullReindex, indexFile, initIndex, removeFile } from "~/lib/indexer"
+import {
+  fullReindex,
+  indexFile as indexFileInDb,
+  initIndex,
+  removeFile,
+  syncIndex,
+} from "~/lib/indexer"
 
 // Track files the app wrote itself, to avoid re-indexing on our own writes
 const inFlightWrites = new Set<string>()
@@ -33,6 +39,7 @@ export const useWorkspaceStore = defineStore("workspace", () => {
   const files = ref<WorkspaceFile[]>([])
   const currentFilePath = ref<string | null>(null)
   const loading = ref(false)
+  const isIndexing = ref(false)
   const db = ref<Database | null>(null)
 
   const { readFile, writeFile, listDirectory, createDirectory } =
@@ -45,10 +52,7 @@ export const useWorkspaceStore = defineStore("workspace", () => {
 
   async function initializeIndex(path: string) {
     db.value = await initIndex(`${path}/index.db`)
-    await fullReindex(
-      { listDirectory, readFile, workspacePath: path },
-      db.value,
-    )
+    await syncIndex({ listDirectory, readFile, workspacePath: path }, db.value)
 
     // Start file watcher
     try {
@@ -78,7 +82,7 @@ export const useWorkspaceStore = defineStore("workspace", () => {
             await removeFile(db.value, changedPath)
           } else {
             const content = await readFile(changedPath)
-            await indexFile(db.value, changedPath, content)
+            await indexFileInDb(db.value, changedPath, content)
           }
           await scanFiles()
         } catch (e) {
@@ -223,6 +227,20 @@ export const useWorkspaceStore = defineStore("workspace", () => {
     await createDirectory(`${workspacePath.value}/templates`)
   }
 
+  async function reindex() {
+    if (!db.value || !workspacePath.value) return
+    isIndexing.value = true
+    try {
+      await fullReindex(
+        { listDirectory, readFile, workspacePath: workspacePath.value },
+        db.value,
+      )
+      await scanFiles()
+    } finally {
+      isIndexing.value = false
+    }
+  }
+
   async function scanFiles() {
     if (!workspacePath.value) return
     loading.value = true
@@ -272,6 +290,28 @@ export const useWorkspaceStore = defineStore("workspace", () => {
     await writeFile(currentFilePath.value, content)
   }
 
+  /**
+   * Index a single file by its relative path. Reads the file from disk if
+   * content is not provided, delegates to the indexer, then refreshes the
+   * in-memory file list so the sidebar stays in sync.
+   *
+   * Used by the page auto-save flow in `[...slug].vue` after each write,
+   * and by the page-creation path (no longer creates files — that's lazy).
+   *
+   * @param relativePath — Path relative to workspace root, e.g. "pages/Foo.md"
+   * @param content — Optional content. If omitted, read from disk.
+   */
+  async function indexFile(
+    relativePath: string,
+    content?: string,
+  ): Promise<void> {
+    if (!workspacePath.value || !db.value) return
+    const fullPath = `${workspacePath.value}/${relativePath}`
+    const fileContent = content ?? (await readFile(fullPath))
+    await indexFileInDb(db.value, relativePath, fileContent)
+    await scanFiles()
+  }
+
   function openFile(relativePath: string) {
     if (!workspacePath.value) return
     currentFilePath.value = `${workspacePath.value}/${relativePath}`
@@ -285,6 +325,8 @@ export const useWorkspaceStore = defineStore("workspace", () => {
     pages,
     currentFilePath,
     loading,
+    isIndexing,
+    reindex,
     db,
     recentWorkspaces,
     initialize,
@@ -295,6 +337,7 @@ export const useWorkspaceStore = defineStore("workspace", () => {
     closeWorkspace,
     loadRecentWorkspaces,
     scanFiles,
+    indexFile,
     loadFile,
     saveFile,
     loadSavedFile,

+ 14 - 8
pnpm-lock.yaml

@@ -72,6 +72,12 @@ importers:
       '@enesis/editor':
         specifier: workspace:*
         version: link:../../packages/editor
+      '@nuxt/fonts':
+        specifier: ^0.14.0
+        version: 0.14.0([email protected]([email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
+      '@nuxt/icon':
+        specifier: ^2.2.3
+        version: 2.2.3([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))
       '@nuxt/ui':
         specifier: ^4.9.0
         version: 4.9.0(5675fc70f939c4eaa8007c47ad9cdf31)
@@ -99,9 +105,15 @@ importers:
       '@tauri-apps/plugin-store':
         specifier: ^2.4.3
         version: 2.4.3
+      '@vueuse/core':
+        specifier: ^14.3.0
+        version: 14.3.0([email protected]([email protected]))
       nuxt:
         specifier: ^4.4.8
         version: 4.4.8(@babel/[email protected](@babel/[email protected]))(@babel/[email protected](@babel/[email protected]))(@biomejs/[email protected])(@parcel/[email protected])(@types/[email protected])(@vue/[email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])([email protected]([email protected])([email protected]([email protected])))([email protected])([email protected]([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))([email protected])
+      tailwindcss:
+        specifier: ^4.3.1
+        version: 4.3.1
       vue:
         specifier: ^3.5.38
         version: 3.5.38([email protected])
@@ -5331,10 +5343,6 @@ packages:
     resolution: {integrity: sha512-F1oWdz8tjT17qe1d5JgDK6z03WGOhYYAN0lK3/D/fzNiy93xswLLEw7pk+3g05onhAy6Bsc6PLNUGhdgVjemMQ==}
     engines: {node: ^16.14.0 || >= 17.3.0}
 
-  [email protected]:
-    resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
-    engines: {node: '>=18'}
-
   [email protected]:
     resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
     engines: {node: '>=18'}
@@ -5999,7 +6007,7 @@ snapshots:
   '@antfu/[email protected]':
     dependencies:
       package-manager-detector: 1.6.0
-      tinyexec: 1.1.2
+      tinyexec: 1.2.4
 
   '@asamuzakjp/[email protected]':
     dependencies:
@@ -7125,7 +7133,7 @@ snapshots:
   '@nuxt/[email protected]([email protected]([email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
     dependencies:
       '@nuxt/devtools-kit': 3.2.4([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
-      '@nuxt/kit': 4.4.6([email protected])
+      '@nuxt/kit': 4.4.8([email protected])
       consola: 3.4.2
       defu: 6.1.7
       fontless: 0.2.1([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
@@ -11517,8 +11525,6 @@ snapshots:
 
   [email protected]: {}
 
-  [email protected]: {}
-
   [email protected]: {}
 
   [email protected]: