Compare commits

...

13 Commits

Author SHA1 Message Date
lucaronin
582d1d25d6 fix: stabilize embedded diagrams 2026-05-04 11:28:53 +02:00
lucaronin
ebf4545d46 fix: show type-derived instance property placeholders 2026-05-04 10:55:04 +02:00
lucaronin
3c483ba0e6 feat(editor): add table of contents panel 2026-05-04 06:08:09 +02:00
lucaronin
71629763ee chore: ignore local codacy runtime 2026-05-04 05:25:43 +02:00
lucaronin
417e37f1d1 refactor(paths): share note path identity 2026-05-04 04:59:50 +02:00
lucaronin
a3e3192b66 refactor(editor): share durable block codecs 2026-05-04 04:09:14 +02:00
lucaronin
86c503c1f9 refactor(ai): share model provider catalog 2026-05-04 02:59:20 +02:00
lucaronin
21a47b4a77 fix(editor): avoid modern array copy in rich export 2026-05-04 02:07:32 +02:00
lucaronin
64d961bd98 fix(editor): type ime cancel listener 2026-05-04 01:18:28 +02:00
lucaronin
1b218f5250 fix(editor): keep toolbar hidden through ime settle 2026-05-04 01:02:07 +02:00
lucaronin
dfb9f98b7a fix(types): block same-path type collisions 2026-05-03 23:44:24 +02:00
lucaronin
6a033cd2db fix(status): keep git popup above panels 2026-05-03 20:25:50 +02:00
lucaronin
b872b6148c fix(menu): route windows menu actions 2026-05-03 20:00:37 +02:00
92 changed files with 3938 additions and 867 deletions

3
.gitignore vendored
View File

@@ -73,3 +73,6 @@ CODE-HEALTH-REPORT.md
.env
.env.local
.env.*.local
# Local Codacy CLI runtime/config generated by the MCP server
.codacy/

View File

@@ -170,6 +170,10 @@ The renderer may cache recently opened or preloaded markdown content, but cached
`useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state.
### Table of Contents Outline
The editor Table of Contents is derived from the live BlockNote document, not from saved Markdown text. `src/utils/tableOfContents.ts` reads structural `heading` blocks with stable ids and levels, extracts inline text from nested BlockNote content, and nests headings by level while preserving document order. `TableOfContentsPanel` receives a document revision from `Editor`, so rich-editor edits refresh the outline immediately without waiting for autosave or a vault reload. Selecting a heading focuses BlockNote and moves the cursor to that block id, while nested headings can be collapsed independently in panel-local UI state.
### Entity Types (isA / type)
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
@@ -200,6 +204,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
- Define instance schema/defaults through ordinary custom frontmatter properties and relationship fields
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
- Serve as the "definition" for their type category
@@ -218,6 +223,8 @@ Each entity type can have a corresponding **type document**: any markdown note w
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
**UI behavior**:
- Clicking a section group header pins the type document at the top of the NoteList if it exists
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
@@ -278,6 +285,7 @@ Tolaria separates **display title** from the file identifier:
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`.
- **Path identity rules** (`src/utils/notePathIdentity.ts`, `vault/path_identity.rs`): note creation, tab selection, rename bookkeeping, pull refresh, git history, and vault cache updates normalize path separators and macOS `/private/tmp` aliases through one owner. Case folding is reserved for collision/deduplication checks; active-note identity remains case-sensitive.
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
@@ -336,7 +344,7 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
## Command Surface
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
@@ -546,21 +554,22 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
### Mermaid Diagrams
Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body.
- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG.
- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails.
- `serializeMermaidAwareBlocks()` wraps the math-aware serializer so math, wikilinks, and diagrams share the same Markdown-first save path.
- `serializeDurableEditorBlocks()` wraps the math-aware serializer so math, wikilinks, Mermaid diagrams, and whiteboards share the same Markdown-first save path.
- The `/mermaid` slash command inserts a placeholder rectangle diagram using the same schema-backed Markdown storage path, avoiding an invalid empty diagram state.
### Tldraw Whiteboards
Defined in `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
- Fenced `tldraw` blocks become `tldrawBlock` schema nodes before BlockNote sees the Markdown body.
- Each `tldrawBlock` stores a stable `boardId` plus the tldraw document snapshot JSON. Session state such as camera, selected tool, and current selection is not persisted into the note.
- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file.
- Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner.
- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts.
### Formatting Surface Policy
@@ -570,7 +579,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useEditorComposing` tracks `compositionstart` and `compositionend` events that target the BlockNote editor and closes the floating formatting toolbar while IME text is being composed, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
@@ -585,16 +594,15 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
```mermaid
flowchart LR
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
B --> C["preProcessTldrawMarkdown(body)\ntldraw fence → token"]
C --> D["preProcessMermaidMarkdown(body)\nmermaid fence → token"]
D --> E["preProcessWikilinks(body)\n[[target]]token"]
E --> F["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
F --> G["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
G --> H["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks + injectTldrawInBlocks\n tokens → schema nodes"]
H --> I["editor.replaceBlocks()\n→ rendered editor"]
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences → tokens"]
C --> D["preProcessWikilinks(body)\n[[target]]token"]
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
F --> G["injectWikilinks + injectMathInBlocks + injectDurableEditorMarkdownBlocks\n tokens → schema nodes"]
G --> H["editor.replaceBlocks()\n→ rendered editor"]
style A fill:#f8f9fa,stroke:#6c757d,color:#000
style I fill:#d4edda,stroke:#28a745,color:#000
style H fill:#d4edda,stroke:#28a745,color:#000
```
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads.
@@ -604,7 +612,7 @@ flowchart LR
```mermaid
flowchart LR
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
B --> C["restoreWikilinks + serializeMermaidAwareBlocks()\nschema nodes → Markdown source"]
B --> C["restoreWikilinks + serializeDurableEditorBlocks()\nschema nodes → Markdown source"]
C --> D["prepend frontmatter yaml"]
D --> E["invoke('save_note_content')\n→ disk write"]
@@ -676,10 +684,11 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
- **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged. For typed instances, missing relationship fields declared on the type document render as gray editable placeholders without copying any default relationship targets into existing notes.
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
@@ -796,7 +805,7 @@ interface Settings {
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -142,7 +142,7 @@ flowchart TD
SB["Sidebar\n(navigation + filters + types)"]
NL["NoteList / PulseView\n(filtered list / activity)"]
ED["Editor\n(BlockNote + diff + raw)"]
IN["Inspector\n(metadata + relationships)"]
IN["Right Panel\n(Inspector + TOC)"]
AIP["AiPanel\n(selected CLI agent + tools)"]
SP["SearchPanel\n(keyword search)"]
ST["StatusBar\n(vault picker + sync + version)"]
@@ -185,10 +185,10 @@ flowchart TD
```
┌────────┬─────────────┬─────────────────────────┬────────────┐
│Sidebar │ Note List │ Editor │ Inspector
│Sidebar │ Note List │ Editor │ Right Panel
│(250px) │ (300px) │ (flex-1) │ (280px) │
│ │ OR │ │ OR │
│ All │ Pulse View │ [Breadcrumb Bar] │ AI Chat
│ All │ Pulse View │ [Breadcrumb Bar] │ TOC
│ Changes│ │ │ OR │
│ Pulse │ [Search] │ # My Note │ AI Agent │
│ Inbox │ [Sort/Filt] │ │ │
@@ -206,8 +206,8 @@ flowchart TD
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / Table of Contents / AI Agent** (200-500px or 40px collapsed): `EditorRightPanel` selects between Inspector (frontmatter, relationships, instances, backlinks, git history), Table of Contents (live BlockNote heading outline with collapsible nested headings and cursor navigation), and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles AI; the List Bullets icon toggles the TOC and moves into the breadcrumb overflow menu when horizontal space is tight. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
@@ -217,7 +217,7 @@ The main Tauri window also persists its last normal size and screen position in
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, while cross-platform custom items such as Check for Updates emit Tolaria command IDs and show visible updater feedback.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
## Multi-Window (Note Windows)
@@ -311,7 +311,7 @@ Large active notes are compacted into a head/tail body snapshot before they ente
### Direct Model Targets
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Local endpoints can omit authentication.
@@ -428,7 +428,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
### Cache File
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
@@ -579,9 +579,10 @@ sequenceDiagram
A->>T: invoke('get_note_content')
T-->>A: raw markdown
A->>A: splitFrontmatter → [yaml, body]
A->>A: preProcessDurableEditorMarkdown(body)
A->>A: preProcessWikilinks(body)
A->>A: tryParseMarkdownToBlocks()
A->>A: injectWikilinks(blocks)
A->>A: injectWikilinks + injectDurableEditorMarkdownBlocks(blocks)
A-->>U: Editor renders note
```
@@ -869,7 +870,7 @@ Shortcut routing is explicit:
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`

View File

@@ -364,7 +364,7 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.

View File

@@ -421,6 +421,8 @@ files:
editor.toolbar.showDiff: 08d579de8d3abdcdfce0953a797362c6
editor.toolbar.openAi: d2e93006570a66709c8ce0dc27082ef1
editor.toolbar.closeAi: cd46973ef73076d612dff9903ce54498
editor.toolbar.openTableOfContents: b733f053ea3fde4a9acd589ec7f58c10
editor.toolbar.closeTableOfContents: d3292972a10edd1a06a627f3552f760a
editor.toolbar.restoreArchived: 1bff5cf4943af64c05b2e9aeadccbc84
editor.toolbar.archive: 63dc964c32e715217b49f8739d49636b
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
@@ -428,6 +430,14 @@ files:
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b
tableOfContents.close: d3292972a10edd1a06a627f3552f760a
tableOfContents.empty: e4f02ad69cbbce1ec65d14215e3d5871
tableOfContents.emptyNoNote: 046d95682b747a30ed8a7b0b1d581629
tableOfContents.navLabel: 598b8ae10dfce818e73d3218daddbdae
tableOfContents.untitledHeading: 93c2dde207965b383b7b22af005ebe4f
tableOfContents.expandHeading: 6353ab44fe75f5dd935789bdab8551a0
tableOfContents.collapseHeading: 040b4c102283028831ea78713235e478
editor.codeBlock.copy: 8e477020fb3f7ab844b91ff1d7de8347
editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c
editor.filename.rename: c31c2b468229232ad6287e734fe67d96

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

File diff suppressed because one or more lines are too long

16
pnpm-lock.yaml generated
View File

@@ -16,7 +16,7 @@ overrides:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: 93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4
hash: c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76
@@ -37,10 +37,10 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/mantine':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -5608,9 +5608,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
@@ -5618,7 +5618,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
@@ -5670,7 +5670,7 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
@@ -5692,7 +5692,7 @@ snapshots:
'@blocknote/react@0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=93736b5b6fa4b9d257d510af50da44c5192f3e3115dfb9548a11f1a4865027e4)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=c93b0eab9c8a1800cc1489627146cbe9037999f409efcc8234b33f670d2ccf8d)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10

View File

@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -70,11 +71,35 @@ pub struct AiModelProviderTestRequest {
pub api_key_override: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct AiModelProviderCatalogEntry {
kind: AiModelProviderKind,
runtime_base_url: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
struct AiProviderSecrets {
provider_api_keys: BTreeMap<String, String>,
}
static AI_MODEL_PROVIDER_CATALOG: OnceLock<Vec<AiModelProviderCatalogEntry>> = OnceLock::new();
fn provider_catalog() -> &'static [AiModelProviderCatalogEntry] {
AI_MODEL_PROVIDER_CATALOG
.get_or_init(|| {
serde_json::from_str(include_str!("../../src/shared/aiModelProviderCatalog.json"))
.expect("bundled AI model provider catalog must be valid JSON")
})
.as_slice()
}
fn provider_default_base_url(kind: &AiModelProviderKind) -> Option<&'static str> {
provider_catalog()
.iter()
.find(|entry| entry.kind == *kind)
.and_then(|entry| entry.runtime_base_url.as_deref())
}
pub fn normalize_ai_model_providers(
providers: Option<Vec<AiModelProvider>>,
) -> Option<Vec<AiModelProvider>> {
@@ -211,15 +236,7 @@ fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 {
}
fn normalized_base_url(request: &AiModelStreamRequest) -> Result<String, String> {
let fallback = match request.provider.kind {
AiModelProviderKind::OpenAi => "https://api.openai.com/v1",
AiModelProviderKind::Anthropic => "https://api.anthropic.com/v1",
AiModelProviderKind::OpenRouter => "https://openrouter.ai/api/v1",
AiModelProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1beta/openai",
AiModelProviderKind::Ollama => "http://localhost:11434/v1",
AiModelProviderKind::LmStudio => "http://127.0.0.1:1234/v1",
AiModelProviderKind::OpenAiCompatible => "",
};
let fallback = provider_default_base_url(&request.provider.kind).unwrap_or("");
let base = request
.provider
.base_url
@@ -588,6 +605,22 @@ mod tests {
assert_eq!(headers, vec![("X-Demo", "demo")]);
}
#[test]
fn shared_provider_catalog_supplies_runtime_base_urls() {
assert_eq!(
provider_default_base_url(&AiModelProviderKind::OpenAi),
Some("https://api.openai.com/v1")
);
assert_eq!(
provider_default_base_url(&AiModelProviderKind::Ollama),
Some("http://localhost:11434/v1")
);
assert_eq!(
provider_default_base_url(&AiModelProviderKind::OpenAiCompatible),
None
);
}
#[test]
fn custom_provider_requires_base_url() {
let mut provider = provider(AiModelProviderKind::OpenAiCompatible);

View File

@@ -1,4 +1,5 @@
use super::git_command;
use crate::vault::path_identity::vault_relative_path_string;
use std::path::Path;
use super::GitCommit;
@@ -7,14 +8,7 @@ use super::GitCommit;
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let relative_str = vault_relative_path_string(vault, file)?;
let output = git_command()
.args([
@@ -23,7 +17,7 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
"-n",
"20",
"--",
relative_str,
&relative_str,
])
.current_dir(vault)
.output()
@@ -70,18 +64,11 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let relative_str = vault_relative_path_string(vault, file)?;
// First try tracked file diff
let output = git_command()
.args(["diff", "--", relative_str])
.args(["diff", "--", &relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff: {}", e))?;
@@ -91,7 +78,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
// If no diff (maybe staged or untracked), try diff --cached
if stdout.is_empty() {
let cached = git_command()
.args(["diff", "--cached", "--", relative_str])
.args(["diff", "--cached", "--", &relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
@@ -103,7 +90,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
// Try showing untracked file as all-new
let status = git_command()
.args(["status", "--porcelain", "--", relative_str])
.args(["status", "--porcelain", "--", &relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
@@ -134,14 +121,7 @@ pub fn get_file_diff_at_commit(
) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let relative_str = vault_relative_path_string(vault, file)?;
// Show diff between commit^ and commit for this file
let output = git_command()
@@ -150,7 +130,7 @@ pub fn get_file_diff_at_commit(
&format!("{}^", commit_hash),
commit_hash,
"--",
relative_str,
&relative_str,
])
.current_dir(vault)
.output()

View File

@@ -1,9 +1,13 @@
#[cfg(not(target_os = "macos"))]
use crate::window_state::MAIN_WINDOW_LABEL;
use serde::{Deserialize, Deserializer};
use std::{
collections::{BTreeMap, HashSet},
error::Error,
sync::OnceLock,
};
#[cfg(not(target_os = "macos"))]
use tauri::{menu::MenuEvent, Manager};
use tauri::{
menu::{MenuBuilder, MenuItem, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder},
App, AppHandle, Emitter,
@@ -234,6 +238,10 @@ fn app_menu_includes_services(target_os: &str) -> bool {
target_os == "macos"
}
fn window_menu_event_handler_required(target_os: &str) -> bool {
target_os != "macos"
}
fn build_manifest_menu_item(
app: &App,
item: &ManifestMenuItem,
@@ -383,6 +391,28 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn Error>> {
let _ = emit_custom_menu_event(app_handle, id);
});
register_window_menu_event_handler(app)?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn register_window_menu_event_handler(app: &App) -> Result<(), Box<dyn Error>> {
debug_assert!(window_menu_event_handler_required(std::env::consts::OS));
let window = app.get_webview_window(MAIN_WINDOW_LABEL).ok_or_else(|| {
format!("setup_menu: window '{MAIN_WINDOW_LABEL}' not found; menu events will not fire")
})?;
let app_handle = app.handle().clone();
window.on_menu_event(move |_window, event: MenuEvent| {
let id = event.id().0.as_str();
let _ = emit_custom_menu_event(&app_handle, id);
});
Ok(())
}
#[cfg(target_os = "macos")]
fn register_window_menu_event_handler(_app: &App) -> Result<(), Box<dyn Error>> {
debug_assert!(!window_menu_event_handler_required(std::env::consts::OS));
Ok(())
}
@@ -564,4 +594,11 @@ mod tests {
assert!(!app_menu_includes_services("windows"));
assert!(!app_menu_includes_services("linux"));
}
#[test]
fn window_menu_event_handler_is_required_off_macos() {
assert!(!window_menu_event_handler_required("macos"));
assert!(window_menu_event_handler_required("windows"));
assert!(window_menu_event_handler_required("linux"));
}
}

View File

@@ -9,6 +9,10 @@ use uuid::Uuid;
use crate::git::{get_all_file_dates, GitDates};
use std::collections::HashMap;
use super::path_identity::{
normalize_path_for_identity, push_unique_relative_path, relative_path_key,
vault_relative_path_string,
};
use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
@@ -83,7 +87,7 @@ fn default_cache_version() -> u32 {
/// Compute a deterministic hex hash of the vault path for use as cache filename.
fn vault_path_hash(vault: &Path) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
vault.to_string_lossy().as_ref().hash(&mut hasher);
normalize_path_for_identity(&vault.to_string_lossy()).hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
@@ -148,28 +152,22 @@ fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
/// Includes all non-hidden files (not just .md) so the cache picks up
/// view files (.yml), binary assets, etc.
fn collect_paths_from_diff(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter(|line| !line.is_empty() && !has_hidden_segment(line))
.map(|line| line.to_string())
.collect()
let mut paths = Vec::new();
for line in stdout.lines() {
push_unique_relative_path(&mut paths, line);
}
paths
}
/// Extract file paths from git status --porcelain output.
/// Includes all non-hidden files so incremental cache updates cover
/// every file type the vault scanner recognises.
fn collect_paths_from_porcelain(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter_map(parse_porcelain_line)
.filter(|(_, path)| !has_hidden_segment(path))
.map(|(_, path)| path)
.collect()
}
/// Return true if any path segment starts with `.` (hidden file/directory).
fn has_hidden_segment(path: &str) -> bool {
path.split('/').any(|seg| seg.starts_with('.'))
let mut paths = Vec::new();
for (_, path) in stdout.lines().filter_map(parse_porcelain_line) {
push_unique_relative_path(&mut paths, path);
}
paths
}
fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String> {
@@ -182,9 +180,7 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
let uncommitted = git_uncommitted_files(vault);
for path in uncommitted.into_iter() {
if !files.contains(&path) {
files.push(path);
}
push_unique_relative_path(&mut files, path);
}
files
@@ -201,17 +197,16 @@ fn git_uncommitted_files(vault: &Path) -> Vec<String> {
// files inside — ls-files resolves them so the cache picks up all new files.
let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"])
.map(|s| {
s.lines()
.filter(|l| !l.is_empty() && !has_hidden_segment(l))
.map(|l| l.to_string())
.collect::<Vec<_>>()
let mut paths = Vec::new();
for line in s.lines() {
push_unique_relative_path(&mut paths, line);
}
paths
})
.unwrap_or_default();
for path in untracked {
if !files.contains(&path) {
files.push(path);
}
push_unique_relative_path(&mut files, path);
}
files
@@ -447,13 +442,12 @@ fn write_cache(
/// Normalize an absolute path to a relative path for comparison with git output.
fn to_relative_path(abs_path: &str, vault: &Path) -> String {
let vault_str = vault.to_string_lossy();
let with_slash = format!("{}/", vault_str);
abs_path
.strip_prefix(&with_slash)
.or_else(|| abs_path.strip_prefix(vault_str.as_ref()))
.unwrap_or(abs_path)
.to_string()
vault_relative_path_string(vault, Path::new(abs_path))
.unwrap_or_else(|_| normalize_path_for_identity(abs_path))
}
fn to_relative_path_key(abs_path: &str, vault: &Path) -> String {
relative_path_key(&to_relative_path(abs_path, vault))
}
/// Parse files from a list of relative paths, skipping any that don't exist.
@@ -535,7 +529,7 @@ fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
// Deduplicate by case-folded relative path
let mut seen = std::collections::HashSet::new();
entries.retain(|e| {
let rel = to_relative_path(&e.path, vault).to_lowercase();
let rel = to_relative_path_key(&e.path, vault);
seen.insert(rel)
});
entries.len() != before
@@ -587,8 +581,9 @@ fn update_same_commit(
let changed = git_uncommitted_files(vault);
let mut entries = cache.entries;
if !changed.is_empty() {
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
let changed_set: std::collections::HashSet<String> =
changed.iter().map(|path| relative_path_key(path)).collect();
entries.retain(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault)));
entries.extend(parse_files_at(vault, &changed, git_dates));
}
// Always finalize: prune_stale_entries inside finalize_and_cache removes
@@ -605,12 +600,15 @@ fn update_different_commit(
) -> Vec<VaultEntry> {
let LoadedCache { cache, fingerprint } = loaded_cache;
let changed_files = git_changed_files(vault, &cache.commit_hash, &current_hash);
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
let changed_set: std::collections::HashSet<String> = changed_files
.iter()
.map(|path| relative_path_key(path))
.collect();
let mut entries: Vec<VaultEntry> = cache
.entries
.into_iter()
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
.filter(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault)))
.collect();
entries.extend(parse_files_at(vault, &changed_files, git_dates));
@@ -618,9 +616,10 @@ fn update_different_commit(
}
fn cache_requires_full_rescan(cache: &VaultCache, vault_path: &Path) -> bool {
let current_vault_str = vault_path.to_string_lossy();
let current_vault_str = normalize_path_for_identity(&vault_path.to_string_lossy());
cache.version != CACHE_VERSION
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref())
|| (!cache.vault_path.is_empty()
&& normalize_path_for_identity(&cache.vault_path) != current_vault_str)
}
fn scan_and_cache_full(
@@ -808,6 +807,17 @@ mod tests {
assert_eq!(hash1, hash2);
}
#[test]
fn test_to_relative_path_normalizes_aliases_and_separators() {
assert_eq!(
to_relative_path(
"/tmp/tolaria-vault/projects\\active.md",
Path::new("/private/tmp/tolaria-vault")
),
"projects/active.md"
);
}
#[test]
fn test_different_vaults_get_different_hashes() {
let hash1 = vault_path_hash(Path::new("/Users/test/Vault1"));

View File

@@ -242,28 +242,53 @@ pub(crate) fn extract_relationships(
continue;
}
match value {
serde_json::Value::String(s) if contains_wikilink(s) => {
relationships.insert(key.clone(), vec![s.clone()]);
}
serde_json::Value::Array(arr) => {
let wikilinks: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| contains_wikilink(s))
.map(|s| s.to_string())
.collect();
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
_ => {}
let wikilinks = relationship_wikilinks(value);
if !wikilinks.is_empty() {
relationships.insert(key.clone(), wikilinks);
}
}
relationships
}
fn relationship_wikilinks(value: &serde_json::Value) -> Vec<String> {
let mut wikilinks = Vec::new();
collect_relationship_wikilinks(value, 0, &mut wikilinks);
wikilinks
}
fn collect_relationship_wikilinks(
value: &serde_json::Value,
depth: usize,
wikilinks: &mut Vec<String>,
) {
match value {
serde_json::Value::String(s) if contains_wikilink(s) => wikilinks.push(s.clone()),
serde_json::Value::Array(arr) => {
if let Some(link) = nested_flow_wikilink(arr, depth) {
wikilinks.push(link);
return;
}
for item in arr {
collect_relationship_wikilinks(item, depth + 1, wikilinks);
}
}
_ => {}
}
}
fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<String> {
if depth == 0 {
return None;
}
match arr {
[serde_json::Value::String(target)] if !contains_wikilink(target) => {
Some(format!("[[{target}]]"))
}
_ => None,
}
}
/// Extract custom scalar properties from raw YAML frontmatter.
pub(crate) fn extract_properties(
data: &HashMap<String, serde_json::Value>,
@@ -276,6 +301,9 @@ pub(crate) fn extract_properties(
}
match value {
serde_json::Value::Null => {
properties.insert(key.clone(), value.clone());
}
serde_json::Value::String(s) if !contains_wikilink(s) => {
properties.insert(key.clone(), value.clone());
}

View File

@@ -114,6 +114,40 @@ fn test_single_element_array_properties_unwrap_to_scalars() {
}
}
#[test]
fn test_blank_scalar_properties_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"book.md",
"---\ntype: Book\nstart date:\nrating: \n---\n# Book\n",
);
assert!(entry
.properties
.get("start date")
.is_some_and(|value| value.is_null()));
assert!(entry
.properties
.get("rating")
.is_some_and(|value| value.is_null()));
}
#[test]
fn test_unquoted_wikilink_relationships_are_preserved() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(
&dir,
"book.md",
"---\ntype: Type\nMentor: [[person/alice]]\n---\n# Book\n",
);
assert_eq!(
entry.relationships.get("Mentor"),
Some(&vec!["[[person/alice]]".to_string()])
);
}
#[test]
fn test_alias_parser_recovers_special_alias_items() {
let cases = [

View File

@@ -10,6 +10,7 @@ mod ignored;
mod image;
mod migration;
mod parsing;
pub(crate) mod path_identity;
mod rename;
mod rename_transaction;
mod title_sync;
@@ -349,11 +350,7 @@ fn lookup_git_dates(
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
) -> Option<(u64, u64)> {
let rel = path
.strip_prefix(vault_path)
.ok()?
.to_string_lossy()
.to_string();
let rel = path_identity::vault_relative_path_string(vault_path, path).ok()?;
git_dates.get(&rel).map(|d| (d.modified_at, d.created_at))
}
@@ -462,11 +459,10 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String>
if is_folder_tree_hidden_dir(&name) {
continue;
}
let rel_path = path
.strip_prefix(vault_root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let rel_path = path_identity::vault_relative_path_string(vault_root, &path)
.unwrap_or_else(|_| {
path_identity::normalize_path_for_identity(&path.to_string_lossy())
});
let children = build_tree(&path, vault_root);
nodes.push(FolderNode {
name,

View File

@@ -0,0 +1,117 @@
use std::path::Path;
type PathText = str;
type RelativePathText = str;
fn normalize_tmp_alias(path: &PathText) -> String {
if path == "/private/tmp" {
return "/tmp".to_string();
}
if let Some(rest) = path.strip_prefix("/private/tmp/") {
return format!("/tmp/{rest}");
}
path.to_string()
}
fn trim_trailing_slashes(path: String) -> String {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() && path.starts_with('/') {
"/".to_string()
} else {
trimmed.to_string()
}
}
pub(crate) fn normalize_path_for_identity(path: &PathText) -> String {
trim_trailing_slashes(normalize_tmp_alias(&path.replace('\\', "/")))
}
pub(crate) fn normalize_relative_path(path: &RelativePathText) -> String {
path.replace('\\', "/").trim_matches('/').to_string()
}
pub(crate) fn relative_path_key(path: &RelativePathText) -> String {
normalize_relative_path(path).to_lowercase()
}
pub(crate) fn has_hidden_segment(path: &RelativePathText) -> bool {
normalize_relative_path(path)
.split('/')
.any(|segment| segment.starts_with('.'))
}
pub(crate) fn push_unique_relative_path(
paths: &mut Vec<String>,
path: impl AsRef<RelativePathText>,
) {
let normalized = normalize_relative_path(path.as_ref());
if normalized.is_empty() || has_hidden_segment(&normalized) {
return;
}
let key = relative_path_key(&normalized);
if !paths
.iter()
.any(|existing| relative_path_key(existing) == key)
{
paths.push(normalized);
}
}
pub(crate) fn vault_relative_path_string(vault: &Path, file: &Path) -> Result<String, String> {
let vault_path = normalize_path_for_identity(&vault.to_string_lossy());
let file_path = normalize_path_for_identity(&file.to_string_lossy());
if file_path == vault_path {
return Ok(String::new());
}
let prefix = format!("{vault_path}/");
file_path
.strip_prefix(&prefix)
.map(normalize_relative_path)
.ok_or_else(|| {
format!(
"File {} is not inside vault {}",
file.display(),
vault.display()
)
})
}
pub(crate) fn vault_relative_markdown_stem(path: &Path, vault: &Path) -> String {
let relative = vault_relative_path_string(vault, path)
.unwrap_or_else(|_| normalize_path_for_identity(&path.to_string_lossy()));
relative
.strip_suffix(".md")
.unwrap_or(&relative)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vault_relative_path_string_normalizes_tmp_alias_and_backslashes() {
assert_eq!(
vault_relative_path_string(
Path::new("/private/tmp/tolaria-vault"),
Path::new("/tmp/tolaria-vault/projects\\active.md"),
)
.unwrap(),
"projects/active.md"
);
}
#[test]
fn test_relative_path_key_is_case_insensitive_without_changing_output_path() {
let mut paths = vec![];
push_unique_relative_path(&mut paths, "Projects\\Active.md");
push_unique_relative_path(&mut paths, "projects/active.md");
assert_eq!(paths, vec!["Projects/Active.md"]);
assert_eq!(
relative_path_key("Projects\\Active.md"),
"projects/active.md"
);
}
}

View File

@@ -7,6 +7,7 @@ use tempfile::NamedTempFile;
use walkdir::WalkDir;
use super::filename_rules::validate_filename_stem;
use super::path_identity::vault_relative_markdown_stem;
use super::rename_transaction::RenameWorkspace;
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
@@ -211,12 +212,7 @@ fn update_note_title_in_content(content: &str, new_title: &str) -> String {
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
fn to_path_stem(path: &Path, vault_root: &Path) -> String {
let relative = path.strip_prefix(vault_root).unwrap_or(path);
let normalized = relative.to_string_lossy().replace('\\', "/");
normalized
.strip_suffix(".md")
.unwrap_or(&normalized)
.to_string()
vault_relative_markdown_stem(path, vault_root)
}
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
@@ -750,6 +746,17 @@ mod tests {
assert_eq!(renames[0].new_path, "新名.md");
}
#[test]
fn test_path_stem_normalizes_tmp_aliases_and_separators() {
assert_eq!(
to_path_stem(
Path::new("/tmp/tolaria-vault/projects\\weekly-review.md"),
Path::new("/private/tmp/tolaria-vault")
),
"projects/weekly-review"
);
}
#[test]
fn test_rename_note_basic() {
let dir = TempDir::new().unwrap();

View File

@@ -7,7 +7,7 @@ use tauri::{
WindowEvent,
};
const MAIN_WINDOW_LABEL: &str = "main";
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
const WINDOW_STATE_FILE: &str = "window-state.json";
const MIN_WINDOW_WIDTH: u32 = 480;
const MIN_WINDOW_HEIGHT: u32 = 400;

View File

@@ -654,6 +654,39 @@ describe('App', () => {
})
})
it('shows immediate feedback while a menu-driven update check is pending', async () => {
let resolveUpdate: ((result: { kind: 'up-to-date' }) => void) | null = null
const checkForUpdates = vi.fn(() => new Promise<{ kind: 'up-to-date' }>((resolve) => {
resolveUpdate = resolve
}))
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(checkForUpdates))
render(<App />)
await waitFor(() => {
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
})
act(() => {
window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates')
})
await waitFor(() => {
expect(screen.getByText('Checking for updates...')).toBeInTheDocument()
})
expect(checkForUpdates).toHaveBeenCalledOnce()
await act(async () => {
resolveUpdate?.({ kind: 'up-to-date' })
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument()
})
})
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)

View File

@@ -101,6 +101,7 @@ import {
getBrowserLanguagePreferences,
resolveEffectiveLocale,
serializeUiLanguagePreference,
translate,
type UiLanguagePreference,
} from './lib/i18n'
import { normalizeReleaseChannel } from './lib/releaseChannel'
@@ -1280,6 +1281,7 @@ function App() {
await restartApp()
return
}
setToastMessage(translate(appLocale, 'update.checking'))
const result = await updateActions.checkForUpdates()
if (result.kind === 'up-to-date') {
const checkedChannel = normalizeReleaseChannel(settings.release_channel)
@@ -1289,7 +1291,7 @@ function App() {
} else {
setToastMessage(result.message)
}
}, [settings.release_channel, updateActions, updateStatus.state, setToastMessage])
}, [appLocale, settings.release_channel, updateActions, updateStatus.state, setToastMessage])
const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return

View File

@@ -1,9 +1,12 @@
import { useState } from 'react'
import {
DEFAULT_MODEL_CAPABILITIES,
aiModelProviderCatalog,
aiModelProviderCatalogEntry,
configuredModelTargets,
isLocalAiProvider,
normalizeAiModelProviders,
type AiModelApiKeyStorage,
type AiModelProvider,
type AiModelProviderKind,
} from '../lib/aiTargets'
@@ -21,7 +24,6 @@ import {
type Translate = ReturnType<typeof createTranslator>
type ProviderMode = 'local' | 'api'
type ApiKeyStorage = 'none' | 'local_file' | 'env'
type TestState = 'idle' | 'testing' | 'success'
interface AiProviderSettingsProps {
@@ -36,67 +38,52 @@ interface ProviderDraft {
name: string
baseUrl: string
modelId: string
apiKeyStorage: ApiKeyStorage
apiKeyStorage: AiModelApiKeyStorage
apiKey: string
apiKeyEnvVar: string
}
const LOCAL_PROVIDER_KINDS: AiModelProviderKind[] = ['ollama', 'lm_studio']
const API_PROVIDER_KINDS: AiModelProviderKind[] = ['open_ai', 'anthropic', 'gemini', 'open_router', 'open_ai_compatible']
const PROVIDER_PRESETS: Record<AiModelProviderKind, { name: string; baseUrl: string }> = {
ollama: { name: 'Ollama', baseUrl: 'http://localhost:11434/v1' },
lm_studio: { name: 'LM Studio', baseUrl: 'http://127.0.0.1:1234/v1' },
open_ai: { name: 'OpenAI', baseUrl: 'https://api.openai.com/v1' },
anthropic: { name: 'Anthropic', baseUrl: 'https://api.anthropic.com/v1' },
gemini: { name: 'Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai' },
open_router: { name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1' },
open_ai_compatible: { name: 'Custom provider', baseUrl: 'https://api.example.com/v1' },
function providerKindsForMode(mode: ProviderMode): AiModelProviderKind[] {
return aiModelProviderCatalog()
.filter((entry) => entry.local === (mode === 'local'))
.map((entry) => entry.kind)
}
function initialDraft(mode: ProviderMode): ProviderDraft {
const kind = mode === 'local' ? 'ollama' : 'open_ai'
const [kind] = providerKindsForMode(mode)
if (!kind) throw new Error(`No AI model providers are configured for ${mode} mode`)
return draftFromProviderKind(kind)
}
function draftFromProviderKind(kind: AiModelProviderKind): ProviderDraft {
const defaults = aiModelProviderCatalogEntry(kind)
return {
kind,
name: PROVIDER_PRESETS[kind].name,
baseUrl: PROVIDER_PRESETS[kind].baseUrl,
name: defaults.name,
baseUrl: defaults.base_url,
modelId: '',
apiKeyStorage: mode === 'local' ? 'none' : 'local_file',
apiKeyStorage: defaults.api_key_storage,
apiKey: '',
apiKeyEnvVar: '',
apiKeyEnvVar: defaults.api_key_env_var ?? '',
}
}
function providerKindOptions(mode: ProviderMode, t: Translate): Array<{ value: AiModelProviderKind; label: string }> {
const kinds = mode === 'local' ? LOCAL_PROVIDER_KINDS : API_PROVIDER_KINDS
return kinds.map((kind) => ({ value: kind, label: providerKindLabel(kind, t) }))
return providerKindsForMode(mode).map((kind) => {
const defaults = aiModelProviderCatalogEntry(kind)
return { value: kind, label: t(defaults.label_key) }
})
}
function providerKindLabel(kind: AiModelProviderKind, t: Translate): string {
function providerPresetPatch(kind: AiModelProviderKind): Pick<ProviderDraft, 'kind' | 'name' | 'baseUrl' | 'apiKeyStorage' | 'apiKeyEnvVar'> {
const defaults = draftFromProviderKind(kind)
return {
ollama: t('settings.aiProviders.kind.ollama'),
lm_studio: t('settings.aiProviders.kind.lmStudio'),
open_ai: t('settings.aiProviders.kind.openAi'),
anthropic: t('settings.aiProviders.kind.anthropic'),
gemini: t('settings.aiProviders.kind.gemini'),
open_router: t('settings.aiProviders.kind.openRouter'),
open_ai_compatible: t('settings.aiProviders.kind.compatible'),
}[kind]
}
function modelPlaceholder(kind: AiModelProviderKind, mode: ProviderMode): string {
if (mode === 'local') return 'llama3.2'
if (kind === 'anthropic') return 'claude-3-5-sonnet-latest'
if (kind === 'gemini') return 'gemini-2.5-flash'
if (kind === 'open_router') return 'openai/gpt-4.1-mini'
return 'gpt-4.1-mini'
}
function apiKeyEnvPlaceholder(kind: AiModelProviderKind): string {
if (kind === 'anthropic') return 'ANTHROPIC_API_KEY'
if (kind === 'gemini') return 'GEMINI_API_KEY'
if (kind === 'open_router') return 'OPENROUTER_API_KEY'
return 'OPENAI_API_KEY'
kind,
name: defaults.name,
baseUrl: defaults.baseUrl,
apiKeyStorage: defaults.apiKeyStorage,
apiKeyEnvVar: defaults.apiKeyEnvVar,
}
}
function buildProvider(draft: ProviderDraft, providerId: string): AiModelProvider {
@@ -210,7 +197,7 @@ function ApiKeyStorageFields({
<>
<label className="space-y-1.5 text-xs font-medium text-foreground">
<span>{t('settings.aiProviders.keyStorage')}</span>
<Select value={draft.apiKeyStorage} onValueChange={(next) => updateDraft({ apiKeyStorage: next as ApiKeyStorage })}>
<Select value={draft.apiKeyStorage} onValueChange={(next) => updateDraft({ apiKeyStorage: next as AiModelApiKeyStorage })}>
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
<SelectValue />
</SelectTrigger>
@@ -235,7 +222,7 @@ function ApiKeyStorageFields({
label={t('settings.aiProviders.keyEnv')}
value={draft.apiKeyEnvVar}
onChange={(apiKeyEnvVar) => updateDraft({ apiKeyEnvVar })}
placeholder={apiKeyEnvPlaceholder(draft.kind)}
placeholder={aiModelProviderCatalogEntry(draft.kind).api_key_env_var ?? ''}
/>
) : null}
</>
@@ -290,7 +277,7 @@ export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderS
resetTest()
updateDraft(patch)
}
const updateKind = (kind: AiModelProviderKind) => updateForm({ kind, ...PROVIDER_PRESETS[kind] })
const updateKind = (kind: AiModelProviderKind) => updateForm(providerPresetPatch(kind))
const canSave = draft.name.trim() && draft.modelId.trim() && (draft.apiKeyStorage !== 'local_file' || draft.apiKey.trim())
const apiKeyOverride = draft.apiKeyStorage === 'local_file' ? draft.apiKey : null
@@ -302,7 +289,7 @@ export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderS
await saveAiModelProviderApiKey(providerId, draft.apiKey)
}
onChange(normalizeAiModelProviders([...providers, buildProvider(draft, providerId)]))
setDraft((current) => ({ ...initialDraft(mode), kind: current.kind, name: current.name, baseUrl: current.baseUrl }))
setDraft((current) => ({ ...draftFromProviderKind(current.kind), name: current.name, baseUrl: current.baseUrl }))
setTestState('idle')
} catch (error) {
setError(error instanceof Error ? error.message : String(error))
@@ -335,7 +322,7 @@ export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderS
<ProviderKindSelect mode={mode} t={t} value={draft.kind} onChange={updateKind} />
<LabeledInput label={t('settings.aiProviders.name')} value={draft.name} onChange={(name) => updateForm({ name })} />
<LabeledInput label={t('settings.aiProviders.baseUrl')} value={draft.baseUrl} onChange={(baseUrl) => updateForm({ baseUrl })} />
<LabeledInput label={t('settings.aiProviders.model')} value={draft.modelId} onChange={(modelId) => updateForm({ modelId })} placeholder={modelPlaceholder(draft.kind, mode)} />
<LabeledInput label={t('settings.aiProviders.model')} value={draft.modelId} onChange={(modelId) => updateForm({ modelId })} placeholder={aiModelProviderCatalogEntry(draft.kind).default_model_id} />
{mode === 'api' ? <ApiKeyStorageFields t={t} draft={draft} updateDraft={updateForm} /> : null}
</div>
<div className="text-xs leading-5 text-muted-foreground">

View File

@@ -425,3 +425,54 @@ describe('BreadcrumbBar — note width toggle', () => {
expect(onToggleNoteWidth).toHaveBeenCalledOnce()
})
})
describe('BreadcrumbBar — table of contents toggle', () => {
it('shows the table of contents action and calls the toggle handler', () => {
const onToggleTableOfContents = vi.fn()
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
onToggleTableOfContents={onToggleTableOfContents}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
expect(onToggleTableOfContents).toHaveBeenCalledOnce()
})
it('uses the close label while the table of contents panel is active', () => {
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
showTableOfContents
onToggleTableOfContents={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Close table of contents' })).toBeInTheDocument()
})
it('offers the table of contents action from the overflow menu', async () => {
const onToggleTableOfContents = vi.fn()
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
onToggleTableOfContents={onToggleTableOfContents}
/>,
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), {
button: 0,
ctrlKey: false,
})
const menu = await screen.findByRole('menu')
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open table of contents' }))
expect(onToggleTableOfContents).toHaveBeenCalledOnce()
})
})

View File

@@ -17,6 +17,7 @@ import {
GitBranch,
Code,
Sparkle,
ListBullets,
SidebarSimple,
Trash,
Archive,
@@ -46,6 +47,8 @@ interface BreadcrumbBarProps {
forceRawMode?: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
showTableOfContents?: boolean
onToggleTableOfContents?: () => void
inspectorCollapsed?: boolean
onToggleInspector?: () => void
onToggleFavorite?: () => void
@@ -351,6 +354,24 @@ function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<Breadc
)
}
function TableOfContentsAction({
showTableOfContents,
locale = 'en',
onToggleTableOfContents,
}: Pick<BreadcrumbBarProps, 'showTableOfContents' | 'locale' | 'onToggleTableOfContents'>) {
if (!onToggleTableOfContents) return null
return (
<IconActionButton
copy={{ label: translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents') }}
onClick={onToggleTableOfContents}
className={cn(showTableOfContents ? 'text-foreground' : 'hover:text-foreground')}
>
<ListBullets size={16} weight={showTableOfContents ? 'bold' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
function ArchiveAction({
archived,
locale = 'en',
@@ -796,6 +817,8 @@ function BreadcrumbActions({
onToggleNoteWidth,
showAIChat,
onToggleAIChat,
showTableOfContents,
onToggleTableOfContents,
inspectorCollapsed,
onToggleInspector,
onToggleFavorite,
@@ -835,6 +858,13 @@ function BreadcrumbActions({
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
</OverflowToolbarAction>
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<OverflowToolbarAction>
<TableOfContentsAction
showTableOfContents={showTableOfContents}
locale={locale}
onToggleTableOfContents={onToggleTableOfContents}
/>
</OverflowToolbarAction>
<OverflowToolbarAction>
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
</OverflowToolbarAction>
@@ -852,6 +882,8 @@ function BreadcrumbActions({
onToggleDiff={onToggleDiff}
noteWidth={noteWidth}
onToggleNoteWidth={onToggleNoteWidth}
showTableOfContents={showTableOfContents}
onToggleTableOfContents={onToggleTableOfContents}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onArchive={onArchive}
@@ -872,6 +904,8 @@ function BreadcrumbOverflowMenu({
onToggleDiff,
noteWidth,
onToggleNoteWidth,
showTableOfContents,
onToggleTableOfContents,
onRevealFile,
onCopyFilePath,
onArchive,
@@ -887,6 +921,8 @@ function BreadcrumbOverflowMenu({
| 'onToggleDiff'
| 'noteWidth'
| 'onToggleNoteWidth'
| 'showTableOfContents'
| 'onToggleTableOfContents'
| 'onRevealFile'
| 'onCopyFilePath'
| 'onArchive'
@@ -901,6 +937,7 @@ function BreadcrumbOverflowMenu({
const diffLabel = translate(locale, diffMenuLabelKey({ showDiffToggle, diffMode, diffLoading }))
const noteWidthLabel = translate(locale, noteWidthLabelKey(noteWidth))
const archiveLabel = translate(locale, archiveLabelKey(entry.archived))
const tableOfContentsLabel = translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents')
return (
<DropdownMenu>
@@ -927,6 +964,10 @@ function BreadcrumbOverflowMenu({
<NoteWidthMenuIcon noteWidth={noteWidth} />
{noteWidthLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!onToggleTableOfContents} onSelect={onToggleTableOfContents}>
<ListBullets size={16} />
{tableOfContentsLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!runRevealAction} onSelect={runRevealAction}>
<FolderOpen size={16} />
{translate(locale, 'editor.toolbar.revealFile')}

View File

@@ -188,6 +188,32 @@ describe('DynamicPropertiesPanel', () => {
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-03')
})
it('shows missing type-defined properties as gray editable placeholders', () => {
const typeEntry = makeEntry({
title: 'Book',
isA: 'Type',
properties: {
'start date': null,
},
})
renderPanel({
entry: makeEntry({ title: 'Dune', isA: 'Book' }),
entries: [typeEntry],
frontmatter: {},
onUpdateProperty,
})
const placeholder = screen.getByTestId('type-derived-property')
expect(within(placeholder).getByText('Start date')).toHaveClass('text-muted-foreground/40')
fireEvent.click(placeholder)
const input = screen.getByDisplayValue('')
fireEvent.change(input, { target: { value: '2026-05-04' } })
fireEvent.blur(input)
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-04')
})
it('hides Owner with wikilink value from Properties panel', () => {
renderPanel({ frontmatter: { Owner: '[[person/luca]]' } })
// Owner with wikilink goes to RelationshipsPanel, not Properties

View File

@@ -153,6 +153,83 @@ function SuggestedPropertySlot({ label, displayMode, onAdd }: {
)
}
function TypeDerivedPropertySlot({
propKey,
editingKey,
displayMode,
autoMode,
vaultStatuses,
vaultTags,
onStartEdit,
onSave,
onSaveList,
onUpdate,
onDisplayModeChange,
locale,
}: {
propKey: string
editingKey: string | null
displayMode: PropertyDisplayMode
autoMode: PropertyDisplayMode
vaultStatuses: string[]
vaultTags: string[]
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
locale: AppLocale
}) {
if (editingKey === propKey) {
return (
<PropertyRow
propKey={propKey}
value=""
editingKey={editingKey}
displayMode={displayMode}
autoMode={autoMode}
vaultStatuses={vaultStatuses}
vaultTags={vaultTags}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={onUpdate}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
}
const PlaceholderIcon = DISPLAY_MODE_ICONS[displayMode]
return (
<Button
type="button"
variant="ghost"
size="sm"
className={PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME}
style={PROPERTY_PANEL_ROW_STYLE}
onClick={() => onStartEdit(propKey)}
disabled={!onUpdate}
data-testid="type-derived-property"
>
<span className={PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME}>
<span
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
data-testid="type-derived-property-icon-slot"
>
<PlaceholderIcon
className="size-3.5 shrink-0 text-muted-foreground/40"
data-testid={`type-derived-property-icon-${displayMode}`}
/>
</span>
<span className="min-w-0 truncate text-muted-foreground/40">{humanizePropertyKey(propKey)}</span>
</span>
<span className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME}>{'\u2014'}</span>
</Button>
)
}
function getExistingPropertyKeys(propertyEntries: [string, FrontmatterValue][], frontmatter: ParsedFrontmatter): Set<string> {
const keys = new Set(propertyEntries.map(([key]) => key.toLowerCase()))
for (const key of Object.keys(frontmatter)) keys.add(key.toLowerCase())
@@ -229,6 +306,139 @@ function useSuggestedPropertyActions({
}
}
function PropertyEntryRows({
source,
entries,
editingKey,
displayOverrides,
vaultStatuses,
vaultTagsByKey,
locale,
onStartEdit,
onSave,
onSaveList,
onUpdate,
onDelete,
onDisplayModeChange,
}: {
source: 'frontmatter' | 'type-derived'
entries: [string, FrontmatterValue][]
editingKey: string | null
displayOverrides: Record<string, PropertyDisplayMode>
vaultStatuses: string[]
vaultTagsByKey: Record<string, string[]>
locale: AppLocale
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void
onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
}) {
return (
<>
{entries.map(([key, value]) => (
source === 'type-derived' ? (
<TypeDerivedPropertySlot
key={`type-derived:${key}`}
propKey={key}
editingKey={editingKey}
displayMode={getEffectiveDisplayMode(key, value, displayOverrides)}
autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={onUpdate}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
) : (
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={onStartEdit} onSave={onSave}
onSaveList={onSaveList} onUpdate={onUpdate}
onDelete={onDelete}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
))}
</>
)
}
function PendingSuggestedPropertyRow({
pendingSuggestedKey,
editingKey,
vaultStatuses,
vaultTagsByKey,
locale,
onStartEdit,
onSave,
onSaveList,
onDisplayModeChange,
}: {
pendingSuggestedKey: string | null
editingKey: string | null
vaultStatuses: string[]
vaultTagsByKey: Record<string, string[]>
locale: AppLocale
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
}) {
if (!pendingSuggestedKey || editingKey !== pendingSuggestedKey) {
return null
}
return (
<PropertyRow
key={`pending:${pendingSuggestedKey}`}
propKey={pendingSuggestedKey}
value=""
editingKey={editingKey}
displayMode={getSuggestedDisplayMode(pendingSuggestedKey)}
autoMode={getSuggestedDisplayMode(pendingSuggestedKey)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[pendingSuggestedKey] ?? []}
onStartEdit={onStartEdit}
onSave={onSave}
onSaveList={onSaveList}
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={onDisplayModeChange}
locale={locale}
/>
)
}
function SuggestedPropertyRows({
properties,
onAdd,
}: {
properties: Array<{ key: string; label: string }>
onAdd: (key: string) => void
}) {
return (
<>
{properties.map(({ key, label }) => (
<SuggestedPropertySlot
key={key}
label={label}
displayMode={getSuggestedDisplayMode(key)}
onAdd={() => onAdd(key)}
/>
))}
</>
)
}
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
@@ -248,12 +458,15 @@ export function DynamicPropertiesPanel({
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
typeDerivedPropertyEntries, handleSaveValue, handleSaveTypeDerivedValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
const [pendingSuggestedKey, setPendingSuggestedKey] = useState<string | null>(null)
const missingTypeName = useMemo(() => resolveMissingTypeName(entry.isA, availableTypes), [entry.isA, availableTypes])
const existingKeys = useMemo(() => getExistingPropertyKeys(propertyEntries, frontmatter), [propertyEntries, frontmatter])
const existingKeys = useMemo(
() => getExistingPropertyKeys([...propertyEntries, ...typeDerivedPropertyEntries], frontmatter),
[propertyEntries, typeDerivedPropertyEntries, frontmatter],
)
const missingSuggested = useMemo(
() => getMissingSuggestedProperties(Boolean(onAddProperty), existingKeys, pendingSuggestedKey),
[existingKeys, onAddProperty, pendingSuggestedKey],
@@ -285,46 +498,47 @@ export function DynamicPropertiesPanel({
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
{propertyEntries.map(([key, value]) => (
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
))}
{pendingSuggestedKey && editingKey === pendingSuggestedKey && (
<PropertyRow
key={`pending:${pendingSuggestedKey}`}
propKey={pendingSuggestedKey}
value=""
editingKey={editingKey}
displayMode={getSuggestedDisplayMode(pendingSuggestedKey)}
autoMode={getSuggestedDisplayMode(pendingSuggestedKey)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[pendingSuggestedKey] ?? []}
onStartEdit={handlePendingSuggestedEdit}
onSave={handleSaveSuggestedValue}
onSaveList={handleSaveList}
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
)}
{missingSuggested.map(({ key, label }) => (
<SuggestedPropertySlot
key={key}
label={label}
displayMode={getSuggestedDisplayMode(key)}
onAdd={() => handleSuggestedAdd(key)}
/>
))}
<PropertyEntryRows
source="frontmatter"
entries={propertyEntries}
editingKey={editingKey}
displayOverrides={displayOverrides}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={setEditingKey}
onSave={handleSaveValue}
onSaveList={handleSaveList}
onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
/>
<PropertyEntryRows
source="type-derived"
entries={typeDerivedPropertyEntries}
editingKey={editingKey}
displayOverrides={displayOverrides}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={setEditingKey}
onSave={handleSaveTypeDerivedValue}
onSaveList={handleSaveList}
onUpdate={onUpdateProperty}
onDisplayModeChange={handleDisplayModeChange}
/>
<PendingSuggestedPropertyRow
pendingSuggestedKey={pendingSuggestedKey}
editingKey={editingKey}
vaultStatuses={vaultStatuses}
vaultTagsByKey={vaultTagsByKey}
locale={locale}
onStartEdit={handlePendingSuggestedEdit}
onSave={handleSaveSuggestedValue}
onSaveList={handleSaveList}
onDisplayModeChange={handleDisplayModeChange}
/>
<SuggestedPropertyRows properties={missingSuggested} onAdd={handleSuggestedAdd} />
{!showAddDialog && (
<AddPropertyButton
locale={locale}

View File

@@ -236,6 +236,7 @@ describe('Editor', () => {
beforeEach(() => {
blockNoteCreation.options = []
blockNoteViewState.onChange = null
mockEditor.document = [{ id: '1', type: 'paragraph', content: [], props: {}, children: [] }]
clearParsedNoteBlockCache()
})
@@ -537,6 +538,26 @@ describe('Editor', () => {
expect(screen.getAllByText('Properties').length).toBeGreaterThan(0)
})
it('renders the table of contents panel from the editor document', () => {
mockEditor.document = [
{ id: 'toc-heading', type: 'heading', content: 'Table Heading', props: { level: 1 }, children: [] },
]
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
inspectorEntry={mockEntry}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
expect(screen.getByTestId('table-of-contents-panel')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Table Heading' })).toBeInTheDocument()
})
// Regression: editor content did not appear on first load because BlockNote's
// replaceBlocks/insertBlocks internally calls flushSync, which fails silently
// when invoked from within React's useEffect. Fix: defer via queueMicrotask.

View File

@@ -8,6 +8,7 @@ import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/
import type { AiTarget } from '../lib/aiTargets'
import { translate, type AppLocale } from '../lib/i18n'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import { trackEvent } from '../lib/telemetry'
import type { VaultEntry, GitCommit, NoteWidthMode, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { FrontmatterValue } from './Inspector'
@@ -436,6 +437,27 @@ function EditorLayout({
}) {
const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null
const showEmptyState = tabs.length === 0 && activeTabPath === null && !isVaultLoading
const [showTableOfContents, setShowTableOfContents] = useState(false)
const [tableOfContentsRevision, setTableOfContentsRevision] = useState(0)
const handleEditorChangeWithToc = useCallback(() => {
handleEditorChange()
setTableOfContentsRevision((revision) => revision + 1)
}, [handleEditorChange])
const handleToggleAIChatExclusive = useCallback(() => {
if (!showAIChat) setShowTableOfContents(false)
onToggleAIChat?.()
}, [onToggleAIChat, showAIChat])
const handleToggleTableOfContents = useCallback(() => {
const opening = !showTableOfContents
if (opening && showAIChat) onToggleAIChat?.()
setShowTableOfContents(opening)
trackEvent('table_of_contents_toggled', { open: opening ? 1 : 0 })
}, [onToggleAIChat, showAIChat, showTableOfContents])
const handleTableOfContentsHeadingSelected = useCallback(() => {
trackEvent('table_of_contents_heading_selected')
}, [])
const visibleTableOfContents = showTableOfContents && !showAIChat
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
@@ -469,11 +491,13 @@ function EditorLayout({
activeStatus={activeStatus}
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
onToggleAIChat={handleToggleAIChatExclusive}
showTableOfContents={visibleTableOfContents}
onToggleTableOfContents={handleToggleTableOfContents}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}
onEditorChange={handleEditorChange}
onEditorChange={handleEditorChangeWithToc}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
@@ -494,11 +518,14 @@ function EditorLayout({
locale={locale}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
{(showAIChat || visibleTableOfContents || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
<EditorRightPanel
showAIChat={showAIChat}
showTableOfContents={visibleTableOfContents}
inspectorCollapsed={inspectorCollapsed}
inspectorWidth={inspectorWidth}
editor={editor}
tableOfContentsRevision={tableOfContentsRevision}
defaultAiAgent={defaultAiAgent}
defaultAiTarget={defaultAiTarget}
defaultAiAgentReadiness={defaultAiAgentReadiness}
@@ -512,7 +539,9 @@ function EditorLayout({
noteList={noteList}
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onToggleAIChat={handleToggleAIChatExclusive}
onToggleTableOfContents={handleToggleTableOfContents}
onTableOfContentsHeadingSelected={handleTableOfContentsHeadingSelected}
onNavigateWikilink={onNavigateWikilink}
onViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}

View File

@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import type { AiTarget } from '../lib/aiTargets'
import type { AppLocale } from '../lib/i18n'
@@ -8,11 +9,15 @@ import { Inspector, type FrontmatterValue } from './Inspector'
import { AiPanelView } from './AiPanel'
import { useAiPanelController } from './useAiPanelController'
import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
import { TableOfContentsPanel } from './TableOfContentsPanel'
interface EditorRightPanelProps {
showAIChat?: boolean
showTableOfContents?: boolean
inspectorCollapsed: boolean
inspectorWidth: number
editor: ReturnType<typeof useCreateBlockNote>
tableOfContentsRevision: number
defaultAiAgent?: AiAgentId
defaultAiTarget?: AiTarget
defaultAiAgentReadiness?: AiAgentReadiness
@@ -27,6 +32,8 @@ interface EditorRightPanelProps {
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onToggleTableOfContents?: () => void
onTableOfContentsHeadingSelected?: () => void
onNavigateWikilink: (target: string) => void
onViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -44,12 +51,13 @@ interface EditorRightPanelProps {
}
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
showAIChat, showTableOfContents, inspectorCollapsed, inspectorWidth,
editor, tableOfContentsRevision,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiTarget, defaultAiAgentReadiness, defaultAiAgentReady = true,
onUnsupportedAiPaste,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onToggleInspector, onToggleAIChat, onToggleTableOfContents, onTableOfContentsHeadingSelected, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
@@ -105,6 +113,24 @@ export function EditorRightPanel({
)
}
if (showTableOfContents) {
return (
<div
className="shrink-0 flex flex-col min-h-0"
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
>
<TableOfContentsPanel
activeEntry={inspectorEntry}
documentRevision={tableOfContentsRevision}
editor={editor}
locale={locale}
onClose={() => onToggleTableOfContents?.()}
onHeadingSelected={() => onTableOfContentsHeadingSelected?.()}
/>
</div>
)
}
if (inspectorCollapsed) return null
return (

View File

@@ -411,6 +411,7 @@
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
user-select: none;
}
.editor__blocknote-container .tldraw-whiteboard .tl-container {
@@ -423,6 +424,10 @@
--tl-layer-following-indicator: 80;
}
.editor__blocknote-container .tldraw-whiteboard [data-radix-popper-content-wrapper] {
position: absolute !important;
}
.editor__blocknote-container .mantine-Popover-dropdown,
.editor__blocknote-container .bn-menu,
.editor__blocknote-container .bn-suggestion-menu {

View File

@@ -96,6 +96,7 @@ function ValidFrontmatterPanels({
/>
<Separator data-testid="inspector-properties-relationships-separator" />
<DynamicRelationshipsPanel
entry={entry}
frontmatter={frontmatter}
entries={entries}
typeEntryMap={typeEntryMap}

View File

@@ -1,6 +1,6 @@
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import { render as rtlRender, screen, fireEvent, act, within } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { TooltipProvider } from './ui/tooltip'
import type { ReferencedByItem } from './InspectorPanels'
@@ -125,6 +125,35 @@ describe('DynamicRelationshipsPanel', () => {
expect(screen.getByText('Related to')).toBeInTheDocument()
})
it('shows missing type-defined relationships as editable placeholders', () => {
const bookType = makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book',
isA: 'Type',
relationships: {
Mentor: ['[[person/alice]]'],
},
})
renderRelationshipsPanel({
entry: makeEntry({ title: 'Dune', isA: 'Book' }),
entries: [...entries, bookType],
frontmatter: {},
onAddProperty,
})
const placeholder = screen.getByTestId('type-derived-relationship')
expect(within(placeholder).getByText('Mentor')).toHaveClass('text-muted-foreground/40')
fireEvent.click(within(placeholder).getByTestId('add-relation-ref'))
fireEvent.change(within(placeholder).getByTestId('add-relation-ref-input'), {
target: { value: 'My Project' },
})
fireEvent.keyDown(within(placeholder).getByTestId('add-relation-ref-input'), { key: 'Enter' })
expect(onAddProperty).toHaveBeenCalledWith('Mentor', '[[project/my-project]]')
})
it('navigates when clicking a relationship link', () => {
render(
<DynamicRelationshipsPanel

View File

@@ -71,6 +71,34 @@ describe('MermaidDiagram', () => {
expect(style?.getAttribute('nonce')).toBe(RUNTIME_STYLE_NONCE)
})
it('keeps Mermaid foreignObject labels visible after sanitizing the SVG', async () => {
mermaidMock.render.mockResolvedValueOnce({
svg: [
'<svg aria-label="Rendered Mermaid">',
'<g class="node">',
'<foreignObject width="200" height="40">',
'<div xmlns="http://www.w3.org/1999/xhtml">',
'<span class="nodeLabel" onclick="alert(1)">Employee<br>clocks in</span>',
'</div>',
'</foreignObject>',
'</g>',
'</svg>',
].join(''),
})
render(
<MermaidDiagram
diagram={'flowchart LR\nA(["Employee clocks in"]) --> B'}
source={'```mermaid\nflowchart LR\nA(["Employee clocks in"]) --> B\n```'}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('mermaid-diagram-viewport')).toHaveTextContent('Employeeclocks in')
})
expect(screen.getByText('Employeeclocks in')).not.toHaveAttribute('onclick')
})
it('falls back to the original source when Mermaid cannot render', async () => {
mermaidMock.render.mockRejectedValueOnce(new Error('parse error'))

View File

@@ -10,6 +10,13 @@ interface SafeSvgDivProps extends HTMLAttributes<HTMLDivElement> {
svg: string
}
const MERMAID_SVG_SANITIZE_CONFIG = {
USE_PROFILES: { svg: true, svgFilters: true, html: true },
ADD_TAGS: ['foreignObject'],
ADD_ATTR: ['xmlns'],
HTML_INTEGRATION_POINTS: { foreignobject: true },
}
function importSanitizedHtmlNodes(html: string): Node[] {
const sanitized = DOMPurify.sanitize(html, {
USE_PROFILES: { html: true, mathMl: true },
@@ -19,13 +26,12 @@ function importSanitizedHtmlNodes(html: string): Node[] {
}
function importSanitizedSvgNode(svg: string): Node | null {
const sanitized = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
})
const parsed = new DOMParser().parseFromString(sanitized, 'image/svg+xml')
if (parsed.querySelector('parsererror')) return null
const sanitized = DOMPurify.sanitize(svg, MERMAID_SVG_SANITIZE_CONFIG)
const parsed = new DOMParser().parseFromString(sanitized, 'text/html')
const parsedSvg = parsed.body.querySelector('svg')
if (!parsedSvg) return null
const svgNode = document.importNode(parsed.documentElement, true)
const svgNode = document.importNode(parsedSvg, true)
svgNode.querySelectorAll('style').forEach((style) => {
style.setAttribute('nonce', RUNTIME_STYLE_NONCE)
})

View File

@@ -567,6 +567,7 @@ describe('StatusBar', () => {
/>
)
fireEvent.click(screen.getByTestId('status-sync'))
expect(screen.getByTestId('status-bar')).toHaveStyle({ zIndex: '30' })
expect(screen.getByTestId('git-status-popup')).toBeInTheDocument()
expect(screen.getByText('main')).toBeInTheDocument()
expect(screen.getByText(/2 ahead/)).toBeInTheDocument()

View File

@@ -17,6 +17,7 @@ import type { VaultOption } from './status-bar/types'
export type { VaultOption } from './status-bar/types'
const COMPACT_STATUS_BAR_MAX_WIDTH = 1000
const STATUS_BAR_STACKING_Z_INDEX = 30
function getWindowWidth() {
return typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth
@@ -246,7 +247,7 @@ function StatusBarFooter(props: StatusBarFooterProps) {
fontSize: 12,
color: 'var(--muted-foreground)',
position: 'relative',
zIndex: 10,
zIndex: STATUS_BAR_STACKING_Z_INDEX,
}}
>
<StatusBarPrimaryFromFooter {...props} />

View File

@@ -0,0 +1,158 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TableOfContentsPanel } from './TableOfContentsPanel'
import type { VaultEntry } from '../types'
const baseEntry: VaultEntry = {
path: '/vault/project/test.md',
filename: 'test.md',
title: 'Test Project',
isA: 'Project',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
template: null,
sort: null,
sidebarLabel: null,
view: null,
visible: null,
properties: {},
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
hasH1: false,
}
function createEditor(documentBlocks: unknown[]) {
return {
document: documentBlocks,
focus: vi.fn(),
setTextCursorPosition: vi.fn(),
}
}
describe('TableOfContentsPanel', () => {
it('renders a nested heading tree and collapses child headings', () => {
const editor = createEditor([
{ id: 'intro', type: 'heading', props: { level: 1 }, content: 'Intro' },
{ id: 'setup', type: 'heading', props: { level: 2 }, content: 'Setup' },
{ id: 'details', type: 'heading', props: { level: 3 }, content: 'Details' },
{ id: 'usage', type: 'heading', props: { level: 1 }, content: 'Usage' },
])
render(
<TableOfContentsPanel
activeEntry={baseEntry}
documentRevision={0}
editor={editor}
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('heading', { name: 'Table of Contents' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Intro' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Setup' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Details' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Collapse Intro' }))
expect(screen.getByRole('button', { name: 'Intro' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Setup' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Usage' })).toBeInTheDocument()
})
it('moves the editor cursor to the selected heading', () => {
const editor = createEditor([
{ id: 'target-heading', type: 'heading', props: { level: 1 }, content: 'Target' },
])
const onHeadingSelected = vi.fn()
render(
<TableOfContentsPanel
activeEntry={baseEntry}
documentRevision={0}
editor={editor}
onClose={vi.fn()}
onHeadingSelected={onHeadingSelected}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Target' }))
expect(editor.focus).toHaveBeenCalledOnce()
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('target-heading', 'start')
expect(onHeadingSelected).toHaveBeenCalledWith(expect.objectContaining({ id: 'target-heading' }))
})
it('updates when the editor document revision changes', () => {
const editor = createEditor([
{ id: 'intro', type: 'heading', props: { level: 1 }, content: 'Intro' },
])
const { rerender } = render(
<TableOfContentsPanel
activeEntry={baseEntry}
documentRevision={0}
editor={editor}
onClose={vi.fn()}
/>,
)
editor.document = [
{ id: 'intro', type: 'heading', props: { level: 1 }, content: 'Intro' },
{ id: 'live-update', type: 'heading', props: { level: 2 }, content: 'Live update' },
]
rerender(
<TableOfContentsPanel
activeEntry={baseEntry}
documentRevision={1}
editor={editor}
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Live update' })).toBeInTheDocument()
})
it('shows an empty state when the note has no headings', () => {
render(
<TableOfContentsPanel
activeEntry={baseEntry}
documentRevision={0}
editor={createEditor([{ id: 'body', type: 'paragraph', content: 'Body' }])}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('No headings in this note')).toBeInTheDocument()
})
it('keeps the close action in the panel header', () => {
const onClose = vi.fn()
render(
<TableOfContentsPanel
activeEntry={baseEntry}
documentRevision={0}
editor={createEditor([])}
onClose={onClose}
/>,
)
const panel = screen.getByTestId('table-of-contents-panel')
fireEvent.click(within(panel).getByRole('button', { name: 'Close table of contents' }))
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,302 @@
import { useCallback, useMemo, useState } from 'react'
import { CaretDown, CaretRight, ListBullets, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useDragRegion } from '../hooks/useDragRegion'
import { translate, type AppLocale } from '../lib/i18n'
import type { VaultEntry } from '../types'
import { extractTableOfContents, type TableOfContentsItem } from '../utils/tableOfContents'
interface TableOfContentsEditor {
document: unknown
focus: () => void
setTextCursorPosition: (blockId: string, placement: 'start' | 'end') => void
}
interface TableOfContentsPanelProps {
activeEntry: VaultEntry | null
documentRevision: number
editor: TableOfContentsEditor
locale?: AppLocale
onClose: () => void
onHeadingSelected?: (item: TableOfContentsItem) => void
}
const EMPTY_COLLAPSED_IDS = new Set<string>()
function headingLabel(item: TableOfContentsItem, locale: AppLocale): string {
return item.text || translate(locale, 'tableOfContents.untitledHeading')
}
function findBlockElement(blockId: string): HTMLElement | null {
const candidates = document.querySelectorAll<HTMLElement>('[data-id], [data-block-id]')
for (const candidate of candidates) {
if (candidate.dataset.id === blockId) return candidate
if (candidate.dataset.blockId === blockId) return candidate
}
return null
}
function scrollHeadingIntoView(blockId: string) {
findBlockElement(blockId)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}
function EmptyTableOfContents({ children }: { children: string }) {
return (
<div className="flex flex-1 items-center justify-center px-6 text-center text-[13px] text-muted-foreground">
{children}
</div>
)
}
function TableOfContentsHeader({
locale,
onClose,
}: {
locale: AppLocale
onClose: () => void
}) {
const { onMouseDown } = useDragRegion()
return (
<div
className="flex shrink-0 items-center gap-2 border-b border-border px-3"
style={{ height: 52 }}
onMouseDown={onMouseDown}
>
<ListBullets size={16} className="shrink-0 text-muted-foreground" />
<h2 className="m-0 truncate text-[13px] font-semibold text-muted-foreground">
{translate(locale, 'tableOfContents.title')}
</h2>
<span className="flex-1" />
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label={translate(locale, 'tableOfContents.close')}
onMouseDown={(event) => event.stopPropagation()}
onClick={onClose}
>
<X size={16} />
</Button>
</div>
)
}
function ToggleChildrenButton({
collapsed,
item,
locale,
onToggle,
}: {
collapsed: boolean
item: TableOfContentsItem
locale: AppLocale
onToggle: (itemId: string) => void
}) {
const label = headingLabel(item, locale)
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6 rounded-md text-muted-foreground hover:text-foreground"
aria-label={translate(locale, collapsed ? 'tableOfContents.expandHeading' : 'tableOfContents.collapseHeading', { heading: label })}
onClick={() => onToggle(item.id)}
>
{collapsed ? <CaretRight size={14} /> : <CaretDown size={14} />}
</Button>
)
}
function TogglePlaceholder() {
return <span className="size-6 shrink-0" aria-hidden="true" />
}
function TableOfContentsNode({
collapsedIds,
depth,
item,
locale,
onSelect,
onToggle,
}: {
collapsedIds: Set<string>
depth: number
item: TableOfContentsItem
locale: AppLocale
onSelect: (item: TableOfContentsItem) => void
onToggle: (itemId: string) => void
}) {
const collapsed = collapsedIds.has(item.id)
const hasChildren = item.children.length > 0
const label = headingLabel(item, locale)
return (
<li>
<div className="flex min-w-0 items-center gap-1" style={{ paddingLeft: depth * 12 }}>
{hasChildren
? <ToggleChildrenButton collapsed={collapsed} item={item} locale={locale} onToggle={onToggle} />
: <TogglePlaceholder />}
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 min-w-0 flex-1 justify-start rounded-md px-2 text-left text-[13px] font-normal text-muted-foreground hover:text-foreground',
item.level === 1 && 'font-medium text-foreground',
)}
onClick={() => onSelect(item)}
>
<span className="truncate">{label}</span>
</Button>
</div>
{hasChildren && !collapsed && (
<ul className="m-0 list-none p-0">
{item.children.map((child) => (
<TableOfContentsNode
key={child.id}
collapsedIds={collapsedIds}
depth={depth + 1}
item={child}
locale={locale}
onSelect={onSelect}
onToggle={onToggle}
/>
))}
</ul>
)}
</li>
)
}
function TableOfContentsTree({
collapsedIds,
items,
locale,
onSelect,
onToggle,
}: {
collapsedIds: Set<string>
items: TableOfContentsItem[]
locale: AppLocale
onSelect: (item: TableOfContentsItem) => void
onToggle: (itemId: string) => void
}) {
return (
<nav aria-label={translate(locale, 'tableOfContents.navLabel')} className="flex-1 overflow-y-auto p-2">
<ul className="m-0 flex list-none flex-col gap-0.5 p-0">
{items.map((item) => (
<TableOfContentsNode
key={item.id}
collapsedIds={collapsedIds}
depth={0}
item={item}
locale={locale}
onSelect={onSelect}
onToggle={onToggle}
/>
))}
</ul>
</nav>
)
}
function useCollapsedHeadingState(activePath: string | null) {
const [collapseState, setCollapseState] = useState<{ path: string | null; ids: Set<string> }>(() => ({
path: null,
ids: new Set(),
}))
const collapsedIds = collapseState.path === activePath ? collapseState.ids : EMPTY_COLLAPSED_IDS
const handleToggle = useCallback((itemId: string) => {
setCollapseState((current) => {
const currentIds = current.path === activePath ? current.ids : EMPTY_COLLAPSED_IDS
const next = new Set(currentIds)
if (next.has(itemId)) {
next.delete(itemId)
} else {
next.add(itemId)
}
return { path: activePath, ids: next }
})
}, [activePath])
return { collapsedIds, handleToggle }
}
function TableOfContentsBody({
activeEntry,
collapsedIds,
items,
locale,
onSelect,
onToggle,
}: {
activeEntry: VaultEntry | null
collapsedIds: Set<string>
items: TableOfContentsItem[]
locale: AppLocale
onSelect: (item: TableOfContentsItem) => void
onToggle: (itemId: string) => void
}) {
if (!activeEntry) {
return <EmptyTableOfContents>{translate(locale, 'tableOfContents.emptyNoNote')}</EmptyTableOfContents>
}
if (items.length === 0) {
return <EmptyTableOfContents>{translate(locale, 'tableOfContents.empty')}</EmptyTableOfContents>
}
return (
<TableOfContentsTree
collapsedIds={collapsedIds}
items={items}
locale={locale}
onSelect={onSelect}
onToggle={onToggle}
/>
)
}
export function TableOfContentsPanel({
activeEntry,
documentRevision,
editor,
locale = 'en',
onClose,
onHeadingSelected,
}: TableOfContentsPanelProps) {
const items = useMemo(
() => extractTableOfContents(editor.document),
[activeEntry?.path, documentRevision, editor],
)
const activePath = activeEntry?.path ?? null
const { collapsedIds, handleToggle } = useCollapsedHeadingState(activePath)
const handleSelect = useCallback((item: TableOfContentsItem) => {
editor.focus()
editor.setTextCursorPosition(item.id, 'start')
scrollHeadingIntoView(item.id)
onHeadingSelected?.(item)
}, [editor, onHeadingSelected])
return (
<aside
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
data-testid="table-of-contents-panel"
>
<TableOfContentsHeader locale={locale} onClose={onClose} />
<TableOfContentsBody
activeEntry={activeEntry}
collapsedIds={collapsedIds}
items={items}
locale={locale}
onSelect={handleSelect}
onToggle={handleToggle}
/>
</aside>
)
}

View File

@@ -1,9 +1,12 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
import {
Box,
Tldraw,
createTLStore,
getSnapshot,
loadSnapshot,
type Editor,
type TLEventInfo,
type TLStoreSnapshot,
} from 'tldraw'
import 'tldraw/tldraw.css'
@@ -80,6 +83,90 @@ function serializeSnapshot(snapshot: TLStoreSnapshot): string {
return `${JSON.stringify(snapshot, null, 2)}\n`
}
function documentZoom(): number {
const inlineZoom = document.documentElement.style.getPropertyValue('zoom')
const computedZoom = getComputedStyle(document.documentElement).zoom
const zoom = inlineZoom || computedZoom
const parsed = Number.parseFloat(zoom)
if (!Number.isFinite(parsed) || parsed <= 0) return 1
return zoom.endsWith('%') ? parsed / 100 : parsed
}
function viewportBounds(screenBounds: Box | HTMLElement): Box | HTMLElement {
if (screenBounds instanceof Box) return screenBounds
const zoom = documentZoom()
if (zoom === 1) return screenBounds
const rect = screenBounds.getBoundingClientRect()
return new Box(
(rect.left || rect.x) / zoom,
(rect.top || rect.y) / zoom,
Math.max(rect.width / zoom, 1),
Math.max(rect.height / zoom, 1),
)
}
function zoomAdjustedPoint<T extends { x: number; y: number; z?: number }>(point: T, zoom: number): T {
return {
...point,
x: point.x / zoom,
y: point.y / zoom,
}
}
function zoomAdjustedEvent(info: TLEventInfo): TLEventInfo {
const zoom = documentZoom()
if (zoom === 1) return info
switch (info.type) {
case 'click':
case 'pinch':
case 'pointer':
case 'wheel':
return {
...info,
point: zoomAdjustedPoint(info.point, zoom),
} as TLEventInfo
default:
return info
}
}
function installZoomAwareViewport(editor: Editor): () => void {
const updateViewportScreenBounds = editor.updateViewportScreenBounds.bind(editor)
const updateViewport: Editor['updateViewportScreenBounds'] = (screenBounds, center) =>
updateViewportScreenBounds(viewportBounds(screenBounds), center)
const dispatch = editor.dispatch.bind(editor)
const animationFrameIds: number[] = []
const timeoutIds: number[] = []
editor.updateViewportScreenBounds = updateViewport
editor.dispatch = (info: TLEventInfo) => dispatch(zoomAdjustedEvent(info))
const updateCurrentCanvas = () => {
const canvas = editor.getContainer().querySelector<HTMLElement>('.tl-canvas')
if (canvas) updateViewport(canvas)
}
const scheduleViewportUpdate = () => {
updateCurrentCanvas()
animationFrameIds.push(window.requestAnimationFrame(updateCurrentCanvas))
timeoutIds.push(window.setTimeout(updateCurrentCanvas, 150))
}
scheduleViewportUpdate()
window.addEventListener('laputa-zoom-change', scheduleViewportUpdate)
return () => {
window.removeEventListener('laputa-zoom-change', scheduleViewportUpdate)
animationFrameIds.forEach((id) => window.cancelAnimationFrame(id))
timeoutIds.forEach((id) => window.clearTimeout(id))
editor.updateViewportScreenBounds = updateViewportScreenBounds
editor.dispatch = dispatch
}
}
export function TldrawWhiteboard({
boardId,
height,
@@ -182,10 +269,14 @@ export function TldrawWhiteboard({
<div
ref={boardRef}
className="tldraw-whiteboard"
contentEditable={false}
data-board-id={boardId}
style={cssSize(visibleSize)}
>
<Tldraw store={store} />
<Tldraw
onMount={installZoomAwareViewport}
store={store}
/>
<div
aria-label="Resize whiteboard width"
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--width"

View File

@@ -24,6 +24,8 @@ type BreadcrumbActions = Pick<
| 'forceRawMode'
| 'showAIChat'
| 'onToggleAIChat'
| 'showTableOfContents'
| 'onToggleTableOfContents'
| 'inspectorCollapsed'
| 'onToggleInspector'
| 'showDiffToggle'
@@ -187,6 +189,8 @@ function ActiveTabBreadcrumb({
forceRawMode={actions.forceRawMode}
showAIChat={actions.showAIChat}
onToggleAIChat={actions.onToggleAIChat}
showTableOfContents={actions.showTableOfContents}
onToggleTableOfContents={actions.onToggleTableOfContents}
inspectorCollapsed={actions.inspectorCollapsed}
onToggleInspector={actions.onToggleInspector}
onToggleFavorite={bindPath(actions.onToggleFavorite, path)}
@@ -227,6 +231,8 @@ function EditorLoadingBreadcrumb({
forceRawMode={false}
showAIChat={actions.showAIChat}
onToggleAIChat={actions.onToggleAIChat}
showTableOfContents={actions.showTableOfContents}
onToggleTableOfContents={actions.onToggleTableOfContents}
inspectorCollapsed={actions.inspectorCollapsed}
onToggleInspector={actions.onToggleInspector}
noteWidth={actions.noteWidth}
@@ -246,6 +252,8 @@ function buildBreadcrumbActions(model: EditorContentModel): BreadcrumbActions {
forceRawMode: model.forceRawMode,
showAIChat: model.showAIChat,
onToggleAIChat: model.onToggleAIChat,
showTableOfContents: model.showTableOfContents,
onToggleTableOfContents: model.onToggleTableOfContents,
inspectorCollapsed: model.inspectorCollapsed,
onToggleInspector: model.onToggleInspector,
showDiffToggle: model.showDiffToggle,

View File

@@ -31,6 +31,8 @@ export interface EditorContentProps {
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
showTableOfContents?: boolean
onToggleTableOfContents?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void

View File

@@ -1,6 +1,6 @@
import { compactMarkdown } from '../utils/compact-markdown'
import { restoreWikilinksInBlocks, splitFrontmatter } from '../utils/wikilinks'
import { serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { serializeDurableEditorBlocks } from '../utils/editorDurableMarkdown'
import { findNearestTextCursorBlockById } from './blockNoteCursorTarget'
interface BlockLike {
@@ -133,11 +133,11 @@ function getLineIndexFromRatio({ totalLines, ratio }: { totalLines: number; rati
}
function serializeBlock(editor: BlockNotePositionEditor, block: BlockLike): string {
return compactMarkdown(serializeMermaidAwareBlocks(editor, restoreWikilinksInBlocks([block])))
return compactMarkdown(serializeDurableEditorBlocks(editor, restoreWikilinksInBlocks([block])))
}
function serializeEditorBody(editor: BlockNotePositionEditor): string {
return compactMarkdown(serializeMermaidAwareBlocks(editor, restoreWikilinksInBlocks(editor.document)))
return compactMarkdown(serializeDurableEditorBlocks(editor, restoreWikilinksInBlocks(editor.document)))
}
function buildBlockLineRanges({

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { MERMAID_BLOCK_TYPE } from '../utils/mermaidMarkdown'
import { serializeEditorDocumentToMarkdown } from './editorRawModeSync'
import { TLDRAW_BLOCK_TYPE } from '../utils/tldrawMarkdown'
import { serializeEditorDocumentToMarkdown, syncActiveTabIntoRawBuffer } from './editorRawModeSync'
describe('editorRawModeSync Mermaid serialization', () => {
it('keeps the original fenced Mermaid source when rich content enters raw mode', () => {
@@ -28,4 +29,39 @@ describe('editorRawModeSync Mermaid serialization', () => {
'---\ntitle: Flow\n---\n\n# Flow\n',
)).toBe(`---\ntitle: Flow\n---\n${source}\n`)
})
it('serializes durable blocks into raw mode even when no pending rich edit was flushed', () => {
const rawLatestContentRef = { current: null as string | null }
const editor = {
document: [{
id: 'board-1',
type: TLDRAW_BLOCK_TYPE,
props: {
boardId: 'planning-map',
height: '520',
snapshot: '{}',
width: '',
},
children: [],
}],
blocksToMarkdownLossy: vi.fn(),
}
const synced = syncActiveTabIntoRawBuffer({
editor: editor as never,
activeTabPath: 'note/whiteboard-embed.md',
activeTabContent: [
'# Whiteboard Embed',
'',
'```tldraw id="planning-map"',
'{}',
'```',
].join('\n'),
rawLatestContentRef,
serializeRichEditorContent: false,
})
expect(synced).toContain('```tldraw id="planning-map" height="520"')
expect(rawLatestContentRef.current).toBe(synced)
})
})

View File

@@ -2,7 +2,7 @@ import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
import { serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { hasDurableEditorBlocks, serializeDurableEditorBlocks } from '../utils/editorDurableMarkdown'
import { portableImageUrls } from '../utils/vaultImages'
interface Tab {
@@ -30,7 +30,7 @@ export function serializeEditorDocumentToMarkdown(
): string {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const rawBodyMarkdown = compactMarkdown(serializeMermaidAwareBlocks(editor, restored))
const rawBodyMarkdown = compactMarkdown(serializeDurableEditorBlocks(editor, restored))
const bodyMarkdown = vaultPath ? portableImageUrls(rawBodyMarkdown, vaultPath) : rawBodyMarkdown
const [frontmatter] = splitFrontmatter(tabContent)
return `${frontmatter}${bodyMarkdown}`
@@ -91,7 +91,8 @@ export function syncActiveTabIntoRawBuffer(options: {
} = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeRichEditorContent
const shouldSerializeRichEditorContent = serializeRichEditorContent || hasDurableEditorBlocks(editor.document)
const syncedContent = shouldSerializeRichEditorContent
? serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath)
: activeTabContent
rawLatestContentRef.current = syncedContent

View File

@@ -177,6 +177,7 @@ const TldrawBlock = createReactBlockSpec(
},
{
runsBefore: ['codeBlock'],
meta: { selectable: false },
render: (props) => (
<Suspense fallback={<div className="tldraw-whiteboard tldraw-whiteboard--loading" />}>
<TldrawWhiteboard

View File

@@ -17,9 +17,11 @@ import { LinkButton } from './LinkButton'
import {
PROPERTY_PANEL_GRID_STYLE,
PROPERTY_PANEL_LABEL_CLASS_NAME,
PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME,
} from '../propertyPanelLayout'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { translate, type AppLocale } from '../../lib/i18n'
import { canonicalFrontmatterKey } from '../../utils/systemMetadata'
const RELATIONSHIP_SECTION_ROW_CLASS_NAME = 'flex min-w-0 flex-col gap-1 px-1.5'
const RELATIONSHIPS_PANEL_GRID_CLASS_NAME = 'grid min-w-0 gap-x-2 gap-y-3'
@@ -27,6 +29,7 @@ const RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME = 'min-w-0 flex-1 truncate'
const RELATIONSHIP_SECTION_VALUE_CLASS_NAME = 'min-w-0'
const RELATIONSHIP_ACTION_ROW_CLASS_NAME = 'min-w-0 px-1.5'
const SUGGESTED_RELATIONSHIPS = ['belongs_to', 'related_to', 'has'] as const
const RELATIONSHIP_SCHEMA_KEYS = new Set(['belongs_to', 'related_to', 'has'])
type RelationshipEntryGroup = {
key: string
@@ -65,16 +68,22 @@ interface TitleSelectionState {
trimmed: string
}
function RelationshipSectionRow({ label, children, dataTestId }: {
function RelationshipSectionRow({ label, children, dataTestId, placeholder = false }: {
label: string
children: ReactNode
dataTestId?: string
locale: AppLocale
placeholder?: boolean
}) {
const labelClassName = placeholder ? PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME : PROPERTY_PANEL_LABEL_CLASS_NAME
const labelTextClassName = placeholder
? `${RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME} text-muted-foreground/40`
: RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME
return (
<div className={RELATIONSHIP_SECTION_ROW_CLASS_NAME} style={{ gridColumn: '1 / -1' }} data-testid={dataTestId}>
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME} data-testid="relationship-section-label">
<span className={RELATIONSHIP_SECTION_LABEL_TEXT_CLASS_NAME}>{humanizePropertyKey(label)}</span>
<span className={labelClassName} data-testid="relationship-section-label">
<span className={labelTextClassName}>{humanizePropertyKey(label)}</span>
</span>
<div className={RELATIONSHIP_SECTION_VALUE_CLASS_NAME}>{children}</div>
</div>
@@ -481,6 +490,56 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
.filter(({ refs }) => refs.length > 0)
}
function findTypeEntry(entries: VaultEntry[], entry?: VaultEntry): VaultEntry | undefined {
if (!entry?.isA || entry.isA === 'Type') return undefined
return entries.find((candidate) => candidate.isA === 'Type' && candidate.title === entry.isA)
}
function isRelationshipSchemaKey(key: string): boolean {
return RELATIONSHIP_SCHEMA_KEYS.has(canonicalFrontmatterKey(key))
}
function buildExistingRelationshipKeys(frontmatter: ParsedFrontmatter, relationshipEntries: RelationshipEntryGroup[]): Set<string> {
const existingKeys = new Set(Object.keys(frontmatter).map(canonicalFrontmatterKey))
for (const group of relationshipEntries) existingKeys.add(canonicalFrontmatterKey(group.key))
return existingKeys
}
function buildTypeDerivedRelationshipEntries({
entry,
entries,
frontmatter,
relationshipEntries,
}: {
entry?: VaultEntry
entries: VaultEntry[]
frontmatter: ParsedFrontmatter
relationshipEntries: RelationshipEntryGroup[]
}): RelationshipEntryGroup[] {
const typeEntry = findTypeEntry(entries, entry)
if (!typeEntry) return []
const existingKeys = buildExistingRelationshipKeys(frontmatter, relationshipEntries)
const result: RelationshipEntryGroup[] = []
const seen = new Set<string>()
const addPlaceholder = (key: string) => {
const canonicalKey = canonicalFrontmatterKey(key)
if (canonicalKey === 'type' || existingKeys.has(canonicalKey) || seen.has(canonicalKey)) return
seen.add(canonicalKey)
result.push({ key, refs: [] })
}
for (const [key, refs] of Object.entries(typeEntry.relationships ?? {})) {
if (refs.length > 0) addPlaceholder(key)
}
for (const key of Object.keys(typeEntry.properties ?? {})) {
if (isRelationshipSchemaKey(key)) addPlaceholder(key)
}
return result
}
function NoteTargetInput({ entries, value, locale, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
entries: VaultEntry[]
value: string
@@ -602,6 +661,7 @@ function useMissingSuggestedRelationships(
}
function useRelationshipPanelState({
entry,
frontmatter,
entries,
vaultPath,
@@ -609,21 +669,30 @@ function useRelationshipPanelState({
onUpdateProperty,
onDeleteProperty,
}: {
entry?: VaultEntry
frontmatter: ParsedFrontmatter
entries: VaultEntry[]
vaultPath?: string
} & RelationshipPanelEditHandlers) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const typeDerivedRelationshipEntries = useMemo(
() => buildTypeDerivedRelationshipEntries({ entry, entries, frontmatter, relationshipEntries }),
[entry, entries, frontmatter, relationshipEntries],
)
const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries])
const { handleRemoveRef, handleAddRef, canEdit } = useRelationshipMutations(relationshipEntries, {
onAddProperty,
onUpdateProperty,
onDeleteProperty,
})
const missingSuggestedRels = useMissingSuggestedRelationships(relationshipEntries, onAddProperty)
const missingSuggestedRels = useMissingSuggestedRelationships(
[...relationshipEntries, ...typeDerivedRelationshipEntries],
onAddProperty,
)
return {
relationshipEntries,
typeDerivedRelationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
@@ -717,16 +786,17 @@ function DisabledLinkButton({ locale }: { locale: AppLocale }) {
)
}
function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, onCreateAndOpenNote }: {
function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, onCreateAndOpenNote, dataTestId = 'suggested-relationship' }: {
label: string
entries: VaultEntry[]
vaultPath: string
onAdd: (ref: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
locale: AppLocale
dataTestId?: string
}) {
return (
<RelationshipSectionRow label={label} dataTestId="suggested-relationship" locale={locale}>
<RelationshipSectionRow label={label} dataTestId={dataTestId} locale={locale} placeholder>
<InlineAddNote
entries={entries}
vaultPath={vaultPath}
@@ -738,8 +808,8 @@ function SuggestedRelationshipSlot({ label, entries, vaultPath, locale, onAdd, o
)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
export function DynamicRelationshipsPanel({ entry, frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote, locale = 'en' }: {
entry?: VaultEntry; frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; vaultPath?: string
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
@@ -749,12 +819,14 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
}) {
const {
relationshipEntries,
typeDerivedRelationshipEntries,
resolvedVaultPath,
handleRemoveRef,
handleAddRef,
canEdit,
missingSuggestedRels,
} = useRelationshipPanelState({
entry,
frontmatter,
entries,
vaultPath,
@@ -774,6 +846,18 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
locale={locale}
/>
))}
{onAddProperty && typeDerivedRelationshipEntries.map(({ key }) => (
<SuggestedRelationshipSlot
key={`type-derived:${key}`}
label={key}
entries={entries}
vaultPath={resolvedVaultPath}
onAdd={(ref) => onAddProperty(key, ref)}
onCreateAndOpenNote={onCreateAndOpenNote}
locale={locale}
dataTestId="type-derived-relationship"
/>
))}
{missingSuggestedRels.map(label => (
<SuggestedRelationshipSlot
key={label}

View File

@@ -330,35 +330,102 @@ describe('tolariaEditorFormatting behavior', () => {
})
it('hides the floating toolbar while the editor is composing IME text', () => {
const editor = createMockEditor('paragraph')
const editorInput = editor.domElement.firstElementChild as HTMLElement
vi.useFakeTimers()
try {
const editor = createMockEditor('paragraph')
const editorInput = editor.domElement.firstElementChild as HTMLElement
useBlockNoteEditorMock.mockReturnValue(editor)
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
render(<TolariaFormattingToolbarController />)
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
act(() => {
fireEvent.compositionStart(editorInput)
})
act(() => {
fireEvent.compositionStart(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
fireEvent.compositionEnd(editorInput)
})
act(() => {
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(250)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
} finally {
vi.useRealTimers()
}
})
it('keeps the floating toolbar hidden through rapid Zhuyin composition settle cycles', () => {
vi.useFakeTimers()
try {
const editor = createMockEditor('paragraph')
const editorInput = editor.domElement.firstElementChild as HTMLElement
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbarController />)
act(() => {
fireEvent.compositionStart(editorInput)
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(120)
fireEvent.compositionStart(editorInput)
fireEvent.compositionEnd(editorInput)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(249)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: undefined,
useFloatingOptions: expect.objectContaining({ open: false }),
}))
act(() => {
vi.advanceTimersByTime(1)
})
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
position: { from: 1, to: 5 },
useFloatingOptions: expect.objectContaining({ open: true }),
}))
} finally {
vi.useRealTimers()
}
})
it('ignores composition events that start outside the editor', () => {

View File

@@ -6,10 +6,31 @@ import type {
} from '@blocknote/core'
import { useEffect, useRef, useState } from 'react'
const COMPOSITION_SETTLE_MS = 250
function eventTargetsEditor(editorElement: Element, target: EventTarget | null) {
return target instanceof Node && editorElement.contains(target)
}
function focusTargetsEditor(editorElement: Element) {
const activeElement = editorElement.ownerDocument.activeElement
return activeElement instanceof Node && editorElement.contains(activeElement)
}
function selectionTargetsEditor(editorElement: Element) {
const anchorNode = editorElement.ownerDocument.getSelection()?.anchorNode
return anchorNode instanceof Node && editorElement.contains(anchorNode)
}
function compositionEventTargetsEditor(
editorElement: Element,
event: CompositionEvent,
) {
return eventTargetsEditor(editorElement, event.target)
|| focusTargetsEditor(editorElement)
|| selectionTargetsEditor(editorElement)
}
export function useEditorComposing<
BSchema extends BlockSchema,
ISchema extends InlineContentSchema,
@@ -17,41 +38,82 @@ export function useEditorComposing<
>(editor: BlockNoteEditor<BSchema, ISchema, SSchema>) {
const [isComposing, setIsComposing] = useState(false)
const composingRef = useRef(false)
const settleTimeoutRef = useRef<number | null>(null)
const editorElement = editor.domElement ?? null
useEffect(() => {
const clearSettleTimeout = () => {
if (settleTimeoutRef.current === null) return
window.clearTimeout(settleTimeoutRef.current)
settleTimeoutRef.current = null
}
const updateComposing = (nextIsComposing: boolean) => {
if (composingRef.current === nextIsComposing) return
composingRef.current = nextIsComposing
setIsComposing(nextIsComposing)
}
const startComposing = () => {
clearSettleTimeout()
updateComposing(true)
}
const finishComposing = () => {
clearSettleTimeout()
settleTimeoutRef.current = window.setTimeout(() => {
settleTimeoutRef.current = null
updateComposing(false)
}, COMPOSITION_SETTLE_MS)
}
clearSettleTimeout()
updateComposing(false)
if (!editorElement) return
const handleCompositionStart = (event: CompositionEvent) => {
if (!eventTargetsEditor(editorElement, event.target)) return
updateComposing(true)
if (!compositionEventTargetsEditor(editorElement, event)) return
startComposing()
}
const handleCompositionUpdate = (event: CompositionEvent) => {
if (!compositionEventTargetsEditor(editorElement, event)) return
startComposing()
}
const handleCompositionEnd = (event: CompositionEvent) => {
if (
!composingRef.current
&& !eventTargetsEditor(editorElement, event.target)
&& !compositionEventTargetsEditor(editorElement, event)
) {
return
}
updateComposing(false)
finishComposing()
}
const handleCompositionCancel: EventListener = (event) => {
if (event instanceof CompositionEvent) {
handleCompositionEnd(event)
return
}
if (!composingRef.current) return
finishComposing()
}
document.addEventListener('compositionstart', handleCompositionStart, true)
document.addEventListener('compositionupdate', handleCompositionUpdate, true)
document.addEventListener('compositionend', handleCompositionEnd, true)
document.addEventListener('compositioncancel', handleCompositionCancel, true)
return () => {
clearSettleTimeout()
document.removeEventListener('compositionstart', handleCompositionStart, true)
document.removeEventListener('compositionupdate', handleCompositionUpdate, true)
document.removeEventListener('compositionend', handleCompositionEnd, true)
document.removeEventListener('compositioncancel', handleCompositionCancel, true)
}
}, [editorElement])

View File

@@ -1,8 +1,7 @@
import type { useCreateBlockNote } from '@blocknote/react'
import { preProcessWikilinks, injectWikilinks } from '../utils/wikilinks'
import { preProcessMathMarkdown, injectMathInBlocks } from '../utils/mathMarkdown'
import { preProcessMermaidMarkdown, injectMermaidInBlocks } from '../utils/mermaidMarkdown'
import { preProcessTldrawMarkdown, injectTldrawInBlocks } from '../utils/tldrawMarkdown'
import { injectDurableEditorMarkdownBlocks, preProcessDurableEditorMarkdown } from '../utils/editorDurableMarkdown'
import { resolveImageUrls } from '../utils/vaultImages'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import { inferCodeBlockLanguages } from '../utils/codeBlockLanguage'
@@ -134,9 +133,8 @@ async function parseMarkdownBlocks(
}
function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath): PreprocessedMarkdown {
const withTldraw = preProcessTldrawMarkdown({ markdown })
const withMermaid = preProcessMermaidMarkdown({ markdown: withTldraw })
const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid
const withDurableBlocks = preProcessDurableEditorMarkdown({ markdown })
const withImages = vaultPath ? resolveImageUrls(withDurableBlocks, vaultPath) : withDurableBlocks
const withWikilinks = preProcessWikilinks(withImages)
return preProcessMathMarkdown({ markdown: withWikilinks })
}
@@ -144,8 +142,7 @@ function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath)
function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
const withWikilinks = injectWikilinks(blocks)
const withMath = injectMathInBlocks(withWikilinks)
const withMermaid = injectMermaidInBlocks(withMath)
return injectTldrawInBlocks(withMermaid) as EditorBlocks
return injectDurableEditorMarkdownBlocks(withMath) as EditorBlocks
}
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {

View File

@@ -3,7 +3,7 @@ import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
import { serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { serializeDurableEditorBlocks } from '../utils/editorDurableMarkdown'
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
import { portableImageUrls } from '../utils/vaultImages'
import { useEditorMountState, useLatestRef } from './editorTabSwapLifecycle'
@@ -123,7 +123,7 @@ function findActiveTab(options: {
function serializeEditorBody(editor: ReturnType<typeof useCreateBlockNote>): string {
const restored = restoreWikilinksInBlocks(editor.document)
return compactMarkdown(serializeMermaidAwareBlocks(editor, restored))
return compactMarkdown(serializeDurableEditorBlocks(editor, restored))
}
function trySerializeEditorBody(

View File

@@ -6,6 +6,7 @@ import type { VaultEntry } from '../types'
import { RAPID_CREATE_NOTE_SETTLE_MS } from './useNoteCreation'
import { useNoteActions } from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
import { GITIGNORED_VISIBILITY_APPLIED_EVENT } from '../lib/gitignoredVisibilityEvents'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
@@ -163,6 +164,31 @@ describe('useNoteActions hook', () => {
warnSpy.mockRestore()
})
it('keeps the active tab open when gitignored visibility reports a /tmp alias', async () => {
const activeEntry = makeEntry({
path: '/private/tmp/tolaria-vault/active.md',
filename: 'active.md',
title: 'Active',
})
const { result } = renderActions([activeEntry])
await act(async () => {
await result.current.handleSelectNote(activeEntry)
})
act(() => {
window.dispatchEvent(new CustomEvent(GITIGNORED_VISIBILITY_APPLIED_EVENT, {
detail: {
hide: true,
visiblePaths: ['/tmp/tolaria-vault/active.md'],
},
}))
})
expect(result.current.activeTabPath).toBe('/private/tmp/tolaria-vault/active.md')
expect(result.current.tabs).toHaveLength(1)
})
it('handleUpdateFrontmatter calls updateEntry with mapped patch', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))

View File

@@ -13,6 +13,7 @@ import {
performRename, loadNoteContent, renameToastMessage, reloadTabsAfterRename, reloadVaultAfterRename,
} from './useNoteRename'
import { runFrontmatterAndApply, type FrontmatterOpOptions } from './frontmatterOps'
import { findByNotePath, notePathFilename, notePathsMatch } from '../utils/notePathIdentity'
export interface NoteActionsConfig {
addEntry: (entry: VaultEntry) => void
@@ -89,18 +90,20 @@ interface RenameAfterTitleChangeParams {
}
async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitleChangeParams): Promise<void> {
const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title
const oldTitle = deps.tabsRef.current.find(t => notePathsMatch(t.entry.path, path))?.entry.title
const result = await performRename({ path, newTitle, vaultPath: deps.vaultPath, oldTitle })
if (result.new_path !== path) {
const newFilename = result.new_path.split('/').pop() ?? ''
if (!notePathsMatch(result.new_path, path)) {
const newFilename = notePathFilename(result.new_path)
deps.onPathRenamed?.(path, result.new_path)
deps.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: newTitle } as Partial<VaultEntry> & { path: string })
const newContent = await loadNoteContent({ path: result.new_path })
deps.setTabs(prev => prev.map(t => t.entry.path === path
deps.setTabs(prev => prev.map(t => notePathsMatch(t.entry.path, path)
? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: newTitle }, content: newContent }
: t))
if (deps.activeTabPathRef.current === path) deps.handleSwitchTab(result.new_path)
const otherTabPaths = deps.tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path)
if (notePathsMatch(deps.activeTabPathRef.current, path)) deps.handleSwitchTab(result.new_path)
const otherTabPaths = deps.tabsRef.current
.filter(t => !notePathsMatch(t.entry.path, path) && !notePathsMatch(t.entry.path, result.new_path))
.map(t => t.entry.path)
await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent: deps.updateTabContent })
}
await reloadVaultAfterRename(deps.reloadVault)
@@ -252,7 +255,7 @@ function useGitignoredVisibilityTabCleanup({
const handleVisibilityApplied = (event: Event) => {
const { hide, visiblePaths } = (event as GitignoredVisibilityAppliedEvent).detail
const activePath = activeTabPathRef.current
if (!hide || !activePath || visiblePaths.includes(activePath)) return
if (!hide || !activePath || visiblePaths.some((path) => notePathsMatch(path, activePath))) return
closeAllTabs()
setToastMessage('Closed hidden Gitignored file')
}
@@ -353,7 +356,7 @@ export function useNoteActions(config: NoteActionsConfig) {
})
const updateTabContent = useCallback((path: string, newContent: string) => {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
setTabs((prev) => prev.map((t) => notePathsMatch(t.entry.path, path) ? { ...t, content: newContent } : t))
}, [setTabs])
const creation = useNoteCreation(config, { openTabWithContent })
@@ -374,7 +377,7 @@ export function useNoteActions(config: NoteActionsConfig) {
path,
key,
value,
callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) },
callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => findByNotePath(entries, p) },
options,
}),
[updateTabContent, updateEntry, setToastMessage, entries],

View File

@@ -8,6 +8,7 @@ import {
buildNoteContent,
resolveNewNote,
resolveNewType,
planNewTypeCreation,
DEFAULT_TEMPLATES,
resolveTemplate,
} from './useNoteCreation'
@@ -319,3 +320,28 @@ describe('resolveNewType', () => {
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
})
describe('planNewTypeCreation', () => {
it('blocks creating a type when a same-slug non-Type note already exists', () => {
const plan = planNewTypeCreation({
entries: [makeEntry({ path: '/my/vault/tasks.md', filename: 'tasks.md', title: 'Tasks', isA: 'Note' })],
typeName: 'Tasks',
vaultPath: '/my/vault',
})
expect(plan).toEqual({
status: 'blocked',
message: 'Cannot create type "Tasks" because tasks.md already exists',
})
})
it('blocks type collisions case-insensitively for cross-platform vaults', () => {
const plan = planNewTypeCreation({
entries: [makeEntry({ path: '/my/vault/TASKS.md', filename: 'TASKS.md', title: 'Tasks', isA: 'Note' })],
typeName: 'tasks',
vaultPath: '/my/vault',
})
expect(plan.status).toBe('blocked')
})
})

View File

@@ -12,8 +12,10 @@ import {
resolveNewNote,
resolveNewType,
resolveTemplate,
resolveTypeInstanceDefaults,
DEFAULT_TEMPLATES,
RAPID_CREATE_NOTE_SETTLE_MS,
planNewNoteCreation,
useNoteCreation,
} from './useNoteCreation'
import type { NoteCreationConfig } from './useNoteCreation'
@@ -169,6 +171,44 @@ describe('resolveNewNote', () => {
expect(entry.status).toBeNull()
expect(content).not.toContain('status:')
})
it('applies valued properties and relationships from the type entry to newly created instances', () => {
const typeEntry = makeEntry({
title: 'Book',
isA: 'Type',
properties: {
Rating: 5,
'start date': null,
},
relationships: {
Author: ['[[person/frank-herbert]]'],
},
})
const defaults = resolveTypeInstanceDefaults({ entries: [typeEntry], typeName: 'Book' })
const { entry, content } = resolveNewNote({
title: 'Dune',
type: 'Book',
vaultPath: '/vault',
defaults,
})
expect(content).toContain('Rating: 5')
expect(content).toContain('Author: "[[person/frank-herbert]]"')
expect(content).not.toContain('start date:')
expect(entry.properties).toEqual({ Rating: 5 })
expect(entry.relationships).toEqual({ Author: ['[[person/frank-herbert]]'] })
})
it('blocks creation when macOS /tmp aliases point at the same note path', () => {
const plan = planNewNoteCreation({
entries: [makeEntry({ path: '/private/tmp/tolaria-vault/briefing.md', filename: 'briefing.md' })],
title: 'Briefing',
type: 'Note',
vaultPath: '/tmp/tolaria-vault',
})
expect(plan.status).toBe('blocked')
})
})
describe('resolveNewType', () => {
@@ -584,11 +624,8 @@ describe('useNoteCreation hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Note" because note.md already exists')
})
it('handleCreateType lets disk creation decide when a stale entry collides with the target type path', async () => {
it('handleCreateType blocks when a loaded non-Type entry collides with the target type path', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce(undefined)
const staleEntry = makeEntry({
path: '/test/vault/pttep.md',
filename: 'pttep.md',
@@ -597,26 +634,16 @@ describe('useNoteCreation hook', () => {
})
const { result } = renderHook(() => useNoteCreation(makeConfig([staleEntry]), tabDeps))
let created = false
let created = true
await act(async () => {
created = await result.current.handleCreateType('PTTEP')
})
expect(created).toBe(true)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
path: '/test/vault/pttep.md',
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: '/test/vault/pttep.md',
content: expect.stringContaining('type: Type'),
})
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
path: '/test/vault/pttep.md',
filename: 'pttep.md',
title: 'PTTEP',
isA: 'Type',
}))
expect(setToastMessage).not.toHaveBeenCalled()
expect(created).toBe(false)
expect(vi.mocked(invoke)).not.toHaveBeenCalled()
expect(addEntry).not.toHaveBeenCalled()
expect(openTabWithContent).not.toHaveBeenCalled()
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "PTTEP" because pttep.md already exists')
})
it('handleCreateType writes new type entries to the vault root even when older type entries live in a folder', async () => {

View File

@@ -6,6 +6,8 @@ import { slugifyNoteStem as slugify } from '../utils/noteSlug'
import { resolveEntry } from '../utils/wikilink'
import { trackEvent } from '../lib/telemetry'
import { cacheNoteContent } from './useTabManagement'
import { findByCollidingNotePath, joinVaultPath, notePathFilename } from '../utils/notePathIdentity'
import { canonicalFrontmatterKey } from '../utils/systemMetadata'
export interface NewEntryParams {
path: string
@@ -87,6 +89,15 @@ export interface NoteContentParams {
status: string | null
template?: string | null
initialEmptyHeading?: boolean
defaults?: TypeInstanceDefault[]
}
type DefaultValue = string | number | boolean | string[]
export interface TypeInstanceDefault {
key: string
value: DefaultValue
kind: 'property' | 'relationship'
}
function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams, 'template' | 'initialEmptyHeading'>): string {
@@ -96,11 +107,103 @@ function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams
return template ? `\n${template}` : ''
}
export function buildNoteContent({ title, type, status, template, initialEmptyHeading = false }: NoteContentParams): string {
function isDefaultablePropertyValue(value: unknown): value is string | number | boolean {
if (typeof value === 'string') return value.trim().length > 0
return typeof value === 'number' || typeof value === 'boolean'
}
function relationshipDefaultValue(refs: string[]): DefaultValue | null {
if (refs.length === 0) return null
return refs.length === 1 ? refs[0] : refs
}
function resolveTypeEntry({ entries, typeName }: TemplateLookupParams): VaultEntry | undefined {
return entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
}
function collectPropertyDefaults(typeEntry: VaultEntry): TypeInstanceDefault[] {
return Object.entries(typeEntry.properties ?? {}).flatMap(([key, value]) => (
isDefaultablePropertyValue(value) ? [{ key, value, kind: 'property' as const }] : []
))
}
function collectRelationshipDefaults(typeEntry: VaultEntry): TypeInstanceDefault[] {
return Object.entries(typeEntry.relationships ?? {}).flatMap(([key, refs]) => {
const value = relationshipDefaultValue(refs)
return value ? [{ key, value, kind: 'relationship' as const }] : []
})
}
function appendUniqueDefault(defaults: TypeInstanceDefault[], seenKeys: Set<string>, defaultValue: TypeInstanceDefault) {
const canonicalKey = canonicalFrontmatterKey(defaultValue.key)
if (canonicalKey === 'type' || canonicalKey === 'title' || seenKeys.has(canonicalKey)) return
seenKeys.add(canonicalKey)
defaults.push(defaultValue)
}
export function resolveTypeInstanceDefaults(params: TemplateLookupParams): TypeInstanceDefault[] {
const typeEntry = resolveTypeEntry(params)
if (!typeEntry) return []
const defaults: TypeInstanceDefault[] = []
const seenKeys = new Set<string>()
const candidateDefaults = [
...collectPropertyDefaults(typeEntry),
...collectRelationshipDefaults(typeEntry),
]
candidateDefaults.forEach((defaultValue) => appendUniqueDefault(defaults, seenKeys, defaultValue))
return defaults
}
function hasOuterWhitespace(value: string): boolean {
return value.trim() !== value
}
function isYamlWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
}
function isAmbiguousYamlScalar(value: string): boolean {
return /^(?:true|false|null|[-+]?\d+(?:\.\d+)?)$/i.test(value)
}
function shouldQuoteYamlString(value: string): boolean {
return [
hasOuterWhitespace,
isYamlWikilink,
isAmbiguousYamlScalar,
(candidate: string) => candidate.includes(':'),
].some((check) => check(value))
}
function formatYamlScalar(value: string | number | boolean): string {
if (typeof value !== 'string') return String(value)
if (shouldQuoteYamlString(value)) return JSON.stringify(value)
return value
}
function appendDefaultFrontmatterLines(lines: string[], defaults: TypeInstanceDefault[]) {
const existingKeys = new Set(lines.map((line) => canonicalFrontmatterKey(line.split(':', 1)[0])))
for (const { key, value } of defaults) {
const canonicalKey = canonicalFrontmatterKey(key)
if (existingKeys.has(canonicalKey)) continue
existingKeys.add(canonicalKey)
if (Array.isArray(value)) {
lines.push(`${key}:`)
value.forEach((item) => lines.push(` - ${formatYamlScalar(item)}`))
} else {
lines.push(`${key}: ${formatYamlScalar(value)}`)
}
}
}
export function buildNoteContent({ title, type, status, template, initialEmptyHeading = false, defaults = [] }: NoteContentParams): string {
const lines = ['---']
if (title) lines.push(`title: ${title}`)
lines.push(`type: ${type}`)
if (status) lines.push(`status: ${status}`)
appendDefaultFrontmatterLines(lines, defaults)
lines.push('---')
const body = buildNoteBody({ template, initialEmptyHeading })
return `${lines.join('\n')}\n${body}`
@@ -111,13 +214,18 @@ export interface NewNoteParams {
type: string
vaultPath: string
template?: string | null
defaults?: TypeInstanceDefault[]
}
export function resolveNewNote({ title, type, vaultPath, template }: NewNoteParams): { entry: VaultEntry; content: string } {
export function resolveNewNote({ title, type, vaultPath, template, defaults = [] }: NewNoteParams): { entry: VaultEntry; content: string } {
const slug = slugify(title)
const status = null
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status })
return { entry, content: buildNoteContent({ title, type, status, template }) }
const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title, type, status })
return applyTypeDefaults({
entry,
content: buildNoteContent({ title, type, status, template, defaults }),
defaults,
})
}
export interface NewTypeParams {
@@ -127,12 +235,43 @@ export interface NewTypeParams {
export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } {
const slug = slugify(typeName)
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title: typeName, type: 'Type', status: null })
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` }
}
type ResolvedEntry = { entry: VaultEntry; content: string }
function relationshipRefs(value: DefaultValue): string[] {
return Array.isArray(value) ? value : [String(value)]
}
function applyTypeDefaults({
entry,
content,
defaults,
}: {
entry: VaultEntry
content: string
defaults: TypeInstanceDefault[]
}): ResolvedEntry {
if (defaults.length === 0) return { entry, content }
const relationships = { ...entry.relationships }
const properties = { ...entry.properties }
for (const defaultValue of defaults) {
if (defaultValue.kind === 'relationship') {
relationships[defaultValue.key] = relationshipRefs(defaultValue.value)
continue
}
properties[defaultValue.key] = defaultValue.value as string | number | boolean
}
return {
entry: { ...entry, relationships, properties },
content,
}
}
interface BlockedCreationPlan {
status: 'blocked'
message: string
@@ -151,17 +290,12 @@ interface ExistingTypeCreationPlan {
export type NoteCreationPlan = BlockedCreationPlan | ReadyCreationPlan
export type TypeCreationPlan = BlockedCreationPlan | ExistingTypeCreationPlan | ReadyCreationPlan
function normalizeComparablePath(path: string): string {
return path.replace(/\\/g, '/').toLocaleLowerCase()
}
function findPathCollision(entries: VaultEntry[], path: string): VaultEntry | undefined {
const target = normalizeComparablePath(path)
return entries.find((entry) => normalizeComparablePath(entry.path) === target)
return findByCollidingNotePath(entries, path)
}
function buildCreationCollisionMessage({ noun, title, path }: { noun: 'note' | 'type'; title: string; path: string }): string {
const filename = path.split('/').pop() ?? path
const filename = notePathFilename(path)
return `Cannot create ${noun} "${title}" because ${filename} already exists`
}
@@ -179,8 +313,9 @@ export function planNewNoteCreation({
type,
vaultPath,
template,
defaults,
}: NewNoteParams & { entries: VaultEntry[] }): NoteCreationPlan {
const resolved = resolveNewNote({ title, type, vaultPath, template })
const resolved = resolveNewNote({ title, type, vaultPath, template, defaults })
const collision = findPathCollision(entries, resolved.entry.path)
if (collision) {
return {
@@ -200,6 +335,14 @@ export function planNewTypeCreation({
if (existingType) return { status: 'existing', entry: existingType }
const resolved = resolveNewType({ typeName, vaultPath })
const collision = findPathCollision(entries, resolved.entry.path)
if (collision) {
return {
status: 'blocked',
message: buildCreationCollisionMessage({ noun: 'type', title: typeName, path: resolved.entry.path }),
}
}
return { status: 'create', resolved }
}
@@ -310,7 +453,8 @@ async function createNamedNote({
creationPath,
}: NoteCreationRequest): Promise<boolean> {
const template = resolveTemplate({ entries, typeName: type })
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template })
const defaults = resolveTypeInstanceDefaults({ entries, typeName: type })
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template, defaults })
if (plan.status === 'blocked') {
setToastMessage(plan.message)
return false
@@ -464,16 +608,21 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
const title = slug_to_title(slug)
const template = resolveTemplate({ entries: deps.entries, typeName: noteType })
const defaults = resolveTypeInstanceDefaults({ entries: deps.entries, typeName: noteType })
const status = null
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
const didPersist = await persistImmediateEntry(deps, entry, content)
const entry = buildNewEntry({ path: joinVaultPath(deps.vaultPath, `${slug}.md`), slug, title, type: noteType, status })
const resolved = applyTypeDefaults({
entry,
content: buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true, defaults }),
defaults,
})
const didPersist = await persistImmediateEntry(deps, resolved.entry, resolved.content)
if (!didPersist) return false
cacheNoteContent(entry.path, content, entry)
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
signalFocusEditor({ path: entry.path, selectTitle: true })
cacheNoteContent(resolved.entry.path, resolved.content, resolved.entry)
deps.openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
signalFocusEditor({ path: resolved.entry.path, selectTitle: true })
return true
}

View File

@@ -127,11 +127,13 @@ describe('useNoteRename hook', () => {
))
const runHandleRenameNote = async ({
path = '/vault/old.md',
entries = [],
renameResult = { new_path: '/vault/new.md', updated_files: 0, failed_updates: 0 },
activePath = null,
onEntryRenamed = vi.fn(),
}: {
path?: string
entries?: VaultEntry[]
renameResult?: RenameNoteResult
activePath?: string | null
@@ -142,7 +144,7 @@ describe('useNoteRename hook', () => {
const { result } = renderUseNoteRename(entries)
await act(async () => {
await result.current.handleRenameNote('/vault/old.md', 'New', '/vault', onEntryRenamed)
await result.current.handleRenameNote(path, 'New', '/vault', onEntryRenamed)
})
return { onEntryRenamed }
@@ -196,6 +198,17 @@ describe('useNoteRename hook', () => {
expect(handleSwitchTab).toHaveBeenCalledWith('/vault/new.md')
})
it('switches active tab when macOS /tmp aliases identify the renamed note', async () => {
await runHandleRenameNote({
path: '/tmp/vault/old.md',
entries: [makeEntry({ path: '/private/tmp/vault/old.md' })],
renameResult: { new_path: '/tmp/vault/new.md', updated_files: 0, failed_updates: 0 },
activePath: '/private/tmp/vault/old.md',
})
expect(handleSwitchTab).toHaveBeenCalledWith('/tmp/vault/new.md')
})
it('handleRenameFilename renames the file while preserving the existing title', async () => {
const entry = makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md', title: 'Project Kickoff' })
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
@@ -331,4 +344,33 @@ describe('useNoteRename hook', () => {
)
expect(setToastMessage).toHaveBeenCalledWith('Moved to "projects" and updated 1 note')
})
it('normalizes folder move targets before sending them to the backend', async () => {
const entry = makeEntry({ path: '/vault/notes/project-kickoff.md', filename: 'project-kickoff.md', title: 'Project Kickoff' })
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_folder') {
return {
new_path: '/vault/projects/active/project-kickoff.md',
updated_files: 0,
failed_updates: 0,
}
}
if (cmd === 'get_note_content') return '# Project Kickoff\n'
return ''
})
const { result } = renderHook(() => useNoteRename(
{ entries: [entry], setToastMessage },
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
))
await act(async () => {
await result.current.handleMoveNoteToFolder('/vault/notes/project-kickoff.md', String.raw`/projects\active/`, '/vault', vi.fn())
})
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_folder', expect.objectContaining({
folder_path: 'projects/active',
}))
expect(setToastMessage).toHaveBeenCalledWith('Moved to "active"')
})
})

View File

@@ -3,6 +3,13 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { slugify } from './useNoteCreation'
import {
findByNotePath,
normalizeVaultRelativePath,
notePathFilename,
notePathsMatch,
vaultRelativePathLabel,
} from '../utils/notePathIdentity'
interface RenameResult {
new_path: string
@@ -98,12 +105,12 @@ export async function performMoveNoteToFolder({
}
export function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
const filename = newPath.split('/').pop() ?? entry.filename
const filename = notePathFilename(newPath)
return { ...entry, path: newPath, filename, title: newTitle }
}
export function buildFilenameRenamedEntry(entry: VaultEntry, newPath: string): VaultEntry {
const filename = newPath.split('/').pop() ?? entry.filename
const filename = notePathFilename(newPath)
return { ...entry, path: newPath, filename }
}
@@ -156,8 +163,7 @@ export function renameToastMessage(updatedFiles: number, failedUpdates = 0): str
}
function folderLabel(params: { folderPath: string }): string {
const trimmed = params.folderPath.trim().replace(/^\/+|\/+$/g, '')
return trimmed.split('/').filter(Boolean).at(-1) ?? trimmed
return vaultRelativePathLabel(params.folderPath)
}
function moveToastMessage(folderPath: string, updatedFiles: number, failedUpdates = 0): string {
@@ -195,8 +201,8 @@ interface Tab {
}
function findRenameEntry(entries: VaultEntry[], tabs: Tab[], path: string): VaultEntry | undefined {
return entries.find((entry) => entry.path === path)
?? tabs.find((tab) => tab.entry.path === path)?.entry
return findByNotePath(entries, path)
?? tabs.find((tab) => notePathsMatch(tab.entry.path, path))?.entry
}
function renameErrorMessage(err: unknown): string {
@@ -260,9 +266,11 @@ function useRenameResultApplier(
const entry = findRenameEntry(entries, currentTabs, oldPath)
const newContent = await loadNoteContent({ path: result.new_path })
const newEntry = buildEntry(entry, result.new_path)
const otherTabPaths = currentTabs.filter((tab) => tab.entry.path !== oldPath).map((tab) => tab.entry.path)
setTabs((prev) => prev.map((tab) => tab.entry.path === oldPath ? { entry: newEntry, content: newContent } : tab))
if (activeTabPathRef.current === oldPath) handleSwitchTab(result.new_path)
const otherTabPaths = currentTabs
.filter((tab) => !notePathsMatch(tab.entry.path, oldPath) && !notePathsMatch(tab.entry.path, result.new_path))
.map((tab) => tab.entry.path)
setTabs((prev) => prev.map((tab) => notePathsMatch(tab.entry.path, oldPath) ? { entry: newEntry, content: newContent } : tab))
if (notePathsMatch(activeTabPathRef.current, oldPath)) handleSwitchTab(result.new_path)
onEntryRenamed(oldPath, newEntry, newContent)
await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent })
await reloadVaultAfterRename(reloadVault)
@@ -310,7 +318,7 @@ async function runRenameAction({
}): Promise<RenameResult | null> {
try {
const result = await perform()
if (allowUnchangedResult && result.new_path === path) return result
if (allowUnchangedResult && notePathsMatch(result.new_path, path)) return result
await applyRenameResult(path, result, buildEntry, onEntryRenamed, { successMessage })
return result
} catch (err) {
@@ -365,7 +373,7 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
vaultPath: string,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
) => {
const normalizedFolderPath = folderPath.trim().replace(/^\/+|\/+$/g, '')
const normalizedFolderPath = normalizeVaultRelativePath(folderPath)
return runRenameAction({
path,
perform: () => performMoveNoteToFolder({ path, folderPath: normalizedFolderPath, vaultPath }),

View File

@@ -122,6 +122,43 @@ describe('usePropertyPanelState', () => {
])
})
it('derives missing instance property placeholders from its type entry', () => {
const entries = [
makeEntry({
title: 'Book',
isA: 'Type',
properties: {
'start date': null,
Rating: 5,
},
}),
makeEntry({
title: 'Dune',
isA: 'Book',
properties: {
Rating: 4,
},
}),
]
const { result } = renderHook(() =>
usePropertyPanelState({
entries,
entryIsA: 'Book',
frontmatter: {
Rating: 4,
},
}),
)
expect(result.current.propertyEntries).toEqual([
['Rating', 4],
])
expect(result.current.typeDerivedPropertyEntries).toEqual([
['start date', null],
])
})
it('saves scalar and list properties through the correct handlers', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()

View File

@@ -10,11 +10,14 @@ import {
removeDisplayModeOverride,
} from '../utils/propertyTypes'
import { containsWikilinks } from '../components/DynamicPropertiesPanel'
import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
import { canonicalFrontmatterKey, canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
// Keys to skip showing in Properties (handled by dedicated UI or internal)
// Compared case-insensitively via isVisibleProperty()
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_archived', 'archived', 'archived_at', '_favorite', '_favorite_index', '_organized'])
const RELATIONSHIP_SCHEMA_KEYS = new Set(['belongs_to', 'related_to', 'has'])
type PropertyEntry = [string, FrontmatterValue]
function coerceValue(raw: string): FrontmatterValue {
if (raw.toLowerCase() === 'true') return true
@@ -88,8 +91,8 @@ function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
return !isHiddenPropertyKey(key) && !containsWikilinks(value)
}
function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): [string, FrontmatterValue][] {
const result: [string, FrontmatterValue][] = []
function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): PropertyEntry[] {
const result: PropertyEntry[] = []
const seen = new Set<string>()
for (const [key, value] of Object.entries(frontmatter)) {
@@ -104,6 +107,46 @@ function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): [string, F
return result
}
function findTypeEntry(entries: VaultEntry[] | undefined, entryIsA: string | null): VaultEntry | undefined {
if (!entryIsA || entryIsA === 'Type') return undefined
return entries?.find((entry) => entry.isA === 'Type' && entry.title === entryIsA)
}
function isRelationshipSchemaKey(key: string): boolean {
return RELATIONSHIP_SCHEMA_KEYS.has(canonicalFrontmatterKey(key))
}
function buildExistingFrontmatterKeys(frontmatter: ParsedFrontmatter): Set<string> {
return new Set(Object.keys(frontmatter).map(canonicalFrontmatterKey))
}
function buildTypeDerivedPropertyEntries({
entries,
entryIsA,
frontmatter,
}: {
entries: VaultEntry[] | undefined
entryIsA: string | null
frontmatter: ParsedFrontmatter
}): PropertyEntry[] {
const typeEntry = findTypeEntry(entries, entryIsA)
if (!typeEntry) return []
const existingKeys = buildExistingFrontmatterKeys(frontmatter)
const result: PropertyEntry[] = []
const seen = new Set<string>()
for (const [key, value] of Object.entries(typeEntry.properties ?? {})) {
const canonicalKey = canonicalFrontmatterKey(key)
if (existingKeys.has(canonicalKey) || seen.has(canonicalKey) || isRelationshipSchemaKey(key)) continue
if (!isVisibleProperty([key, value])) continue
seen.add(canonicalKey)
result.push([key, value])
}
return result
}
function addTagValues(tagsByKey: Map<string, Set<string>>, key: string, value: unknown) {
if (!Array.isArray(value)) return
@@ -197,6 +240,10 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries), [entries])
const propertyEntries = useMemo(() => buildVisiblePropertyEntries(frontmatter), [frontmatter])
const typeDerivedPropertyEntries = useMemo(
() => buildTypeDerivedPropertyEntries({ entries, entryIsA, frontmatter }),
[entries, entryIsA, frontmatter],
)
const handleSaveValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
@@ -204,6 +251,12 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
const handleSaveTypeDerivedValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
if (!onUpdateProperty || newValue.trim() === '') return
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
const handleSaveList = useCallback((key: string, newItems: string[]) => {
if (!onUpdateProperty) return
reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key)
@@ -226,7 +279,7 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
return {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries, typeDerivedPropertyEntries,
handleSaveValue, handleSaveTypeDerivedValue, handleSaveList, handleAdd, handleDisplayModeChange,
}
}

View File

@@ -20,6 +20,7 @@ import {
prefetchNoteContent as prefetchNoteContentInMemory,
} from './noteContentCache'
import { clearParsedNoteBlockCache } from './editorParsedBlockCache'
import { notePathsMatch } from '../utils/notePathIdentity'
interface Tab {
entry: VaultEntry
@@ -89,18 +90,6 @@ function resetRequestedPathIfStillPending(
}
}
function normalizeComparablePath(path: string): string {
return path
.replaceAll('\\', '/')
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
.replace(/\/+$/u, '')
}
function pathsMatch(leftPath: string | null, rightPath: string | null): boolean {
if (!leftPath || !rightPath) return false
return normalizeComparablePath(leftPath) === normalizeComparablePath(rightPath)
}
function setSingleTab(
tabsRef: React.MutableRefObject<Tab[]>,
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
@@ -123,8 +112,8 @@ function isAlreadyViewingPath(
activeTabPathRef: React.MutableRefObject<string | null>,
path: string,
) {
return pathsMatch(activeTabPathRef.current, path)
|| tabsRef.current.some((tab) => pathsMatch(tab.entry.path, path))
return notePathsMatch(activeTabPathRef.current, path)
|| tabsRef.current.some((tab) => notePathsMatch(tab.entry.path, path))
}
function startEntryNavigation(options: {
@@ -203,8 +192,8 @@ function shouldApplyLoadedEntry(options: {
if (navSeqRef.current !== seq) return false
if (forceReload) return true
if (!pathsMatch(activeTabPathRef.current, path)) return true
const openTab = tabsRef.current.find((tab) => pathsMatch(tab.entry.path, path))
if (!notePathsMatch(activeTabPathRef.current, path)) return true
const openTab = tabsRef.current.find((tab) => notePathsMatch(tab.entry.path, path))
return !openTab || openTab.content !== content
}
@@ -472,7 +461,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
) => {
const seq = ++beforeNavigateSeqRef.current
const currentPath = activeTabPathRef.current
if (beforeNavigate && currentPath && !pathsMatch(currentPath, targetPath)) {
if (beforeNavigate && currentPath && !notePathsMatch(currentPath, targetPath)) {
try {
markNoteOpenTrace(targetPath, 'beforeNavigateStart')
await beforeNavigate(currentPath, targetPath)
@@ -491,7 +480,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
/** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
requestedActiveTabPathRef.current = entry.path
const alreadyViewingDirtyEntry = pathsMatch(entry.path, activeTabPathRef.current)
const alreadyViewingDirtyEntry = notePathsMatch(entry.path, activeTabPathRef.current)
&& !!hasUnsavedChanges?.(entry.path)
if (!alreadyViewingDirtyEntry) {
beginNoteOpenTrace(entry.path, 'select-note')
@@ -532,7 +521,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
requestedActiveTabPathRef.current = entry.path
const replacingDifferentEntry = !pathsMatch(entry.path, activeTabPathRef.current)
const replacingDifferentEntry = !notePathsMatch(entry.path, activeTabPathRef.current)
if (replacingDifferentEntry) {
beginNoteOpenTrace(entry.path, 'replace-active-tab')
}

88
src/lib/aiTargets.test.ts Normal file
View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
LOCAL_AI_PROVIDER_KINDS,
aiModelProviderCatalog,
aiModelProviderCatalogEntry,
isLocalAiProvider,
normalizeAiModelProviders,
type AiModelProvider,
} from './aiTargets'
function provider(kind: AiModelProvider['kind']): AiModelProvider {
return {
id: ' Demo ',
name: ' Demo Provider ',
kind,
base_url: ' https://example.com/v1 ',
api_key_storage: null,
api_key_env_var: ' DEMO_API_KEY ',
headers: null,
models: [{
id: ' demo-model ',
display_name: ' Demo Model ',
context_window: null,
max_output_tokens: null,
capabilities: {
streaming: true,
tools: false,
vision: false,
json_mode: true,
reasoning: false,
},
}],
}
}
describe('ai target provider contract', () => {
it('keeps provider defaults in one catalog with stable grouping metadata', () => {
const entries = aiModelProviderCatalog()
const kinds = entries.map((entry) => entry.kind)
expect(kinds).toEqual([
'ollama',
'lm_studio',
'open_ai',
'anthropic',
'gemini',
'open_router',
'open_ai_compatible',
])
expect(new Set(kinds).size).toBe(kinds.length)
expect(LOCAL_AI_PROVIDER_KINDS).toEqual(['ollama', 'lm_studio'])
expect(aiModelProviderCatalogEntry('anthropic')).toMatchObject({
name: 'Anthropic',
base_url: 'https://api.anthropic.com/v1',
api_key_storage: 'local_file',
api_key_env_var: 'ANTHROPIC_API_KEY',
default_model_id: 'claude-3-5-sonnet-latest',
local: false,
})
expect(aiModelProviderCatalogEntry('open_ai_compatible')).toMatchObject({
base_url: 'https://api.example.com/v1',
api_key_env_var: 'OPENAI_API_KEY',
local: false,
})
})
it('normalizes saved providers while using the catalog for local/provider classification', () => {
const normalized = normalizeAiModelProviders([
provider('open_ai_compatible'),
{ ...provider('ollama'), id: ' ', name: 'Missing ID' },
])
expect(normalized).toHaveLength(1)
expect(normalized[0]).toMatchObject({
id: 'demo',
name: 'Demo Provider',
base_url: 'https://example.com/v1',
api_key_env_var: 'DEMO_API_KEY',
api_key_storage: 'env',
})
expect(normalized[0].models[0]).toMatchObject({
id: 'demo-model',
display_name: 'Demo Model',
})
expect(isLocalAiProvider(provider('lm_studio'))).toBe(true)
expect(isLocalAiProvider(provider('open_router'))).toBe(false)
})
})

View File

@@ -5,10 +5,13 @@ import {
type AiAgentId,
type AiAgentsStatus,
} from './aiAgents'
import providerCatalog from '../shared/aiModelProviderCatalog.json' with { type: 'json' }
import type { Settings } from '../types'
import type { TranslationKey } from './i18n'
export type AiModelProviderKind = 'open_ai' | 'anthropic' | 'open_ai_compatible' | 'ollama' | 'lm_studio' | 'open_router' | 'gemini'
export type AiTargetKind = 'agent' | 'api_model'
export type AiModelApiKeyStorage = 'none' | 'env' | 'local_file'
export interface AiModelCapabilities {
streaming: boolean
@@ -31,12 +34,24 @@ export interface AiModelProvider {
name: string
kind: AiModelProviderKind
base_url?: string | null
api_key_storage?: 'none' | 'env' | 'local_file' | null
api_key_storage?: AiModelApiKeyStorage | null
api_key_env_var?: string | null
headers?: Record<string, string> | null
models: AiModelDefinition[]
}
export interface AiModelProviderCatalogEntry {
kind: AiModelProviderKind
name: string
label_key: TranslationKey
base_url: string
runtime_base_url: string | null
default_model_id: string
api_key_storage: AiModelApiKeyStorage
api_key_env_var: string | null
local: boolean
}
export type AiTarget =
| { kind: 'agent'; agent: AiAgentId; id: string; label: string; shortLabel: string }
| { kind: 'api_model'; provider: AiModelProvider; model: AiModelDefinition; id: string; label: string; shortLabel: string }
@@ -45,7 +60,14 @@ export type AiModelTarget = Extract<AiTarget, { kind: 'api_model' }>
export const AI_TARGET_PREFIX_AGENT = 'agent:'
export const AI_TARGET_PREFIX_MODEL = 'model:'
export const LOCAL_AI_PROVIDER_KINDS: readonly AiModelProviderKind[] = ['ollama', 'lm_studio']
const AI_MODEL_PROVIDER_CATALOG = providerCatalog as readonly AiModelProviderCatalogEntry[]
const AI_MODEL_PROVIDER_CATALOG_BY_KIND = new Map<AiModelProviderKind, AiModelProviderCatalogEntry>(
AI_MODEL_PROVIDER_CATALOG.map((entry) => [entry.kind, entry]),
)
export const LOCAL_AI_PROVIDER_KINDS: readonly AiModelProviderKind[] = AI_MODEL_PROVIDER_CATALOG
.filter((entry) => entry.local)
.map((entry) => entry.kind)
export const DEFAULT_MODEL_CAPABILITIES: AiModelCapabilities = {
streaming: false,
@@ -55,6 +77,16 @@ export const DEFAULT_MODEL_CAPABILITIES: AiModelCapabilities = {
reasoning: false,
}
export function aiModelProviderCatalog(): readonly AiModelProviderCatalogEntry[] {
return AI_MODEL_PROVIDER_CATALOG
}
export function aiModelProviderCatalogEntry(kind: AiModelProviderKind): AiModelProviderCatalogEntry {
const entry = AI_MODEL_PROVIDER_CATALOG_BY_KIND.get(kind)
if (!entry) throw new Error(`Unknown AI model provider kind: ${kind}`)
return entry
}
export function agentTargetId(agent: AiAgentId): string {
return `${AI_TARGET_PREFIX_AGENT}${agent}`
}
@@ -148,7 +180,7 @@ function emptyToNull(value: string | null | undefined): string | null {
}
export function isLocalAiProvider(provider: AiModelProvider): boolean {
return LOCAL_AI_PROVIDER_KINDS.includes(provider.kind)
return aiModelProviderCatalogEntry(provider.kind).local
}
export function aiTargetReady(target: AiTarget, statuses: AiAgentsStatus): boolean {

View File

@@ -0,0 +1,58 @@
import { BlockNoteEditor } from '@blocknote/core'
import { afterEach, describe, expect, it } from 'vitest'
const arrayToReversedDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'toReversed')
function removeArrayToReversed() {
Object.defineProperty(Array.prototype, 'toReversed', {
configurable: true,
writable: true,
value: undefined,
})
}
function restoreArrayToReversed() {
if (arrayToReversedDescriptor) {
Object.defineProperty(Array.prototype, 'toReversed', arrayToReversedDescriptor)
return
}
delete Array.prototype.toReversed
}
afterEach(() => {
restoreArrayToReversed()
})
describe('patched BlockNote rich text copy compatibility', () => {
it('serializes marked rich text without Array.prototype.toReversed', () => {
removeArrayToReversed()
const editor = BlockNoteEditor.create({
initialContent: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Copied rich text',
styles: { bold: true, italic: true },
},
],
},
],
})
try {
const html = editor.blocksToHTMLLossy(editor.document)
const fullHtml = editor.blocksToFullHTML(editor.document)
const markdown = editor.blocksToMarkdownLossy(editor.document)
expect(html).toContain('Copied rich text')
expect(fullHtml).toContain('Copied rich text')
expect(markdown).toContain('Copied rich text')
} finally {
editor._tiptapEditor.destroy()
}
})
})

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Aktuellen Diff anzeigen",
"editor.toolbar.openAi": "AI-Panel öffnen",
"editor.toolbar.closeAi": "AI-Panel schließen",
"editor.toolbar.openTableOfContents": "Inhaltsverzeichnis öffnen",
"editor.toolbar.closeTableOfContents": "Inhaltsverzeichnis schließen",
"editor.toolbar.restoreArchived": "Diese archivierte Notiz wiederherstellen",
"editor.toolbar.archive": "Diese Notiz archivieren",
"editor.toolbar.delete": "Diese Notiz löschen",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Dateipfad kopieren",
"editor.toolbar.moreActions": "Weitere Notizaktionen",
"editor.toolbar.openProperties": "Eigenschaften-Panel öffnen",
"tableOfContents.title": "Inhaltsverzeichnis",
"tableOfContents.close": "Inhaltsverzeichnis schließen",
"tableOfContents.empty": "Keine Überschriften in dieser Notiz",
"tableOfContents.emptyNoNote": "Keine Notiz ausgewählt",
"tableOfContents.navLabel": "Inhaltsverzeichnis",
"tableOfContents.untitledHeading": "Überschrift ohne Titel",
"tableOfContents.expandHeading": "{heading} erweitern",
"tableOfContents.collapseHeading": "{heading} einklappen",
"editor.codeBlock.copy": "Code in die Zwischenablage kopieren",
"editor.imageLightbox.title": "Bildvorschau",
"editor.filename.rename": "Dateinamen umbenennen",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Show the current diff",
"editor.toolbar.openAi": "Open the AI panel",
"editor.toolbar.closeAi": "Close the AI panel",
"editor.toolbar.openTableOfContents": "Open table of contents",
"editor.toolbar.closeTableOfContents": "Close table of contents",
"editor.toolbar.restoreArchived": "Restore this archived note",
"editor.toolbar.archive": "Archive this note",
"editor.toolbar.delete": "Delete this note",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Copy file path",
"editor.toolbar.moreActions": "More note actions",
"editor.toolbar.openProperties": "Open the properties panel",
"tableOfContents.title": "Table of Contents",
"tableOfContents.close": "Close table of contents",
"tableOfContents.empty": "No headings in this note",
"tableOfContents.emptyNoNote": "No note selected",
"tableOfContents.navLabel": "Table of contents",
"tableOfContents.untitledHeading": "Untitled heading",
"tableOfContents.expandHeading": "Expand {heading}",
"tableOfContents.collapseHeading": "Collapse {heading}",
"editor.codeBlock.copy": "Copy code to clipboard",
"editor.imageLightbox.title": "Image preview",
"editor.filename.rename": "Rename filename",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Mostrar el diff actual",
"editor.toolbar.openAi": "Abrir el panel de IA",
"editor.toolbar.closeAi": "Cerrar el panel de IA",
"editor.toolbar.openTableOfContents": "Abrir índice",
"editor.toolbar.closeTableOfContents": "Cerrar el índice",
"editor.toolbar.restoreArchived": "Restaurar esta nota archivada",
"editor.toolbar.archive": "Archivar esta nota",
"editor.toolbar.delete": "Eliminar esta nota",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
"editor.toolbar.moreActions": "Más acciones de nota",
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
"tableOfContents.title": "Índice",
"tableOfContents.close": "Cerrar índice",
"tableOfContents.empty": "No hay encabezados en esta nota",
"tableOfContents.emptyNoNote": "No se ha seleccionado ninguna nota",
"tableOfContents.navLabel": "Índice",
"tableOfContents.untitledHeading": "Encabezado sin título",
"tableOfContents.expandHeading": "Expandir {heading}",
"tableOfContents.collapseHeading": "Ocultar {heading}",
"editor.codeBlock.copy": "Copiar código al portapapeles",
"editor.imageLightbox.title": "Vista previa de la imagen",
"editor.filename.rename": "Cambiar el nombre del archivo",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Mostrar el diff actual",
"editor.toolbar.openAi": "Abrir el panel de IA",
"editor.toolbar.closeAi": "Cerrar el panel de IA",
"editor.toolbar.openTableOfContents": "Abrir el índice",
"editor.toolbar.closeTableOfContents": "Cerrar el índice",
"editor.toolbar.restoreArchived": "Restaurar esta nota archivada",
"editor.toolbar.archive": "Archivar esta nota",
"editor.toolbar.delete": "Eliminar esta nota",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
"editor.toolbar.moreActions": "Más acciones de nota",
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
"tableOfContents.title": "Índice",
"tableOfContents.close": "Cerrar el índice",
"tableOfContents.empty": "No hay encabezados en esta nota",
"tableOfContents.emptyNoNote": "Ninguna nota seleccionada",
"tableOfContents.navLabel": "Índice",
"tableOfContents.untitledHeading": "Encabezado sin título",
"tableOfContents.expandHeading": "Expandir {heading}",
"tableOfContents.collapseHeading": "Contraer {heading}",
"editor.codeBlock.copy": "Copiar código al portapapeles",
"editor.imageLightbox.title": "Vista previa de la imagen",
"editor.filename.rename": "Cambiar el nombre del archivo",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Afficher le diff actuel",
"editor.toolbar.openAi": "Ouvrir le panneau d'IA",
"editor.toolbar.closeAi": "Fermer le panneau d'IA",
"editor.toolbar.openTableOfContents": "Ouvrir la table des matières",
"editor.toolbar.closeTableOfContents": "Fermer la table des matières",
"editor.toolbar.restoreArchived": "Restaurer cette note archivée",
"editor.toolbar.archive": "Archiver cette note",
"editor.toolbar.delete": "Supprimer cette note",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Copier le chemin du fichier",
"editor.toolbar.moreActions": "Autres actions de note",
"editor.toolbar.openProperties": "Ouvrir le panneau des propriétés",
"tableOfContents.title": "Table des matières",
"tableOfContents.close": "Fermer la table des matières",
"tableOfContents.empty": "Aucun titre dans cette note",
"tableOfContents.emptyNoNote": "Aucune note sélectionnée",
"tableOfContents.navLabel": "Table des matières",
"tableOfContents.untitledHeading": "Titre sans titre",
"tableOfContents.expandHeading": "Développer {heading}",
"tableOfContents.collapseHeading": "Réduire {heading}",
"editor.codeBlock.copy": "Copier le code dans le presse-papiers",
"editor.imageLightbox.title": "Aperçu de l'image",
"editor.filename.rename": "Renommer le fichier",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Mostra il diff corrente",
"editor.toolbar.openAi": "Apri il pannello IA",
"editor.toolbar.closeAi": "Chiudi il pannello IA",
"editor.toolbar.openTableOfContents": "Apri l'indice",
"editor.toolbar.closeTableOfContents": "Chiudi l'indice",
"editor.toolbar.restoreArchived": "Ripristina questa nota archiviata",
"editor.toolbar.archive": "Archivia questa nota",
"editor.toolbar.delete": "Elimina questa nota",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Copia il percorso del file",
"editor.toolbar.moreActions": "Altre azioni della nota",
"editor.toolbar.openProperties": "Apri il pannello delle proprietà",
"tableOfContents.title": "Indice",
"tableOfContents.close": "Chiudi l'indice",
"tableOfContents.empty": "Nessun titolo in questa nota",
"tableOfContents.emptyNoNote": "Nessuna nota selezionata",
"tableOfContents.navLabel": "Indice",
"tableOfContents.untitledHeading": "Intestazione senza titolo",
"tableOfContents.expandHeading": "Espandi {heading}",
"tableOfContents.collapseHeading": "Comprimi {heading}",
"editor.codeBlock.copy": "Copia il codice negli appunti",
"editor.imageLightbox.title": "Anteprima immagine",
"editor.filename.rename": "Rinomina nome file",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "現在の差分を表示",
"editor.toolbar.openAi": "AIパネルを開く",
"editor.toolbar.closeAi": "AIパネルを閉じる",
"editor.toolbar.openTableOfContents": "目次を開く",
"editor.toolbar.closeTableOfContents": "目次を閉じる",
"editor.toolbar.restoreArchived": "このアーカイブされたノートを復元する",
"editor.toolbar.archive": "このノートをアーカイブする",
"editor.toolbar.delete": "このノートを削除",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "ファイルパスをコピー",
"editor.toolbar.moreActions": "その他のノート操作",
"editor.toolbar.openProperties": "プロパティパネルを開く",
"tableOfContents.title": "目次",
"tableOfContents.close": "目次を閉じる",
"tableOfContents.empty": "このノートには見出しがありません",
"tableOfContents.emptyNoNote": "ノートが選択されていません",
"tableOfContents.navLabel": "目次",
"tableOfContents.untitledHeading": "無題の見出し",
"tableOfContents.expandHeading": "{heading}を展開",
"tableOfContents.collapseHeading": "{heading}を折りたたむ",
"editor.codeBlock.copy": "コードをクリップボードにコピー",
"editor.imageLightbox.title": "画像プレビュー",
"editor.filename.rename": "ファイル名を変更",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "현재 diff 표시",
"editor.toolbar.openAi": "AI 패널 열기",
"editor.toolbar.closeAi": "AI 패널 닫기",
"editor.toolbar.openTableOfContents": "목차 열기",
"editor.toolbar.closeTableOfContents": "목차 닫기",
"editor.toolbar.restoreArchived": "이 보관된 노트 복원",
"editor.toolbar.archive": "이 노트 보관",
"editor.toolbar.delete": "이 노트 삭제",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "파일 경로 복사",
"editor.toolbar.moreActions": "추가 노트 작업",
"editor.toolbar.openProperties": "속성 패널 열기",
"tableOfContents.title": "목차",
"tableOfContents.close": "목차 닫기",
"tableOfContents.empty": "이 노트에는 제목이 없습니다",
"tableOfContents.emptyNoNote": "선택한 노트가 없습니다",
"tableOfContents.navLabel": "목차",
"tableOfContents.untitledHeading": "제목 없는 머리글",
"tableOfContents.expandHeading": "{heading} 펼치기",
"tableOfContents.collapseHeading": "{heading} 접기",
"editor.codeBlock.copy": "클립보드에 코드 복사",
"editor.imageLightbox.title": "이미지 미리 보기",
"editor.filename.rename": "파일 이름 변경",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Pokaż bieżący diff",
"editor.toolbar.openAi": "Otwórz panel AI",
"editor.toolbar.closeAi": "Zamknij panel AI",
"editor.toolbar.openTableOfContents": "Otwórz spis treści",
"editor.toolbar.closeTableOfContents": "Zamknij spis treści",
"editor.toolbar.restoreArchived": "Przywróć tę zarchiwizowaną notatkę",
"editor.toolbar.archive": "Archiwizuj tę notatkę",
"editor.toolbar.delete": "Usuń tę notatkę",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Kopiuj ścieżkę pliku",
"editor.toolbar.moreActions": "Więcej działań notatki",
"editor.toolbar.openProperties": "Otwórz panel właściwości",
"tableOfContents.title": "Spis treści",
"tableOfContents.close": "Zamknij spis treści",
"tableOfContents.empty": "Brak nagłówków w tej notatce",
"tableOfContents.emptyNoNote": "Nie wybrano żadnej notatki",
"tableOfContents.navLabel": "Spis treści",
"tableOfContents.untitledHeading": "Nagłówek bez tytułu",
"tableOfContents.expandHeading": "Rozwiń {heading}",
"tableOfContents.collapseHeading": "Zwiń {heading}",
"editor.codeBlock.copy": "Skopiuj kod do schowka",
"editor.imageLightbox.title": "Podgląd obrazu",
"editor.filename.rename": "Zmień nazwę pliku",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Exibir o diff atual",
"editor.toolbar.openAi": "Abrir o painel de IA",
"editor.toolbar.closeAi": "Fechar o painel de IA",
"editor.toolbar.openTableOfContents": "Abrir índice",
"editor.toolbar.closeTableOfContents": "Fechar índice",
"editor.toolbar.restoreArchived": "Restaurar esta nota arquivada",
"editor.toolbar.archive": "Arquivar esta nota",
"editor.toolbar.delete": "Excluir esta nota",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Copiar caminho do arquivo",
"editor.toolbar.moreActions": "Mais ações da nota",
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
"tableOfContents.title": "Índice",
"tableOfContents.close": "Fechar índice",
"tableOfContents.empty": "Não há títulos nesta nota",
"tableOfContents.emptyNoNote": "Nenhuma nota selecionada",
"tableOfContents.navLabel": "Índice",
"tableOfContents.untitledHeading": "Título sem nome",
"tableOfContents.expandHeading": "Expandir {heading}",
"tableOfContents.collapseHeading": "Recolher {heading}",
"editor.codeBlock.copy": "Copiar código para a área de transferência",
"editor.imageLightbox.title": "Pré-visualização da imagem",
"editor.filename.rename": "Renomear nome do arquivo",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Mostrar o diff atual",
"editor.toolbar.openAi": "Abrir o painel de IA",
"editor.toolbar.closeAi": "Fechar o painel de IA",
"editor.toolbar.openTableOfContents": "Abrir índice",
"editor.toolbar.closeTableOfContents": "Fechar índice",
"editor.toolbar.restoreArchived": "Repor esta nota arquivada",
"editor.toolbar.archive": "Arquivar esta nota",
"editor.toolbar.delete": "Eliminar esta nota",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Copiar caminho do ficheiro",
"editor.toolbar.moreActions": "Mais ações da nota",
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
"tableOfContents.title": "Índice",
"tableOfContents.close": "Fechar índice",
"tableOfContents.empty": "Não há títulos nesta nota",
"tableOfContents.emptyNoNote": "Nenhuma nota selecionada",
"tableOfContents.navLabel": "Índice",
"tableOfContents.untitledHeading": "Título sem nome",
"tableOfContents.expandHeading": "Expandir {heading}",
"tableOfContents.collapseHeading": "Fechar {heading}",
"editor.codeBlock.copy": "Copiar código para a área de transferência",
"editor.imageLightbox.title": "Pré-visualização da imagem",
"editor.filename.rename": "Mudar o nome do ficheiro",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Показать текущий diff",
"editor.toolbar.openAi": "Открыть панель ИИ",
"editor.toolbar.closeAi": "Закрыть панель ИИ",
"editor.toolbar.openTableOfContents": "Открыть оглавление",
"editor.toolbar.closeTableOfContents": "Закрыть оглавление",
"editor.toolbar.restoreArchived": "Восстановить эту заархивированную заметку",
"editor.toolbar.archive": "Архивировать эту заметку",
"editor.toolbar.delete": "Удалить эту заметку",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Копировать путь к файлу",
"editor.toolbar.moreActions": "Другие действия с заметкой",
"editor.toolbar.openProperties": "Открыть панель свойств",
"tableOfContents.title": "Содержание",
"tableOfContents.close": "Закрыть оглавление",
"tableOfContents.empty": "В этой заметке нет заголовков",
"tableOfContents.emptyNoNote": "Ни одна заметка не выбрана",
"tableOfContents.navLabel": "Содержание",
"tableOfContents.untitledHeading": "Заголовок без названия",
"tableOfContents.expandHeading": "Развернуть {heading}",
"tableOfContents.collapseHeading": "Свернуть {heading}",
"editor.codeBlock.copy": "Скопировать код в буфер обмена",
"editor.imageLightbox.title": "Предварительный просмотр изображения",
"editor.filename.rename": "Переименовать файл",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "Hiển thị diff hiện tại",
"editor.toolbar.openAi": "Mở bảng AI",
"editor.toolbar.closeAi": "Đóng bảng AI",
"editor.toolbar.openTableOfContents": "Mở mục lục",
"editor.toolbar.closeTableOfContents": "Đóng mục lục",
"editor.toolbar.restoreArchived": "Khôi phục ghi chú đã lưu trữ này",
"editor.toolbar.archive": "Lưu trữ ghi chú này",
"editor.toolbar.delete": "Xóa ghi chú này",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "Sao chép đường dẫn tệp",
"editor.toolbar.moreActions": "Thêm hành động ghi chú",
"editor.toolbar.openProperties": "Mở bảng thuộc tính",
"tableOfContents.title": "Mục lục",
"tableOfContents.close": "Đóng mục lục",
"tableOfContents.empty": "Không có tiêu đề trong ghi chú này",
"tableOfContents.emptyNoNote": "Chưa chọn ghi chú nào",
"tableOfContents.navLabel": "Mục lục",
"tableOfContents.untitledHeading": "Tiêu đề chưa có tên",
"tableOfContents.expandHeading": "Mở rộng {heading}",
"tableOfContents.collapseHeading": "Thu gọn {heading}",
"editor.codeBlock.copy": "Copy code to clipboard",
"editor.imageLightbox.title": "Image preview",
"editor.filename.rename": "Đổi tên tệp",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "显示当前差异",
"editor.toolbar.openAi": "打开 AI 面板",
"editor.toolbar.closeAi": "关闭 AI 面板",
"editor.toolbar.openTableOfContents": "打开目录",
"editor.toolbar.closeTableOfContents": "关闭目录",
"editor.toolbar.restoreArchived": "恢复这条归档笔记",
"editor.toolbar.archive": "归档这条笔记",
"editor.toolbar.delete": "删除这条笔记",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "复制文件路径",
"editor.toolbar.moreActions": "更多笔记操作",
"editor.toolbar.openProperties": "打开属性面板",
"tableOfContents.title": "目录",
"tableOfContents.close": "关闭目录",
"tableOfContents.empty": "本笔记中没有标题",
"tableOfContents.emptyNoNote": "未选择任何笔记",
"tableOfContents.navLabel": "目录",
"tableOfContents.untitledHeading": "无标题标题",
"tableOfContents.expandHeading": "展开 {heading}",
"tableOfContents.collapseHeading": "折叠 {heading}",
"editor.codeBlock.copy": "将代码复制到剪贴板",
"editor.imageLightbox.title": "图像预览",
"editor.filename.rename": "重命名文件名",

View File

@@ -419,6 +419,8 @@
"editor.toolbar.showDiff": "顯示當前差異",
"editor.toolbar.openAi": "開啟 AI 面板",
"editor.toolbar.closeAi": "關閉 AI 面板",
"editor.toolbar.openTableOfContents": "開啟目錄",
"editor.toolbar.closeTableOfContents": "關閉目錄",
"editor.toolbar.restoreArchived": "恢復這條歸檔筆記",
"editor.toolbar.archive": "歸檔這條筆記",
"editor.toolbar.delete": "刪除這條筆記",
@@ -426,6 +428,14 @@
"editor.toolbar.copyFilePath": "複製檔案路徑",
"editor.toolbar.moreActions": "更多筆記操作",
"editor.toolbar.openProperties": "開啟屬性面板",
"tableOfContents.title": "目錄",
"tableOfContents.close": "關閉目錄",
"tableOfContents.empty": "此筆記中沒有標題",
"tableOfContents.emptyNoNote": "未選取任何筆記",
"tableOfContents.navLabel": "目錄",
"tableOfContents.untitledHeading": "無標題標題",
"tableOfContents.expandHeading": "展開 {heading}",
"tableOfContents.collapseHeading": "折疊 {heading}",
"editor.codeBlock.copy": "複製代碼至剪貼簿",
"editor.imageLightbox.title": "圖片預覽",
"editor.filename.rename": "重新命名檔名",

View File

@@ -0,0 +1,79 @@
[
{
"kind": "ollama",
"name": "Ollama",
"label_key": "settings.aiProviders.kind.ollama",
"base_url": "http://localhost:11434/v1",
"runtime_base_url": "http://localhost:11434/v1",
"default_model_id": "llama3.2",
"api_key_storage": "none",
"api_key_env_var": null,
"local": true
},
{
"kind": "lm_studio",
"name": "LM Studio",
"label_key": "settings.aiProviders.kind.lmStudio",
"base_url": "http://127.0.0.1:1234/v1",
"runtime_base_url": "http://127.0.0.1:1234/v1",
"default_model_id": "llama3.2",
"api_key_storage": "none",
"api_key_env_var": null,
"local": true
},
{
"kind": "open_ai",
"name": "OpenAI",
"label_key": "settings.aiProviders.kind.openAi",
"base_url": "https://api.openai.com/v1",
"runtime_base_url": "https://api.openai.com/v1",
"default_model_id": "gpt-4.1-mini",
"api_key_storage": "local_file",
"api_key_env_var": "OPENAI_API_KEY",
"local": false
},
{
"kind": "anthropic",
"name": "Anthropic",
"label_key": "settings.aiProviders.kind.anthropic",
"base_url": "https://api.anthropic.com/v1",
"runtime_base_url": "https://api.anthropic.com/v1",
"default_model_id": "claude-3-5-sonnet-latest",
"api_key_storage": "local_file",
"api_key_env_var": "ANTHROPIC_API_KEY",
"local": false
},
{
"kind": "gemini",
"name": "Gemini",
"label_key": "settings.aiProviders.kind.gemini",
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"runtime_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"default_model_id": "gemini-2.5-flash",
"api_key_storage": "local_file",
"api_key_env_var": "GEMINI_API_KEY",
"local": false
},
{
"kind": "open_router",
"name": "OpenRouter",
"label_key": "settings.aiProviders.kind.openRouter",
"base_url": "https://openrouter.ai/api/v1",
"runtime_base_url": "https://openrouter.ai/api/v1",
"default_model_id": "openai/gpt-4.1-mini",
"api_key_storage": "local_file",
"api_key_env_var": "OPENROUTER_API_KEY",
"local": false
},
{
"kind": "open_ai_compatible",
"name": "Custom provider",
"label_key": "settings.aiProviders.kind.compatible",
"base_url": "https://api.example.com/v1",
"runtime_base_url": null,
"default_model_id": "gpt-4.1-mini",
"api_key_storage": "local_file",
"api_key_env_var": "OPENAI_API_KEY",
"local": false
}
]

View File

@@ -0,0 +1,293 @@
export interface InlineItem {
type: string
text?: string
props?: Record<string, string>
content?: unknown
[key: string]: unknown
}
export interface BlockLike {
type?: string
content?: InlineItem[]
props?: Record<string, string>
children?: BlockLike[]
[key: string]: unknown
}
export interface MarkdownSerializer {
blocksToMarkdownLossy: (blocks: unknown[]) => string
}
export interface DurableFencePayloadInput {
lines: string[]
start: number
end: number
metadata: unknown
}
export interface DurableBlockCodec {
tokenPrefix: string
tokenSuffix: string
readFenceMetadata: (info: string) => unknown | null
buildPayload: (input: DurableFencePayloadInput) => unknown
decodePayload: (payload: unknown) => unknown | null
buildBlock: (block: BlockLike, payload: unknown) => BlockLike
readCodeBlock?: (block: BlockLike) => unknown | null
isBlock: (block: BlockLike) => boolean
serializeBlock: (block: BlockLike) => string
}
type FenceCharacter = '`' | '~'
interface MarkdownLine {
line: string
}
interface FenceOpening {
character: FenceCharacter
length: number
metadata: unknown
}
interface MatchedFenceOpening {
codec: DurableBlockCodec
opening: FenceOpening
}
interface FenceSearch {
lines: string[]
start: number
opening: FenceOpening
}
interface SerializeDurableBlocksOptions {
blocks: unknown[]
codecs: readonly DurableBlockCodec[]
serializeOrdinaryBlocks: (blocks: unknown[]) => string
}
export function lineEnding({ line }: MarkdownLine): string {
if (line.endsWith('\r\n')) return '\r\n'
return line.endsWith('\n') ? '\n' : ''
}
export function lineText({ line }: MarkdownLine): string {
const ending = lineEnding({ line })
return ending ? line.slice(0, -ending.length) : line
}
function splitMarkdownLines(markdown: string): string[] {
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? []
return lines.filter((line, index) => line !== '' || index < lines.length - 1)
}
function encodePayload(payload: unknown): string {
return encodeURIComponent(JSON.stringify(payload))
}
function decodePayload(codec: DurableBlockCodec, encoded: string): unknown | null {
try {
return codec.decodePayload(JSON.parse(decodeURIComponent(encoded)))
} catch {
return null
}
}
function durableToken(codec: DurableBlockCodec, payload: unknown): string {
return `${codec.tokenPrefix}${encodePayload(payload)}${codec.tokenSuffix}`
}
function readDurableToken(codec: DurableBlockCodec, text: string): unknown | null {
const trimmed = text.trim()
if (!trimmed.startsWith(codec.tokenPrefix) || !trimmed.endsWith(codec.tokenSuffix)) return null
return decodePayload(codec, trimmed.slice(codec.tokenPrefix.length, -codec.tokenSuffix.length))
}
function readFenceOpening(line: string, codec: DurableBlockCodec): FenceOpening | null {
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/.exec(line)
if (!match) return null
const metadata = codec.readFenceMetadata(match[3])
if (metadata === null) return null
const fence = match[2]
return {
character: fence[0] as FenceCharacter,
length: fence.length,
metadata,
}
}
function readMatchedFenceOpening(line: string, codecs: readonly DurableBlockCodec[]): MatchedFenceOpening | null {
for (const codec of codecs) {
const opening = readFenceOpening(line, codec)
if (opening) return { codec, opening }
}
return null
}
function isClosingFence({ line, opening }: MarkdownLine & { opening: FenceOpening }): boolean {
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line)
if (!match) return false
const fence = match[2]
return fence[0] === opening.character && fence.length >= opening.length
}
function findClosingFence({ lines, start, opening }: FenceSearch): number {
for (let index = start + 1; index < lines.length; index++) {
if (isClosingFence({ line: lineText({ line: lines[index] }), opening })) return index
}
return -1
}
export function preProcessDurableMarkdownBlocks({
markdown,
codecs,
}: {
markdown: string
codecs: readonly DurableBlockCodec[]
}): string {
const lines = splitMarkdownLines(markdown)
const result: string[] = []
for (let index = 0; index < lines.length; index++) {
const matched = readMatchedFenceOpening(lineText({ line: lines[index] }), codecs)
if (!matched) {
result.push(lines[index])
continue
}
const closingIndex = findClosingFence({ lines, start: index, opening: matched.opening })
if (closingIndex === -1) {
result.push(lines[index])
continue
}
const payload = matched.codec.buildPayload({
lines,
start: index,
end: closingIndex,
metadata: matched.opening.metadata,
})
result.push(`${durableToken(matched.codec, payload)}${lineEnding({ line: lines[closingIndex] })}`)
index = closingIndex
}
return result.join('')
}
function readSingleTextContent(content: InlineItem[] | undefined): string | null {
const onlyItem = content?.length === 1 ? content[0] : null
if (onlyItem?.type !== 'text' || typeof onlyItem.text !== 'string') return null
return onlyItem.text
}
function readTokenPayload(block: BlockLike, codecs: readonly DurableBlockCodec[]): { codec: DurableBlockCodec; payload: unknown } | null {
const text = readSingleTextContent(block.content)
if (text === null) return null
for (const codec of codecs) {
const payload = readDurableToken(codec, text)
if (payload !== null) return { codec, payload }
}
return null
}
function readCodeBlockPayload(block: BlockLike, codecs: readonly DurableBlockCodec[]): { codec: DurableBlockCodec; payload: unknown } | null {
for (const codec of codecs) {
const payload = codec.readCodeBlock?.(block) ?? null
if (payload !== null) return { codec, payload }
}
return null
}
function injectDurableMarkdownBlock(block: BlockLike, codecs: readonly DurableBlockCodec[]): BlockLike {
const tokenPayload = readTokenPayload(block, codecs)
if (tokenPayload) return tokenPayload.codec.buildBlock(block, tokenPayload.payload)
const codeBlockPayload = readCodeBlockPayload(block, codecs)
if (codeBlockPayload) return codeBlockPayload.codec.buildBlock(block, codeBlockPayload.payload)
const children = Array.isArray(block.children)
? block.children.map(child => injectDurableMarkdownBlock(child, codecs))
: block.children
return { ...block, children }
}
export function injectDurableMarkdownBlocks({
blocks,
codecs,
}: {
blocks: unknown[]
codecs: readonly DurableBlockCodec[]
}): unknown[] {
return (blocks as BlockLike[]).map(block => injectDurableMarkdownBlock(block, codecs))
}
function findBlockCodec(block: BlockLike, codecs: readonly DurableBlockCodec[]): DurableBlockCodec | null {
return codecs.find(codec => codec.isBlock(block)) ?? null
}
function hasDurableMarkdownBlock(block: BlockLike, codecs: readonly DurableBlockCodec[]): boolean {
if (findBlockCodec(block, codecs)) return true
return Array.isArray(block.children)
? block.children.some(child => hasDurableMarkdownBlock(child, codecs))
: false
}
export function hasDurableMarkdownBlocks({
blocks,
codecs,
}: {
blocks: unknown[]
codecs: readonly DurableBlockCodec[]
}): boolean {
return (blocks as BlockLike[]).some(block => hasDurableMarkdownBlock(block, codecs))
}
export function serializeDurableMarkdownBlocks({
blocks,
codecs,
serializeOrdinaryBlocks,
}: SerializeDurableBlocksOptions): string {
const chunks: string[] = []
let pending: unknown[] = []
const flushPending = () => {
if (pending.length === 0) return
const markdown = serializeOrdinaryBlocks(pending).trimEnd()
if (markdown) chunks.push(markdown)
pending = []
}
for (const block of blocks as BlockLike[]) {
const codec = findBlockCodec(block, codecs)
if (!codec) {
pending.push(block)
continue
}
flushPending()
chunks.push(codec.serializeBlock(block))
}
flushPending()
return chunks.join('\n\n')
}
export function readCodeBlockLanguage({ block }: { block: BlockLike }): string | null {
const language = block.props?.language
if (typeof language !== 'string') return null
return language.trim().split(/\s+/u)[0]?.toLowerCase() ?? null
}
export function readInlineText(content: InlineItem[] | undefined): string | null {
if (!Array.isArray(content)) return null
return content.map((item) => (
item.type === 'text' && typeof item.text === 'string' ? item.text : ''
)).join('')
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'
import {
injectDurableEditorMarkdownBlocks,
preProcessDurableEditorMarkdown,
serializeDurableEditorBlocks,
} from './editorDurableMarkdown'
import { MERMAID_BLOCK_TYPE } from './mermaidMarkdown'
import { TLDRAW_BLOCK_TYPE } from './tldrawMarkdown'
describe('editor durable markdown blocks', () => {
it('round-trips Mermaid and tldraw blocks through one durable pipeline', () => {
const markdown = [
'Intro',
'',
'```tldraw id="map" height="640" width="900"',
'{ "store": {} }',
'```',
'',
'```mermaid',
'flowchart LR',
' A --> B',
'```',
].join('\n')
const preprocessed = preProcessDurableEditorMarkdown({ markdown })
const blocks = injectDurableEditorMarkdownBlocks([
{ type: 'paragraph', content: [{ type: 'text', text: 'Intro', styles: {} }], children: [] },
{ type: 'paragraph', content: [{ type: 'text', text: preprocessed.split('\n\n')[1], styles: {} }], children: [] },
{ type: 'paragraph', content: [{ type: 'text', text: preprocessed.split('\n\n')[2], styles: {} }], children: [] },
]) as Array<{ type: string; props?: Record<string, string>; content?: Array<{ text?: string }> }>
expect(blocks.map(block => block.type)).toEqual(['paragraph', TLDRAW_BLOCK_TYPE, MERMAID_BLOCK_TYPE])
expect(blocks[1].props).toMatchObject({ boardId: 'map', height: '640', snapshot: '{ "store": {} }', width: '900' })
expect(blocks[2].props).toMatchObject({ diagram: 'flowchart LR\n A --> B\n' })
const editor = {
blocksToMarkdownLossy: vi.fn((ordinaryBlocks: unknown[]) => {
return (ordinaryBlocks as Array<{ content?: Array<{ text?: string }> }>)
.map(block => block.content?.map(item => item.text ?? '').join('') ?? '')
.join('\n\n')
}),
}
expect(serializeDurableEditorBlocks(editor, blocks)).toBe(markdown)
})
})

View File

@@ -0,0 +1,44 @@
import {
hasDurableMarkdownBlocks,
injectDurableMarkdownBlocks,
preProcessDurableMarkdownBlocks,
serializeDurableMarkdownBlocks,
type MarkdownSerializer,
} from './durableMarkdownBlocks'
import { serializeMathAwareBlocks } from './mathMarkdown'
import { mermaidMarkdownCodec } from './mermaidMarkdown'
import { tldrawMarkdownCodec } from './tldrawMarkdown'
const EDITOR_DURABLE_MARKDOWN_CODECS = [
mermaidMarkdownCodec,
tldrawMarkdownCodec,
] as const
export function preProcessDurableEditorMarkdown({ markdown }: { markdown: string }): string {
return preProcessDurableMarkdownBlocks({
markdown,
codecs: EDITOR_DURABLE_MARKDOWN_CODECS,
})
}
export function injectDurableEditorMarkdownBlocks(blocks: unknown[]): unknown[] {
return injectDurableMarkdownBlocks({
blocks,
codecs: EDITOR_DURABLE_MARKDOWN_CODECS,
})
}
export function serializeDurableEditorBlocks(editor: MarkdownSerializer, blocks: unknown[]): string {
return serializeDurableMarkdownBlocks({
blocks,
codecs: EDITOR_DURABLE_MARKDOWN_CODECS,
serializeOrdinaryBlocks: ordinaryBlocks => serializeMathAwareBlocks(editor, ordinaryBlocks),
})
}
export function hasDurableEditorBlocks(blocks: unknown[]): boolean {
return hasDurableMarkdownBlocks({
blocks,
codecs: EDITOR_DURABLE_MARKDOWN_CODECS,
})
}

View File

@@ -3,8 +3,8 @@ import {
MERMAID_BLOCK_TYPE,
injectMermaidInBlocks,
preProcessMermaidMarkdown,
serializeMermaidAwareBlocks,
} from './mermaidMarkdown'
import { serializeDurableEditorBlocks } from './editorDurableMarkdown'
import { TLDRAW_BLOCK_TYPE } from './tldrawMarkdown'
describe('mermaid markdown round-trip', () => {
@@ -49,7 +49,7 @@ describe('mermaid markdown round-trip', () => {
{ type: MERMAID_BLOCK_TYPE, props: { source: secondSource, diagram: 'sequenceDiagram\nAlice->>Bob: Hi\n' }, children: [] },
]
expect(serializeMermaidAwareBlocks(editor, blocks)).toBe([
expect(serializeDurableEditorBlocks(editor, blocks)).toBe([
'Intro',
firstSource,
'Between',
@@ -129,7 +129,7 @@ describe('mermaid markdown round-trip', () => {
children: [],
}]
expect(serializeMermaidAwareBlocks(editor, blocks)).toBe(
expect(serializeDurableEditorBlocks(editor, blocks)).toBe(
'```mermaid\nflowchart LR\nA --> B\n```',
)
})
@@ -148,7 +148,7 @@ describe('mermaid markdown round-trip', () => {
{ type: MERMAID_BLOCK_TYPE, props: { source: '', diagram: 'flowchart LR\nA --> B' }, children: [] },
]
expect(serializeMermaidAwareBlocks(editor, blocks)).toBe([
expect(serializeDurableEditorBlocks(editor, blocks)).toBe([
'Intro',
'```tldraw id="map" height="640" width="900"\n{ "store": {} }\n```',
'```mermaid\nflowchart LR\nA --> B\n```',

View File

@@ -1,182 +1,51 @@
import { serializeMathAwareBlocks } from './mathMarkdown'
import { isTldrawBlock, tldrawMarkdown } from './tldrawMarkdown'
import {
type BlockLike,
type DurableBlockCodec,
type DurableFencePayloadInput,
injectDurableMarkdownBlocks,
preProcessDurableMarkdownBlocks,
readCodeBlockLanguage,
readInlineText,
} from './durableMarkdownBlocks'
export const MERMAID_BLOCK_TYPE = 'mermaidBlock'
const TOKEN_PREFIX = '@@TOLARIA_MERMAID_BLOCK:'
const TOKEN_SUFFIX = '@@'
interface InlineItem {
type: string
text?: string
props?: Record<string, string>
content?: unknown
[key: string]: unknown
}
interface BlockLike {
type?: string
content?: InlineItem[]
props?: Record<string, string>
children?: BlockLike[]
[key: string]: unknown
}
interface MarkdownSerializer {
blocksToMarkdownLossy: (blocks: unknown[]) => string
}
interface MermaidPayload {
source: string
diagram: string
}
interface MermaidFenceStart {
character: '`' | '~'
length: number
}
interface MarkdownLine {
line: string
}
interface EncodedPayload {
encoded: string
}
interface TokenText {
text: string
}
interface FenceSearch {
lines: string[]
start: number
opening: MermaidFenceStart
}
interface FenceRange {
lines: string[]
start: number
end: number
}
interface DiagramSource {
diagram: string
}
interface CodeBlockSource {
block: BlockLike
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function lineEnding({ line }: MarkdownLine): string {
if (line.endsWith('\r\n')) return '\r\n'
return line.endsWith('\n') ? '\n' : ''
function decodeMermaidPayload(payload: unknown): MermaidPayload | null {
if (!isRecord(payload)) return null
if (typeof payload.source !== 'string') return null
if (typeof payload.diagram !== 'string') return null
return { source: payload.source, diagram: payload.diagram }
}
function lineText({ line }: MarkdownLine): string {
const ending = lineEnding({ line })
return ending ? line.slice(0, -ending.length) : line
function readMermaidFenceMetadata(info: string): Record<string, never> | null {
const language = info.trim().split(/\s+/u)[0]?.toLowerCase()
return language === 'mermaid' ? {} : null
}
function splitMarkdownLines(markdown: string): string[] {
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? []
return lines.filter((line, index) => line !== '' || index < lines.length - 1)
}
function encodePayload(payload: MermaidPayload): string {
return encodeURIComponent(JSON.stringify(payload))
}
function decodePayload({ encoded }: EncodedPayload): MermaidPayload | null {
try {
const payload = JSON.parse(decodeURIComponent(encoded)) as Partial<MermaidPayload>
if (typeof payload.source !== 'string') return null
if (typeof payload.diagram !== 'string') return null
return { source: payload.source, diagram: payload.diagram }
} catch {
return null
}
}
function mermaidToken(payload: MermaidPayload): string {
return `${TOKEN_PREFIX}${encodePayload(payload)}${TOKEN_SUFFIX}`
}
function readMermaidToken({ text }: TokenText): MermaidPayload | null {
const trimmed = text.trim()
if (!trimmed.startsWith(TOKEN_PREFIX) || !trimmed.endsWith(TOKEN_SUFFIX)) return null
return decodePayload({ encoded: trimmed.slice(TOKEN_PREFIX.length, -TOKEN_SUFFIX.length) })
}
function readMermaidFenceStart({ line }: MarkdownLine): MermaidFenceStart | null {
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/.exec(line)
if (!match) return null
const fence = match[2]
const language = match[3].trim().split(/\s+/)[0]?.toLowerCase()
if (language !== 'mermaid') return null
return {
character: fence[0] as '`' | '~',
length: fence.length,
}
}
function isClosingFence({ line, opening }: MarkdownLine & { opening: MermaidFenceStart }): boolean {
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line)
if (!match) return false
const fence = match[2]
return fence[0] === opening.character && fence.length >= opening.length
}
function findClosingFence({ lines, start, opening }: FenceSearch): number {
for (let index = start + 1; index < lines.length; index++) {
if (isClosingFence({ line: lineText({ line: lines[index] }), opening })) return index
}
return -1
}
function buildPayload({ lines, start, end }: FenceRange): MermaidPayload {
function buildMermaidPayload({ lines, start, end }: DurableFencePayloadInput): MermaidPayload {
return {
source: lines.slice(start, end + 1).join(''),
diagram: lines.slice(start + 1, end).join(''),
}
}
export function preProcessMermaidMarkdown({ markdown }: { markdown: string }): string {
const lines = splitMarkdownLines(markdown)
const result: string[] = []
for (let index = 0; index < lines.length; index++) {
const opening = readMermaidFenceStart({ line: lineText({ line: lines[index] }) })
if (!opening) {
result.push(lines[index])
continue
}
const closingIndex = findClosingFence({ lines, start: index, opening })
if (closingIndex === -1) {
result.push(lines[index])
continue
}
const payload = buildPayload({ lines, start: index, end: closingIndex })
result.push(`${mermaidToken(payload)}${lineEnding({ line: lines[closingIndex] })}`)
index = closingIndex
}
return result.join('')
}
function readMermaidPayload(content: InlineItem[] | undefined): MermaidPayload | null {
const onlyItem = content?.length === 1 ? content[0] : null
if (onlyItem?.type !== 'text' || typeof onlyItem.text !== 'string') return null
return readMermaidToken({ text: onlyItem.text })
}
function buildMermaidBlock({ block, payload }: { block: BlockLike; payload: MermaidPayload }): BlockLike {
function buildMermaidBlock(block: BlockLike, payload: MermaidPayload): BlockLike {
return {
...block,
type: MERMAID_BLOCK_TYPE,
@@ -195,23 +64,9 @@ export function mermaidFenceSource({ diagram }: DiagramSource): string {
return `\`\`\`mermaid\n${body}\`\`\``
}
function readCodeBlockLanguage({ block }: CodeBlockSource): string | null {
const language = block.props?.language
if (typeof language !== 'string') return null
return language.trim().split(/\s+/)[0]?.toLowerCase() ?? null
}
function readInlineText(content: InlineItem[] | undefined): string | null {
if (!Array.isArray(content)) return null
return content.map((item) => (
item.type === 'text' && typeof item.text === 'string' ? item.text : ''
)).join('')
}
function looksLikeMermaidDiagram(diagram: string): boolean {
const firstStatement = diagram
.split(/\r?\n/)
.split(/\r?\n/u)
.map(line => line.trim())
.find(line => line.length > 0 && !line.startsWith('%%'))
@@ -232,7 +87,7 @@ function shouldInjectCodeBlockAsMermaid({
return looksLikeMermaidDiagram(diagram)
}
function readMermaidCodeBlock({ block }: CodeBlockSource): MermaidPayload | null {
function readMermaidCodeBlock(block: BlockLike): MermaidPayload | null {
if (block.type !== 'codeBlock') return null
const diagram = readInlineText(block.content)
@@ -246,17 +101,6 @@ function readMermaidCodeBlock({ block }: CodeBlockSource): MermaidPayload | null
}
}
function injectMermaidInBlock(block: BlockLike): BlockLike {
const payload = readMermaidPayload(block.content)
if (payload) return buildMermaidBlock({ block, payload })
const codeBlockPayload = readMermaidCodeBlock({ block })
if (codeBlockPayload) return buildMermaidBlock({ block, payload: codeBlockPayload })
const children = Array.isArray(block.children) ? block.children.map(injectMermaidInBlock) : block.children
return { ...block, children }
}
function isMermaidBlock(block: BlockLike): boolean {
return block.type === MERMAID_BLOCK_TYPE
&& typeof block.props?.source === 'string'
@@ -270,34 +114,22 @@ function mermaidMarkdown(block: BlockLike): string {
return mermaidFenceSource({ diagram: block.props?.diagram ?? '' })
}
export const mermaidMarkdownCodec: DurableBlockCodec = {
tokenPrefix: TOKEN_PREFIX,
tokenSuffix: TOKEN_SUFFIX,
readFenceMetadata: readMermaidFenceMetadata,
buildPayload: buildMermaidPayload,
decodePayload: decodeMermaidPayload,
buildBlock: (block, payload) => buildMermaidBlock(block, payload as MermaidPayload),
readCodeBlock: readMermaidCodeBlock,
isBlock: isMermaidBlock,
serializeBlock: mermaidMarkdown,
}
export function preProcessMermaidMarkdown({ markdown }: { markdown: string }): string {
return preProcessDurableMarkdownBlocks({ markdown, codecs: [mermaidMarkdownCodec] })
}
export function injectMermaidInBlocks(blocks: unknown[]): unknown[] {
return (blocks as BlockLike[]).map(injectMermaidInBlock)
}
export function serializeMermaidAwareBlocks(editor: MarkdownSerializer, blocks: unknown[]): string {
const chunks: string[] = []
let pending: unknown[] = []
const flushPending = () => {
if (pending.length === 0) return
const markdown = serializeMathAwareBlocks(editor, pending).trimEnd()
if (markdown) chunks.push(markdown)
pending = []
}
for (const block of blocks as BlockLike[]) {
if (isMermaidBlock(block)) {
flushPending()
chunks.push(mermaidMarkdown(block))
} else if (isTldrawBlock(block)) {
flushPending()
chunks.push(tldrawMarkdown(block))
} else {
pending.push(block)
}
}
flushPending()
return chunks.join('\n\n')
return injectDurableMarkdownBlocks({ blocks, codecs: [mermaidMarkdownCodec] })
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
findByCollidingNotePath,
joinVaultPath,
normalizeVaultRelativePath,
notePathFilename,
notePathsCollide,
notePathsMatch,
vaultRelativePathLabel,
} from './notePathIdentity'
describe('notePathIdentity', () => {
it('matches macOS /tmp aliases and separator variants without folding case', () => {
expect(notePathsMatch('/private/tmp/vault/Project\\Active.md', '/tmp/vault/Project/Active.md')).toBe(true)
expect(notePathsMatch('/tmp/vault/Project.md', '/tmp/vault/project.md')).toBe(false)
})
it('uses case-insensitive comparison only for collision checks', () => {
expect(notePathsCollide('/private/tmp/vault/Project.md', '/tmp/vault/project.md')).toBe(true)
expect(findByCollidingNotePath([{ path: '/tmp/vault/project.md' }], '/private/tmp/vault/Project.md')).toEqual({
path: '/tmp/vault/project.md',
})
})
it('normalizes relative folder paths and labels', () => {
expect(normalizeVaultRelativePath(String.raw`/projects\active/`)).toBe('projects/active')
expect(vaultRelativePathLabel(String.raw`/projects\active/`)).toBe('active')
})
it('joins vault paths without changing Windows verbatim roots', () => {
const vaultPath = String.raw`\\?\C:\Users\alex\Tolaria`
expect(joinVaultPath(vaultPath, 'note.md')).toBe(String.raw`\\?\C:\Users\alex\Tolaria/note.md`)
})
it('extracts filenames from slash or backslash paths', () => {
expect(notePathFilename(String.raw`C:\Users\alex\Tolaria\note.md`)).toBe('note.md')
})
})

View File

@@ -0,0 +1,67 @@
export type NotePath = string
export type VaultPath = string
export type VaultRelativePath = string
type PathLike = NotePath | null | undefined
type ItemWithNotePath = { path: NotePath }
function stripWindowsExtendedPathPrefix(path: NotePath): NotePath {
return path
.replace(/^\\\\\?\\UNC\\/iu, '//')
.replace(/^\\\\\?\\/u, '')
}
export function normalizeNotePathSeparators(path: NotePath): NotePath {
return stripWindowsExtendedPathPrefix(path).replaceAll('\\', '/')
}
export function normalizeNotePathForIdentity(path: NotePath): NotePath {
return normalizeNotePathSeparators(path)
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
.replace(/\/+$/u, '')
}
export function normalizeNotePathForCollision(path: NotePath): NotePath {
return normalizeNotePathForIdentity(path).toLocaleLowerCase()
}
export function notePathsMatch(leftPath: PathLike, rightPath: PathLike): boolean {
if (!leftPath || !rightPath) return false
return normalizeNotePathForIdentity(leftPath) === normalizeNotePathForIdentity(rightPath)
}
export function notePathsCollide(leftPath: PathLike, rightPath: PathLike): boolean {
if (!leftPath || !rightPath) return false
return normalizeNotePathForCollision(leftPath) === normalizeNotePathForCollision(rightPath)
}
export function findByNotePath<T extends ItemWithNotePath>(items: T[], path: PathLike): T | undefined {
if (!path) return undefined
return items.find((item) => notePathsMatch(item.path, path))
}
export function findByCollidingNotePath<T extends ItemWithNotePath>(items: T[], path: PathLike): T | undefined {
if (!path) return undefined
return items.find((item) => notePathsCollide(item.path, path))
}
export function notePathFilename(path: NotePath): string {
const normalized = normalizeNotePathSeparators(path).replace(/\/+$/u, '')
return normalized.split('/').filter(Boolean).at(-1) ?? normalized
}
export function normalizeVaultRelativePath(path: VaultRelativePath): VaultRelativePath {
return normalizeNotePathSeparators(path.trim()).replace(/^\/+|\/+$/gu, '')
}
export function vaultRelativePathLabel(path: VaultRelativePath): string {
const normalized = normalizeVaultRelativePath(path)
return normalized.split('/').filter(Boolean).at(-1) ?? normalized
}
export function joinVaultPath(vaultPath: VaultPath, relativePath: VaultRelativePath): NotePath {
const root = vaultPath.replace(/[\\/]+$/u, '') || '/'
const child = normalizeVaultRelativePath(relativePath)
if (!child) return root
return root === '/' ? `/${child}` : `${root}/${child}`
}

View File

@@ -1,4 +1,5 @@
import type { VaultEntry } from '../types'
import { findByNotePath, joinVaultPath, normalizeNotePathForIdentity, notePathsMatch } from './notePathIdentity'
interface PulledVaultRefreshOptions {
activeTabPath: string | null
@@ -13,25 +14,17 @@ interface PulledVaultRefreshOptions {
vaultPath: string
}
function normalizePath(path: string): string {
return path
.replaceAll('\\', '/')
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
.replace(/\/+$/u, '')
}
function resolveUpdatedFilePath(path: string, vaultPath: string): string {
if (path.startsWith('/')) return normalizePath(path)
return normalizePath(`${vaultPath}/${path}`)
if (path.startsWith('/')) return normalizeNotePathForIdentity(path)
return normalizeNotePathForIdentity(joinVaultPath(vaultPath, path))
}
function didPullUpdateActiveNote(updatedFiles: string[], vaultPath: string, activeTabPath: string): boolean {
const normalizedActivePath = normalizePath(activeTabPath)
return updatedFiles.some((path) => resolveUpdatedFilePath(path, vaultPath) === normalizedActivePath)
return updatedFiles.some((path) => notePathsMatch(resolveUpdatedFilePath(path, vaultPath), activeTabPath))
}
function didActivePathChange(initialPath: string, latestPath: string): boolean {
return normalizePath(initialPath) !== normalizePath(latestPath)
return !notePathsMatch(initialPath, latestPath)
}
export async function refreshPulledVaultState(options: PulledVaultRefreshOptions): Promise<VaultEntry[]> {
@@ -59,7 +52,7 @@ export async function refreshPulledVaultState(options: PulledVaultRefreshOptions
if (didActivePathChange(activeTabPath, latestActiveTabPath)) return entries
if (hasUnsavedChanges(latestActiveTabPath)) return entries
const refreshedEntry = entries.find(entry => normalizePath(entry.path) === normalizePath(latestActiveTabPath))
const refreshedEntry = findByNotePath(entries, latestActiveTabPath)
if (!refreshedEntry) {
closeAllTabs()
return entries

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import { extractTableOfContents } from './tableOfContents'
describe('extractTableOfContents', () => {
it('builds a nested outline from BlockNote heading blocks', () => {
const blocks = [
{
id: 'intro',
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Intro' },
{ type: 'text', text: ' overview' },
],
},
{
id: 'body',
type: 'paragraph',
content: [{ type: 'text', text: 'Ignored paragraph' }],
},
{
id: 'scope',
type: 'heading',
props: { level: 2 },
content: [{ type: 'text', text: 'Scope' }],
},
{
id: 'detail',
type: 'heading',
props: { level: 3 },
content: [{ type: 'link', content: [{ type: 'text', text: 'Deep link' }] }],
},
{
id: 'next',
type: 'heading',
props: { level: 2 },
content: 'Next steps',
},
]
expect(extractTableOfContents(blocks)).toEqual([
{
id: 'intro',
level: 1,
text: 'Intro overview',
children: [
{
id: 'scope',
level: 2,
text: 'Scope',
children: [
{
id: 'detail',
level: 3,
text: 'Deep link',
children: [],
},
],
},
{
id: 'next',
level: 2,
text: 'Next steps',
children: [],
},
],
},
])
})
it('keeps nested BlockNote children in document order', () => {
const blocks = [
{
id: 'parent',
type: 'paragraph',
children: [
{
id: 'child-heading',
type: 'heading',
props: { level: '2' },
content: [{ type: 'text', text: 'Child heading' }],
},
],
},
{
id: 'top-heading',
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: 'Top heading' }],
},
]
expect(extractTableOfContents(blocks).map((item) => item.id)).toEqual([
'child-heading',
'top-heading',
])
})
it('ignores malformed headings without stable ids or levels', () => {
expect(extractTableOfContents([
{ id: '', type: 'heading', props: { level: 1 }, content: 'No id' },
{ id: 'bad-level', type: 'heading', props: { level: 9 }, content: 'Bad level' },
{ id: 'ok', type: 'heading', props: { level: 2 }, content: [] },
])).toEqual([
{
id: 'ok',
level: 2,
text: '',
children: [],
},
])
})
})

View File

@@ -0,0 +1,124 @@
export interface TableOfContentsItem {
id: string
level: number
text: string
children: TableOfContentsItem[]
}
type UnknownRecord = Record<string, unknown>
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readString(record: UnknownRecord, key: string): string | null {
const value = record[key]
return typeof value === 'string' ? value : null
}
function readChildren(record: UnknownRecord): unknown[] {
const value = record.children
return Array.isArray(value) ? value : []
}
function normalizeText(value: string): string {
return value.replace(/\s+/g, ' ').trim()
}
function collectTextSegments(value: unknown, segments: string[]) {
if (typeof value === 'string') {
segments.push(value)
return
}
if (Array.isArray(value)) {
for (const item of value) collectTextSegments(item, segments)
return
}
if (!isRecord(value)) return
const text = readString(value, 'text')
if (text !== null) segments.push(text)
collectTextSegments(value.content, segments)
collectTextSegments(value.children, segments)
}
function extractInlineText(content: unknown): string {
const segments: string[] = []
collectTextSegments(content, segments)
return normalizeText(segments.join(' '))
}
function parseHeadingLevel(rawLevel: unknown): number | null {
const level =
typeof rawLevel === 'string'
? Number(rawLevel)
: typeof rawLevel === 'number'
? rawLevel
: Number.NaN
if (!Number.isInteger(level)) return null
if (level < 1 || level > 6) return null
return level
}
function headingLevel(block: UnknownRecord): number | null {
if (readString(block, 'type') !== 'heading') return null
const props = isRecord(block.props) ? block.props : {}
return parseHeadingLevel(props.level)
}
function buildHeadingItem(block: UnknownRecord): TableOfContentsItem | null {
const id = readString(block, 'id')
const level = headingLevel(block)
if (!id || level === null) return null
return {
id,
level,
text: extractInlineText(block.content),
children: [],
}
}
function collectHeadingItems(blocks: unknown): TableOfContentsItem[] {
if (!Array.isArray(blocks)) return []
const items: TableOfContentsItem[] = []
for (const block of blocks) {
if (!isRecord(block)) continue
const heading = buildHeadingItem(block)
if (heading) items.push(heading)
items.push(...collectHeadingItems(readChildren(block)))
}
return items
}
function appendHeadingItem(
roots: TableOfContentsItem[],
stack: TableOfContentsItem[],
item: TableOfContentsItem,
) {
while (stack.length > 0 && stack[stack.length - 1].level >= item.level) {
stack.pop()
}
const parent = stack[stack.length - 1]
if (parent) {
parent.children.push(item)
} else {
roots.push(item)
}
stack.push(item)
}
export function extractTableOfContents(blocks: unknown): TableOfContentsItem[] {
const roots: TableOfContentsItem[] = []
const stack: TableOfContentsItem[] = []
for (const item of collectHeadingItems(blocks)) {
appendHeadingItem(roots, stack, item)
}
return roots
}

View File

@@ -1,25 +1,19 @@
import {
type BlockLike,
type DurableBlockCodec,
type DurableFencePayloadInput,
injectDurableMarkdownBlocks,
preProcessDurableMarkdownBlocks,
readCodeBlockLanguage,
readInlineText,
} from './durableMarkdownBlocks'
export const TLDRAW_BLOCK_TYPE = 'tldrawBlock'
export const TLDRAW_DEFAULT_HEIGHT = '520'
const TOKEN_PREFIX = '@@TOLARIA_TLDRAW_BLOCK:'
const TOKEN_SUFFIX = '@@'
interface InlineItem {
type: string
text?: string
props?: Record<string, string>
content?: unknown
[key: string]: unknown
}
interface BlockLike {
type?: string
content?: InlineItem[]
props?: Record<string, string>
children?: BlockLike[]
[key: string]: unknown
}
interface TldrawPayload {
boardId: string
height: string
@@ -27,39 +21,6 @@ interface TldrawPayload {
width: string
}
interface TldrawFenceStart {
character: '`' | '~'
length: number
boardId: string
height: string
width: string
}
interface MarkdownLine {
line: string
}
interface EncodedPayload {
encoded: string
}
interface TokenText {
text: string
}
interface FenceSearch {
lines: string[]
start: number
opening: TldrawFenceStart
}
interface FenceRange {
lines: string[]
start: number
end: number
opening: TldrawFenceStart
}
interface SnapshotSource {
snapshot: string
}
@@ -68,64 +29,28 @@ interface FenceAttribute {
value: string
}
interface CodeBlockSource {
block: BlockLike
}
interface FenceMetadata {
info: string
}
interface FenceAttributeRequest {
info: string
name: 'height' | 'id' | 'width'
}
function lineEnding({ line }: MarkdownLine): string {
if (line.endsWith('\r\n')) return '\r\n'
return line.endsWith('\n') ? '\n' : ''
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function lineText({ line }: MarkdownLine): string {
const ending = lineEnding({ line })
return ending ? line.slice(0, -ending.length) : line
}
function decodeTldrawPayload(payload: unknown): TldrawPayload | null {
if (!isRecord(payload)) return null
if (typeof payload.boardId !== 'string') return null
if (typeof payload.snapshot !== 'string') return null
function splitMarkdownLines({ markdown }: { markdown: string }): string[] {
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? []
return lines.filter((line, index) => line !== '' || index < lines.length - 1)
}
function encodePayload(payload: TldrawPayload): string {
return encodeURIComponent(JSON.stringify(payload))
}
function decodePayload({ encoded }: EncodedPayload): TldrawPayload | null {
try {
const payload = JSON.parse(decodeURIComponent(encoded)) as Partial<TldrawPayload>
if (typeof payload.boardId !== 'string') return null
if (typeof payload.snapshot !== 'string') return null
return {
boardId: payload.boardId,
height: typeof payload.height === 'string' ? payload.height : TLDRAW_DEFAULT_HEIGHT,
snapshot: payload.snapshot,
width: typeof payload.width === 'string' ? payload.width : '',
}
} catch {
return null
return {
boardId: payload.boardId,
height: typeof payload.height === 'string' ? payload.height : TLDRAW_DEFAULT_HEIGHT,
snapshot: payload.snapshot,
width: typeof payload.width === 'string' ? payload.width : '',
}
}
function tldrawToken(payload: TldrawPayload): string {
return `${TOKEN_PREFIX}${encodePayload(payload)}${TOKEN_SUFFIX}`
}
function readTldrawToken({ text }: TokenText): TldrawPayload | null {
const trimmed = text.trim()
if (!trimmed.startsWith(TOKEN_PREFIX) || !trimmed.endsWith(TOKEN_SUFFIX)) return null
return decodePayload({ encoded: trimmed.slice(TOKEN_PREFIX.length, -TOKEN_SUFFIX.length) })
}
function readFenceAttribute({ info, name }: FenceAttributeRequest): string {
for (const match of info.matchAll(/\b([A-Za-z][\w-]*)=(?:"([^"]+)"|'([^']+)'|([^\s]+))/gu)) {
if (match[1] === name) return match[2] ?? match[3] ?? match[4] ?? ''
@@ -133,7 +58,7 @@ function readFenceAttribute({ info, name }: FenceAttributeRequest): string {
return ''
}
function readFenceMetadata({ info }: FenceMetadata): Pick<TldrawPayload, 'boardId' | 'height' | 'width'> {
function readFenceMetadata(info: string): Pick<TldrawPayload, 'boardId' | 'height' | 'width'> {
return {
boardId: readFenceAttribute({ info, name: 'id' }),
height: readFenceAttribute({ info, name: 'height' }) || TLDRAW_DEFAULT_HEIGHT,
@@ -141,77 +66,20 @@ function readFenceMetadata({ info }: FenceMetadata): Pick<TldrawPayload, 'boardI
}
}
function readTldrawFenceStart({ line }: MarkdownLine): TldrawFenceStart | null {
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/.exec(line)
if (!match) return null
const fence = match[2]
const [language = '', ...infoParts] = match[3].trim().split(/\s+/u)
function readTldrawFenceMetadata(info: string): Pick<TldrawPayload, 'boardId' | 'height' | 'width'> | null {
const [language = '', ...infoParts] = info.trim().split(/\s+/u)
if (language.toLowerCase() !== 'tldraw') return null
const metadata = readFenceMetadata({ info: infoParts.join(' ') })
return readFenceMetadata(infoParts.join(' '))
}
function buildTldrawPayload({ lines, start, end, metadata }: DurableFencePayloadInput): TldrawPayload {
const fenceMetadata = metadata as Pick<TldrawPayload, 'boardId' | 'height' | 'width'>
return {
character: fence[0] as '`' | '~',
length: fence.length,
...metadata,
}
}
function isClosingFence({ line, opening }: MarkdownLine & { opening: TldrawFenceStart }): boolean {
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line)
if (!match) return false
const fence = match[2]
return fence[0] === opening.character && fence.length >= opening.length
}
function findClosingFence({ lines, start, opening }: FenceSearch): number {
for (let index = start + 1; index < lines.length; index++) {
if (isClosingFence({ line: lineText({ line: lines[index] }), opening })) return index
}
return -1
}
function buildPayload({ lines, start, end, opening }: FenceRange): TldrawPayload {
return {
boardId: opening.boardId,
height: opening.height,
...fenceMetadata,
snapshot: lines.slice(start + 1, end).join('').trim(),
width: opening.width,
}
}
export function preProcessTldrawMarkdown({ markdown }: { markdown: string }): string {
const lines = splitMarkdownLines({ markdown })
const result: string[] = []
for (let index = 0; index < lines.length; index++) {
const opening = readTldrawFenceStart({ line: lineText({ line: lines[index] }) })
if (!opening) {
result.push(lines[index])
continue
}
const closingIndex = findClosingFence({ lines, start: index, opening })
if (closingIndex === -1) {
result.push(lines[index])
continue
}
result.push(`${tldrawToken(buildPayload({ lines, start: index, end: closingIndex, opening }))}${lineEnding({ line: lines[closingIndex] })}`)
index = closingIndex
}
return result.join('')
}
function readTldrawPayload(content: InlineItem[] | undefined): TldrawPayload | null {
const onlyItem = content?.length === 1 ? content[0] : null
if (onlyItem?.type !== 'text' || typeof onlyItem.text !== 'string') return null
return readTldrawToken({ text: onlyItem.text })
}
function buildTldrawBlock(block: BlockLike, payload: TldrawPayload): BlockLike {
return {
...block,
@@ -228,21 +96,7 @@ function buildTldrawBlock(block: BlockLike, payload: TldrawPayload): BlockLike {
}
}
function readCodeBlockLanguage({ block }: CodeBlockSource): string | null {
const language = block.props?.language
if (typeof language !== 'string') return null
return language.trim().split(/\s+/u)[0]?.toLowerCase() ?? null
}
function readInlineText(content: InlineItem[] | undefined): string | null {
if (!Array.isArray(content)) return null
return content.map((item) => (
item.type === 'text' && typeof item.text === 'string' ? item.text : ''
)).join('')
}
function readTldrawCodeBlock({ block }: CodeBlockSource): TldrawPayload | null {
function readTldrawCodeBlock(block: BlockLike): TldrawPayload | null {
if (block.type !== 'codeBlock') return null
if (readCodeBlockLanguage({ block }) !== 'tldraw') return null
@@ -257,17 +111,6 @@ function readTldrawCodeBlock({ block }: CodeBlockSource): TldrawPayload | null {
}
}
function injectTldrawInBlock(block: BlockLike): BlockLike {
const payload = readTldrawPayload(block.content)
if (payload) return buildTldrawBlock(block, payload)
const codeBlockPayload = readTldrawCodeBlock({ block })
if (codeBlockPayload) return buildTldrawBlock(block, codeBlockPayload)
const children = Array.isArray(block.children) ? block.children.map(injectTldrawInBlock) : block.children
return { ...block, children }
}
function fenceLengthForSnapshot({ snapshot }: SnapshotSource): number {
const longestRun = Math.max(0, ...Array.from(snapshot.matchAll(/`+/gu), match => match[0].length))
return Math.max(3, longestRun + 1)
@@ -292,10 +135,6 @@ function tldrawFenceMetadata({ boardId, height, width }: Omit<TldrawPayload, 'sn
return attributes.length > 0 ? ` ${attributes.join(' ')}` : ''
}
export function injectTldrawInBlocks(blocks: unknown[]): unknown[] {
return (blocks as BlockLike[]).map(injectTldrawInBlock)
}
export function isTldrawBlock(block: BlockLike): boolean {
return block.type === TLDRAW_BLOCK_TYPE
&& typeof block.props?.snapshot === 'string'
@@ -310,3 +149,23 @@ export function tldrawMarkdown(block: BlockLike): string {
width: block.props?.width ?? '',
})
}
export const tldrawMarkdownCodec: DurableBlockCodec = {
tokenPrefix: TOKEN_PREFIX,
tokenSuffix: TOKEN_SUFFIX,
readFenceMetadata: readTldrawFenceMetadata,
buildPayload: buildTldrawPayload,
decodePayload: decodeTldrawPayload,
buildBlock: (block, payload) => buildTldrawBlock(block, payload as TldrawPayload),
readCodeBlock: readTldrawCodeBlock,
isBlock: isTldrawBlock,
serializeBlock: tldrawMarkdown,
}
export function preProcessTldrawMarkdown({ markdown }: { markdown: string }): string {
return preProcessDurableMarkdownBlocks({ markdown, codecs: [tldrawMarkdownCodec] })
}
export function injectTldrawInBlocks(blocks: unknown[]): unknown[] {
return injectDurableMarkdownBlocks({ blocks, codecs: [tldrawMarkdownCodec] })
}

View File

@@ -60,6 +60,43 @@ async function getRawEditorContent(page: Page): Promise<string> {
})
}
async function hasSelectedEditorNode(page: Page): Promise<boolean> {
return page.evaluate(() => document.querySelector('.ProseMirror-selectednode') !== null)
}
async function expectNoEditorNodeSelection(page: Page): Promise<void> {
expect(await hasSelectedEditorNode(page)).toBe(false)
}
async function applyZoom(page: Page, percent: number): Promise<void> {
await page.evaluate((pct) => {
document.documentElement.style.setProperty('zoom', `${pct}%`)
window.dispatchEvent(new Event('laputa-zoom-change'))
}, percent)
await page.waitForTimeout(250)
}
async function firstTldrawShapeOrigin(page: Page): Promise<{ x: number, y: number } | null> {
return page.locator('.tl-shape').first().evaluate((element) => {
const whiteboard = element.closest('.tldraw-whiteboard')
if (!whiteboard) return null
const matrix = new DOMMatrixReadOnly(getComputedStyle(element).transform)
const boardBox = whiteboard.getBoundingClientRect()
const zoomStyle = document.documentElement.style.getPropertyValue('zoom')
|| getComputedStyle(document.documentElement).zoom
const parsedZoom = Number.parseFloat(zoomStyle)
const zoom = Number.isFinite(parsedZoom) && parsedZoom > 0
? zoomStyle.endsWith('%') ? parsedZoom / 100 : parsedZoom
: 1
return {
x: boardBox.x + matrix.m41 * zoom,
y: boardBox.y + matrix.m42 * zoom,
}
})
}
test('tldraw whiteboard fences render as embedded canvases and remain Markdown-durable', async ({ page }) => {
await openNote(page, 'Whiteboard Embed')
@@ -68,6 +105,7 @@ test('tldraw whiteboard fences render as embedded canvases and remain Markdown-d
await expect(page.locator('.bn-editor')).toContainText('Context before the board.')
await expect(page.locator('.bn-editor')).toContainText('Context after the board.')
await page.waitForTimeout(500)
await toggleRawMode(page, '.cm-content')
const rawAfterRichMode = await getRawEditorContent(page)
@@ -75,3 +113,68 @@ test('tldraw whiteboard fences render as embedded canvases and remain Markdown-d
expect(rawAfterRichMode).toContain('{}')
expect(rawAfterRichMode).not.toContain('@@TOLARIA_TLDRAW')
})
test('embedded tldraw interactions stay inside the whiteboard', async ({ page }) => {
await openNote(page, 'Whiteboard Embed')
const whiteboard = page.locator('.tldraw-whiteboard')
await expect(whiteboard).toBeVisible({ timeout: 20_000 })
const boardBox = await whiteboard.boundingBox()
expect(boardBox).not.toBeNull()
await page.mouse.click(boardBox!.x + boardBox!.width / 2, boardBox!.y + boardBox!.height / 2)
await expectNoEditorNodeSelection(page)
await page.getByTestId('tools.select').click()
await expectNoEditorNodeSelection(page)
const pageMenuButton = page.getByTestId('page-menu.button')
const buttonBox = await pageMenuButton.boundingBox()
expect(buttonBox).not.toBeNull()
await pageMenuButton.click()
const pageMenu = page.locator('.tlui-page-menu__wrapper')
await expect(pageMenu).toBeVisible({ timeout: 5_000 })
const menuBox = await pageMenu.boundingBox()
expect(menuBox).not.toBeNull()
expect(menuBox!.x).toBeGreaterThanOrEqual(boardBox!.x - 1)
expect(menuBox!.x).toBeLessThanOrEqual(buttonBox!.x + 1)
await expectNoEditorNodeSelection(page)
})
test('embedded tldraw drawing uses the clicked coordinates while zoomed', async ({ page }) => {
await openNote(page, 'Whiteboard Embed')
await applyZoom(page, 110)
const whiteboard = page.locator('.tldraw-whiteboard')
await expect(whiteboard).toBeVisible({ timeout: 20_000 })
const boardBox = await whiteboard.boundingBox()
expect(boardBox).not.toBeNull()
await page.getByTestId('tools.draw').click()
const start = {
x: boardBox!.x + 180,
y: boardBox!.y + 180,
}
const end = {
x: start.x + 120,
y: start.y + 90,
}
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(end.x, end.y, { steps: 8 })
await page.mouse.up()
const shape = page.locator('.tl-shape').first()
await expect(shape).toBeVisible({ timeout: 5_000 })
const shapeOrigin = await firstTldrawShapeOrigin(page)
expect(shapeOrigin).not.toBeNull()
expect(Math.abs(shapeOrigin!.x - start.x)).toBeLessThan(30)
expect(Math.abs(shapeOrigin!.y - start.y)).toBeLessThan(30)
})

View File

@@ -0,0 +1,77 @@
import { test, expect, type Page } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette, sendShortcut } from './helpers'
let tempVaultDir: string
function writeFixtureNote(vaultPath: string, filename: string, content: string): string {
const notePath = path.join(vaultPath, filename)
fs.writeFileSync(notePath, content, 'utf8')
return notePath
}
async function openNoteViaQuickOpen(page: Page, query: string): Promise<void> {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
await searchInput.fill(query)
await page.keyboard.press('Enter')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(new RegExp(query, 'i'), { timeout: 5_000 })
}
test.describe('Type-derived instance properties', () => {
test.beforeEach(async ({ page }) => {
tempVaultDir = createFixtureVaultCopy()
writeFixtureNote(
tempVaultDir,
'book.md',
'---\ntype: Type\nstart date:\nRating: 5\nMentor: [[person/alice]]\n---\n# Book\n',
)
writeFixtureNote(
tempVaultDir,
'dune.md',
'---\ntype: Book\n---\n# Dune\n\nExisting instance without type schema fields.\n',
)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.setViewportSize({ width: 1600, height: 900 })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('type schema placeholders stay visible and valued defaults seed new instances @smoke', async ({ page }) => {
const existingBookPath = path.join(tempVaultDir, 'dune.md')
await openNoteViaQuickOpen(page, 'Dune')
await sendShortcut(page, 'i', ['Control', 'Shift'])
const startDatePlaceholder = page.getByTestId('type-derived-property').filter({ hasText: 'Start date' })
await expect(startDatePlaceholder).toBeVisible()
await expect(startDatePlaceholder.getByText('Start date')).toHaveClass(/text-muted-foreground\/40/)
const mentorPlaceholder = page.getByTestId('type-derived-relationship').filter({ hasText: 'Mentor' })
await expect(mentorPlaceholder).toBeVisible()
await expect(mentorPlaceholder.getByText('Mentor')).toHaveClass(/text-muted-foreground\/40/)
await startDatePlaceholder.click()
const startDateRow = page.getByTestId('editable-property').filter({ hasText: 'Start date' })
await startDateRow.locator('input').fill('2026-05-04')
await startDateRow.locator('input').blur()
await expect.poll(() => fs.readFileSync(existingBookPath, 'utf8')).toContain('start date: "2026-05-04"')
await page.locator('aside').getByText('Books', { exact: true }).first().click()
await page.locator('[title="Create new note"]').first().click()
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-book-\d+/i, { timeout: 5_000 })
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
const rawEditor = page.locator('.cm-content')
await expect(rawEditor).toContainText('type: Book')
await expect(rawEditor).toContainText('Rating: 5')
await expect(rawEditor).toContainText('Mentor: "[[person/alice]]"')
await expect(rawEditor).not.toContainText('start date:')
})
})

View File

@@ -59,18 +59,51 @@ function extractWikiLinks(value: string): string[] {
/** Extract wiki-links from a frontmatter value (string or array of strings). */
function wikiLinksFromValue(value: unknown): string[] {
if (typeof value === 'string') return extractWikiLinks(value)
if (Array.isArray(value)) {
return value.flatMap((v) => (typeof v === 'string' ? extractWikiLinks(v) : []))
}
return []
return collectWikiLinksFromValue(value, 0)
}
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic relationships)
function collectWikiLinksFromValue(value: unknown, depth: number): string[] {
if (typeof value === 'string') return extractWikiLinks(value)
if (!Array.isArray(value)) return []
const nestedLink = nestedFlowWikilink(value, depth)
if (nestedLink) return [nestedLink]
return value.flatMap((item) => collectWikiLinksFromValue(item, depth + 1))
}
function nestedFlowWikilink(value: unknown[], depth: number): string | null {
if (depth === 0 || value.length !== 1 || typeof value[0] !== 'string') return null
return extractWikiLinks(value[0]).length === 0 ? `[[${value[0]}]]` : null
}
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic properties/relationships)
const DEDICATED_KEYS = new Set([
'aliases', 'is_a', 'is a', 'belongs_to', 'belongs to',
'related_to', 'related to', 'status', 'title',
])
'aliases', 'is_a', 'is a', 'type', 'status', 'title', '_archived',
'archived', '_icon', 'icon', 'color', '_order', 'order',
'_sidebar_label', 'sidebar_label', 'sidebar label', 'template',
'_sort', 'sort', 'view', '_width', 'width', 'visible',
'_organized', '_favorite', '_favorite_index', '_list_properties_display',
].map((key) => key.toLowerCase()))
type FrontmatterPropertyValue = string | number | boolean | null
type VaultSearchResult = { title: string; path: string; snippet: string; score: number; note_type: string | null }
interface SearchEntryInput {
entry: VaultEntry
query: string
rawContent: string
}
interface SearchRequestInput {
query: string
vaultPath: string
}
interface SearchResponseInput {
mode: string
query: string
results: VaultSearchResult[]
}
function getFrontmatterValue(
frontmatter: Record<string, unknown>,
@@ -211,6 +244,38 @@ function frontmatterRelationships(frontmatter: Record<string, unknown>): Record<
return relationships
}
function frontmatterProperties(frontmatter: Record<string, unknown>): Record<string, FrontmatterPropertyValue> {
const properties: Record<string, FrontmatterPropertyValue> = {}
for (const [key, value] of Object.entries(frontmatter)) {
if (DEDICATED_KEYS.has(key.toLowerCase()) || key.trim().startsWith('_')) continue
const propertyValue = frontmatterPropertyValue(value)
if (propertyValue !== undefined) properties[key] = propertyValue
}
return properties
}
function isScalarFrontmatterProperty(value: unknown): value is number | boolean {
return typeof value === 'number' || typeof value === 'boolean'
}
function singleStringArrayValue(value: unknown): string | undefined {
if (!Array.isArray(value)) return undefined
if (value.length !== 1) return undefined
return typeof value[0] === 'string' ? value[0] : undefined
}
function wikiLinkFreeString(value: string): string | undefined {
return extractWikiLinks(value).length === 0 ? value : undefined
}
function frontmatterPropertyValue(value: unknown): FrontmatterPropertyValue | undefined {
if (value === null) return null
if (isScalarFrontmatterProperty(value)) return value
if (typeof value === 'string') return wikiLinkFreeString(value)
const singleArrayValue = singleStringArrayValue(value)
return singleArrayValue === undefined ? undefined : wikiLinkFreeString(singleArrayValue)
}
function parseMarkdownFile(filePath: string): VaultEntry | null {
try {
const raw = readUtf8File(filePath)
@@ -252,7 +317,7 @@ function parseMarkdownFile(filePath: string): VaultEntry | null {
view: frontmatterString(fm, 'view'),
visible: frontmatterBool(fm, 'visible'),
outgoingLinks: [],
properties: {},
properties: frontmatterProperties(fm),
}
} catch {
return null
@@ -390,21 +455,39 @@ function handleVaultSearch(url: URL, res: ServerResponse): boolean {
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const mode = url.searchParams.get('mode') ?? 'all'
if (!vaultPath || !query) {
sendJson(res, { results: [], elapsed_ms: 0, query, mode })
sendVaultSearchResponse(res, { results: [], query, mode })
return true
}
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
sendVaultSearchResponse(res, {
results: collectVaultSearchResults({ vaultPath, query }),
query,
mode,
})
return true
}
function collectVaultSearchResults({ vaultPath, query }: SearchRequestInput): VaultSearchResult[] {
const results: VaultSearchResult[] = []
for (const filePath of findMarkdownFiles(vaultPath)) {
const entry = parseMarkdownFile(filePath)
if (!entry || entry.trashed) continue
const raw = readUtf8File(filePath)
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
}
const rawContent = readUtf8File(filePath)
if (entryMatchesSearch({ entry, rawContent, query })) results.push(searchResultFromEntry(entry))
}
sendJson(res, { results: results.slice(0, 20), elapsed_ms: 1, query, mode })
return true
return results.slice(0, 20)
}
function entryMatchesSearch({ entry, rawContent, query }: SearchEntryInput): boolean {
return entry.title.toLowerCase().includes(query) || rawContent.toLowerCase().includes(query)
}
function searchResultFromEntry(entry: VaultEntry): VaultSearchResult {
return { title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA }
}
function sendVaultSearchResponse(res: ServerResponse, { results, query, mode }: SearchResponseInput): void {
sendJson(res, { results, elapsed_ms: results.length > 0 ? 1 : 0, query, mode })
}
async function handleVaultSave(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {