Compare commits
9 Commits
codex/mobi
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
417e37f1d1 | ||
|
|
a3e3192b66 | ||
|
|
86c503c1f9 | ||
|
|
21a47b4a77 | ||
|
|
64d961bd98 | ||
|
|
1b218f5250 | ||
|
|
dfb9f98b7a | ||
|
|
6a033cd2db | ||
|
|
b872b6148c |
@@ -278,6 +278,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 +337,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 +547,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 +572,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 +587,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 +605,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"]
|
||||
|
||||
@@ -796,7 +797,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
|
||||
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¤t_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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
117
src-tauri/src/vault/path_identity.rs
Normal file
117
src-tauri/src/vault/path_identity.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveTemplate,
|
||||
DEFAULT_TEMPLATES,
|
||||
RAPID_CREATE_NOTE_SETTLE_MS,
|
||||
planNewNoteCreation,
|
||||
useNoteCreation,
|
||||
} from './useNoteCreation'
|
||||
import type { NoteCreationConfig } from './useNoteCreation'
|
||||
@@ -169,6 +170,17 @@ describe('resolveNewNote', () => {
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).not.toContain('status:')
|
||||
})
|
||||
|
||||
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 +596,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 +606,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 () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
|
||||
export interface NewEntryParams {
|
||||
path: string
|
||||
@@ -116,7 +117,7 @@ export interface NewNoteParams {
|
||||
export function resolveNewNote({ title, type, vaultPath, template }: NewNoteParams): { entry: VaultEntry; content: string } {
|
||||
const slug = slugify(title)
|
||||
const status = null
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status })
|
||||
const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title, type, status })
|
||||
return { entry, content: buildNoteContent({ title, type, status, template }) }
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ 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` }
|
||||
}
|
||||
|
||||
@@ -151,17 +152,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`
|
||||
}
|
||||
|
||||
@@ -200,6 +196,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 }
|
||||
}
|
||||
|
||||
@@ -465,7 +469,7 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr
|
||||
const title = slug_to_title(slug)
|
||||
const template = resolveTemplate({ entries: deps.entries, typeName: noteType })
|
||||
const status = null
|
||||
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
|
||||
const entry = buildNewEntry({ path: joinVaultPath(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)
|
||||
if (!didPersist) return false
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
88
src/lib/aiTargets.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
58
src/lib/blockNoteCopyCompatibility.regression.test.ts
Normal file
58
src/lib/blockNoteCopyCompatibility.regression.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
79
src/shared/aiModelProviderCatalog.json
Normal file
79
src/shared/aiModelProviderCatalog.json
Normal 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
|
||||
}
|
||||
]
|
||||
293
src/utils/durableMarkdownBlocks.ts
Normal file
293
src/utils/durableMarkdownBlocks.ts
Normal 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('')
|
||||
}
|
||||
45
src/utils/editorDurableMarkdown.test.ts
Normal file
45
src/utils/editorDurableMarkdown.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
44
src/utils/editorDurableMarkdown.ts
Normal file
44
src/utils/editorDurableMarkdown.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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```',
|
||||
|
||||
@@ -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] })
|
||||
}
|
||||
|
||||
38
src/utils/notePathIdentity.test.ts
Normal file
38
src/utils/notePathIdentity.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
67
src/utils/notePathIdentity.ts
Normal file
67
src/utils/notePathIdentity.ts
Normal 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}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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] })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user