Compare commits
34 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c503c1f9 | ||
|
|
21a47b4a77 | ||
|
|
64d961bd98 | ||
|
|
1b218f5250 | ||
|
|
dfb9f98b7a | ||
|
|
6a033cd2db | ||
|
|
b872b6148c | ||
|
|
c7b6832cb1 | ||
|
|
05a9aacd1e | ||
|
|
701ebe3f07 | ||
|
|
f49b8f60dc | ||
|
|
39615f49f3 | ||
|
|
3d7a0ee936 | ||
|
|
9faee02e04 | ||
|
|
dfff3a848b | ||
|
|
aaf0336733 | ||
|
|
5b906cb3a4 | ||
|
|
0a40f12a6a | ||
|
|
033759598a | ||
|
|
871b62fbd3 | ||
|
|
aa310b25df | ||
|
|
dbb12e96fe | ||
|
|
c795f118d0 | ||
|
|
d96d6efd57 | ||
|
|
67623d848b | ||
|
|
45d72297b3 | ||
|
|
4f86b97e8d | ||
|
|
b6dd6189a2 | ||
|
|
ba07bafc97 | ||
|
|
6d86770abf | ||
|
|
e23ba0ae27 | ||
|
|
7a97f24a0e | ||
|
|
20f34ab9d1 | ||
|
|
19b31cc96a |
@@ -49,7 +49,13 @@
|
||||
"laputa-qa-reference.md",
|
||||
"attachments/laputa-reference.png"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rtl-mixed-direction",
|
||||
"reason": "Arabic and mixed English/Arabic paragraphs keep rich editor and raw editor BiDi QA anchored to the fixture.",
|
||||
"files": [
|
||||
"rtl-mixed-direction-qa.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: Note
|
||||
topics:
|
||||
- "[[topic-writing]]"
|
||||
---
|
||||
|
||||
# RTL Mixed Direction QA
|
||||
|
||||
مرحبا بالعالم. هذه فقرة عربية لاختبار اتجاه النص من اليمين إلى اليسار داخل محرر تولاريا.
|
||||
|
||||
English text should keep reading left to right when it appears next to Arabic content.
|
||||
|
||||
English then مرحبا بالعالم keeps both scripts readable on one line.
|
||||
|
||||
مرحبا بالعالم then English keeps the Arabic run anchored correctly while preserving the English words.
|
||||
|
||||
Use this note when checking rich editor and raw Markdown editor behavior for automatic LTR/RTL direction.
|
||||
@@ -336,7 +336,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.
|
||||
|
||||
@@ -367,7 +367,7 @@ Command-facing vault content is filtered through `vault::filter_gitignored_entri
|
||||
|
||||
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
|
||||
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
@@ -405,7 +405,7 @@ The `with_frontmatter()` helper wraps this in a read-transform-write cycle on th
|
||||
|
||||
## Git Integration
|
||||
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2).
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2). Path-producing commands use `core.quotePath=false` so Unicode note filenames stay as UTF-8 paths across status, history, cache invalidation, and rename detection.
|
||||
|
||||
### Data Types
|
||||
|
||||
@@ -532,8 +532,8 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
|
||||
|
||||
- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`.
|
||||
- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM.
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts` → `typescript`.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface.
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript at creation time. Parsed unlabeled code blocks then run through Tolaria's lightweight language inference, while explicit fence languages and user dropdown choices still win.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep the dedicated code-block shell instead of inheriting the muted inline surface.
|
||||
|
||||
### Markdown Math
|
||||
|
||||
@@ -552,6 +552,16 @@ Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`,
|
||||
- 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.
|
||||
- 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`:
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -560,10 +570,11 @@ 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 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, and list blocks. 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 rendered text range for the hovered block, so H1/H2 typography, line-height, 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.
|
||||
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
|
||||
- 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.
|
||||
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Checklist checkbox handlers also re-resolve the live block before updating `checked`, making delayed clicks after note reloads a no-op instead of a stale block mutation. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
|
||||
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
|
||||
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
|
||||
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
|
||||
@@ -574,18 +585,19 @@ 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["preProcessMermaidMarkdown(body)\nmermaid fence → token"]
|
||||
C --> D["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
|
||||
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
F --> G["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks\n tokens → schema nodes"]
|
||||
G --> H["editor.replaceBlocks()\n→ rendered editor"]
|
||||
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"]
|
||||
|
||||
style A fill:#f8f9fa,stroke:#6c757d,color:#000
|
||||
style H fill:#d4edda,stroke:#28a745,color:#000
|
||||
style I fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math and Mermaid placeholder tokens use ASCII sentinels with URI-encoded payloads.
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
@@ -663,6 +675,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
|
||||
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
@@ -774,6 +787,8 @@ interface Settings {
|
||||
ui_language: AppLocale | null
|
||||
note_width_mode: 'normal' | 'wide' | null
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
|
||||
default_ai_target: string | null // "agent:codex" or "model:<provider>/<model>"
|
||||
ai_model_providers: AiModelProvider[] | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
all_notes_show_pdfs: boolean | null // null = default false
|
||||
all_notes_show_images: boolean | null // null = default false
|
||||
@@ -781,7 +796,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` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `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
|
||||
|
||||
@@ -800,6 +815,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
|
||||
### Product Events
|
||||
- **File previews** — `file_preview_opened`, `file_preview_action`, and `file_preview_failed` report only preview/action categories such as `image`, `pdf`, `unsupported`, `open_external`, `copy_path`, and `reveal`.
|
||||
- **Inline image lightbox** — `inline_image_lightbox_opened` records that a rich-editor inline image was opened from double-click, without sending note paths, image URLs, alt text, or file names.
|
||||
- **Code block copy** — `code_block_copied` records that the rich-editor code-block copy action was used, without sending note paths, languages, or code content.
|
||||
- **AI agent sessions** — `ai_agent_message_sent`, `ai_agent_message_blocked`, `ai_agent_response_completed`, `ai_agent_response_failed`, and `ai_agent_permission_mode_changed` use only agent ids, permission modes, counts, and coarse status categories.
|
||||
- **All Notes visibility** — `all_notes_visibility_changed` records only the toggled category and enabled state.
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Code block highlighting | @blocknote/code-block | 0.46.2 |
|
||||
| Diagram rendering | Mermaid | 11.14.0 |
|
||||
| Whiteboard rendering | tldraw | 4.5.10 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
@@ -216,8 +217,8 @@ 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.
|
||||
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 rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, while the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
|
||||
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)
|
||||
|
||||
@@ -243,13 +244,13 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts` + `aiTargets.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-target selection, and the per-vault Safe / Power User permission mode shown in the panel header for coding agents
|
||||
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Codex, OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
@@ -298,19 +299,25 @@ The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the act
|
||||
|
||||
```json
|
||||
{
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "content" },
|
||||
"linkedNotes": [{ "path", "title", "content" }],
|
||||
"openTabs": [{ "title", "snippet" }],
|
||||
"vaultMetadata": { "noteTypes", "stats", "filter" },
|
||||
"references": [{ "title", "path", "type" }]
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "body", "wordCount", "bodyTruncated?" },
|
||||
"openTabs": [{ "path", "title", "type", "frontmatter" }],
|
||||
"noteList": [{ "path", "title", "type" }],
|
||||
"vault": { "types", "totalNotes" },
|
||||
"referencedNotes": [{ "title", "path", "type" }]
|
||||
}
|
||||
```
|
||||
|
||||
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
|
||||
Large active notes are compacted into a head/tail body snapshot before they enter the CLI prompt. The snapshot records `bodyTruncated` metadata and instructs agents to call `get_note(path)` before content-sensitive edits or summaries, keeping lower-context OpenCode providers from failing on oversized active-note context while preserving access to the full note through MCP.
|
||||
|
||||
### 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. `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.
|
||||
|
||||
### Authentication
|
||||
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings.
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables.
|
||||
|
||||
## MCP Server
|
||||
|
||||
@@ -350,7 +357,7 @@ Tolaria can register itself as an MCP server in:
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria` and `/usr/lib/tolaria`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -490,6 +497,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
|
||||
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
|
||||
|
||||
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
|
||||
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
@@ -556,6 +565,11 @@ sequenceDiagram
|
||||
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
alt Runtime vault path disappears
|
||||
VL->>T: invoke('check_vault_exists')
|
||||
VL-->>A: unavailable vault path + cleared stale state
|
||||
A-->>U: WelcomeScreen (vault missing)
|
||||
end
|
||||
A->>T: useMcpStatus — check explicit MCP setup state
|
||||
A->>T: sync_mcp_bridge_vault(selected path)
|
||||
VL-->>A: entries ready
|
||||
@@ -855,7 +869,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.
|
||||
|
||||
@@ -449,7 +449,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Work with external MCP setup
|
||||
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
|
||||
|
||||
43
docs/adr/0107-markdown-durable-tldraw-whiteboards.md
Normal file
43
docs/adr/0107-markdown-durable-tldraw-whiteboards.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0107"
|
||||
title: "Markdown-durable tldraw whiteboards in notes"
|
||||
status: active
|
||||
date: 2026-05-03
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are durable Markdown files, while whiteboard editing needs an interactive canvas with structured shape data. tldraw provides a mature React whiteboard runtime and exposes snapshot APIs that can persist the document without using browser-local `persistenceKey` storage.
|
||||
|
||||
The storage decision needs to preserve Tolaria's filesystem source-of-truth rule: deleting local caches must not lose a board, raw mode must expose the canonical source, and Git should track the board together with the surrounding note.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support whiteboards as Markdown-durable fenced `tldraw` blocks backed by tldraw document snapshots.**
|
||||
|
||||
The implementation:
|
||||
|
||||
- Converts fenced `tldraw` blocks into temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces those placeholders with `tldrawBlock` schema blocks that store a stable board id and tldraw document snapshot JSON.
|
||||
- Renders each block with the `tldraw` package inside the rich editor.
|
||||
- Debounces tldraw document changes into BlockNote block props so Tolaria's normal autosave writes the snapshot into the `.md` file.
|
||||
- Serializes `tldrawBlock` nodes back to fenced Markdown before save, raw-mode entry, and editor-position snapshots.
|
||||
- Adds a `/whiteboard` slash command that inserts the same block format.
|
||||
|
||||
Session state such as camera position, selected shapes, and selected tools is not persisted into the note. Preview images are intentionally omitted from the initial design; they may be introduced later as derived cache artifacts for note lists, search results, or graph cards.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Markdown fenced block with tldraw document snapshot** (chosen): keeps the board in the note file, matches Tolaria's Mermaid/math round-trip model, and works for both full whiteboard notes and embedded boards inside larger notes.
|
||||
- **tldraw `persistenceKey` / IndexedDB**: simplest app integration, but violates the vault-as-source-of-truth rule and would lose boards when browser storage is cleared.
|
||||
- **Separate `.tldr` files embedded from Markdown**: keeps JSON out of prose notes, but fragments note ownership, makes embedded boards harder to move with their parent note, and complicates Git history for small board edits.
|
||||
- **Preview-image-first storage**: useful for thumbnails, but not editable source. It would make the PNG an attractive but stale source of truth.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/tldrawMarkdown.ts` is the canonical parser/serializer bridge for whiteboard blocks.
|
||||
- `src/components/TldrawWhiteboard.tsx` owns the tldraw runtime integration and only persists document snapshots.
|
||||
- Raw mode remains a direct source editor for the fenced JSON.
|
||||
- Embedded whiteboards and future full-note whiteboard templates share the same storage format.
|
||||
- Asset support is deferred; when added, asset bytes should live in vault-relative attachment paths referenced from the tldraw asset records.
|
||||
30
docs/adr/0108-direct-model-ai-targets.md
Normal file
30
docs/adr/0108-direct-model-ai-targets.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0108"
|
||||
title: "Direct model AI targets alongside coding agents"
|
||||
status: active
|
||||
date: 2026-05-03
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's AI panel originally targeted desktop coding-agent CLIs only. That works well for tool-capable vault editing, but it excludes users who run local model servers, users who prefer OpenAI or Anthropic APIs, and future mobile builds where desktop subprocesses cannot run.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria models AI selection as an AI target.** Targets can be desktop coding agents or direct model endpoints. Coding agents keep the existing Safe / Power User permission modes and tool access. Direct model targets run in Chat mode: they receive note context and conversation history, but they do not get vault-write tools or shell access.
|
||||
|
||||
Direct model provider metadata is stored in app settings. Provider API secrets are not stored in settings; hosted providers can either save a key in Tolaria's local app-data secrets file or read a key from a named environment variable. The local secrets file is outside the vault, outside project worktrees, and written with owner-only file permissions on Unix platforms. Local providers such as Ollama and LM Studio can run without a key.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **AI target abstraction** (chosen): supports agents, local models, hosted APIs, and mobile-compatible model runtimes without pretending all AI backends have the same capabilities.
|
||||
- **Treat custom providers as OpenCode configuration only**: lower implementation cost on desktop, but does not help mobile and keeps API users dependent on a coding-agent install.
|
||||
- **Direct API runtime with write tools immediately**: powerful, but would require Tolaria-owned tool loops, confirmations, retries, and safety semantics before the basic chat value is proven.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Settings owns durable provider setup and default target selection.
|
||||
- The status bar becomes a quick target switcher across agents and configured model targets.
|
||||
- The AI panel explains capability differences: agents show Safe / Power User; direct model targets show Chat mode.
|
||||
- Future work can migrate local secrets to OS keychain storage and add read/write tool loops without changing the top-level target model.
|
||||
25
docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md
Normal file
25
docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 0108. Sanitized Rendered Markup and Safe User Regex
|
||||
|
||||
Date: 2026-05-03
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria renders generated SVG/HTML from trusted libraries such as Mermaid and KaTeX, and it allows users to opt into regex matching in filters and editor find/replace. Codacy SRM flagged the raw markup insertion and direct regex construction as Critical XSS/DoS risks.
|
||||
|
||||
## Decision
|
||||
|
||||
Add direct runtime dependencies on `dompurify` and `safe-regex2`.
|
||||
|
||||
Rendered Mermaid SVG and KaTeX HTML must be sanitized before insertion and mounted through DOM nodes rather than React `dangerouslySetInnerHTML`. User-provided regex sources must be length-bounded and checked with `safe-regex2` before compilation.
|
||||
|
||||
SCA-reported vulnerable transitive dependencies are pinned through package-manager overrides so Codacy resolves patched floors until upstream dependencies adopt them naturally. This includes `protobufjs`, the MCP SDK web-server stack, and Vite's parser/build transitive stack. Rust lockfile-only updates keep OpenSSL, rustls-webpki, and tar on patched versions without changing public Tauri APIs.
|
||||
|
||||
## Consequences
|
||||
|
||||
Markup rendering now has an explicit sanitizer boundary that is shared by Mermaid and math rendering. User regex features remain available, but unsafe or overly large expressions fail validation instead of running in the UI thread.
|
||||
|
||||
The overrides should be removed once the dependency graph no longer pulls the vulnerable versions.
|
||||
@@ -159,4 +159,6 @@ proposed → active → superseded
|
||||
| [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active |
|
||||
| [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active |
|
||||
| [0106](0106-shared-app-command-manifest.md) | Shared app command manifest | active |
|
||||
| [0107](0107-markdown-durable-tldraw-whiteboards.md) | Markdown-durable tldraw whiteboards in notes | active |
|
||||
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |
|
||||
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
|
||||
|
||||
78
lara.lock
78
lara.lock
@@ -104,7 +104,9 @@ files:
|
||||
settings.sync.title: 36f62a237420dcdc7096ad3a9018c4d9
|
||||
settings.sync.description: 06a6957c40867be2c28783183dc1bf5f
|
||||
settings.pullInterval: 4f95ca677398604bca241427261ed07b
|
||||
settings.pullIntervalDescription: 4a75c15f44d59dc8cb100ec2d0693048
|
||||
settings.releaseChannel: 2da37055e15a217f18bd403922085c3f
|
||||
settings.releaseChannelDescription: c5ae9b7f25376df383d36603366ac1fa
|
||||
settings.releaseStable: fa3aff3c185c6dc7754235f397c2099a
|
||||
settings.releaseAlpha: 6132295fcf5570fb8b0a944ef322a598
|
||||
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
|
||||
@@ -123,7 +125,9 @@ files:
|
||||
settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75
|
||||
settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554
|
||||
settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6
|
||||
settings.autogit.idleThresholdDescription: 521140491e8c9d5a1d7b7739bc333358
|
||||
settings.autogit.inactiveThreshold: d28cb60d1e70e020ae8d1294e32dd474
|
||||
settings.autogit.inactiveThresholdDescription: f9e43c90bd10bae937c2cfa5c2767f1c
|
||||
settings.titles.title: 761443f70a5067ecf015c6fb3fae9cef
|
||||
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
|
||||
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
|
||||
@@ -134,26 +138,75 @@ files:
|
||||
settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6
|
||||
settings.allNotesVisibility.title: 0e07c3d48b91e1a4c42c1ff3e5751ec3
|
||||
settings.allNotesVisibility.description: bc9caa48296281401aad694fed65a2d9
|
||||
settings.allNotesVisibility.pdfs: 76663c34a88bd4225613a1a947127bcf
|
||||
settings.allNotesVisibility.pdfsDescription: 33bfa167c125ddd713f40af11d233121
|
||||
settings.allNotesVisibility.images: fff0d600f8a0b5e19e88bfb821dd1157
|
||||
settings.allNotesVisibility.imagesDescription: 54e1fe8f20b426de2fe457322862fb4b
|
||||
settings.allNotesVisibility.unsupported: 91de79ad2f20abd62e445f20584d3b8e
|
||||
settings.allNotesVisibility.unsupportedDescription: c1ecaa108af2de4680ab535585c475e8
|
||||
settings.allNotesVisibility.pdfs: 27ce5e1e1761ff362dbf75c76c6b4941
|
||||
settings.allNotesVisibility.pdfsDescription: 8191ed4a095cdd60ef856ce0ff213a48
|
||||
settings.allNotesVisibility.images: ea15460e63b6e8e92f8c03a79eeb6e4e
|
||||
settings.allNotesVisibility.imagesDescription: 1704e10d5e5cdc17d2d2a5fb7b8969bf
|
||||
settings.allNotesVisibility.unsupported: 257fe04419c9f6543a43e58b1edf9eb5
|
||||
settings.allNotesVisibility.unsupportedDescription: a3b8aa66900c742f001cd4533b3df44a
|
||||
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
|
||||
settings.aiAgents.description: a13222e67eb121676ff6dfc7b085f02d
|
||||
settings.aiAgents.description: 042a8037f1a4f90c76ce31a49fdff59c
|
||||
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
|
||||
settings.aiAgents.defaultTarget: 5acfc74fd97a6dd2d67d15c66de5eed5
|
||||
settings.aiAgents.agentGroup: 965530cbbec45b1754ed3ece120399f7
|
||||
settings.aiAgents.localGroup: d34f4b997ce78da5aa57d657a5d6fcb5
|
||||
settings.aiAgents.apiGroup: 589a257cbd265eaa7de93de8b4084dff
|
||||
settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff
|
||||
settings.aiAgents.missing: ea21841da70e6405af19fabc4ff8bdd9
|
||||
settings.aiAgents.ready: 909a648c713be25fcd050725d0a41e37
|
||||
settings.aiAgents.notInstalled: 0ac3f76ef78bfdbab6331731ee56ba22
|
||||
settings.aiAgents.apiReady: e5b06f6ef4ef5f4bc2848d46195e3f48
|
||||
settings.aiAgents.apiLocalKey: 119de2f31867b273a134f05bc56b9e57
|
||||
settings.aiAgents.apiEnv: e011db3dff54ed3deae37a9615e31ebf
|
||||
settings.aiAgents.apiNoKey: 5bbccbfcd09b28902bbf5319dd6e6053
|
||||
settings.aiAgents.installedTitle: 5cb9b5ae5ebc66652814dff4584705cc
|
||||
settings.aiAgents.installedDescription: fb9ba9667725fb40fa1e5d58a2c02692
|
||||
settings.aiAgents.noVersion: 89831adb7114946e916b9c31d57a1e33
|
||||
settings.aiProviders.title: 63eb84d8510f9f086d282ec7d47adaa1
|
||||
settings.aiProviders.description: 6c86c013eb6fb198b51e29ec47d66464
|
||||
settings.aiProviders.localTitle: c7c20443d8aaf04271df9d713f5c02ce
|
||||
settings.aiProviders.localDescription: 7abed40ef2e46741c64ff47e423cca39
|
||||
settings.aiProviders.apiTitle: 66537b9e5200ebbbfbd091144d8afbcc
|
||||
settings.aiProviders.apiDescription: 6ec773352e05a805af3abcebeff4e756
|
||||
settings.aiProviders.kind: 27703c8f150ac4bb0a3a83a7857353af
|
||||
settings.aiProviders.kind.ollama: c23e8db458397f37c8e8d98e94e55a18
|
||||
settings.aiProviders.kind.lmStudio: 7b9b319662715e90583e823a6c3cfdbe
|
||||
settings.aiProviders.kind.openAi: 0523b13262b12c215d8009938f5c14f1
|
||||
settings.aiProviders.kind.anthropic: f431db65cea024a5f19eab835afefee2
|
||||
settings.aiProviders.kind.gemini: 766cc4dd4d5005652e8514e3513683f8
|
||||
settings.aiProviders.kind.openRouter: a0607683815b6585fb80e7cac72ac59a
|
||||
settings.aiProviders.kind.compatible: 1a4dc90a183c26d9026c6ac7727aa4cd
|
||||
settings.aiProviders.name: 49ee3087348e8d44e1feda1917443987
|
||||
settings.aiProviders.baseUrl: ade86bc4899761ad46c52e381b6228bb
|
||||
settings.aiProviders.model: 6bd36c36869288188863bd53a9bc5ed1
|
||||
settings.aiProviders.key: 656a6828d7ef1bb791e42087c4b5ee6e
|
||||
settings.aiProviders.keyPlaceholder: 57dbf3720af3f156018bfff7f5a99b23
|
||||
settings.aiProviders.keyStorage: 3e5b8cfbc66d3a33dad3fda058bf5851
|
||||
settings.aiProviders.keyStorage.local: 7c6189f734f3c66d162a3f06529af1d9
|
||||
settings.aiProviders.keyStorage.env: a71b071971d7c54e9af381c9bea093d9
|
||||
settings.aiProviders.keyStorage.none: 94c841331ccd5179a283487f96285c90
|
||||
settings.aiProviders.keyEnv: cb72671bd5cf65512d4c94e8ad6182ef
|
||||
settings.aiProviders.keySafety: 0d59d7e4def35e51c4bb785d74180afc
|
||||
settings.aiProviders.keySafetyLocal: 8ef2ba0192ac725fa1a3e737e852387c
|
||||
settings.aiProviders.localSafety: 60fb65e4e0d35cec3135584d8219e8fd
|
||||
settings.aiProviders.add: a9f8e8b4ca3cbc4fc3bff5d59d6a5419
|
||||
settings.aiProviders.addLocal: 681985f2e412f22fcd55a0553f6e4bee
|
||||
settings.aiProviders.addApi: 767b46f2a51aa9dd050cb226cd86e192
|
||||
settings.aiProviders.test: 602009d5aea1c1ef06fd777968f7e80e
|
||||
settings.aiProviders.testing: 9d770c909c2c69b09eae2372c4cf405d
|
||||
settings.aiProviders.testSuccess: 51677ed54231e41a4fe40850d90b64b2
|
||||
settings.aiProviders.empty: 9378027dec7f93fcc5011c5e528bdf82
|
||||
settings.aiProviders.defaultEndpoint: 666b355c305dd4c7b4649d3d7b4c5d11
|
||||
settings.aiProviders.keyLocalSaved: f4a102d6a67212e21f0f31f0758aea81
|
||||
settings.aiProviders.keyEnvSaved: bfed2d745dd76dbea9341d27e06c39f2
|
||||
settings.aiProviders.noKey: 1ab089f80ef4dc6c1ce3e173f5b405a0
|
||||
settings.workflow.title: 24f47cdbe9ddba774a7cc53e51d9032e
|
||||
settings.workflow.description: aa9a07539b87cf407c3edc8a22732d45
|
||||
settings.workflow.explicit: 54a5b5c27937d25271c3127d093e8361
|
||||
settings.workflow.explicitDescription: ce523427b5dd0f9895f63d1fbb90ad24
|
||||
settings.workflow.autoAdvance: 9ed337f5bc61603d19a1ef35f3a3a243
|
||||
settings.workflow.autoAdvanceDescription: ae58ad8abf03df7cbdae8c3a725258f7
|
||||
settings.privacy.title: 0dd6c14e00cc9f45a68c1bca88d1317d
|
||||
settings.privacy.title: aa96a21412def0d916f43b639424f8e4
|
||||
settings.privacy.description: 33d53059be620b58e38b8e5c5ab26b27
|
||||
settings.privacy.crashReporting: b4efc5b61526566d431e99242f5c21b4
|
||||
settings.privacy.crashReportingDescription: e9d7c7efce44adc08d0460be1aad5c42
|
||||
@@ -165,6 +218,7 @@ files:
|
||||
common.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
common.create: 686e697538050e4664636337cc3b834f
|
||||
common.save: c9cc8cce247e49bae79f15173ce97354
|
||||
common.remove: 1063e38cb53d94d386f21227fcd84717
|
||||
customize.color: cb5feb1b7314637725a2e73bdc9f7295
|
||||
customize.icon: 817434295a673aed431435658b65d9a7
|
||||
customize.searchIcons: 0688a8098567785a05cb4cf937cbb880
|
||||
@@ -194,6 +248,8 @@ files:
|
||||
ai.panel.status.checking: 118740af1c4521b917e29791d661db82
|
||||
ai.panel.status.missing: fe07f8eac233c229d81cbe9aa25668a0
|
||||
ai.panel.status.ready: 296a0ac791eab2df130789f55fdc109b
|
||||
ai.panel.mode.chat: 55dcdf017b51fc96f7b5f9d63013b95d
|
||||
ai.panel.mode.chatDescription: d7744ae39e7920a8eac7b21b58fce348
|
||||
ai.panel.copyMcpConfig: 6a8c9fe401557c870e3a90bbfab01b25
|
||||
ai.panel.mcpConfig: 53bac93d0314941c8d2c1163026f3ad9
|
||||
ai.panel.newChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
|
||||
@@ -370,7 +426,9 @@ files:
|
||||
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
|
||||
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
|
||||
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
|
||||
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
|
||||
editor.codeBlock.copy: 8e477020fb3f7ab844b91ff1d7de8347
|
||||
editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c
|
||||
editor.filename.rename: c31c2b468229232ad6287e734fe67d96
|
||||
editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f
|
||||
@@ -489,6 +547,7 @@ files:
|
||||
status.ai.noAgentsTooltip: 1bdc02ec2dd7f9701b4371ffd5770318
|
||||
status.ai.selectedMissing: a37f3dbc73725219e90d709fe0d3ad97
|
||||
status.ai.defaultAgent: 79650d57d1678e56c75dbbcba6ea87f7
|
||||
status.ai.defaultTarget: 65ea5bdb609833c4baffd2cbd0e86542
|
||||
status.ai.restoreDetails: 47a913b58ce40add6521bc93e807476f
|
||||
status.ai.withGuidance: 1eafd34810db6bba54dac7aa549b5182
|
||||
status.ai.active: 059e3a3167c2ab00f4ca872e82a48d98
|
||||
@@ -498,6 +557,9 @@ files:
|
||||
status.ai.vaultGuidance: 7bb4644efe6ae7cec9993c8bd85f1519
|
||||
status.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
|
||||
status.ai.openOptions: d387b1ab67586c7a2d83db8900a4dd8e
|
||||
status.ai.modelTargets: 8f3ceb96e088f8bdaecc236778401ac9
|
||||
status.ai.localChat: 215a24e3d38fb8b68deea6dad0142326
|
||||
status.ai.apiChat: 6dffd5b9997885efe701631cea5c470d
|
||||
pulse.title: 16d2b386b2034b9488996466aaae0b57
|
||||
pulse.today: 1dd1c5fb7f25cd41b291d43a89e3aefd
|
||||
pulse.yesterday: ebfe9ce86e6e9fb953aa7a25b59c1956
|
||||
|
||||
@@ -162,15 +162,6 @@ const TOOLS = [
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
search_notes: handleSearchNotes,
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
highlight_editor: handleHighlightEditor,
|
||||
refresh_vault: handleRefreshVault,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
const results = await searchNotes(VAULT_PATH, args.query, args.limit)
|
||||
const text = results.length === 0
|
||||
@@ -207,6 +198,25 @@ function handleRefreshVault(args) {
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
function callToolHandler(name, args) {
|
||||
switch (name) {
|
||||
case 'search_notes':
|
||||
return handleSearchNotes(args)
|
||||
case 'get_vault_context':
|
||||
return handleVaultContext()
|
||||
case 'get_note':
|
||||
return handleGetNote(args)
|
||||
case 'open_note':
|
||||
return handleOpenNote(args)
|
||||
case 'highlight_editor':
|
||||
return handleHighlightEditor(args)
|
||||
case 'refresh_vault':
|
||||
return handleRefreshVault(args)
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
@@ -220,12 +230,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
const handler = TOOL_HANDLERS[name]
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
try {
|
||||
return await handler(args)
|
||||
return await callToolHandler(name, args)
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
||||
|
||||
32
mcp-server/package-lock.json
generated
32
mcp-server/package-lock.json
generated
@@ -14,9 +14,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"version": "1.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
|
||||
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -431,12 +431,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.2.tgz",
|
||||
"integrity": "sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -619,9 +619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",
|
||||
"integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==",
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -670,9 +670,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -882,9 +882,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
||||
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -12,5 +12,11 @@
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"hono": "4.12.14",
|
||||
"path-to-regexp": "8.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import {
|
||||
mkdtemp, mkdir, open, rm,
|
||||
} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -16,12 +18,12 @@ const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
|
||||
await fs.mkdir(path.join(tmpDir, 'project'), { recursive: true })
|
||||
await fs.mkdir(path.join(tmpDir, 'note'), { recursive: true })
|
||||
await mkdir(path.join(tmpDir, 'project'), { recursive: true })
|
||||
await mkdir(path.join(tmpDir, 'note'), { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'test-project.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'project', 'test-project.md'), `---
|
||||
title: Test Project
|
||||
is_a: Project
|
||||
status: Active
|
||||
@@ -32,7 +34,7 @@ status: Active
|
||||
This is a test project for the MCP server.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'note', 'daily-log.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'note', 'daily-log.md'), `---
|
||||
title: Daily Log
|
||||
is_a: Note
|
||||
---
|
||||
@@ -42,7 +44,7 @@ is_a: Note
|
||||
Today I worked on the MCP server implementation.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'second-project.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'project', 'second-project.md'), `---
|
||||
title: Second Project
|
||||
type: Project
|
||||
status: Draft
|
||||
@@ -57,7 +59,7 @@ Another project for testing list and context.
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('findMarkdownFiles', () => {
|
||||
@@ -240,17 +242,26 @@ describe('stdio process lifecycle', () => {
|
||||
})
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideDir = await mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
|
||||
try {
|
||||
await fs.writeFile(outsideNote, '# Outside\n')
|
||||
await writeTextFile(outsideNote, '# Outside\n')
|
||||
await assert.rejects(
|
||||
() => getNote(tmpDir, resolveNotePath(outsideNote)),
|
||||
{ message: ACTIVE_VAULT_ERROR },
|
||||
)
|
||||
} finally {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true })
|
||||
await rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTextFile(filePath, content) {
|
||||
const handle = await open(filePath, 'w')
|
||||
try {
|
||||
await handle.writeFile(content, 'utf-8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Write operations are handled by the app-managed agent's active permission
|
||||
* profile and native file-edit tools when available.
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import { open, opendir, realpath } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
@@ -16,17 +16,17 @@ const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
*/
|
||||
export async function findMarkdownFiles(dir) {
|
||||
const results = []
|
||||
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const items = await opendir(dir)
|
||||
for await (const item of items) {
|
||||
await collectMarkdownFile(results, dir, item)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function resolveVaultNotePath(vaultPath, notePath) {
|
||||
const vaultRoot = await fs.realpath(vaultPath)
|
||||
const vaultRoot = await realpath(vaultPath)
|
||||
const requestedPath = resolveRequestedNotePath(vaultRoot, notePath)
|
||||
const noteRealPath = await fs.realpath(requestedPath)
|
||||
const noteRealPath = await realpath(requestedPath)
|
||||
const relativePath = path.relative(vaultRoot, noteRealPath)
|
||||
|
||||
if (!isVaultRelativePath(relativePath)) {
|
||||
@@ -51,7 +51,7 @@ export async function getNote(vaultPath, notePath) {
|
||||
noteRealPath,
|
||||
relativePath,
|
||||
} = await resolveVaultNotePath(vaultPath, notePath)
|
||||
const raw = await fs.readFile(noteRealPath, 'utf-8')
|
||||
const raw = await readUtf8File(noteRealPath)
|
||||
const parsed = matter(raw)
|
||||
return {
|
||||
path: relativePath,
|
||||
@@ -74,7 +74,7 @@ export async function searchNotes(vaultPath, query, limit = 10) {
|
||||
|
||||
for (const filePath of files) {
|
||||
if (results.length >= limit) break
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const content = await readUtf8File(filePath)
|
||||
const filename = path.basename(filePath, '.md')
|
||||
const titleMatch = extractTitle(content, filename)
|
||||
if (!matchesSearchQuery(titleMatch, content, q)) continue
|
||||
@@ -126,7 +126,8 @@ export async function vaultContext(vaultPath) {
|
||||
async function collectMarkdownFile(results, dir, item) {
|
||||
if (item.name.startsWith('.')) return
|
||||
|
||||
const full = path.join(dir, item.name)
|
||||
const full = resolveInside(dir, item.name)
|
||||
if (!full) return
|
||||
if (item.isDirectory()) {
|
||||
results.push(...await findMarkdownFiles(full))
|
||||
return
|
||||
@@ -139,7 +140,16 @@ async function collectMarkdownFile(results, dir, item) {
|
||||
|
||||
function resolveRequestedNotePath(vaultRoot, notePath) {
|
||||
if (path.isAbsolute(notePath)) return notePath
|
||||
return path.resolve(vaultRoot, notePath)
|
||||
const resolved = resolveInside(vaultRoot, notePath)
|
||||
if (!resolved) throw new Error(ACTIVE_VAULT_ERROR)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function resolveInside(root, target) {
|
||||
const resolved = path.resolve(root, target)
|
||||
const relative = path.relative(root, resolved)
|
||||
if (isVaultRelativePath(relative)) return resolved
|
||||
return null
|
||||
}
|
||||
|
||||
function isVaultRelativePath(relativePath) {
|
||||
@@ -151,11 +161,11 @@ function matchesSearchQuery(title, content, query) {
|
||||
}
|
||||
|
||||
async function readVaultContextNote(vaultPath, filePath) {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const raw = await readUtf8File(filePath)
|
||||
const parsed = matter(raw)
|
||||
const rel = path.relative(vaultPath, filePath)
|
||||
const topFolder = extractTopFolder(rel)
|
||||
const stat = await fs.stat(filePath)
|
||||
const stat = await statFile(filePath)
|
||||
const type = parsed.data.type || parsed.data.is_a || null
|
||||
|
||||
return {
|
||||
@@ -179,8 +189,8 @@ async function readConfigFiles(vaultPath) {
|
||||
const configFiles = {}
|
||||
|
||||
try {
|
||||
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
|
||||
configFiles.agents = await fs.readFile(agentsPath, 'utf-8')
|
||||
const agentsPath = resolveInside(vaultPath, 'config/agents.md')
|
||||
if (agentsPath) configFiles.agents = await readUtf8File(agentsPath)
|
||||
} catch {
|
||||
// config/agents.md may not exist yet
|
||||
}
|
||||
@@ -188,6 +198,24 @@ async function readConfigFiles(vaultPath) {
|
||||
return configFiles
|
||||
}
|
||||
|
||||
async function readUtf8File(filePath) {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
return await handle.readFile('utf-8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function statFile(filePath) {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
return await handle.stat()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract title from markdown content (first H1 or frontmatter title).
|
||||
* @param {string} content
|
||||
|
||||
@@ -38,6 +38,7 @@ const TRUSTED_UI_ORIGINS = new Set([
|
||||
/** @type {WebSocketServer | null} */
|
||||
let uiBridge = null
|
||||
let vaultPath = null
|
||||
const UNKNOWN_TOOL = Symbol('unknown tool')
|
||||
|
||||
function activeVaultPath() {
|
||||
vaultPath ??= requireVaultPath()
|
||||
@@ -53,30 +54,65 @@ function broadcastUiAction(action, payload) {
|
||||
}
|
||||
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(activeVaultPath(), args.query, args.limit),
|
||||
vault_context: () => vaultContext(activeVaultPath()),
|
||||
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
|
||||
async function readNoteTool(args) {
|
||||
const note = await getNote(activeVaultPath(), args.path)
|
||||
return { content: note.content, frontmatter: note.frontmatter }
|
||||
}
|
||||
|
||||
function uiOpenNoteTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_note', { path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function uiOpenTabTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function highlightTool(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function uiSetFilterTool(args) {
|
||||
broadcastUiAction('set_filter', { filterType: args.type })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function refreshVaultTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args?.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const TOOL_EXECUTORS = [
|
||||
['open_note', readNoteTool],
|
||||
['read_note', readNoteTool],
|
||||
['search_notes', (args) => searchNotes(activeVaultPath(), args.query, args.limit)],
|
||||
['vault_context', () => vaultContext(activeVaultPath())],
|
||||
['ui_open_note', uiOpenNoteTool],
|
||||
['ui_open_tab', uiOpenTabTool],
|
||||
['ui_highlight', highlightTool],
|
||||
['highlight_editor', highlightTool],
|
||||
['ui_set_filter', uiSetFilterTool],
|
||||
['refresh_vault', refreshVaultTool],
|
||||
]
|
||||
|
||||
function callToolHandler(tool, args) {
|
||||
const executor = TOOL_EXECUTORS.find(([name]) => name === tool)?.[1]
|
||||
return executor ? executor(args) : UNKNOWN_TOOL
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
const msg = JSON.parse(data)
|
||||
const { id, tool, args } = msg
|
||||
|
||||
const handler = TOOL_HANDLERS[tool]
|
||||
if (!handler) {
|
||||
return { id, error: `Unknown tool: ${tool}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(args || {})
|
||||
const result = await callToolHandler(tool, args || {})
|
||||
if (result === UNKNOWN_TOOL) {
|
||||
return { id, error: `Unknown tool: ${tool}` }
|
||||
}
|
||||
return { id, result }
|
||||
} catch (err) {
|
||||
return { id, error: err.message }
|
||||
|
||||
19
package.json
19
package.json
@@ -17,7 +17,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
@@ -58,6 +58,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "3.4.2",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mermaid": "^11.14.0",
|
||||
@@ -70,8 +71,10 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-regex2": "5.1.1",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tldraw": "^4.5.10",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unicode-emoji-json": "^0.8.0"
|
||||
},
|
||||
@@ -98,8 +101,20 @@
|
||||
"jsdom": "^28.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.2",
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"hono": "4.12.14",
|
||||
"path-to-regexp": "8.4.0",
|
||||
"picomatch": "4.0.4",
|
||||
"postcss": "8.5.10",
|
||||
"protobufjs": "7.5.6",
|
||||
"rollup": "4.59.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
904
pnpm-lock.yaml
generated
904
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,14 @@
|
||||
*/
|
||||
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
closeSync,
|
||||
createReadStream,
|
||||
fstatSync,
|
||||
openSync,
|
||||
opendirSync,
|
||||
readFileSync,
|
||||
} from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import matter from 'gray-matter'
|
||||
@@ -16,8 +23,71 @@ const REPO_DIR = path.resolve(__dirname, '..')
|
||||
const PORT = 5173
|
||||
|
||||
function isAllowedPath(p) {
|
||||
const resolved = path.resolve(p)
|
||||
return resolved.startsWith(REPO_DIR)
|
||||
return isInsideRelativePath(path.relative(REPO_DIR, p))
|
||||
}
|
||||
|
||||
function isInsideRelativePath(relative) {
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function resolveInside(root, target) {
|
||||
const normalizedTarget = path.normalize(target)
|
||||
if (path.isAbsolute(normalizedTarget)) return null
|
||||
const candidate = path.normalize(`${root}${path.sep}${normalizedTarget}`)
|
||||
return isInsideRelativePath(path.relative(root, candidate)) ? candidate : null
|
||||
}
|
||||
|
||||
function readUtf8File(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return readFileSync(fd, 'utf-8')
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function pathStats(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return fstatSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
pathStats(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function directoryEntries(dir) {
|
||||
const directory = opendirSync(dir)
|
||||
try {
|
||||
const entries = []
|
||||
let entry = directory.readSync()
|
||||
while (entry) {
|
||||
entries.push(entry)
|
||||
entry = directory.readSync()
|
||||
}
|
||||
return entries
|
||||
} finally {
|
||||
directory.closeSync()
|
||||
}
|
||||
}
|
||||
|
||||
function streamFile(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
return createReadStream(null, { fd, autoClose: true })
|
||||
}
|
||||
|
||||
function staticAssetPath(url) {
|
||||
const pathname = new URL(url, 'http://localhost').pathname
|
||||
const requested = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '')
|
||||
return resolveInside(DIST_DIR, requested) ?? path.normalize(`${DIST_DIR}${path.sep}index.html`)
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
@@ -34,8 +104,9 @@ const MIME = {
|
||||
function findMarkdownFiles(dir) {
|
||||
const results = []
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
for (const entry of directoryEntries(dir)) {
|
||||
const full = resolveInside(dir, entry.name)
|
||||
if (!full) continue
|
||||
if (entry.isDirectory()) results.push(...findMarkdownFiles(full))
|
||||
else if (entry.name.endsWith('.md')) results.push(full)
|
||||
}
|
||||
@@ -51,9 +122,9 @@ function extractWikiLinks(value) {
|
||||
|
||||
function parseMarkdownFile(filePath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const raw = readUtf8File(filePath)
|
||||
const { data: fm, content } = matter(raw)
|
||||
const stat = fs.statSync(filePath)
|
||||
const stat = pathStats(filePath)
|
||||
|
||||
const DEDICATED = new Set(['aliases','Is A','Belongs to','Related to','Status','Owner','Cadence','Created at'])
|
||||
const relationships = {}
|
||||
@@ -98,7 +169,7 @@ function serveVaultApi(url, res) {
|
||||
|
||||
if (params.pathname === '/api/vault/list') {
|
||||
const dir = params.searchParams.get('path')
|
||||
if (!dir || !isAllowedPath(dir) || !fs.existsSync(dir)) {
|
||||
if (!dir || !isAllowedPath(dir) || !pathExists(dir)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
const entries = findMarkdownFiles(dir).map(parseMarkdownFile).filter(Boolean)
|
||||
@@ -109,22 +180,22 @@ function serveVaultApi(url, res) {
|
||||
|
||||
if (params.pathname === '/api/vault/content') {
|
||||
const file = params.searchParams.get('path')
|
||||
if (!file || !isAllowedPath(file) || !fs.existsSync(file)) {
|
||||
if (!file || !isAllowedPath(file) || !pathExists(file)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ content: fs.readFileSync(file, 'utf-8') }))
|
||||
res.end(JSON.stringify({ content: readUtf8File(file) }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (params.pathname === '/api/vault/all-content') {
|
||||
const dir = params.searchParams.get('path')
|
||||
if (!dir || !isAllowedPath(dir) || !fs.existsSync(dir)) {
|
||||
if (!dir || !isAllowedPath(dir) || !pathExists(dir)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
const map = {}
|
||||
for (const f of findMarkdownFiles(dir)) {
|
||||
try { map[f] = fs.readFileSync(f, 'utf-8') } catch {}
|
||||
try { map[f] = readUtf8File(f) } catch {}
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(map))
|
||||
@@ -146,13 +217,13 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
|
||||
// Static files
|
||||
let filePath = path.join(DIST_DIR, url === '/' ? 'index.html' : url)
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(DIST_DIR, 'index.html') // SPA fallback
|
||||
let filePath = staticAssetPath(url)
|
||||
if (!pathExists(filePath) || pathStats(filePath).isDirectory()) {
|
||||
filePath = path.normalize(`${DIST_DIR}${path.sep}index.html`) // SPA fallback
|
||||
}
|
||||
const ext = path.extname(filePath)
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' })
|
||||
fs.createReadStream(filePath).pipe(res)
|
||||
streamFile(filePath).pipe(res)
|
||||
})
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from 'node:fs'
|
||||
import {
|
||||
closeSync, fstatSync, openSync, opendirSync, readFileSync,
|
||||
} from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -7,7 +9,42 @@ const localesDir = path.join(root, 'src/lib/locales')
|
||||
const sourcePath = path.join(localesDir, 'en.json')
|
||||
|
||||
function readCatalog(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
return JSON.parse(readUtf8File(filePath))
|
||||
}
|
||||
|
||||
function readUtf8File(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return readFileSync(fd, 'utf8')
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function directoryFiles(dirPath) {
|
||||
const dir = opendirSync(dirPath)
|
||||
try {
|
||||
const files = []
|
||||
let entry = dir.readSync()
|
||||
while (entry) {
|
||||
if (entry.isFile()) files.push(entry.name)
|
||||
entry = dir.readSync()
|
||||
}
|
||||
return files
|
||||
} finally {
|
||||
dir.closeSync()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
const fd = openSync(dirPath, 'r')
|
||||
try {
|
||||
if (!fstatSync(fd).isDirectory()) {
|
||||
throw new Error(`${dirPath} is not a directory`)
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function isFlatObject(value) {
|
||||
@@ -74,7 +111,8 @@ const sourceCatalog = readCatalog(sourcePath)
|
||||
assertFlatStringCatalog('en', sourceCatalog)
|
||||
|
||||
const sourceKeys = Object.keys(sourceCatalog).sort()
|
||||
const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json'))
|
||||
ensureDirectory(localesDir)
|
||||
const localeFiles = directoryFiles(localesDir).filter((file) => file.endsWith('.json'))
|
||||
const issues = []
|
||||
|
||||
for (const file of localeFiles) {
|
||||
|
||||
155
src-tauri/Cargo.lock
generated
155
src-tauri/Cargo.lock
generated
@@ -892,7 +892,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1079,7 +1079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1486,8 +1486,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1497,9 +1499,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1857,6 +1861,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2361,6 +2366,12 @@ dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2897,9 +2908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
version = "0.10.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
@@ -2929,9 +2940,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
version = "0.9.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3391,6 +3402,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@@ -3437,6 +3503,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3457,6 +3533,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3475,6 +3561,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3612,6 +3707,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
@@ -3619,6 +3715,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3626,6 +3724,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
@@ -3633,6 +3732,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3763,6 +3863,12 @@ version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3782,7 +3888,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3817,6 +3923,7 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3838,7 +3945,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3849,9 +3956,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4603,9 +4710,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -5002,7 +5109,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5185,6 +5292,7 @@ dependencies = [
|
||||
"log",
|
||||
"notify",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5808,6 +5916,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.2"
|
||||
@@ -5861,6 +5979,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -5919,7 +6046,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -39,5 +39,6 @@ tauri-plugin-prevent-default = "4.0.4"
|
||||
sentry = "0.37"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tempfile = "3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
701
src-tauri/src/ai_models.rs
Normal file
701
src-tauri/src/ai_models.rs
Normal file
@@ -0,0 +1,701 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
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")]
|
||||
pub enum AiModelProviderKind {
|
||||
OpenAi,
|
||||
Anthropic,
|
||||
OpenAiCompatible,
|
||||
Ollama,
|
||||
LmStudio,
|
||||
OpenRouter,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiModelApiKeyStorage {
|
||||
None,
|
||||
Env,
|
||||
LocalFile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelCapabilities {
|
||||
pub streaming: bool,
|
||||
pub tools: bool,
|
||||
pub vision: bool,
|
||||
pub json_mode: bool,
|
||||
pub reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelDefinition {
|
||||
pub id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub context_window: Option<u32>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub capabilities: AiModelCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelProvider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: AiModelProviderKind,
|
||||
pub base_url: Option<String>,
|
||||
pub api_key_storage: Option<AiModelApiKeyStorage>,
|
||||
pub api_key_env_var: Option<String>,
|
||||
pub headers: Option<BTreeMap<String, String>>,
|
||||
pub models: Vec<AiModelDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AiModelStreamRequest {
|
||||
pub provider: AiModelProvider,
|
||||
pub model_id: String,
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub api_key_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AiModelProviderTestRequest {
|
||||
pub provider: AiModelProvider,
|
||||
pub model_id: String,
|
||||
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>> {
|
||||
let normalized = providers?
|
||||
.into_iter()
|
||||
.filter_map(normalize_ai_model_provider)
|
||||
.collect::<Vec<_>>();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ai_model_provider(mut provider: AiModelProvider) -> Option<AiModelProvider> {
|
||||
provider.id = provider.id.trim().to_ascii_lowercase();
|
||||
provider.name = provider.name.trim().to_string();
|
||||
provider.base_url = normalize_optional_string(provider.base_url);
|
||||
provider.api_key_env_var = normalize_optional_string(provider.api_key_env_var);
|
||||
provider.api_key_storage = normalize_api_key_storage(&provider);
|
||||
provider.models = normalized_models(provider.models);
|
||||
|
||||
is_valid_provider(&provider).then_some(provider)
|
||||
}
|
||||
|
||||
fn normalize_api_key_storage(provider: &AiModelProvider) -> Option<AiModelApiKeyStorage> {
|
||||
match provider.api_key_storage {
|
||||
Some(AiModelApiKeyStorage::LocalFile) => Some(AiModelApiKeyStorage::LocalFile),
|
||||
Some(AiModelApiKeyStorage::Env) | None if provider.api_key_env_var.is_some() => {
|
||||
Some(AiModelApiKeyStorage::Env)
|
||||
}
|
||||
_ => Some(AiModelApiKeyStorage::None),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_models(models: Vec<AiModelDefinition>) -> Vec<AiModelDefinition> {
|
||||
models
|
||||
.into_iter()
|
||||
.filter_map(|mut model| {
|
||||
model.id = model.id.trim().to_string();
|
||||
model.display_name = normalize_optional_string(model.display_name);
|
||||
(!model.id.is_empty()).then_some(model)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_valid_provider(provider: &AiModelProvider) -> bool {
|
||||
!provider.id.is_empty() && !provider.name.is_empty() && !provider.models.is_empty()
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|candidate| candidate.trim().to_string())
|
||||
.filter(|candidate| !candidate.is_empty())
|
||||
}
|
||||
|
||||
pub fn run_ai_model_stream<F>(request: AiModelStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: format!("api-{}", uuid::Uuid::new_v4()),
|
||||
});
|
||||
|
||||
let text = send_model_message(&request)?;
|
||||
|
||||
emit(AiAgentStreamEvent::TextDelta { text });
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
|
||||
let request = AiModelStreamRequest {
|
||||
provider: request.provider,
|
||||
model_id: request.model_id,
|
||||
message: "Reply with exactly OK.".into(),
|
||||
system_prompt: Some(
|
||||
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
|
||||
.into(),
|
||||
),
|
||||
api_key_override: normalize_optional_string(request.api_key_override),
|
||||
};
|
||||
send_model_message(&request)
|
||||
}
|
||||
|
||||
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
match request.provider.kind {
|
||||
AiModelProviderKind::Anthropic => send_anthropic_message(request),
|
||||
_ => send_openai_compatible_message(request),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let endpoint = format!("{}/chat/completions", normalized_base_url(request)?);
|
||||
let mut messages = Vec::new();
|
||||
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
|
||||
messages.push(serde_json::json!({ "role": "system", "content": system_prompt }));
|
||||
}
|
||||
messages.push(serde_json::json!({ "role": "user", "content": request.message }));
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": request.model_id,
|
||||
"messages": messages,
|
||||
"stream": false
|
||||
});
|
||||
let json = send_json_request(request, endpoint, payload)?;
|
||||
extract_openai_text(&json)
|
||||
}
|
||||
|
||||
fn send_anthropic_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let endpoint = format!("{}/messages", normalized_base_url(request)?);
|
||||
let mut payload = serde_json::json!({
|
||||
"model": request.model_id,
|
||||
"max_tokens": selected_max_tokens(request),
|
||||
"messages": [{ "role": "user", "content": request.message }]
|
||||
});
|
||||
|
||||
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
|
||||
payload["system"] = serde_json::Value::String(system_prompt.to_string());
|
||||
}
|
||||
|
||||
let json = send_json_request(request, endpoint, payload)?;
|
||||
extract_anthropic_text(&json)
|
||||
}
|
||||
|
||||
fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 {
|
||||
request
|
||||
.provider
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.id == request.model_id)
|
||||
.and_then(|model| model.max_output_tokens)
|
||||
.unwrap_or(4096)
|
||||
}
|
||||
|
||||
fn normalized_base_url(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let fallback = provider_default_base_url(&request.provider.kind).unwrap_or("");
|
||||
let base = request
|
||||
.provider
|
||||
.base_url
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
.unwrap_or(fallback)
|
||||
.trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
return Err("Custom API providers need a base URL.".into());
|
||||
}
|
||||
Ok(base.to_string())
|
||||
}
|
||||
|
||||
fn send_json_request(
|
||||
request: &AiModelStreamRequest,
|
||||
endpoint: String,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|error| format!("Failed to create HTTP client: {error}"))?;
|
||||
let builder = client.post(endpoint).json(&payload);
|
||||
let builder = apply_auth_headers(builder, request)?;
|
||||
let builder = apply_provider_headers(builder, request);
|
||||
let response = send_provider_request(builder)?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.map_err(|error| format!("Failed to read AI provider response: {error}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"AI provider returned {status}: {}",
|
||||
truncate_error(&text)
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|error| format!("Failed to parse AI provider response: {error}"))
|
||||
}
|
||||
|
||||
fn apply_auth_headers(
|
||||
builder: reqwest::blocking::RequestBuilder,
|
||||
request: &AiModelStreamRequest,
|
||||
) -> Result<reqwest::blocking::RequestBuilder, String> {
|
||||
let Some(api_key) = api_key_from_provider(request)? else {
|
||||
return Ok(builder);
|
||||
};
|
||||
|
||||
Ok(match request.provider.kind {
|
||||
AiModelProviderKind::Anthropic => builder.header("x-api-key", api_key),
|
||||
_ => builder.bearer_auth(api_key),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_provider_headers(
|
||||
mut builder: reqwest::blocking::RequestBuilder,
|
||||
request: &AiModelStreamRequest,
|
||||
) -> reqwest::blocking::RequestBuilder {
|
||||
if matches!(request.provider.kind, AiModelProviderKind::Anthropic) {
|
||||
builder = builder.header("anthropic-version", "2023-06-01");
|
||||
}
|
||||
for (key, value) in safe_custom_headers(request) {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
fn safe_custom_headers(request: &AiModelStreamRequest) -> Vec<(&String, &String)> {
|
||||
request
|
||||
.provider
|
||||
.headers
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|headers| headers.iter())
|
||||
.filter(|(key, value)| {
|
||||
!key.eq_ignore_ascii_case("authorization") && non_empty_option(Some(value)).is_some()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn send_provider_request(
|
||||
builder: reqwest::blocking::RequestBuilder,
|
||||
) -> Result<reqwest::blocking::Response, String> {
|
||||
builder
|
||||
.send()
|
||||
.map_err(|error| format!("AI provider request failed: {error}"))
|
||||
}
|
||||
|
||||
pub fn save_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
|
||||
let provider_id = normalize_secret_provider_id(&provider_id)?;
|
||||
let api_key = api_key.trim().to_string();
|
||||
if api_key.is_empty() {
|
||||
return Err("API key cannot be empty.".into());
|
||||
}
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.insert(provider_id, api_key);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
|
||||
pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
let provider_id = normalize_secret_provider_id(&provider_id)?;
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.remove(&provider_id);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
|
||||
fn normalize_secret_provider_id(provider_id: &str) -> Result<String, String> {
|
||||
let provider_id = provider_id.trim().to_ascii_lowercase();
|
||||
if provider_id.is_empty() {
|
||||
Err("Provider ID cannot be empty.".into())
|
||||
} else {
|
||||
Ok(provider_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn secrets_path() -> Result<std::path::PathBuf, String> {
|
||||
crate::settings::preferred_app_config_path("ai-provider-secrets.json")
|
||||
}
|
||||
|
||||
fn read_secrets_at(path: &Path) -> Result<AiProviderSecrets, String> {
|
||||
if !path.exists() {
|
||||
return Ok(AiProviderSecrets::default());
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|error| format!("Failed to read AI provider secrets: {error}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|error| format!("Failed to parse AI provider secrets: {error}"))
|
||||
}
|
||||
|
||||
fn write_secrets_at(path: &Path, secrets: &AiProviderSecrets) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("Failed to create AI provider secrets directory: {error}"))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(secrets)
|
||||
.map_err(|error| format!("Failed to serialize AI provider secrets: {error}"))?;
|
||||
write_secret_file(path, json)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Failed to open AI provider secrets file: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|error| format!("Failed to write AI provider secrets: {error}"))?;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("Failed to secure AI provider secrets file: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
|
||||
fs::write(path, content)
|
||||
.map_err(|error| format!("Failed to write AI provider secrets: {error}"))
|
||||
}
|
||||
|
||||
fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
let secrets = read_secrets_at(&secrets_path()?)?;
|
||||
let api_key = secrets
|
||||
.provider_api_keys
|
||||
.get(&request.provider.id)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
if api_key.is_none() {
|
||||
return Err(format!(
|
||||
"No local API key is saved for {}.",
|
||||
request.provider.name
|
||||
));
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
let Some(name) = request
|
||||
.provider
|
||||
.api_key_env_var
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
std::env::var(name)
|
||||
.map(Some)
|
||||
.map_err(|_| format!("Environment variable {name} is not set for this AI provider."))
|
||||
}
|
||||
|
||||
fn api_key_from_provider(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
if let Some(api_key) = request
|
||||
.api_key_override
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
.map(str::to_string)
|
||||
{
|
||||
return Ok(Some(api_key));
|
||||
}
|
||||
|
||||
match request.provider.api_key_storage {
|
||||
Some(AiModelApiKeyStorage::LocalFile) => api_key_from_local_file(request),
|
||||
Some(AiModelApiKeyStorage::Env) => api_key_from_env(request),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_openai_text(json: &serde_json::Value) -> Result<String, String> {
|
||||
json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.ok_or_else(|| "AI provider response did not include assistant text.".to_string())
|
||||
}
|
||||
|
||||
fn extract_anthropic_text(json: &serde_json::Value) -> Result<String, String> {
|
||||
let text = json["content"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|block| block["text"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if text.trim().is_empty() {
|
||||
Err("Anthropic response did not include assistant text.".into())
|
||||
} else {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_option(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn non_empty_str(value: &str) -> Option<&str> {
|
||||
non_empty_option(Some(value))
|
||||
}
|
||||
|
||||
fn truncate_error(value: &str) -> String {
|
||||
const MAX_ERROR_LENGTH: usize = 600;
|
||||
if value.len() <= MAX_ERROR_LENGTH {
|
||||
return value.to_string();
|
||||
}
|
||||
format!("{}...", &value[..MAX_ERROR_LENGTH])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn capabilities() -> AiModelCapabilities {
|
||||
AiModelCapabilities {
|
||||
streaming: true,
|
||||
tools: false,
|
||||
vision: false,
|
||||
json_mode: true,
|
||||
reasoning: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn model(id: &str) -> AiModelDefinition {
|
||||
AiModelDefinition {
|
||||
id: id.into(),
|
||||
display_name: Some(" Demo Model ".into()),
|
||||
context_window: Some(128_000),
|
||||
max_output_tokens: Some(8192),
|
||||
capabilities: capabilities(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(kind: AiModelProviderKind) -> AiModelProvider {
|
||||
AiModelProvider {
|
||||
id: " Demo ".into(),
|
||||
name: " Demo Provider ".into(),
|
||||
kind,
|
||||
base_url: Some(" https://example.com/v1/ ".into()),
|
||||
api_key_storage: None,
|
||||
api_key_env_var: Some(" DEMO_API_KEY ".into()),
|
||||
headers: None,
|
||||
models: vec![model(" demo-model "), model(" ")],
|
||||
}
|
||||
}
|
||||
|
||||
fn request(provider: AiModelProvider) -> AiModelStreamRequest {
|
||||
AiModelStreamRequest {
|
||||
provider,
|
||||
model_id: "demo-model".into(),
|
||||
message: "Hello".into(),
|
||||
system_prompt: Some(" Be concise. ".into()),
|
||||
api_key_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_providers_trims_values_and_filters_invalid_entries() {
|
||||
let invalid = AiModelProvider {
|
||||
id: " ".into(),
|
||||
name: "Missing".into(),
|
||||
kind: AiModelProviderKind::OpenAiCompatible,
|
||||
base_url: None,
|
||||
api_key_storage: None,
|
||||
api_key_env_var: None,
|
||||
headers: None,
|
||||
models: vec![model("ignored")],
|
||||
};
|
||||
|
||||
let providers = normalize_ai_model_providers(Some(vec![
|
||||
invalid,
|
||||
provider(AiModelProviderKind::OpenAiCompatible),
|
||||
]))
|
||||
.expect("valid provider should remain");
|
||||
|
||||
assert_eq!(providers.len(), 1);
|
||||
let normalized = &providers[0];
|
||||
assert_eq!(normalized.id, "demo");
|
||||
assert_eq!(normalized.name, "Demo Provider");
|
||||
assert_eq!(
|
||||
normalized.base_url.as_deref(),
|
||||
Some("https://example.com/v1/")
|
||||
);
|
||||
assert_eq!(normalized.api_key_env_var.as_deref(), Some("DEMO_API_KEY"));
|
||||
assert_eq!(normalized.api_key_storage, Some(AiModelApiKeyStorage::Env));
|
||||
assert_eq!(normalized.models.len(), 1);
|
||||
assert_eq!(normalized.models[0].id, "demo-model");
|
||||
assert_eq!(
|
||||
normalized.models[0].display_name.as_deref(),
|
||||
Some("Demo Model")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_providers_returns_none_for_missing_or_empty_sets() {
|
||||
assert_eq!(normalize_ai_model_providers(None), None);
|
||||
assert_eq!(normalize_ai_model_providers(Some(Vec::new())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_request_helpers_resolve_defaults_and_safe_headers() {
|
||||
let mut provider = provider(AiModelProviderKind::Anthropic);
|
||||
provider.base_url = None;
|
||||
provider.headers = Some(BTreeMap::from([
|
||||
("Authorization".into(), "ignored".into()),
|
||||
("X-Demo".into(), "demo".into()),
|
||||
("X-Blank".into(), " ".into()),
|
||||
]));
|
||||
provider.models = vec![model("demo-model")];
|
||||
let request = request(provider);
|
||||
let headers = safe_custom_headers(&request)
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
normalized_base_url(&request).unwrap(),
|
||||
"https://api.anthropic.com/v1"
|
||||
);
|
||||
assert_eq!(selected_max_tokens(&request), 8192);
|
||||
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);
|
||||
provider.base_url = Some(" ".into());
|
||||
|
||||
assert_eq!(
|
||||
normalized_base_url(&request(provider)).unwrap_err(),
|
||||
"Custom API providers need a base URL.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_provider_text_payloads_and_reports_empty_responses() {
|
||||
let openai = json!({
|
||||
"choices": [{ "message": { "content": "Hello from OpenAI" } }]
|
||||
});
|
||||
let anthropic = json!({
|
||||
"content": [
|
||||
{ "text": "Hello " },
|
||||
{ "text": "from Anthropic" }
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(extract_openai_text(&openai).unwrap(), "Hello from OpenAI");
|
||||
assert_eq!(
|
||||
extract_anthropic_text(&anthropic).unwrap(),
|
||||
"Hello from Anthropic"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_openai_text(&json!({ "choices": [] })).unwrap_err(),
|
||||
"AI provider response did not include assistant text.",
|
||||
);
|
||||
assert_eq!(
|
||||
extract_anthropic_text(&json!({ "content": [{ "type": "thinking" }] })).unwrap_err(),
|
||||
"Anthropic response did not include assistant text.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_reads_and_validates_local_provider_secrets() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested/secrets.json");
|
||||
let secrets = AiProviderSecrets {
|
||||
provider_api_keys: BTreeMap::from([("demo".into(), "secret".into())]),
|
||||
};
|
||||
|
||||
write_secrets_at(&path, &secrets).unwrap();
|
||||
|
||||
assert_eq!(read_secrets_at(&path).unwrap(), secrets);
|
||||
assert_eq!(
|
||||
read_secrets_at(&dir.path().join("missing.json")).unwrap(),
|
||||
AiProviderSecrets::default()
|
||||
);
|
||||
assert_eq!(normalize_secret_provider_id(" Demo ").unwrap(), "demo");
|
||||
assert_eq!(
|
||||
normalize_secret_provider_id(" ").unwrap_err(),
|
||||
"Provider ID cannot be empty.",
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_provider_errors_without_touching_short_errors() {
|
||||
let short = "provider unavailable";
|
||||
let long = "x".repeat(605);
|
||||
|
||||
assert_eq!(truncate_error(short), short);
|
||||
assert_eq!(truncate_error(&long).len(), 603);
|
||||
assert!(truncate_error(&long).ends_with("..."));
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,21 @@ fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
crate::hidden_command(codex_path_lookup_command())
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn codex_path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
@@ -102,13 +110,27 @@ fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".codex/bin/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".npm/bin/codex.cmd"),
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex"),
|
||||
home.join(".bun/bin/codex.exe"),
|
||||
home.join(".linuxbrew/bin/codex"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
@@ -208,6 +230,7 @@ fn build_codex_command(
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(vault_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
@@ -218,6 +241,7 @@ fn build_codex_args(
|
||||
last_message_path: Option<&Path>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
let node_path = crate::mcp::find_node()?;
|
||||
|
||||
let mut args = vec![
|
||||
"--sandbox".into(),
|
||||
@@ -229,14 +253,11 @@ fn build_codex_args(
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
codex_config_string("mcp_servers.tolaria.command", &node_path.to_string_lossy()),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
codex_mcp_env_config(&request.vault_path),
|
||||
];
|
||||
|
||||
if let Some(path) = last_message_path {
|
||||
@@ -247,6 +268,30 @@ fn build_codex_args(
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn codex_config_string(key: &str, value: &str) -> String {
|
||||
format!(r#"{key}="{}""#, toml_escape(value))
|
||||
}
|
||||
|
||||
fn codex_config_string_list(key: &str, values: &[&str]) -> String {
|
||||
let values = values
|
||||
.iter()
|
||||
.map(|value| format!(r#""{}""#, toml_escape(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{key}=[{values}]")
|
||||
}
|
||||
|
||||
fn codex_mcp_env_config(vault_path: &str) -> String {
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",WS_UI_PORT="9711"}}"#,
|
||||
toml_escape(vault_path)
|
||||
)
|
||||
}
|
||||
|
||||
fn toml_escape(value: &str) -> String {
|
||||
value.replace('\\', r#"\\"#).replace('"', r#"\""#)
|
||||
}
|
||||
|
||||
fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str {
|
||||
match permission_mode {
|
||||
crate::ai_agents::AiAgentPermissionMode::Safe => "read-only",
|
||||
@@ -543,6 +588,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_resolved_mcp_node_and_ui_bridge_env() {
|
||||
let args = build_codex_args(
|
||||
&AgentStreamRequest {
|
||||
message: "Read [[Test note]]".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let command_override = args
|
||||
.iter()
|
||||
.find(|arg| arg.starts_with("mcp_servers.tolaria.command="))
|
||||
.expect("Codex should receive a transient Tolaria MCP command");
|
||||
|
||||
assert!(
|
||||
!command_override.ends_with(r#""node""#),
|
||||
"Codex MCP command should use Tolaria's resolved Node path, got {command_override}"
|
||||
);
|
||||
assert!(
|
||||
command_override.contains('/'),
|
||||
"Codex MCP command should be an absolute Node path, got {command_override}"
|
||||
);
|
||||
assert!(args.iter().any(|arg| arg.contains(r#"WS_UI_PORT="9711""#)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
@@ -664,6 +738,88 @@ exit 2
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open() {
|
||||
use std::io::Read;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.arg("codex_stdin_probe_parent_child")
|
||||
.arg("--ignored")
|
||||
.arg("--nocapture")
|
||||
.env("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let child_stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
drop(child_stdin);
|
||||
panic!("Codex stdin probe child timed out");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
drop(child_stdin);
|
||||
let mut stdout_text = String::new();
|
||||
let mut stderr_text = String::new();
|
||||
stdout.read_to_string(&mut stdout_text).unwrap();
|
||||
stderr.read_to_string(&mut stderr_text).unwrap();
|
||||
|
||||
assert!(
|
||||
status.success(),
|
||||
"Codex stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "spawned by run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open"]
|
||||
#[test]
|
||||
fn codex_stdin_probe_parent_child() {
|
||||
if std::env::var_os("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
"codex",
|
||||
r#"stdin="$(cat)"
|
||||
if [ -n "$stdin" ]; then
|
||||
echo "stdin was not closed" >&2
|
||||
exit 9
|
||||
fi
|
||||
printf '%s\n' '{"type":"thread.started","thread_id":"stdin-ok"}'
|
||||
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"stdin closed"}}'
|
||||
"#,
|
||||
);
|
||||
let mut events = Vec::new();
|
||||
let result = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
codex_request(vault.path(), AiAgentPermissionMode::Safe),
|
||||
|event| events.push(event),
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap(), "stdin-ok");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "stdin closed"
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
@@ -705,6 +861,34 @@ exit 2
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from("C:/Users/alex");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
home.join(".npm/bin/codex.cmd"),
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_models::{AiModelProviderTestRequest, AiModelStreamRequest};
|
||||
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::vault::VaultAiGuidanceStatus;
|
||||
|
||||
@@ -81,14 +83,54 @@ define_desktop_stream_command!(
|
||||
crate::claude_cli::run_chat_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn normalize_agent_request(mut request: AiAgentStreamRequest) -> AiAgentStreamRequest {
|
||||
request.vault_path = expand_tilde(&request.vault_path).into_owned();
|
||||
request
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn run_normalized_ai_agent_stream(
|
||||
request: AiAgentStreamRequest,
|
||||
emitter: StreamEmitter<crate::ai_agents::AiAgentStreamEvent>,
|
||||
) -> Result<String, String> {
|
||||
crate::ai_agents::run_ai_agent_stream(normalize_agent_request(request), emitter)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_agent,
|
||||
AiAgentStreamRequest,
|
||||
"ai-agent-stream",
|
||||
crate::ai_agents::run_ai_agent_stream
|
||||
run_normalized_ai_agent_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_model,
|
||||
AiModelStreamRequest,
|
||||
"ai-model-stream",
|
||||
crate::ai_models::run_ai_model_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
|
||||
crate::ai_models::save_provider_api_key(provider_id, api_key)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
crate::ai_models::delete_provider_api_key(provider_id)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
|
||||
crate::ai_models::test_ai_model_provider(request)
|
||||
}
|
||||
|
||||
// ── Claude CLI (mobile stubs) ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -145,11 +187,84 @@ pub async fn stream_ai_agent(
|
||||
Err("CLI AI agents are not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_model(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: crate::ai_models::AiModelStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Direct AI model chat is not available in this mobile build yet.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn save_ai_model_provider_api_key(
|
||||
_provider_id: String,
|
||||
_api_key: String,
|
||||
) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn test_ai_model_provider(
|
||||
_request: crate::ai_models::AiModelProviderTestRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Direct AI model tests are not available in this mobile build yet.".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault::AiGuidanceFileState;
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn normalize_agent_request_expands_tilde_in_vault_path() {
|
||||
use crate::ai_agents::AiAgentId;
|
||||
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let request = AiAgentStreamRequest {
|
||||
agent: AiAgentId::ClaudeCode,
|
||||
message: "hi".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "~/Vaults/content".into(),
|
||||
permission_mode: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_agent_request(request);
|
||||
|
||||
assert_eq!(
|
||||
normalized.vault_path,
|
||||
format!("{}/Vaults/content", home.display()),
|
||||
"vault_path must be tilde-expanded so spawned agents can chdir into it",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn normalize_agent_request_leaves_absolute_vault_path_untouched() {
|
||||
use crate::ai_agents::AiAgentId;
|
||||
|
||||
let request = AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "hi".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/Users/example/vault".into(),
|
||||
permission_mode: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_agent_request(request);
|
||||
|
||||
assert_eq!(normalized.vault_path, "/Users/example/vault");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guidance_commands_report_and_restore_vault_guidance_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -244,6 +244,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-c".to_string(),
|
||||
"core.quotePath=false".to_string(),
|
||||
"clone".to_string(),
|
||||
"--quiet".to_string(),
|
||||
"https://example.com/repo.git".to_string(),
|
||||
|
||||
@@ -187,37 +187,53 @@ mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
fn force_quoted_git_paths(vault: &Path) {
|
||||
git_command()
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_and_commit_file(
|
||||
vault: &Path,
|
||||
relative_path: &str,
|
||||
content: &str,
|
||||
message: &str,
|
||||
) -> PathBuf {
|
||||
let file = vault.join(relative_path);
|
||||
fs::write(&file, content).unwrap();
|
||||
git_command()
|
||||
.args(["add", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", message])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
fn head_hash(vault: &Path) -> String {
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&log.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("test.md");
|
||||
|
||||
fs::write(&file, "# Initial\n").unwrap();
|
||||
git_command()
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# Updated\n\nNew content.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Update test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let file = write_and_commit_file(vault, "test.md", "# Initial\n", "Initial commit");
|
||||
write_and_commit_file(vault, "test.md", "# Updated\n\nNew content.", "Update test");
|
||||
|
||||
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
@@ -245,19 +261,13 @@ mod tests {
|
||||
fn test_get_file_diff() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("diff-test.md");
|
||||
|
||||
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "diff-test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Add diff-test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"diff-test.md",
|
||||
"# Test\n\nOriginal content.",
|
||||
"Add diff-test",
|
||||
);
|
||||
|
||||
fs::write(&file, "# Test\n\nModified content.").unwrap();
|
||||
|
||||
@@ -272,39 +282,21 @@ mod tests {
|
||||
fn test_get_file_diff_at_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("diff-at-commit.md");
|
||||
|
||||
fs::write(&file, "# First\n\nOriginal content.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "First commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"diff-at-commit.md",
|
||||
"# First\n\nOriginal content.",
|
||||
"First commit",
|
||||
);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
"diff-at-commit.md",
|
||||
"# First\n\nModified content.",
|
||||
"Second commit",
|
||||
);
|
||||
|
||||
fs::write(&file, "# First\n\nModified content.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Second commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Get hash of second commit
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
@@ -318,26 +310,15 @@ mod tests {
|
||||
fn test_get_file_diff_at_initial_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("initial.md");
|
||||
|
||||
fs::write(&file, "# Initial\n\nHello world.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "initial.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"initial.md",
|
||||
"# Initial\n\nHello world.",
|
||||
"Initial commit",
|
||||
);
|
||||
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
@@ -346,4 +327,35 @@ mod tests {
|
||||
assert!(diff.contains("+# Initial"));
|
||||
assert!(diff.contains("+Hello world."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_commit_preserves_chinese_filename_and_content() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let relative_path = "中文笔记.md";
|
||||
let file = vault.join(relative_path);
|
||||
|
||||
force_quoted_git_paths(vault);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
relative_path,
|
||||
"# 初始\n\n第一行\n",
|
||||
"Add Chinese note",
|
||||
);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
relative_path,
|
||||
"# 初始\n\n第二行\n",
|
||||
"Update Chinese note",
|
||||
);
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(diff.contains("diff --git a/中文笔记.md b/中文笔记.md"));
|
||||
assert!(diff.contains("-第一行"));
|
||||
assert!(diff.contains("+第二行"));
|
||||
assert!(!diff.contains("\\344"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,10 @@ const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never co
|
||||
*.swp\n\
|
||||
*.swo\n";
|
||||
|
||||
fn git_command() -> Command {
|
||||
pub(crate) fn git_command() -> Command {
|
||||
let mut command = crate::hidden_command("git");
|
||||
sanitize_linux_appimage_git_env(&mut command);
|
||||
command.args(["-c", "core.quotePath=false"]);
|
||||
command
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,14 @@ mod tests {
|
||||
git_commit(vp, "initial").unwrap();
|
||||
}
|
||||
|
||||
fn force_quoted_git_paths(vault: &Path) {
|
||||
git_command()
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -380,6 +388,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_chinese_markdown_path() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
let relative_path = "中文笔记.md";
|
||||
|
||||
force_quoted_git_paths(vault);
|
||||
write_and_commit_markdown(vault, vp, relative_path, "# 初始\n");
|
||||
fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vp).unwrap();
|
||||
let file = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == relative_path)
|
||||
.expect("Chinese markdown path should be reported as modified");
|
||||
|
||||
assert_eq!(file.status, "modified");
|
||||
assert!(file.path.ends_with(relative_path));
|
||||
assert_eq!(file.added_lines, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_flow_modified_files_then_commit_clears() {
|
||||
let dir = setup_git_repo();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod ai_agents;
|
||||
pub mod ai_models;
|
||||
pub mod app_updater;
|
||||
pub mod claude_cli;
|
||||
mod cli_agent_runtime;
|
||||
@@ -413,6 +414,10 @@ macro_rules! app_invoke_handler {
|
||||
commands::restore_vault_ai_guidance,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_ai_agent,
|
||||
commands::stream_ai_model,
|
||||
commands::save_ai_model_provider_api_key,
|
||||
commands::delete_ai_model_provider_api_key,
|
||||
commands::test_ai_model_provider,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_vault_asset_scope_for_window,
|
||||
|
||||
@@ -15,6 +15,10 @@ const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
|
||||
},
|
||||
];
|
||||
|
||||
const COLRV1_EMOJI_FONT_FILE: &str = "Noto-COLRv1.ttf";
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
const TOLARIA_COLRV1_FONTCONFIG_FILE: &str = "tolaria-appimage-no-colrv1-emoji.conf";
|
||||
|
||||
const WAYLAND_CLIENT_PRELOAD_CANDIDATES: [&str; 7] = [
|
||||
"/usr/lib64/libwayland-client.so.0",
|
||||
"/usr/lib64/libwayland-client.so",
|
||||
@@ -40,11 +44,38 @@ where
|
||||
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
|
||||
}
|
||||
|
||||
fn has_non_empty_env<F>(get_var: &mut F, key: &str) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
get_var(key).is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn has_explicit_fontconfig_env<F>(get_var: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
["FONTCONFIG_FILE", "FONTCONFIG_PATH"]
|
||||
.into_iter()
|
||||
.any(|key| has_non_empty_env(get_var, key))
|
||||
}
|
||||
|
||||
fn can_apply_colrv1_font_guard<F>(get_var: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
if !is_linux_appimage_launch(&mut *get_var) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!has_explicit_fontconfig_env(get_var)
|
||||
}
|
||||
|
||||
fn is_wayland_session<F>(mut get_var: F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
get_var("WAYLAND_DISPLAY").is_some_and(|value| !value.trim().is_empty())
|
||||
has_non_empty_env(&mut get_var, "WAYLAND_DISPLAY")
|
||||
|| get_var("XDG_SESSION_TYPE")
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("wayland"))
|
||||
}
|
||||
@@ -81,7 +112,7 @@ where
|
||||
return None;
|
||||
}
|
||||
|
||||
if get_var("LD_PRELOAD").is_some_and(|value| !value.trim().is_empty())
|
||||
if has_non_empty_env(&mut get_var, "LD_PRELOAD")
|
||||
|| get_var("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED").is_some_and(|value| value == "1")
|
||||
{
|
||||
return None;
|
||||
@@ -92,6 +123,54 @@ where
|
||||
.find(|path| candidate_matches(path))
|
||||
}
|
||||
|
||||
fn colrv1_emoji_font_path_with<F, M>(mut get_var: F, mut match_emoji_font: M) -> Option<String>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
M: FnMut() -> Option<String>,
|
||||
{
|
||||
if !can_apply_colrv1_font_guard(&mut get_var) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let font_path = match_emoji_font()?;
|
||||
|
||||
std::path::Path::new(font_path.trim())
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(COLRV1_EMOJI_FONT_FILE))
|
||||
.then(|| font_path.trim().to_string())
|
||||
}
|
||||
|
||||
fn escape_xml_text(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn colrv1_emoji_fontconfig_contents(rejected_font_path: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<include ignore_missing="yes">/etc/fonts/fonts.conf</include>
|
||||
<selectfont>
|
||||
<rejectfont>
|
||||
<pattern>
|
||||
<patelt name="file">
|
||||
<string>{}</string>
|
||||
</patelt>
|
||||
</pattern>
|
||||
</rejectfont>
|
||||
</selectfont>
|
||||
</fontconfig>
|
||||
"#,
|
||||
escape_xml_text(rejected_font_path)
|
||||
)
|
||||
}
|
||||
|
||||
fn startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
@@ -102,21 +181,78 @@ where
|
||||
|
||||
LINUX_APPIMAGE_WEBKIT_OVERRIDES
|
||||
.into_iter()
|
||||
.filter(|env_override| {
|
||||
!get_var(env_override.key).is_some_and(|value| !value.trim().is_empty())
|
||||
})
|
||||
.filter(|env_override| !has_non_empty_env(&mut get_var, env_override.key))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
pub(crate) fn apply_startup_env_overrides() {
|
||||
apply_wayland_client_preload();
|
||||
apply_colrv1_emoji_font_guard();
|
||||
|
||||
for env_override in startup_env_overrides_with(|key| std::env::var(key).ok()) {
|
||||
std::env::set_var(env_override.key, env_override.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_colrv1_emoji_font_guard() {
|
||||
let Some(font_path) =
|
||||
colrv1_emoji_font_path_with(|key| std::env::var(key).ok(), match_emoji_font_path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(config_path) = colrv1_fontconfig_file_path() else {
|
||||
eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to resolve cache directory");
|
||||
return;
|
||||
};
|
||||
let Some(parent) = config_path.parent() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(error) = std::fs::create_dir_all(parent) {
|
||||
eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to prepare cache ({error})");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = std::fs::write(&config_path, colrv1_emoji_fontconfig_contents(&font_path)) {
|
||||
eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to write config ({error})");
|
||||
return;
|
||||
}
|
||||
|
||||
std::env::set_var("FONTCONFIG_FILE", config_path.as_os_str());
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn match_emoji_font_path() -> Option<String> {
|
||||
let output = std::process::Command::new("fc-match")
|
||||
.args(["-f", "%{file}\n", "emoji"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn colrv1_fontconfig_file_path() -> Option<std::path::PathBuf> {
|
||||
let cache_dir =
|
||||
dirs::cache_dir().or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?;
|
||||
|
||||
Some(
|
||||
cache_dir
|
||||
.join("tolaria")
|
||||
.join(TOLARIA_COLRV1_FONTCONFIG_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_wayland_client_preload() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -149,8 +285,8 @@ fn apply_wayland_client_preload() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
elf_library_matches_process, startup_env_overrides_with, wayland_client_preload_path_with,
|
||||
StartupEnvOverride,
|
||||
colrv1_emoji_font_path_with, colrv1_emoji_fontconfig_contents, elf_library_matches_process,
|
||||
startup_env_overrides_with, wayland_client_preload_path_with, StartupEnvOverride,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -199,6 +335,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colrv1_font_guard_targets_reported_appimage_emoji_font() {
|
||||
let font_path = colrv1_emoji_font_path_with(
|
||||
|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|| Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
font_path.as_deref(),
|
||||
Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colrv1_font_guard_preserves_explicit_fontconfig_settings() {
|
||||
let font_path = colrv1_emoji_font_path_with(
|
||||
|key| match key {
|
||||
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
|
||||
"FONTCONFIG_FILE" => Some("/tmp/custom-fontconfig.conf".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|| Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(font_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colrv1_font_guard_ignores_other_emoji_fonts() {
|
||||
let font_path = colrv1_emoji_font_path_with(
|
||||
|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|| Some("/usr/share/fonts/noto/NotoColorEmoji.ttf".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(font_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colrv1_fontconfig_includes_system_fonts_and_rejects_matched_file() {
|
||||
let contents = colrv1_emoji_fontconfig_contents("/tmp/fonts/A&B/Noto-COLRv1.ttf");
|
||||
|
||||
assert!(
|
||||
contents.contains("<include ignore_missing=\"yes\">/etc/fonts/fonts.conf</include>")
|
||||
);
|
||||
assert!(contents.contains("<patelt name=\"file\">"));
|
||||
assert!(contents.contains("<string>/tmp/fonts/A&B/Noto-COLRv1.ttf</string>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_preload_uses_first_available_system_library() {
|
||||
let preload_path = wayland_client_preload_path_with(
|
||||
|
||||
@@ -284,6 +284,10 @@ fn linux_package_mcp_server_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
root.join("Tolaria").join("mcp-server"),
|
||||
root.join("Tolaria").join("resources").join("mcp-server"),
|
||||
root.join("lib").join("tolaria").join("mcp-server"),
|
||||
root.join("lib")
|
||||
.join("tolaria")
|
||||
.join("resources")
|
||||
.join("mcp-server"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -759,12 +763,26 @@ mod tests {
|
||||
PathBuf::from("/usr/local/Tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/local/Tolaria/resources/mcp-server"),
|
||||
PathBuf::from("/usr/local/lib/tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/local/lib/tolaria/resources/mcp-server"),
|
||||
PathBuf::from("/usr/lib/tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/lib/tolaria/resources/mcp-server"),
|
||||
];
|
||||
|
||||
assert!(expected.iter().all(|path| candidates.contains(path)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_candidates_include_linux_appimage_resource_root() {
|
||||
let dev_path = Path::new("/repo/mcp-server");
|
||||
let exe_path = Path::new("/tmp/.mount_tolaria/usr/bin/tolaria");
|
||||
let appdir = Path::new("/tmp/.mount_tolaria");
|
||||
let candidates = mcp_server_dir_candidates(dev_path, exe_path, Some(appdir));
|
||||
|
||||
assert!(candidates.contains(&PathBuf::from(
|
||||
"/tmp/.mount_tolaria/usr/lib/tolaria/resources/mcp-server"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_new_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ai_models::{normalize_ai_model_providers, AiModelProvider};
|
||||
|
||||
const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
|
||||
@@ -78,6 +80,8 @@ pub struct Settings {
|
||||
pub note_width_mode: Option<String>,
|
||||
pub initial_h1_auto_rename_enabled: Option<bool>,
|
||||
pub default_ai_agent: Option<String>,
|
||||
pub default_ai_target: Option<String>,
|
||||
pub ai_model_providers: Option<Vec<AiModelProvider>>,
|
||||
pub hide_gitignored_files: Option<bool>,
|
||||
pub all_notes_show_pdfs: Option<bool>,
|
||||
pub all_notes_show_images: Option<bool>,
|
||||
@@ -179,6 +183,8 @@ fn normalize_settings(settings: Settings) -> Settings {
|
||||
note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()),
|
||||
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
|
||||
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
|
||||
default_ai_target: normalize_optional_string(settings.default_ai_target),
|
||||
ai_model_providers: normalize_ai_model_providers(settings.ai_model_providers),
|
||||
hide_gitignored_files: settings.hide_gitignored_files,
|
||||
all_notes_show_pdfs: settings.all_notes_show_pdfs,
|
||||
all_notes_show_images: settings.all_notes_show_images,
|
||||
@@ -326,6 +332,8 @@ mod tests {
|
||||
note_width_mode: Some("wide".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
default_ai_target: Some("agent:codex".to_string()),
|
||||
ai_model_providers: None,
|
||||
hide_gitignored_files: Some(false),
|
||||
all_notes_show_pdfs: Some(true),
|
||||
all_notes_show_images: Some(true),
|
||||
|
||||
@@ -125,7 +125,7 @@ fn git_head_hash(vault: &Path) -> Option<String> {
|
||||
|
||||
/// Run a git command in the given directory and return stdout if successful.
|
||||
fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = crate::hidden_command("git")
|
||||
let output = crate::git::git_command()
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -768,6 +768,14 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn force_quoted_git_paths(vault: &Path) {
|
||||
crate::hidden_command("git")
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_path_is_outside_vault() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
@@ -1007,6 +1015,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_uncommitted_files_preserves_chinese_markdown_path() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
let relative_path = "中文笔记.md";
|
||||
|
||||
force_quoted_git_paths(vault);
|
||||
create_test_file(vault, relative_path, "# 初始\n");
|
||||
git_add_commit(vault, "init");
|
||||
create_test_file(vault, relative_path, "# 初始\n\n更新\n");
|
||||
|
||||
let changed = git_uncommitted_files(vault);
|
||||
|
||||
assert_eq!(changed, vec![relative_path.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_file_still_added() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
|
||||
@@ -501,7 +501,7 @@ pub struct DetectedRename {
|
||||
|
||||
/// Detect renamed files by comparing working tree against HEAD using git diff.
|
||||
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
|
||||
let output = crate::hidden_command("git")
|
||||
let output = crate::git::git_command()
|
||||
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -601,6 +601,27 @@ mod tests {
|
||||
vault.join(relative_path)
|
||||
}
|
||||
|
||||
fn run_git(vault: &Path, args: &[&str]) {
|
||||
let output = crate::hidden_command("git")
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn init_git_repo_with_quoted_paths(vault: &Path) {
|
||||
run_git(vault, &["init"]);
|
||||
run_git(vault, &["config", "user.email", "test@test.com"]);
|
||||
run_git(vault, &["config", "user.name", "Test"]);
|
||||
run_git(vault, &["config", "core.quotePath", "true"]);
|
||||
}
|
||||
|
||||
fn assert_rename_note_filename_error<P>(
|
||||
new_filename_stem: impl AsRef<str>,
|
||||
existing_destination: Option<P>,
|
||||
@@ -710,6 +731,25 @@ mod tests {
|
||||
assert_unicode_rename_frontmatter(&result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_renames_preserves_chinese_markdown_paths() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
init_git_repo_with_quoted_paths(vault);
|
||||
create_test_file(vault, "旧名.md", "# 旧名\n");
|
||||
run_git(vault, &["add", "旧名.md"]);
|
||||
run_git(vault, &["commit", "-m", "add chinese note"]);
|
||||
fs::rename(vault.join("旧名.md"), vault.join("新名.md")).unwrap();
|
||||
run_git(vault, &["add", "-A"]);
|
||||
|
||||
let renames = detect_renames(vault).unwrap();
|
||||
|
||||
assert_eq!(renames.len(), 1);
|
||||
assert_eq!(renames[0].old_path, "旧名.md");
|
||||
assert_eq!(renames[0].new_path, "新名.md");
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -311,7 +311,7 @@ function resetMockCommandResults() {
|
||||
}
|
||||
|
||||
function resolveMockCommandResult(cmd: string, args?: unknown) {
|
||||
const result = mockCommandResults[cmd]
|
||||
const result = Reflect.get(mockCommandResults, cmd) as unknown
|
||||
return typeof result === 'function'
|
||||
? (result as (input?: unknown) => unknown)(args)
|
||||
: result ?? null
|
||||
@@ -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)
|
||||
@@ -669,7 +702,7 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
|
||||
expect(screen.getByText('AI is ready')).toBeInTheDocument()
|
||||
}, { timeout: SLOW_APP_READY_TIMEOUT_MS })
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -684,7 +717,7 @@ describe('App', () => {
|
||||
expect(screen.getByText('Manage External AI Tools')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('mcp-setup-dialog')).toBeInTheDocument()
|
||||
expect(screen.queryByText('AI agents ready')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('AI is ready')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('routes right-panel AI chat messages to the selected default agent', async () => {
|
||||
|
||||
36
src/App.tsx
36
src/App.tsx
@@ -101,6 +101,7 @@ import {
|
||||
getBrowserLanguagePreferences,
|
||||
resolveEffectiveLocale,
|
||||
serializeUiLanguagePreference,
|
||||
translate,
|
||||
type UiLanguagePreference,
|
||||
} from './lib/i18n'
|
||||
import { normalizeReleaseChannel } from './lib/releaseChannel'
|
||||
@@ -108,6 +109,7 @@ import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { isActiveVaultUnavailableError } from './utils/vaultErrors'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { filenameStemToTitle } from './utils/noteTitle'
|
||||
import {
|
||||
@@ -375,6 +377,7 @@ function App() {
|
||||
}, [resolvedPath, setToastMessage])
|
||||
|
||||
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
|
||||
const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
|
||||
const {
|
||||
markInternalWrite: markRecentVaultWrite,
|
||||
filterExternalPaths: filterExternalVaultPaths,
|
||||
@@ -578,6 +581,9 @@ function App() {
|
||||
markRecentVaultWrite(path)
|
||||
vault.loadModifiedFiles()
|
||||
}, [markRecentVaultWrite, vault])
|
||||
const handleMissingActiveVault = useCallback(() => {
|
||||
if (!noteWindowParams && resolvedPath) vault.markVaultUnavailable(resolvedPath)
|
||||
}, [noteWindowParams, resolvedPath, vault])
|
||||
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
@@ -596,6 +602,7 @@ function App() {
|
||||
unsavedPaths: vault.unsavedPaths,
|
||||
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
|
||||
onNewNotePersisted: handleCreatedVaultEntryPersisted,
|
||||
onMissingActiveVault: handleMissingActiveVault,
|
||||
onTypeStateChanged: async () => { await vault.reloadVault() },
|
||||
replaceEntry: vault.replaceEntry,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
@@ -1169,7 +1176,15 @@ function App() {
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
try {
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
} catch (err) {
|
||||
if (isActiveVaultUnavailableError(err)) {
|
||||
vault.markVaultUnavailable(resolvedPath)
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
await vault.reloadViews()
|
||||
await vault.reloadVault()
|
||||
vault.reloadFolders()
|
||||
@@ -1266,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)
|
||||
@@ -1275,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
|
||||
@@ -1636,8 +1652,17 @@ function App() {
|
||||
}
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = shouldResumeFreshStartOnboarding
|
||||
if (!noteWindowParams && (runtimeMissingVaultPath || onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = runtimeMissingVaultPath
|
||||
? {
|
||||
...onboarding,
|
||||
state: {
|
||||
status: 'vault-missing' as const,
|
||||
vaultPath: runtimeMissingVaultPath,
|
||||
defaultPath: vaultSwitcher.defaultPath || runtimeMissingVaultPath,
|
||||
},
|
||||
}
|
||||
: shouldResumeFreshStartOnboarding
|
||||
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
|
||||
: onboarding
|
||||
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
|
||||
@@ -1702,6 +1727,7 @@ function App() {
|
||||
onToggleInspector={handleToggleInspector}
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
|
||||
defaultAiTarget={aiAgentPreferences.defaultAiTarget}
|
||||
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={setToastMessage}
|
||||
@@ -1755,7 +1781,7 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} defaultAiTarget={settings.default_ai_target ?? undefined} aiModelProviders={settings.ai_model_providers ?? []} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onSetDefaultAiTarget={aiAgentPreferences.setDefaultAiTarget} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
|
||||
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('AiAgentsOnboardingPrompt', () => {
|
||||
claude_code: { status: 'installed', version: '1.0.20' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
|
||||
expect(screen.getByText('AI is ready')).toBeInTheDocument()
|
||||
expectMissingAgentInstallLinks()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue')
|
||||
})
|
||||
@@ -66,12 +66,12 @@ describe('AiAgentsOnboardingPrompt', () => {
|
||||
it('shows the missing state when no agents are installed', () => {
|
||||
renderPrompt()
|
||||
|
||||
expect(screen.getByText('No AI agents detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose how Tolaria should use AI')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('claude-onboarding-screen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-claude_code')).toBeInTheDocument()
|
||||
expectMissingAgentInstallLinks()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue without it')
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Set up later')
|
||||
})
|
||||
|
||||
it('opens the agent install links', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Cloud, HardDrive, Loader2, Terminal } from 'lucide-react'
|
||||
import {
|
||||
AI_AGENT_DEFINITIONS,
|
||||
getAiAgentDefinition,
|
||||
@@ -20,7 +20,7 @@ function getPromptCopy(statuses: AiAgentsStatus) {
|
||||
if (isAiAgentsStatusChecking(statuses)) {
|
||||
return {
|
||||
accentClassName: 'bg-muted text-muted-foreground',
|
||||
description: 'Checking which AI agents are available on this machine.',
|
||||
description: 'Checking coding agents. You can also use a local model or API provider.',
|
||||
icon: <Loader2 className="size-7 animate-spin" />,
|
||||
title: 'Checking AI agents',
|
||||
}
|
||||
@@ -29,20 +29,54 @@ function getPromptCopy(statuses: AiAgentsStatus) {
|
||||
if (!hasAnyInstalledAiAgent(statuses)) {
|
||||
return {
|
||||
accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]',
|
||||
description: 'Tolaria works best with a local CLI AI agent installed.',
|
||||
description: 'Connect a local model, an API provider, or a desktop coding agent.',
|
||||
icon: <Bot className="size-7" />,
|
||||
title: 'No AI agents detected',
|
||||
title: 'Choose how Tolaria should use AI',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]',
|
||||
description: 'Your AI agents are ready to use in Tolaria.',
|
||||
description: 'You can use the detected coding agents, or add local/API models in Settings.',
|
||||
icon: <CheckCircle2 className="size-7" />,
|
||||
title: 'AI agents ready',
|
||||
title: 'AI is ready',
|
||||
}
|
||||
}
|
||||
|
||||
function AiModeChoices() {
|
||||
const choices = [
|
||||
{
|
||||
icon: <HardDrive className="size-4" />,
|
||||
title: 'Local model',
|
||||
description: 'Use Ollama, LM Studio, or another local OpenAI-compatible endpoint. API keys are usually not needed.',
|
||||
},
|
||||
{
|
||||
icon: <Cloud className="size-4" />,
|
||||
title: 'API provider',
|
||||
description: 'Use OpenAI, Anthropic, OpenRouter, or a gateway. API keys are read from environment variables, not saved in settings.',
|
||||
},
|
||||
{
|
||||
icon: <Terminal className="size-4" />,
|
||||
title: 'Coding agent',
|
||||
description: 'Use Claude Code, Codex, OpenCode, Gemini CLI, or Pi for tool-capable vault editing on desktop.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{choices.map((choice) => (
|
||||
<div key={choice.title} className="rounded-lg border border-border bg-muted/20 p-3 text-left">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
{choice.icon}
|
||||
{choice.title}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-muted-foreground">{choice.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -104,6 +138,7 @@ export function AiAgentsOnboardingPrompt({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<AiModeChoices />
|
||||
{showLegacyClaudeCompatibility ? (
|
||||
<div
|
||||
className="rounded-lg border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] px-4 py-3 text-left"
|
||||
@@ -138,7 +173,7 @@ export function AiAgentsOnboardingPrompt({
|
||||
disabled={isAiAgentsStatusChecking(statuses)}
|
||||
data-testid={showLegacyClaudeCompatibility ? 'claude-onboarding-continue' : undefined}
|
||||
>
|
||||
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Continue without it'}
|
||||
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Set up later'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
|
||||
@@ -197,6 +197,18 @@ describe('AiPanel', () => {
|
||||
expect(screen.getByTestId('ai-panel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps long AI agent drafts inside a scrollable composer while keeping send visible', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
editor.textContent = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}`).join('\n')
|
||||
fireEvent.input(editor)
|
||||
|
||||
expect(editor).toHaveClass('max-h-[160px]', 'overflow-y-auto', 'overscroll-contain')
|
||||
expect(editor).toHaveStyle({ maxHeight: '160px', overflowY: 'auto' })
|
||||
expect(screen.getByTestId('agent-send')).toBeVisible()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AiAgentId,
|
||||
type AiAgentReadiness,
|
||||
} from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import { type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -25,6 +26,7 @@ interface AiPanelProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -47,6 +49,7 @@ interface AiPanelViewProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -64,6 +67,7 @@ export function AiPanelView({
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
locale = 'en',
|
||||
@@ -75,7 +79,9 @@ export function AiPanelView({
|
||||
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
|
||||
const activeTarget = defaultAiTarget
|
||||
const agentLabel = activeTarget?.label ?? getAiAgentDefinition(defaultAiAgent).label
|
||||
const targetKind = activeTarget?.kind ?? 'agent'
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
@@ -118,6 +124,7 @@ export function AiPanelView({
|
||||
<AiPanelHeader
|
||||
agentLabel={agentLabel}
|
||||
agentReadiness={defaultAiAgentReadiness}
|
||||
targetKind={targetKind}
|
||||
locale={locale}
|
||||
permissionMode={permissionMode}
|
||||
permissionModeDisabled={isActive}
|
||||
@@ -159,6 +166,7 @@ export function AiPanel({
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
locale = 'en',
|
||||
@@ -178,6 +186,7 @@ export function AiPanel({
|
||||
const controller = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
|
||||
defaultAiAgentReadiness,
|
||||
activeEntry,
|
||||
@@ -200,6 +209,7 @@ export function AiPanel({
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={providedDefaultAiAgentReady}
|
||||
locale={locale}
|
||||
|
||||
55
src/components/AiPanelChrome.performance.test.tsx
Normal file
55
src/components/AiPanelChrome.performance.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AiPanelMessageHistory } from './AiPanelChrome'
|
||||
|
||||
const aiMessageRender = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./AiMessage', () => ({
|
||||
AiMessage: (props: { id?: string; response: string }) => {
|
||||
aiMessageRender(props.id)
|
||||
return <div data-testid="ai-message">{props.response}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
function HistoryRerenderHarness() {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [messages] = useState([{
|
||||
userMessage: 'Explain the note',
|
||||
actions: [],
|
||||
response: 'Here is a long answer with [[Test Note]].',
|
||||
id: 'msg-stable',
|
||||
}])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setDraft('typing')}>type</button>
|
||||
<span data-testid="draft">{draft}</span>
|
||||
<AiPanelMessageHistory
|
||||
agentLabel="Claude Code"
|
||||
agentReadiness="ready"
|
||||
messages={messages}
|
||||
isActive={false}
|
||||
onOpenNote={noop}
|
||||
onNavigateWikilink={noop}
|
||||
hasContext
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AiPanelChrome performance', () => {
|
||||
it('keeps stable message history from re-rendering while composer state changes', () => {
|
||||
render(<HistoryRerenderHarness />)
|
||||
expect(screen.getByTestId('ai-message')).toHaveTextContent('Here is a long answer')
|
||||
expect(aiMessageRender).toHaveBeenCalledTimes(1)
|
||||
aiMessageRender.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'type' }))
|
||||
|
||||
expect(screen.getByTestId('draft')).toHaveTextContent('typing')
|
||||
expect(aiMessageRender).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -19,6 +19,7 @@ import type { VaultEntry } from '../types'
|
||||
interface AiPanelHeaderProps {
|
||||
agentLabel: string
|
||||
agentReadiness: AiAgentReadiness
|
||||
targetKind?: 'agent' | 'api_model'
|
||||
locale?: AppLocale
|
||||
permissionMode: AiAgentPermissionMode
|
||||
permissionModeDisabled: boolean
|
||||
@@ -164,9 +165,10 @@ function AiPanelEmptyState({
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelHeader({
|
||||
export const AiPanelHeader = memo(function AiPanelHeader({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
targetKind = 'agent',
|
||||
locale = 'en',
|
||||
permissionMode,
|
||||
permissionModeDisabled,
|
||||
@@ -175,7 +177,9 @@ export function AiPanelHeader({
|
||||
onNewChat,
|
||||
}: AiPanelHeaderProps) {
|
||||
const t = createTranslator(locale)
|
||||
const modeLabel = aiAgentPermissionModeLabels(permissionMode, locale).short
|
||||
const modeLabel = targetKind === 'api_model'
|
||||
? t('ai.panel.mode.chat')
|
||||
: aiAgentPermissionModeLabels(permissionMode, locale).short
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -213,15 +217,21 @@ export function AiPanelHeader({
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<AiPermissionModeToggle
|
||||
value={permissionMode}
|
||||
locale={locale}
|
||||
disabled={permissionModeDisabled}
|
||||
onChange={onPermissionModeChange}
|
||||
/>
|
||||
{targetKind === 'agent' ? (
|
||||
<AiPermissionModeToggle
|
||||
value={permissionMode}
|
||||
locale={locale}
|
||||
disabled={permissionModeDisabled}
|
||||
onChange={onPermissionModeChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border bg-muted px-3 py-2 text-[11px] leading-5 text-muted-foreground">
|
||||
{t('ai.panel.mode.chatDescription')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function AiPermissionModeToggle({
|
||||
value,
|
||||
@@ -277,7 +287,11 @@ function AiPermissionModeToggle({
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: AiPanelContextBarProps) {
|
||||
export const AiPanelContextBar = memo(function AiPanelContextBar({
|
||||
activeEntry,
|
||||
linkedCount,
|
||||
locale = 'en',
|
||||
}: AiPanelContextBarProps) {
|
||||
const t = createTranslator(locale)
|
||||
|
||||
return (
|
||||
@@ -293,9 +307,9 @@ export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: A
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function AiPanelMessageHistory({
|
||||
export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
locale = 'en',
|
||||
@@ -332,7 +346,7 @@ export function AiPanelMessageHistory({
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function AiPanelComposer({
|
||||
entries,
|
||||
@@ -365,7 +379,7 @@ export function AiPanelComposer({
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
@@ -375,6 +389,8 @@ export function AiPanelComposer({
|
||||
disabled={composerDisabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
editorClassName="max-h-[160px] overflow-y-auto overscroll-contain"
|
||||
editorStyle={{ maxHeight: 160, overflowY: 'auto', overscrollBehavior: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
350
src/components/AiProviderSettings.tsx
Normal file
350
src/components/AiProviderSettings.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DEFAULT_MODEL_CAPABILITIES,
|
||||
aiModelProviderCatalog,
|
||||
aiModelProviderCatalogEntry,
|
||||
configuredModelTargets,
|
||||
isLocalAiProvider,
|
||||
normalizeAiModelProviders,
|
||||
type AiModelApiKeyStorage,
|
||||
type AiModelProvider,
|
||||
type AiModelProviderKind,
|
||||
} from '../lib/aiTargets'
|
||||
import type { createTranslator } from '../lib/i18n'
|
||||
import { deleteAiModelProviderApiKey, saveAiModelProviderApiKey, testAiModelProvider } from '../utils/aiProviderSecrets'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select'
|
||||
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
type ProviderMode = 'local' | 'api'
|
||||
type TestState = 'idle' | 'testing' | 'success'
|
||||
|
||||
interface AiProviderSettingsProps {
|
||||
t: Translate
|
||||
mode: ProviderMode
|
||||
providers: AiModelProvider[]
|
||||
onChange: (providers: AiModelProvider[]) => void
|
||||
}
|
||||
|
||||
interface ProviderDraft {
|
||||
kind: AiModelProviderKind
|
||||
name: string
|
||||
baseUrl: string
|
||||
modelId: string
|
||||
apiKeyStorage: AiModelApiKeyStorage
|
||||
apiKey: string
|
||||
apiKeyEnvVar: string
|
||||
}
|
||||
|
||||
function providerKindsForMode(mode: ProviderMode): AiModelProviderKind[] {
|
||||
return aiModelProviderCatalog()
|
||||
.filter((entry) => entry.local === (mode === 'local'))
|
||||
.map((entry) => entry.kind)
|
||||
}
|
||||
|
||||
function initialDraft(mode: ProviderMode): ProviderDraft {
|
||||
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: defaults.name,
|
||||
baseUrl: defaults.base_url,
|
||||
modelId: '',
|
||||
apiKeyStorage: defaults.api_key_storage,
|
||||
apiKey: '',
|
||||
apiKeyEnvVar: defaults.api_key_env_var ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function providerKindOptions(mode: ProviderMode, t: Translate): Array<{ value: AiModelProviderKind; label: string }> {
|
||||
return providerKindsForMode(mode).map((kind) => {
|
||||
const defaults = aiModelProviderCatalogEntry(kind)
|
||||
return { value: kind, label: t(defaults.label_key) }
|
||||
})
|
||||
}
|
||||
|
||||
function providerPresetPatch(kind: AiModelProviderKind): Pick<ProviderDraft, 'kind' | 'name' | 'baseUrl' | 'apiKeyStorage' | 'apiKeyEnvVar'> {
|
||||
const defaults = draftFromProviderKind(kind)
|
||||
return {
|
||||
kind,
|
||||
name: defaults.name,
|
||||
baseUrl: defaults.baseUrl,
|
||||
apiKeyStorage: defaults.apiKeyStorage,
|
||||
apiKeyEnvVar: defaults.apiKeyEnvVar,
|
||||
}
|
||||
}
|
||||
|
||||
function buildProvider(draft: ProviderDraft, providerId: string): AiModelProvider {
|
||||
return {
|
||||
id: providerId,
|
||||
name: draft.name,
|
||||
kind: draft.kind,
|
||||
base_url: draft.baseUrl || null,
|
||||
api_key_storage: draft.apiKeyStorage,
|
||||
api_key_env_var: draft.apiKeyStorage === 'env' ? draft.apiKeyEnvVar || null : null,
|
||||
headers: null,
|
||||
models: [{
|
||||
id: draft.modelId,
|
||||
display_name: null,
|
||||
context_window: null,
|
||||
max_output_tokens: null,
|
||||
capabilities: DEFAULT_MODEL_CAPABILITIES,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function providerModeTitle(mode: ProviderMode, t: Translate): string {
|
||||
return mode === 'local' ? t('settings.aiProviders.localTitle') : t('settings.aiProviders.apiTitle')
|
||||
}
|
||||
|
||||
function providerModeDescription(mode: ProviderMode, t: Translate): string {
|
||||
return mode === 'local' ? t('settings.aiProviders.localDescription') : t('settings.aiProviders.apiDescription')
|
||||
}
|
||||
|
||||
function providerStorageLabel(provider: AiModelProvider, t: Translate): string {
|
||||
if (provider.api_key_storage === 'local_file') return t('settings.aiProviders.keyLocalSaved')
|
||||
if (provider.api_key_storage === 'env' && provider.api_key_env_var) {
|
||||
return t('settings.aiProviders.keyEnvSaved', { env: provider.api_key_env_var })
|
||||
}
|
||||
return t('settings.aiProviders.noKey')
|
||||
}
|
||||
|
||||
function visibleProviders(providers: AiModelProvider[], mode: ProviderMode): AiModelProvider[] {
|
||||
return providers.filter((provider) => mode === 'local' ? isLocalAiProvider(provider) : !isLocalAiProvider(provider))
|
||||
}
|
||||
|
||||
function editableInputClassName(): string {
|
||||
return 'border-border bg-background text-foreground placeholder:text-muted-foreground/65 shadow-xs'
|
||||
}
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
type?: 'text' | 'password'
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5 text-xs font-medium text-foreground">
|
||||
<span>{label}</span>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={editableInputClassName()}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderKindSelect({
|
||||
mode,
|
||||
t,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
mode: ProviderMode
|
||||
t: Translate
|
||||
value: AiModelProviderKind
|
||||
onChange: (value: AiModelProviderKind) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5 text-xs font-medium text-foreground">
|
||||
<span>{t('settings.aiProviders.kind')}</span>
|
||||
<Select value={value} onValueChange={(next) => onChange(next as AiModelProviderKind)}>
|
||||
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerKindOptions(mode, t).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyStorageFields({
|
||||
t,
|
||||
draft,
|
||||
updateDraft,
|
||||
}: {
|
||||
t: Translate
|
||||
draft: ProviderDraft
|
||||
updateDraft: (patch: Partial<ProviderDraft>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<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 AiModelApiKeyStorage })}>
|
||||
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local_file">{t('settings.aiProviders.keyStorage.local')}</SelectItem>
|
||||
<SelectItem value="env">{t('settings.aiProviders.keyStorage.env')}</SelectItem>
|
||||
<SelectItem value="none">{t('settings.aiProviders.keyStorage.none')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
{draft.apiKeyStorage === 'local_file' ? (
|
||||
<LabeledInput
|
||||
label={t('settings.aiProviders.key')}
|
||||
value={draft.apiKey}
|
||||
onChange={(apiKey) => updateDraft({ apiKey })}
|
||||
placeholder={t('settings.aiProviders.keyPlaceholder')}
|
||||
type="password"
|
||||
/>
|
||||
) : null}
|
||||
{draft.apiKeyStorage === 'env' ? (
|
||||
<LabeledInput
|
||||
label={t('settings.aiProviders.keyEnv')}
|
||||
value={draft.apiKeyEnvVar}
|
||||
onChange={(apiKeyEnvVar) => updateDraft({ apiKeyEnvVar })}
|
||||
placeholder={aiModelProviderCatalogEntry(draft.kind).api_key_env_var ?? ''}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderList({
|
||||
t,
|
||||
mode,
|
||||
providers,
|
||||
onRemove,
|
||||
}: {
|
||||
t: Translate
|
||||
mode: ProviderMode
|
||||
providers: AiModelProvider[]
|
||||
onRemove: (providerId: string) => void
|
||||
}) {
|
||||
const visible = visibleProviders(providers, mode)
|
||||
if (visible.length === 0) {
|
||||
return <div className="rounded-md border border-dashed border-border bg-background px-3 py-2 text-xs text-muted-foreground">{t('settings.aiProviders.empty')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{configuredModelTargets(visible).map((target) => (
|
||||
<div key={target.id} className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">{target.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{target.provider.base_url || t('settings.aiProviders.defaultEndpoint')} · {providerStorageLabel(target.provider, t)}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onRemove(target.provider.id)}>
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderSettingsProps) {
|
||||
const [draft, setDraft] = useState<ProviderDraft>(() => initialDraft(mode))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testState, setTestState] = useState<TestState>('idle')
|
||||
const updateDraft = (patch: Partial<ProviderDraft>) => setDraft((current) => ({ ...current, ...patch }))
|
||||
const resetTest = () => {
|
||||
setTestState('idle')
|
||||
setError(null)
|
||||
}
|
||||
const updateForm = (patch: Partial<ProviderDraft>) => {
|
||||
resetTest()
|
||||
updateDraft(patch)
|
||||
}
|
||||
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
|
||||
|
||||
const addProvider = async () => {
|
||||
const providerId = `${draft.kind}-${Date.now().toString(36)}`
|
||||
setError(null)
|
||||
try {
|
||||
if (draft.apiKeyStorage === 'local_file') {
|
||||
await saveAiModelProviderApiKey(providerId, draft.apiKey)
|
||||
}
|
||||
onChange(normalizeAiModelProviders([...providers, buildProvider(draft, providerId)]))
|
||||
setDraft((current) => ({ ...draftFromProviderKind(current.kind), name: current.name, baseUrl: current.baseUrl }))
|
||||
setTestState('idle')
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
const testProvider = async () => {
|
||||
setError(null)
|
||||
setTestState('testing')
|
||||
try {
|
||||
await testAiModelProvider(buildProvider(draft, 'draft-provider-test'), draft.modelId, apiKeyOverride)
|
||||
setTestState('success')
|
||||
} catch (error) {
|
||||
setTestState('idle')
|
||||
setError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
const removeProvider = (providerId: string) => {
|
||||
void deleteAiModelProviderApiKey(providerId)
|
||||
onChange(providers.filter((provider) => provider.id !== providerId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-3" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{providerModeTitle(mode, t)}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{providerModeDescription(mode, t)}</div>
|
||||
</div>
|
||||
<ProviderList t={t} mode={mode} providers={providers} onRemove={removeProvider} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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={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">
|
||||
{mode === 'api' ? t('settings.aiProviders.keySafetyLocal') : t('settings.aiProviders.localSafety')}
|
||||
</div>
|
||||
{testState === 'success' ? <div className="text-xs text-emerald-700">{t('settings.aiProviders.testSuccess')}</div> : null}
|
||||
{error ? <div className="text-xs text-destructive">{error}</div> : null}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" size="sm" onClick={() => void addProvider()} disabled={!canSave}>
|
||||
{mode === 'local' ? t('settings.aiProviders.addLocal') : t('settings.aiProviders.addApi')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void testProvider()}
|
||||
disabled={!canSave || testState === 'testing'}
|
||||
>
|
||||
{testState === 'testing' ? t('settings.aiProviders.testing') : t('settings.aiProviders.test')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -307,6 +307,24 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => {
|
||||
expect(widthActionGroup).toHaveClass('gap-2')
|
||||
})
|
||||
|
||||
it('end-aligns toolbar action tooltips so zoomed windows keep them inside the right edge', async () => {
|
||||
render(
|
||||
<BreadcrumbBar
|
||||
entry={baseEntry}
|
||||
{...defaultProps}
|
||||
onToggleFavorite={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
fireEvent.focus(screen.getByRole('button', { name: 'Add to favorites' }))
|
||||
})
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
expect(document.querySelector('[data-slot="tooltip-content"]')).toHaveAttribute('data-align', 'end')
|
||||
expect(tooltip).toHaveTextContent('Add to favorites')
|
||||
})
|
||||
|
||||
it('lets the title use the free space before the fixed drag gap', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ function IconActionButton({
|
||||
style,
|
||||
children,
|
||||
testId,
|
||||
tooltipAlign,
|
||||
tooltipAlign = 'end',
|
||||
}: {
|
||||
copy: ActionTooltipCopy
|
||||
onClick?: () => void
|
||||
|
||||
@@ -176,6 +176,18 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('\u2014').parentElement).toHaveClass('justify-start', 'text-left')
|
||||
})
|
||||
|
||||
it('keeps present empty properties visible and editable', () => {
|
||||
renderPanel({ frontmatter: { 'start date': '' }, onUpdateProperty })
|
||||
|
||||
expect(screen.getByText('Start date')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('\u2014'))
|
||||
const input = screen.getByDisplayValue('')
|
||||
fireEvent.change(input, { target: { value: '2026-05-03' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-03')
|
||||
})
|
||||
|
||||
it('hides Owner with wikilink value from Properties panel', () => {
|
||||
renderPanel({ frontmatter: { Owner: '[[person/luca]]' } })
|
||||
// Owner with wikilink goes to RelationshipsPanel, not Properties
|
||||
|
||||
@@ -5,6 +5,7 @@ import '@blocknote/mantine/style.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import type { VaultEntry, GitCommit, NoteWidthMode, NoteStatus } from '../types'
|
||||
@@ -56,6 +57,7 @@ interface EditorProps {
|
||||
onToggleInspector: () => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
onInspectorResize: (delta: number) => void
|
||||
@@ -346,6 +348,7 @@ function EditorLayout({
|
||||
onInspectorResize,
|
||||
inspectorWidth,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness,
|
||||
defaultAiAgentReady,
|
||||
inspectorEntry,
|
||||
@@ -410,6 +413,7 @@ function EditorLayout({
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady: boolean
|
||||
inspectorEntry: VaultEntry | null
|
||||
@@ -496,6 +500,7 @@ function EditorLayout({
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
@@ -529,122 +534,52 @@ function EditorLayout({
|
||||
)
|
||||
}
|
||||
|
||||
type EditorRuntime = ReturnType<typeof useEditorSetup>
|
||||
type EditorLayoutProps = Parameters<typeof EditorLayout>[0]
|
||||
|
||||
function buildEditorLayoutProps(
|
||||
props: EditorProps,
|
||||
runtime: EditorRuntime,
|
||||
findRequest: RawEditorFindRequest | null,
|
||||
): EditorLayoutProps {
|
||||
return {
|
||||
...props,
|
||||
...runtime,
|
||||
activeTabPath: props.activeTabPath,
|
||||
defaultAiAgent: props.defaultAiAgent ?? DEFAULT_AI_AGENT,
|
||||
defaultAiAgentReady: props.defaultAiAgentReady ?? true,
|
||||
findRequest,
|
||||
}
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
isVaultLoading,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
|
||||
onUnsupportedAiPaste,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
noteWidth, onToggleNoteWidth,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale,
|
||||
} = props
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
const runtime = useEditorSetup({
|
||||
tabs: props.tabs,
|
||||
activeTabPath: props.activeTabPath,
|
||||
vaultPath: props.vaultPath,
|
||||
onContentChange: props.onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
getNoteStatus: props.getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef,
|
||||
diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
const findRequest = useEditorFindCommand({
|
||||
activeTab,
|
||||
findInNoteRef,
|
||||
handleToggleRawExclusive,
|
||||
rawMode,
|
||||
activeTab: runtime.activeTab,
|
||||
findInNoteRef: props.findInNoteRef,
|
||||
handleToggleRawExclusive: runtime.handleToggleRawExclusive,
|
||||
rawMode: runtime.rawMode,
|
||||
})
|
||||
useRegisterEditorContentFlushes({
|
||||
activeTab,
|
||||
flushPendingEditorChange,
|
||||
flushPendingEditorContentRef,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
activeTab: runtime.activeTab,
|
||||
flushPendingEditorChange: runtime.flushPendingEditorChange,
|
||||
flushPendingEditorContentRef: props.flushPendingEditorContentRef,
|
||||
rawLatestContentRef: runtime.rawLatestContentRef,
|
||||
rawMode: runtime.rawMode,
|
||||
onContentChange: props.onContentChange,
|
||||
flushPendingRawContentRef: props.flushPendingRawContentRef,
|
||||
})
|
||||
return (
|
||||
<EditorLayout
|
||||
tabs={tabs}
|
||||
activeTabPath={props.activeTabPath}
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
isVaultLoading={isVaultLoading}
|
||||
entries={entries}
|
||||
editor={editor}
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
handleToggleDiffExclusive={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
handleToggleRawExclusive={handleToggleRawExclusive}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
handleEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onOpenExternalFile={onOpenExternalFile}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteWidth={noteWidth}
|
||||
onToggleNoteWidth={onToggleNoteWidth}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
onInspectorResize={onInspectorResize}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
gitHistory={gitHistory}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
handleViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
return <EditorLayout {...buildEditorLayoutProps(props, runtime, findRequest)} />
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
@@ -13,6 +14,7 @@ interface EditorRightPanelProps {
|
||||
inspectorCollapsed: boolean
|
||||
inspectorWidth: number
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
@@ -43,7 +45,7 @@ interface EditorRightPanelProps {
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiTarget, defaultAiAgentReadiness, defaultAiAgentReady = true,
|
||||
onUnsupportedAiPaste,
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
|
||||
noteList, noteListFilter,
|
||||
@@ -55,6 +57,7 @@ export function EditorRightPanel({
|
||||
const aiPanelController = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady,
|
||||
defaultAiAgentReadiness,
|
||||
activeEntry: inspectorEntry,
|
||||
@@ -91,6 +94,7 @@ export function EditorRightPanel({
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
locale={locale}
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
============================================== */
|
||||
|
||||
/* --- Editor root --- */
|
||||
.editor__blocknote-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-editor {
|
||||
font-family: var(--editor-font-family);
|
||||
font-size: var(--editor-font-size);
|
||||
@@ -23,6 +27,12 @@
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Let each rich-text block resolve its own base direction for mixed LTR/RTL notes. */
|
||||
.editor__blocknote-container .bn-inline-content {
|
||||
unicode-bidi: plaintext;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Override BlockNote's default line-height on block-outer so our theme controls it */
|
||||
.editor__blocknote-container .bn-block-outer {
|
||||
line-height: var(--editor-line-height) !important;
|
||||
@@ -392,6 +402,75 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard {
|
||||
position: relative;
|
||||
width: min(var(--tldraw-whiteboard-width, 100%), 100%);
|
||||
max-width: 100%;
|
||||
height: var(--tldraw-whiteboard-height, 520px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard .tl-container {
|
||||
--color-background: var(--card);
|
||||
--tl-layer-panels: 30;
|
||||
--tl-layer-menus: 40;
|
||||
--tl-layer-toasts: 50;
|
||||
--tl-layer-cursor: 60;
|
||||
--tl-layer-header-footer: 70;
|
||||
--tl-layer-following-indicator: 80;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mantine-Popover-dropdown,
|
||||
.editor__blocknote-container .bn-menu,
|
||||
.editor__blocknote-container .bn-suggestion-menu {
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard__resize-handle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard__resize-handle--width {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 12px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard__resize-handle--height {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard__resize-handle--corner {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard__resize-handle--corner::after {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 2px solid color-mix(in srgb, var(--foreground) 42%, transparent);
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--foreground) 42%, transparent);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.editor__blocknote-container[data-follow-links] .bn-editor a,
|
||||
.editor__blocknote-container[data-follow-links] .wikilink {
|
||||
cursor: pointer;
|
||||
@@ -402,6 +481,26 @@
|
||||
background-color: var(--editor-code-block-background) !important;
|
||||
border: 1px solid var(--editor-code-block-border);
|
||||
color: var(--editor-code-block-text) !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor__code-block-copy {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.editor__code-block-copy [data-editor-code-copy-button] {
|
||||
background: transparent !important;
|
||||
border-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--editor-code-block-language);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor__code-block-copy [data-editor-code-copy-button]:hover,
|
||||
.editor__code-block-copy [data-editor-code-copy-button]:focus-visible {
|
||||
background: transparent !important;
|
||||
color: var(--editor-code-block-text);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > pre {
|
||||
@@ -428,11 +527,6 @@
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:root:not(.dark) .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki,
|
||||
[data-theme="light"] .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki {
|
||||
color: var(--editor-code-block-text) !important;
|
||||
}
|
||||
|
||||
/* --- Blockquote --- */
|
||||
.editor__blocknote-container [data-content-type="blockquote"],
|
||||
.editor__blocknote-container blockquote {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
import { compileSafeUserRegex } from '../utils/safeRegex'
|
||||
import { FilterFieldCombobox } from './FilterFieldCombobox'
|
||||
import { DateValueInput } from './filter-builder/DateValueInput'
|
||||
|
||||
@@ -32,12 +33,7 @@ function normalizeRegexFlag(op: FilterOp, enabled: boolean): boolean | undefined
|
||||
|
||||
function hasInvalidRegex(value: string, regexEnabled: boolean): boolean {
|
||||
if (!regexEnabled) return false
|
||||
try {
|
||||
new RegExp(value, 'i')
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
return !compileSafeUserRegex(value, 'i').ok
|
||||
}
|
||||
|
||||
function isFilterGroup(node: FilterNode): node is FilterGroup {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -57,6 +58,7 @@ interface InlineWikilinkInputProps {
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
dataTestId?: string
|
||||
editorClassName?: string
|
||||
editorStyle?: CSSProperties
|
||||
suggestionListVariant?: 'floating' | 'palette'
|
||||
suggestionEmptyLabel?: string
|
||||
paletteHeader?: ReactNode
|
||||
@@ -168,6 +170,7 @@ export function InlineWikilinkInput({
|
||||
inputRef,
|
||||
dataTestId = 'agent-input',
|
||||
editorClassName,
|
||||
editorStyle,
|
||||
suggestionListVariant = 'floating',
|
||||
suggestionEmptyLabel = 'No matching notes',
|
||||
paletteHeader,
|
||||
@@ -472,6 +475,7 @@ export function InlineWikilinkInput({
|
||||
inputRef={setCombinedRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
editorStyle={editorStyle}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onInput={handleInput}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, createElement } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
@@ -158,6 +159,7 @@ export function InlineWikilinkEditorField({
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
editorStyle,
|
||||
onCompositionEnd,
|
||||
onCompositionStart,
|
||||
onInput,
|
||||
@@ -175,6 +177,7 @@ export function InlineWikilinkEditorField({
|
||||
inputRef: React.Ref<HTMLDivElement>
|
||||
dataTestId: string
|
||||
editorClassName?: string
|
||||
editorStyle?: CSSProperties
|
||||
onCompositionEnd: () => void
|
||||
onCompositionStart: () => void
|
||||
onInput: () => void
|
||||
@@ -222,7 +225,7 @@ export function InlineWikilinkEditorField({
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
onMouseUp={onSelectionChange}
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
style={{ ...editorStyle, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
segment.kind === 'text'
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { RUNTIME_STYLE_NONCE } from '@/lib/runtimeStyleNonce'
|
||||
import { SafeSvgDiv } from './SafeMarkup'
|
||||
|
||||
type MermaidApi = typeof import('mermaid')['default']
|
||||
|
||||
@@ -52,18 +52,6 @@ function initializeMermaid(mermaid: MermaidApi) {
|
||||
initialized = true
|
||||
}
|
||||
|
||||
function withRuntimeStyleNonce(svg: string): string {
|
||||
if (!svg.includes('<style')) return svg
|
||||
if (typeof document === 'undefined') return svg
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = svg
|
||||
container.querySelectorAll('style').forEach((style) => {
|
||||
style.setAttribute('nonce', RUNTIME_STYLE_NONCE)
|
||||
})
|
||||
return container.innerHTML
|
||||
}
|
||||
|
||||
async function renderMermaidDiagram({
|
||||
diagram,
|
||||
renderId,
|
||||
@@ -75,7 +63,7 @@ async function renderMermaidDiagram({
|
||||
const mermaid = (await import('mermaid')).default
|
||||
initializeMermaid(mermaid)
|
||||
const result = await mermaid.render(renderId, diagram)
|
||||
return withRuntimeStyleNonce(result.svg)
|
||||
return result.svg
|
||||
}
|
||||
const nextRender = renderQueue.then(render, render)
|
||||
renderQueue = nextRender.then(() => undefined, () => undefined)
|
||||
@@ -84,13 +72,13 @@ async function renderMermaidDiagram({
|
||||
|
||||
function MermaidSvgViewport({ ariaLabel, className, svg, testId }: MermaidSvgViewportProps) {
|
||||
return (
|
||||
<div
|
||||
<SafeSvgDiv
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
data-testid={testId}
|
||||
role="img"
|
||||
svg={svg}
|
||||
tabIndex={0}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
73
src/components/PrivacySettingsSection.tsx
Normal file
73
src/components/PrivacySettingsSection.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { createTranslator } from '../lib/i18n'
|
||||
import { SectionHeading, SettingsGroup, SettingsGroupItem } from './SettingsControls'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
|
||||
interface PrivacySettingsSectionProps {
|
||||
t: Translate
|
||||
crashReporting: boolean
|
||||
setCrashReporting: (value: boolean) => void
|
||||
analytics: boolean
|
||||
setAnalytics: (value: boolean) => void
|
||||
}
|
||||
|
||||
function isChecked(checked: boolean | 'indeterminate'): boolean {
|
||||
return checked === true
|
||||
}
|
||||
|
||||
function TelemetryToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
testId: string
|
||||
}) {
|
||||
return (
|
||||
<SettingsGroupItem testId={testId}>
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<Checkbox checked={checked} onCheckedChange={(value) => onChange(isChecked(value))} className="mt-0.5" />
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||
<span className="block text-xs leading-5 text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
</SettingsGroupItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrivacySettingsSection({
|
||||
t,
|
||||
crashReporting,
|
||||
setCrashReporting,
|
||||
analytics,
|
||||
setAnalytics,
|
||||
}: PrivacySettingsSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading title={t('settings.privacy.title')} />
|
||||
<SettingsGroup>
|
||||
<TelemetryToggle
|
||||
label={t('settings.privacy.crashReporting')}
|
||||
description={t('settings.privacy.crashReportingDescription')}
|
||||
checked={crashReporting}
|
||||
onChange={setCrashReporting}
|
||||
testId="settings-crash-reporting"
|
||||
/>
|
||||
<TelemetryToggle
|
||||
label={t('settings.privacy.analytics')}
|
||||
description={t('settings.privacy.analyticsDescription')}
|
||||
checked={analytics}
|
||||
onChange={setAnalytics}
|
||||
testId="settings-analytics"
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -428,33 +428,49 @@ type ScalarRendererProps = SmartCellProps & {
|
||||
editProps: ScalarEditProps
|
||||
}
|
||||
|
||||
const SCALAR_DISPLAY_RENDERERS: Partial<Record<PropertyDisplayMode, (props: ScalarRendererProps) => ReactNode>> = {
|
||||
status: ({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }) => (
|
||||
<StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
|
||||
),
|
||||
tags: ({ propKey, value, isEditing, vaultTags, onSaveList, onStartEdit }) => (
|
||||
<TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
|
||||
),
|
||||
date: ({ propKey, value, isEditing, onSave, onStartEdit }) => (
|
||||
<DateValue
|
||||
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
|
||||
value={String(value ?? '')}
|
||||
onSave={(nextValue) => onSave(propKey, nextValue)}
|
||||
autoOpen={isEditing}
|
||||
onCancel={() => onStartEdit(null)}
|
||||
type ScalarDisplayRenderer = (props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) => ReactNode
|
||||
|
||||
const SCALAR_DISPLAY_RENDERERS: readonly [PropertyDisplayMode, ScalarDisplayRenderer][] = [
|
||||
['status', (props) => (
|
||||
<StatusValue
|
||||
propKey={props.propKey}
|
||||
value={props.value ?? ''}
|
||||
isEditing={props.isEditing}
|
||||
vaultStatuses={props.vaultStatuses}
|
||||
onSave={props.onSave}
|
||||
onStartEdit={props.onStartEdit}
|
||||
/>
|
||||
),
|
||||
number: ({ editProps }) => <NumberValue {...editProps} />,
|
||||
boolean: ({ propKey, value, onUpdate }) => {
|
||||
const boolVal = toBooleanValue(value)
|
||||
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
|
||||
},
|
||||
url: ({ editProps }) => <UrlValue {...editProps} />,
|
||||
color: ({ editProps }) => <ColorEditableValue {...editProps} />,
|
||||
}
|
||||
)],
|
||||
['tags', (props) => (
|
||||
<TagsValue
|
||||
propKey={props.propKey}
|
||||
value={props.value ? [String(props.value)] : []}
|
||||
isEditing={props.isEditing}
|
||||
vaultTags={props.vaultTags}
|
||||
onSave={props.onSaveList}
|
||||
onStartEdit={props.onStartEdit}
|
||||
/>
|
||||
)],
|
||||
['date', (props) => (
|
||||
<DateValue
|
||||
key={`${props.propKey}:${props.isEditing ? 'editing' : 'view'}`}
|
||||
value={String(props.value ?? '')}
|
||||
onSave={(nextValue) => props.onSave(props.propKey, nextValue)}
|
||||
autoOpen={props.isEditing}
|
||||
onCancel={() => props.onStartEdit(null)}
|
||||
/>
|
||||
)],
|
||||
['number', (props) => <NumberValue {...props.editProps} />],
|
||||
['boolean', (props) => {
|
||||
const boolVal = toBooleanValue(props.value)
|
||||
return <BooleanToggle value={boolVal} onToggle={() => props.onUpdate?.(props.propKey, !boolVal)} />
|
||||
}],
|
||||
['url', (props) => <UrlValue {...props.editProps} />],
|
||||
['color', (props) => <ColorEditableValue {...props.editProps} />],
|
||||
]
|
||||
|
||||
function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) {
|
||||
const renderer = SCALAR_DISPLAY_RENDERERS[props.resolvedMode]
|
||||
const renderer = SCALAR_DISPLAY_RENDERERS.find(([mode]) => mode === props.resolvedMode)?.[1]
|
||||
return renderer ? renderer(props) : <EditableValue {...props.editProps} />
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ describe('ResizeHandle', () => {
|
||||
expect(handle.style.cursor || handle.className).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stacks above z-indexed panel surfaces', () => {
|
||||
const { container } = render(<ResizeHandle onResize={vi.fn()} />)
|
||||
const handle = container.firstChild as HTMLElement
|
||||
expect(handle.className).toContain('z-30')
|
||||
})
|
||||
|
||||
it('calls onResize with delta during drag', () => {
|
||||
const onResize = vi.fn()
|
||||
const { container } = render(<ResizeHandle onResize={onResize} />)
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-10 -ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
|
||||
className="relative z-30 -ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)
|
||||
|
||||
54
src/components/SafeMarkup.tsx
Normal file
54
src/components/SafeMarkup.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useLayoutEffect, useRef, type HTMLAttributes } from 'react'
|
||||
import { RUNTIME_STYLE_NONCE } from '@/lib/runtimeStyleNonce'
|
||||
|
||||
interface SafeHtmlSpanProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
html: string
|
||||
}
|
||||
|
||||
interface SafeSvgDivProps extends HTMLAttributes<HTMLDivElement> {
|
||||
svg: string
|
||||
}
|
||||
|
||||
function importSanitizedHtmlNodes(html: string): Node[] {
|
||||
const sanitized = DOMPurify.sanitize(html, {
|
||||
USE_PROFILES: { html: true, mathMl: true },
|
||||
})
|
||||
const parsed = new DOMParser().parseFromString(sanitized, 'text/html')
|
||||
return Array.from(parsed.body.childNodes, (node) => document.importNode(node, true))
|
||||
}
|
||||
|
||||
function importSanitizedSvgNode(svg: string): Node | null {
|
||||
const sanitized = DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
})
|
||||
const parsed = new DOMParser().parseFromString(sanitized, 'image/svg+xml')
|
||||
if (parsed.querySelector('parsererror')) return null
|
||||
|
||||
const svgNode = document.importNode(parsed.documentElement, true)
|
||||
svgNode.querySelectorAll('style').forEach((style) => {
|
||||
style.setAttribute('nonce', RUNTIME_STYLE_NONCE)
|
||||
})
|
||||
return svgNode
|
||||
}
|
||||
|
||||
export function SafeHtmlSpan({ html, ...props }: SafeHtmlSpanProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current?.replaceChildren(...importSanitizedHtmlNodes(html))
|
||||
}, [html])
|
||||
|
||||
return <span {...props} ref={ref} />
|
||||
}
|
||||
|
||||
export function SafeSvgDiv({ svg, ...props }: SafeSvgDivProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = importSanitizedSvgNode(svg)
|
||||
ref.current?.replaceChildren(...(node ? [node] : []))
|
||||
}, [svg])
|
||||
|
||||
return <div {...props} ref={ref} />
|
||||
}
|
||||
273
src/components/SettingsControls.tsx
Normal file
273
src/components/SettingsControls.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select'
|
||||
import { Switch } from './ui/switch'
|
||||
|
||||
type SelectOption = { value: string; label: string }
|
||||
type ControlWidth = 'auto' | 'compact' | 'default' | 'wide'
|
||||
|
||||
const SETTINGS_GROUP_ITEM_CLASS = 'border-b border-border px-4 py-3 last:border-b-0'
|
||||
|
||||
function sanitizePositiveInteger(value: number | null | undefined, fallback: number): number {
|
||||
if (value === null || value === undefined || !Number.isFinite(value) || value < 1) return fallback
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
children: ReactNode
|
||||
id?: string
|
||||
showDivider?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div id={id} className="scroll-mt-4" style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: '18px 0' }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
title,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsGroup({ children }: { children: ReactNode }) {
|
||||
return <div className="overflow-hidden rounded-md border border-border bg-card">{children}</div>
|
||||
}
|
||||
|
||||
export function SettingsGroupItem({
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
testId?: string
|
||||
}) {
|
||||
return <div className={SETTINGS_GROUP_ITEM_CLASS} data-testid={testId}>{children}</div>
|
||||
}
|
||||
|
||||
function controlWidthClass(width: ControlWidth): string {
|
||||
if (width === 'auto') return 'lg:w-auto'
|
||||
if (width === 'compact') return 'lg:w-56'
|
||||
if (width === 'wide') return 'lg:w-[420px]'
|
||||
return 'lg:w-80'
|
||||
}
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
controlWidth = 'default',
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
controlWidth?: ControlWidth
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`${SETTINGS_GROUP_ITEM_CLASS} flex flex-col gap-3 lg:flex-row lg:items-center`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
{description ? <div className="text-xs leading-5 text-muted-foreground">{description}</div> : null}
|
||||
</div>
|
||||
<div className={`w-full min-w-0 lg:shrink-0 ${controlWidthClass(controlWidth)}`}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectControl({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
testId,
|
||||
ariaLabel,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
testId: string
|
||||
ariaLabel: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger
|
||||
className="w-full bg-transparent"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
data-value={value}
|
||||
data-settings-autofocus={autoFocus ? 'true' : undefined}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" data-anchor-strategy="popper">
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function NumberInputControl({
|
||||
value,
|
||||
onValueChange,
|
||||
testId,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number
|
||||
onValueChange: (value: number) => void
|
||||
testId: string
|
||||
ariaLabel: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
id={testId}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(event) => onValueChange(sanitizePositiveInteger(Number(event.target.value), value))}
|
||||
data-testid={testId}
|
||||
className="w-full bg-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsSwitchControl({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return <Switch checked={checked} onCheckedChange={onChange} aria-label={label} disabled={disabled} />
|
||||
}
|
||||
|
||||
export function LabeledSelect({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
testId,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string }>
|
||||
testId: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>{label}</label>
|
||||
<SelectControl
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
options={options}
|
||||
testId={testId}
|
||||
ariaLabel={label}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabeledNumberInput({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
testId,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onValueChange: (value: number) => void
|
||||
testId: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }} htmlFor={testId}>{label}</label>
|
||||
<NumberInputControl
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
testId={testId}
|
||||
ariaLabel={label}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsSwitchRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={`${SETTINGS_GROUP_ITEM_CLASS} flex flex-col gap-3 lg:flex-row lg:items-center`}
|
||||
style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1 }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
<div className="text-xs leading-5 text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<div className="flex justify-start lg:shrink-0 lg:justify-end">
|
||||
<SettingsSwitchControl label={label} checked={checked} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
31
src/components/SettingsFooter.tsx
Normal file
31
src/components/SettingsFooter.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { createTranslator } from '../lib/i18n'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
|
||||
export function SettingsFooter({
|
||||
onClose,
|
||||
onSave,
|
||||
t,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
t: Translate
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between shrink-0"
|
||||
style={{ height: 56, padding: '0 24px', borderTop: '1px solid var(--border)' }}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{t('settings.footerShortcut')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t('settings.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onSave} data-testid="settings-save">
|
||||
{t('settings.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
import type { Settings } from '../types'
|
||||
import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode'
|
||||
import type { AiAgentsStatus } from '../lib/aiAgents'
|
||||
|
||||
const { trackEventMock } = vi.hoisted(() => ({
|
||||
trackEventMock: vi.fn(),
|
||||
@@ -83,7 +84,48 @@ describe('SettingsPanel', () => {
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sync & Updates')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Sync & Updates').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('separates coding agents, local models, and API models in AI settings', async () => {
|
||||
const aiAgentsStatus: AiAgentsStatus = {
|
||||
claude_code: { status: 'installed', version: '2.1.18' },
|
||||
codex: { status: 'missing', version: null },
|
||||
opencode: { status: 'missing', version: null },
|
||||
pi: { status: 'missing', version: null },
|
||||
gemini: { status: 'missing', version: null },
|
||||
}
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
settings={emptySettings}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Recognized coding agents')).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code')).toBeInTheDocument()
|
||||
expect(screen.getByText('2.1.18')).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Local model' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'API model' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Local model' }), { button: 0, ctrlKey: false })
|
||||
fireEvent.change(screen.getByLabelText('Model ID'), { target: { value: 'llama3.2' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test model' }))
|
||||
expect(await screen.findByText('Connection works. The model replied successfully.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add local model' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Recognized coding agents')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: 'API model' }), { button: 0, ctrlKey: false })
|
||||
expect(screen.getByRole('button', { name: 'Add API model' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Add local model' })).not.toBeInTheDocument()
|
||||
fireEvent.pointerDown(screen.getByText('OpenAI').closest('button')!, { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Gemini' }))
|
||||
expect(screen.getByDisplayValue('Gemini')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('https://generativelanguage.googleapis.com/v1beta/openai')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('gemini-2.5-flash')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the draft language when stored settings finish loading', () => {
|
||||
@@ -140,18 +182,18 @@ describe('SettingsPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders All Notes file visibility checkboxes off by default', () => {
|
||||
it('renders All Notes file visibility switches off by default', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('All Notes visibility')).toBeInTheDocument()
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByText('Show PDFs')).toBeInTheDocument()
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('preserves saved All Notes file visibility checkboxes', () => {
|
||||
it('preserves saved All Notes file visibility switches', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
@@ -166,20 +208,18 @@ describe('SettingsPanel', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('saves All Notes file visibility from keyboard toggles before Escape close', () => {
|
||||
it('saves All Notes file visibility immediately before Escape close', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
const pdfCheckbox = within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')
|
||||
pdfCheckbox.focus()
|
||||
fireEvent.keyDown(pdfCheckbox, { key: ' ', code: 'Space' })
|
||||
fireEvent.keyUp(pdfCheckbox, { key: ' ', code: 'Space' })
|
||||
const pdfSwitch = within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('switch')
|
||||
fireEvent.click(pdfSwitch)
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -195,7 +235,7 @@ describe('SettingsPanel', () => {
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox'))
|
||||
fireEvent.click(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('switch'))
|
||||
|
||||
expect(trackEventMock).toHaveBeenCalledWith('all_notes_visibility_changed', {
|
||||
category: 'images',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import { insertPlainTextFromClipboardText } from '../utils/plainTextPaste'
|
||||
import { TooltipProvider } from './ui/tooltip'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
capturedLinkToolbarProps: null as null | Record<string, unknown>,
|
||||
@@ -265,6 +266,7 @@ function renderEditorHarness(editor = createEditor()) {
|
||||
entries={[makeEntry()]}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider },
|
||||
)
|
||||
|
||||
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
|
||||
@@ -302,6 +304,25 @@ function appendToolbarButton(container: Element, className: string, text: string
|
||||
return button
|
||||
}
|
||||
|
||||
function createTitleHeadingFixture(container: Element) {
|
||||
const titleHeading = document.createElement('div')
|
||||
titleHeading.setAttribute('data-content-type', 'heading')
|
||||
titleHeading.setAttribute('data-level', '1')
|
||||
|
||||
const inlineHeading = document.createElement('div')
|
||||
inlineHeading.className = 'bn-inline-content'
|
||||
titleHeading.appendChild(inlineHeading)
|
||||
container.appendChild(titleHeading)
|
||||
|
||||
return inlineHeading
|
||||
}
|
||||
|
||||
function clipboardDataFor(formats: Record<string, string>) {
|
||||
return {
|
||||
getData: vi.fn((format: string) => formats[format] ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
describe('SingleEditorView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -571,11 +592,14 @@ describe('SingleEditorView', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('copies selected fenced code text without markdown escape backslashes', () => {
|
||||
it('copies selected fenced code text without markdown escape backslashes', async () => {
|
||||
const json = '{\n "id": "Demo"\n}'
|
||||
const { container } = renderEditorHarness()
|
||||
const { codeBlock, code } = createCodeBlockFixture(json)
|
||||
container.appendChild(codeBlock)
|
||||
await act(async () => {
|
||||
container.appendChild(codeBlock)
|
||||
await Promise.resolve()
|
||||
})
|
||||
selectNodeContents(code)
|
||||
|
||||
const clipboardData = { setData: vi.fn() }
|
||||
@@ -584,12 +608,36 @@ describe('SingleEditorView', () => {
|
||||
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', json)
|
||||
})
|
||||
|
||||
it('does not override full-note copy selections that merely include a code block', () => {
|
||||
it('copies fenced code from the code-block action button', async () => {
|
||||
const json = '{\n "id": "Demo"\n}'
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(window.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const { container, editor } = renderEditorHarness()
|
||||
const { codeBlock } = createCodeBlockFixture(json)
|
||||
act(() => {
|
||||
container.appendChild(codeBlock)
|
||||
})
|
||||
|
||||
fireEvent.mouseMove(codeBlock)
|
||||
const copyButton = await screen.findByRole('button', { name: 'Copy code to clipboard' })
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith(json))
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not override full-note copy selections that merely include a code block', async () => {
|
||||
const { container } = renderEditorHarness()
|
||||
const paragraph = document.createElement('p')
|
||||
paragraph.textContent = 'Before'
|
||||
const { codeBlock, code } = createCodeBlockFixture('const value = 1')
|
||||
container.append(paragraph, codeBlock)
|
||||
await act(async () => {
|
||||
container.append(paragraph, codeBlock)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStartBefore(paragraph)
|
||||
@@ -616,6 +664,34 @@ describe('SingleEditorView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes rich title-heading paste through safe plain-text inline insertion', () => {
|
||||
const { container, editor } = renderEditorHarness()
|
||||
const inlineHeading = createTitleHeadingFixture(container)
|
||||
const clipboardData = clipboardDataFor({
|
||||
'text/html': '<h1>Pasted <em>Title</em></h1><table><tr><td>Cell</td></tr></table>',
|
||||
'text/plain': 'Pasted Title\nCell',
|
||||
})
|
||||
|
||||
const didBubble = fireEvent.paste(inlineHeading, { clipboardData })
|
||||
|
||||
expect(didBubble).toBe(false)
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(editor.insertInlineContent).toHaveBeenCalledWith('Pasted Title\nCell', {
|
||||
updateSelection: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves plain title-heading paste on BlockNote native handling', () => {
|
||||
const { container, editor } = renderEditorHarness()
|
||||
const inlineHeading = createTitleHeadingFixture(container)
|
||||
const clipboardData = clipboardDataFor({ 'text/plain': 'Plain Title' })
|
||||
|
||||
const didBubble = fireEvent.paste(inlineHeading, { clipboardData })
|
||||
|
||||
expect(didBubble).toBe(true)
|
||||
expect(editor.insertInlineContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes clicks on the empty title wrapper back into the H1 block', async () => {
|
||||
const editor = createEditor()
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useCallback, useMemo, useRef, useContext } from 'react'
|
||||
import { useEffect, useCallback, useMemo, useRef, useContext, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import {
|
||||
useCreateBlockNote,
|
||||
@@ -16,12 +17,14 @@ import {
|
||||
} from '@blocknote/react'
|
||||
import { components } from '@blocknote/mantine'
|
||||
import { MantineContext, MantineProvider } from '@mantine/core'
|
||||
import { Copy } from '@phosphor-icons/react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
|
||||
import { useEditorTheme } from '../hooks/useTheme'
|
||||
import { useImageDrop } from '../hooks/useImageDrop'
|
||||
import { useImageLightbox } from '../hooks/useImageLightbox'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import { createTranslator, type AppLocale } from '../lib/i18n'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
|
||||
@@ -42,6 +45,8 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
|
||||
import { useEditorLinkActivation } from './useEditorLinkActivation'
|
||||
import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
|
||||
import { ImageLightbox } from './ImageLightbox'
|
||||
import { ActionTooltip } from './ui/action-tooltip'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
activatePlainTextPasteTarget,
|
||||
registerPlainTextPasteTarget,
|
||||
@@ -59,6 +64,7 @@ const CONTAINER_CLICK_IGNORE_SELECTOR = [
|
||||
'.bn-link-toolbar',
|
||||
'.bn-side-menu',
|
||||
'.bn-form-popover',
|
||||
'[data-editor-code-copy]',
|
||||
'[role="menu"]',
|
||||
'[role="dialog"]',
|
||||
].join(', ')
|
||||
@@ -280,6 +286,7 @@ function isSelectionInsideElement(element: HTMLElement): boolean {
|
||||
const TITLE_HEADING_SELECTOR = 'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])'
|
||||
const TITLE_HEADING_WRAPPER_SELECTOR = '.bn-block-outer, .bn-block'
|
||||
const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]'
|
||||
const CODE_BLOCK_COPY_RESET_MS = 1200
|
||||
|
||||
function nodeElement(node: Node | null): HTMLElement | null {
|
||||
if (!node) return null
|
||||
@@ -336,6 +343,151 @@ function selectedCodeBlockText(options: {
|
||||
return options.selection?.toString() || range.cloneContents().textContent || ''
|
||||
}
|
||||
|
||||
function codeBlockText(codeBlock: HTMLElement): string {
|
||||
const codeElement = codeBlock.querySelector<HTMLElement>('pre code')
|
||||
return codeElement?.textContent ?? ''
|
||||
}
|
||||
|
||||
async function writeClipboardText(text: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
await invoke('copy_text_to_clipboard', { text })
|
||||
return
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API is unavailable')
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
type CodeBlockCopyTarget = {
|
||||
codeBlock: HTMLElement
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
function codeBlockCopyTarget(codeBlock: HTMLElement, container: HTMLElement): CodeBlockCopyTarget {
|
||||
const codeBlockRect = codeBlock.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
codeBlock,
|
||||
left: codeBlockRect.right - containerRect.left + container.scrollLeft - 30,
|
||||
top: codeBlockRect.top - containerRect.top + container.scrollTop + 6,
|
||||
}
|
||||
}
|
||||
|
||||
function sameCopyTarget(left: CodeBlockCopyTarget | null, right: CodeBlockCopyTarget): boolean {
|
||||
return Boolean(
|
||||
left
|
||||
&& left.codeBlock === right.codeBlock
|
||||
&& left.left === right.left
|
||||
&& left.top === right.top,
|
||||
)
|
||||
}
|
||||
|
||||
function useCodeBlockCopyTarget(containerRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const [copyTarget, setCopyTarget] = useState<CodeBlockCopyTarget | null>(null)
|
||||
|
||||
const showCopyTarget = useCallback((codeBlock: HTMLElement) => {
|
||||
const container = containerRef.current
|
||||
if (!container || !container.contains(codeBlock)) return
|
||||
|
||||
const nextTarget = codeBlockCopyTarget(codeBlock, container)
|
||||
setCopyTarget((previous) => sameCopyTarget(previous, nextTarget) ? previous : nextTarget)
|
||||
}, [containerRef])
|
||||
|
||||
const updateFromEventTarget = useCallback((target: EventTarget | null) => {
|
||||
const container = containerRef.current
|
||||
if (!(target instanceof HTMLElement) || !container) return
|
||||
if (target.closest('[data-editor-code-copy]')) return
|
||||
|
||||
const codeBlock = target.closest<HTMLElement>(CODE_BLOCK_SELECTOR)
|
||||
if (codeBlock && container.contains(codeBlock)) {
|
||||
showCopyTarget(codeBlock)
|
||||
return
|
||||
}
|
||||
|
||||
setCopyTarget(null)
|
||||
}, [containerRef, showCopyTarget])
|
||||
|
||||
const handleMouseMove = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
updateFromEventTarget(event.target)
|
||||
}, [updateFromEventTarget])
|
||||
|
||||
const handleFocus = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
||||
updateFromEventTarget(event.target)
|
||||
}, [updateFromEventTarget])
|
||||
|
||||
const clearCopyTarget = useCallback(() => setCopyTarget(null), [])
|
||||
|
||||
return { clearCopyTarget, copyTarget, handleFocus, handleMouseMove }
|
||||
}
|
||||
|
||||
function CodeBlockCopyButton({ copyTarget, locale }: { copyTarget: CodeBlockCopyTarget; locale: AppLocale }) {
|
||||
const [active, setActive] = useState(false)
|
||||
const resetTimerRef = useRef<number | null>(null)
|
||||
const t = useMemo(() => createTranslator(locale), [locale])
|
||||
const label = t('editor.codeBlock.copy')
|
||||
|
||||
useEffect(() => () => {
|
||||
if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current)
|
||||
}, [])
|
||||
|
||||
const handleCopy = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
void writeClipboardText(codeBlockText(copyTarget.codeBlock))
|
||||
.then(() => {
|
||||
trackEvent('code_block_copied')
|
||||
setActive(true)
|
||||
if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current)
|
||||
resetTimerRef.current = window.setTimeout(() => {
|
||||
setActive(false)
|
||||
resetTimerRef.current = null
|
||||
}, CODE_BLOCK_COPY_RESET_MS)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[editor] Failed to copy code block:', error)
|
||||
})
|
||||
}, [copyTarget])
|
||||
|
||||
const stopEditorMouseDown = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="editor__code-block-copy"
|
||||
contentEditable={false}
|
||||
data-editor-code-copy
|
||||
style={{ left: copyTarget.left, top: copyTarget.top }}
|
||||
>
|
||||
<ActionTooltip copy={{ label }} side="left" align="center">
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="border-transparent bg-transparent text-muted-foreground shadow-none hover:bg-transparent hover:text-foreground focus-visible:bg-transparent focus-visible:text-foreground"
|
||||
data-editor-code-copy-button
|
||||
onBlur={() => setActive(false)}
|
||||
onClick={handleCopy}
|
||||
onFocus={() => setActive(true)}
|
||||
onMouseDown={stopEditorMouseDown}
|
||||
onMouseEnter={() => setActive(true)}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy aria-hidden="true" className="size-6" weight={active ? 'fill' : 'regular'} />
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function findTitleHeadingElement(target: HTMLElement): HTMLElement | null {
|
||||
const directHeading = target.closest<HTMLElement>(TITLE_HEADING_SELECTOR)
|
||||
if (directHeading) return directHeading
|
||||
@@ -344,6 +496,45 @@ function findTitleHeadingElement(target: HTMLElement): HTMLElement | null {
|
||||
return titleWrapper?.querySelector<HTMLElement>(TITLE_HEADING_SELECTOR) ?? null
|
||||
}
|
||||
|
||||
function richClipboardPlainText(clipboardData: DataTransfer): string | null {
|
||||
const text = clipboardData.getData('text/plain')
|
||||
const html = clipboardData.getData('text/html')
|
||||
|
||||
return text.length > 0 && html.length > 0 ? text : null
|
||||
}
|
||||
|
||||
function eventTargetElement(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Node)) return null
|
||||
return nodeElement(target)
|
||||
}
|
||||
|
||||
function useTitleHeadingRichPasteHandler(options: {
|
||||
editable: boolean
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
runEditorAction: (action: SuggestionAction) => void
|
||||
}) {
|
||||
const { editable, editor, runEditorAction } = options
|
||||
|
||||
return useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
|
||||
const target = eventTargetElement(event.target)
|
||||
if (!target) return
|
||||
|
||||
const titleHeading = findTitleHeadingElement(target)
|
||||
if (!titleHeading || !event.currentTarget.contains(titleHeading)) return
|
||||
|
||||
const text = richClipboardPlainText(event.clipboardData)
|
||||
if (!text) return
|
||||
|
||||
event.preventDefault()
|
||||
runEditorAction(() => {
|
||||
editor.focus()
|
||||
editor.insertInlineContent(text, { updateSelection: true })
|
||||
})
|
||||
}, [editable, editor, runEditorAction])
|
||||
}
|
||||
|
||||
function queueTitleHeadingCursorRepair(
|
||||
target: HTMLElement,
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
@@ -569,6 +760,55 @@ function useSuggestionMenuItems(options: {
|
||||
}
|
||||
}
|
||||
|
||||
type EditorInteractionControllersProps = ReturnType<typeof useSuggestionMenuItems> & {
|
||||
runEditorAction: (action: SuggestionAction) => void
|
||||
}
|
||||
|
||||
function EditorInteractionControllers({
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
getWikilinkItems,
|
||||
runEditorAction,
|
||||
}: EditorInteractionControllersProps) {
|
||||
return (
|
||||
<>
|
||||
<SideMenuController sideMenu={TolariaSideMenu} />
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={TolariaFormattingToolbar}
|
||||
floatingUIOptions={{
|
||||
elementProps: {
|
||||
onMouseDownCapture: handleToolbarMouseDownCapture,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<LinkToolbarController
|
||||
linkToolbar={TolariaLinkToolbar}
|
||||
floatingUIOptions={{
|
||||
elementProps: {
|
||||
onMouseDownCapture: handleToolbarMouseDownCapture,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="/"
|
||||
getItems={getSlashMenuItems}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="[["
|
||||
getItems={getWikilinkItems}
|
||||
suggestionMenuComponent={WikilinkSuggestionMenu}
|
||||
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="@"
|
||||
getItems={getPersonMentionItems}
|
||||
suggestionMenuComponent={WikilinkSuggestionMenu}
|
||||
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Insert an image block after the current cursor position. */
|
||||
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const editorRef = useRef(editor)
|
||||
@@ -642,6 +882,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const onImageUrl = useInsertImageCallback(editor)
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
const lightbox = useImageLightbox({ containerRef })
|
||||
const {
|
||||
clearCopyTarget,
|
||||
copyTarget,
|
||||
handleFocus: handleCodeBlockCopyFocus,
|
||||
handleMouseMove: handleCodeBlockCopyMouseMove,
|
||||
} = useCodeBlockCopyTarget(containerRef)
|
||||
useBlockNoteSideMenuHoverGuard(containerRef)
|
||||
useEditorLinkActivation(containerRef, onNavigateWikilink)
|
||||
|
||||
@@ -672,12 +918,17 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
editor,
|
||||
runEditorAction,
|
||||
})
|
||||
const handleTitleHeadingRichPaste = useTitleHeadingRichPasteHandler({
|
||||
editable,
|
||||
editor,
|
||||
runEditorAction,
|
||||
})
|
||||
const handleFocusCapture = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
||||
activatePlainTextPaste()
|
||||
handleCodeBlockCopyFocus(event)
|
||||
}, [activatePlainTextPaste, handleCodeBlockCopyFocus])
|
||||
const insertWikilink = useInsertWikilink(editor, runEditorAction)
|
||||
const {
|
||||
getWikilinkItems,
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
} = useSuggestionMenuItems({
|
||||
const suggestionMenuItems = useSuggestionMenuItems({
|
||||
baseItems,
|
||||
editor,
|
||||
insertWikilink,
|
||||
@@ -693,8 +944,11 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
style={cssVars as React.CSSProperties}
|
||||
onClick={handleContainerClick}
|
||||
onCopyCapture={handleCodeBlockCopy}
|
||||
onFocusCapture={activatePlainTextPaste}
|
||||
onFocusCapture={handleFocusCapture}
|
||||
onMouseLeave={clearCopyTarget}
|
||||
onMouseDownCapture={activatePlainTextPaste}
|
||||
onMouseMove={handleCodeBlockCopyMouseMove}
|
||||
onPasteCapture={handleTitleHeadingRichPaste}
|
||||
>
|
||||
{isDragOver && (
|
||||
<div className="editor__drop-overlay">
|
||||
@@ -711,40 +965,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
slashMenu={false}
|
||||
sideMenu={false}
|
||||
>
|
||||
<SideMenuController sideMenu={TolariaSideMenu} />
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={TolariaFormattingToolbar}
|
||||
floatingUIOptions={{
|
||||
elementProps: {
|
||||
onMouseDownCapture: handleToolbarMouseDownCapture,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<LinkToolbarController
|
||||
linkToolbar={TolariaLinkToolbar}
|
||||
floatingUIOptions={{
|
||||
elementProps: {
|
||||
onMouseDownCapture: handleToolbarMouseDownCapture,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="/"
|
||||
getItems={getSlashMenuItems}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="[["
|
||||
getItems={getWikilinkItems}
|
||||
suggestionMenuComponent={WikilinkSuggestionMenu}
|
||||
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="@"
|
||||
getItems={getPersonMentionItems}
|
||||
suggestionMenuComponent={WikilinkSuggestionMenu}
|
||||
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
|
||||
<EditorInteractionControllers
|
||||
{...suggestionMenuItems}
|
||||
runEditorAction={runEditorAction}
|
||||
/>
|
||||
</SharedContextBlockNoteView>
|
||||
{copyTarget && <CodeBlockCopyButton copyTarget={copyTarget} locale={locale} />}
|
||||
<ImageLightbox image={lightbox.image} locale={locale} onClose={lightbox.close} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { AiModelProvider } from '../lib/aiTargets'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
|
||||
@@ -16,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
|
||||
@@ -92,7 +94,10 @@ interface StatusBarProps {
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
@@ -135,7 +140,10 @@ function StatusBarPrimaryFromFooter({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
@@ -175,7 +183,10 @@ function StatusBarPrimaryFromFooter({
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
@@ -233,10 +244,10 @@ function StatusBarFooter(props: StatusBarFooterProps) {
|
||||
background: 'var(--sidebar)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: stacked ? '4px 8px' : '0 8px',
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
color: 'var(--muted-foreground)',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
zIndex: STATUS_BAR_STACKING_Z_INDEX,
|
||||
}}
|
||||
>
|
||||
<StatusBarPrimaryFromFooter {...props} />
|
||||
|
||||
209
src/components/TldrawWhiteboard.tsx
Normal file
209
src/components/TldrawWhiteboard.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import {
|
||||
Tldraw,
|
||||
createTLStore,
|
||||
getSnapshot,
|
||||
loadSnapshot,
|
||||
type TLStoreSnapshot,
|
||||
} from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
interface TldrawWhiteboardProps {
|
||||
boardId: string
|
||||
height: string
|
||||
snapshot: string
|
||||
width: string
|
||||
onSnapshotChange: (snapshot: string) => void
|
||||
onSizeChange: (size: TldrawWhiteboardSize) => void
|
||||
}
|
||||
|
||||
interface TldrawWhiteboardSize {
|
||||
height: string
|
||||
width: string
|
||||
}
|
||||
|
||||
interface PixelSize {
|
||||
height: number
|
||||
width: number | null
|
||||
}
|
||||
|
||||
interface ResizeStart {
|
||||
height: number
|
||||
pointerX: number
|
||||
pointerY: number
|
||||
width: number
|
||||
}
|
||||
|
||||
type ResizeMode = 'height' | 'width' | 'both'
|
||||
|
||||
const DEFAULT_HEIGHT = 520
|
||||
const MIN_HEIGHT = 260
|
||||
const MIN_WIDTH = 360
|
||||
|
||||
function parsePixelValue(value: string, fallback: number): number {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function normalizeSize({ height, width }: TldrawWhiteboardSize): PixelSize {
|
||||
return {
|
||||
height: parsePixelValue(height, DEFAULT_HEIGHT),
|
||||
width: width ? parsePixelValue(width, MIN_WIDTH) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function sizeToProps({ height, width }: PixelSize): TldrawWhiteboardSize {
|
||||
return {
|
||||
height: String(Math.max(MIN_HEIGHT, Math.round(height))),
|
||||
width: width === null ? '' : String(Math.max(MIN_WIDTH, Math.round(width))),
|
||||
}
|
||||
}
|
||||
|
||||
function cssSize({ height, width }: PixelSize): CSSProperties {
|
||||
return {
|
||||
'--tldraw-whiteboard-height': `${Math.max(MIN_HEIGHT, height)}px`,
|
||||
'--tldraw-whiteboard-width': width === null ? '100%' : `${Math.max(MIN_WIDTH, width)}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
|
||||
function parseSnapshot(source: string): TLStoreSnapshot | null {
|
||||
if (!source.trim()) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(source) as TLStoreSnapshot
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSnapshot(snapshot: TLStoreSnapshot): string {
|
||||
return `${JSON.stringify(snapshot, null, 2)}\n`
|
||||
}
|
||||
|
||||
export function TldrawWhiteboard({
|
||||
boardId,
|
||||
height,
|
||||
snapshot,
|
||||
width,
|
||||
onSnapshotChange,
|
||||
onSizeChange,
|
||||
}: TldrawWhiteboardProps) {
|
||||
const store = useMemo(() => createTLStore(), [])
|
||||
const boardRef = useRef<HTMLDivElement | null>(null)
|
||||
const savedSnapshotRef = useRef<string | null>(null)
|
||||
const onSnapshotChangeRef = useRef(onSnapshotChange)
|
||||
const persistedSize = useMemo(() => normalizeSize({ height, width }), [height, width])
|
||||
const [resizingSize, setResizingSize] = useState<PixelSize | null>(null)
|
||||
const visibleSize = resizingSize ?? persistedSize
|
||||
|
||||
useEffect(() => {
|
||||
onSnapshotChangeRef.current = onSnapshotChange
|
||||
}, [onSnapshotChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot === savedSnapshotRef.current) return
|
||||
|
||||
const parsed = parseSnapshot(snapshot)
|
||||
if (parsed) {
|
||||
try {
|
||||
loadSnapshot(store, parsed)
|
||||
savedSnapshotRef.current = snapshot
|
||||
return
|
||||
} catch {
|
||||
// Fall through to an empty board when legacy or hand-edited JSON is invalid.
|
||||
}
|
||||
}
|
||||
|
||||
savedSnapshotRef.current = serializeSnapshot(getSnapshot(store).document)
|
||||
}, [snapshot, store])
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | null = null
|
||||
|
||||
const flushSnapshot = () => {
|
||||
timeoutId = null
|
||||
const nextSnapshot = serializeSnapshot(getSnapshot(store).document)
|
||||
if (nextSnapshot === savedSnapshotRef.current) return
|
||||
|
||||
savedSnapshotRef.current = nextSnapshot
|
||||
onSnapshotChangeRef.current(nextSnapshot)
|
||||
}
|
||||
|
||||
const scheduleSnapshotFlush = () => {
|
||||
if (timeoutId !== null) window.clearTimeout(timeoutId)
|
||||
timeoutId = window.setTimeout(flushSnapshot, 350)
|
||||
}
|
||||
|
||||
const cleanup = store.listen(scheduleSnapshotFlush, { source: 'user', scope: 'document' })
|
||||
return () => {
|
||||
cleanup()
|
||||
if (timeoutId !== null) window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [store])
|
||||
|
||||
const startResize = (mode: ResizeMode) => (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const rect = boardRef.current?.getBoundingClientRect()
|
||||
const start: ResizeStart = {
|
||||
height: visibleSize.height,
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
width: visibleSize.width ?? rect?.width ?? MIN_WIDTH,
|
||||
}
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
const nextSize = {
|
||||
height: mode === 'width' ? start.height : start.height + moveEvent.clientY - start.pointerY,
|
||||
width: mode === 'height' ? visibleSize.width : start.width + moveEvent.clientX - start.pointerX,
|
||||
}
|
||||
setResizingSize(normalizeSize(sizeToProps(nextSize)))
|
||||
}
|
||||
|
||||
const onPointerUp = (upEvent: PointerEvent) => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
|
||||
const finalSize = {
|
||||
height: mode === 'width' ? start.height : start.height + upEvent.clientY - start.pointerY,
|
||||
width: mode === 'height' ? visibleSize.width : start.width + upEvent.clientX - start.pointerX,
|
||||
}
|
||||
const nextProps = sizeToProps(normalizeSize(sizeToProps(finalSize)))
|
||||
setResizingSize(null)
|
||||
onSizeChange(nextProps)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp, { once: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={boardRef}
|
||||
className="tldraw-whiteboard"
|
||||
data-board-id={boardId}
|
||||
style={cssSize(visibleSize)}
|
||||
>
|
||||
<Tldraw store={store} />
|
||||
<div
|
||||
aria-label="Resize whiteboard width"
|
||||
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--width"
|
||||
onPointerDown={startResize('width')}
|
||||
role="separator"
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize whiteboard height"
|
||||
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--height"
|
||||
onPointerDown={startResize('height')}
|
||||
role="separator"
|
||||
/>
|
||||
<div
|
||||
aria-label="Resize whiteboard"
|
||||
className="tldraw-whiteboard__resize-handle tldraw-whiteboard__resize-handle--corner"
|
||||
onPointerDown={startResize('both')}
|
||||
role="separator"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -437,6 +437,26 @@ describe('WikilinkChatInput', () => {
|
||||
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets native modified delete shortcuts reach the browser editor pipeline', () => {
|
||||
const onDraftChange = vi.fn()
|
||||
render(<Controlled onDraftChange={onDraftChange} />)
|
||||
updateEditorText('delete the previous words')
|
||||
onDraftChange.mockClear()
|
||||
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
const optionBackspace = createEvent.keyDown(editor, { key: 'Backspace', altKey: true })
|
||||
fireEvent(editor, optionBackspace)
|
||||
const commandBackspace = createEvent.keyDown(editor, { key: 'Backspace', metaKey: true })
|
||||
fireEvent(editor, commandBackspace)
|
||||
const controlDelete = createEvent.keyDown(editor, { key: 'Delete', ctrlKey: true })
|
||||
fireEvent(editor, controlDelete)
|
||||
|
||||
expect(optionBackspace.defaultPrevented).toBe(false)
|
||||
expect(commandBackspace.defaultPrevented).toBe(false)
|
||||
expect(controlDelete.defaultPrevented).toBe(false)
|
||||
expect(onDraftChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits serialized wikilink text and resolved references', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
@@ -11,6 +12,8 @@ interface WikilinkChatInputProps {
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
editorClassName?: string
|
||||
editorStyle?: CSSProperties
|
||||
}
|
||||
|
||||
export function WikilinkChatInput({
|
||||
@@ -22,6 +25,8 @@ export function WikilinkChatInput({
|
||||
disabled,
|
||||
placeholder,
|
||||
inputRef,
|
||||
editorClassName,
|
||||
editorStyle,
|
||||
}: WikilinkChatInputProps) {
|
||||
return (
|
||||
<InlineWikilinkInput
|
||||
@@ -33,6 +38,8 @@ export function WikilinkChatInput({
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
editorClassName={editorClassName}
|
||||
editorStyle={editorStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { codeBlockOptions } from '@blocknote/code-block'
|
||||
import type { CodeBlockOptions } from '@blocknote/core'
|
||||
|
||||
const LIGHT_CODE_THEME = 'github-light'
|
||||
const DARK_CODE_THEME = 'github-dark'
|
||||
|
||||
type TolariaCodeHighlighter = Awaited<ReturnType<NonNullable<typeof codeBlockOptions.createHighlighter>>>
|
||||
|
||||
function supportsShikiPrecompiledRegexFlags() {
|
||||
try {
|
||||
new RegExp('', 'd')
|
||||
@@ -11,9 +16,31 @@ function supportsShikiPrecompiledRegexFlags() {
|
||||
}
|
||||
}
|
||||
|
||||
function currentCodeBlockTheme() {
|
||||
if (typeof document === 'undefined') return LIGHT_CODE_THEME
|
||||
|
||||
const root = document.documentElement
|
||||
return root.classList.contains('dark') || root.dataset.theme === 'dark'
|
||||
? DARK_CODE_THEME
|
||||
: LIGHT_CODE_THEME
|
||||
}
|
||||
|
||||
function prioritizeTheme(themes: string[], theme: string) {
|
||||
return [theme, ...themes.filter((candidate) => candidate !== theme)]
|
||||
}
|
||||
|
||||
async function createTolariaCodeHighlighter(): Promise<TolariaCodeHighlighter> {
|
||||
const highlighter = await codeBlockOptions.createHighlighter()
|
||||
return {
|
||||
...highlighter,
|
||||
getLoadedThemes: () => prioritizeTheme(highlighter.getLoadedThemes(), currentCodeBlockTheme()),
|
||||
}
|
||||
}
|
||||
|
||||
export function createTolariaCodeBlockOptions(): Partial<CodeBlockOptions> {
|
||||
const options: Partial<CodeBlockOptions> = {
|
||||
...codeBlockOptions,
|
||||
createHighlighter: createTolariaCodeHighlighter,
|
||||
defaultLanguage: 'text',
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,21 @@ import {
|
||||
defaultInlineContentSpecs,
|
||||
} from '@blocknote/core'
|
||||
import { createReactBlockSpec, createReactInlineContentSpec } from '@blocknote/react'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown'
|
||||
import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown'
|
||||
import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { createTolariaCodeBlockOptions } from './codeBlockOptions'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { MermaidDiagram } from './MermaidDiagram'
|
||||
import { SafeHtmlSpan } from './SafeMarkup'
|
||||
|
||||
const TldrawWhiteboard = lazy(() => import('./TldrawWhiteboard').then(module => ({
|
||||
default: module.TldrawWhiteboard,
|
||||
})))
|
||||
|
||||
// Module-level cache so the WikiLink renderer (defined outside React) can access entries
|
||||
export const _wikilinkEntriesRef: { current: VaultEntry[] } = { current: [] }
|
||||
@@ -67,13 +74,13 @@ export const WikiLink = createReactInlineContentSpec(
|
||||
function MathRender({ latex, displayMode }: { latex: string; displayMode: boolean }) {
|
||||
const source = displayMode ? `$$\n${latex}\n$$` : `$${latex}$`
|
||||
return (
|
||||
<span
|
||||
<SafeHtmlSpan
|
||||
aria-label={`Math: ${latex}`}
|
||||
className={displayMode ? 'math math--block' : 'math math--inline'}
|
||||
data-latex={latex}
|
||||
html={renderMathToHtml({ latex, displayMode })}
|
||||
role="img"
|
||||
title={source}
|
||||
dangerouslySetInnerHTML={{ __html: renderMathToHtml({ latex, displayMode }) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -157,9 +164,58 @@ const MermaidBlock = createReactBlockSpec(
|
||||
},
|
||||
)
|
||||
|
||||
const TldrawBlock = createReactBlockSpec(
|
||||
{
|
||||
type: TLDRAW_BLOCK_TYPE,
|
||||
propSchema: {
|
||||
boardId: { default: '' },
|
||||
height: { default: TLDRAW_DEFAULT_HEIGHT },
|
||||
snapshot: { default: '{}' },
|
||||
width: { default: '' },
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
runsBefore: ['codeBlock'],
|
||||
render: (props) => (
|
||||
<Suspense fallback={<div className="tldraw-whiteboard tldraw-whiteboard--loading" />}>
|
||||
<TldrawWhiteboard
|
||||
boardId={props.block.props.boardId}
|
||||
height={props.block.props.height}
|
||||
snapshot={props.block.props.snapshot}
|
||||
width={props.block.props.width}
|
||||
onSnapshotChange={(snapshot) => {
|
||||
props.editor.updateBlock(props.block, {
|
||||
type: TLDRAW_BLOCK_TYPE,
|
||||
props: {
|
||||
boardId: props.block.props.boardId,
|
||||
height: props.block.props.height,
|
||||
snapshot,
|
||||
width: props.block.props.width,
|
||||
},
|
||||
})
|
||||
}}
|
||||
onSizeChange={(size) => {
|
||||
props.editor.updateBlock(props.block, {
|
||||
type: TLDRAW_BLOCK_TYPE,
|
||||
props: {
|
||||
boardId: props.block.props.boardId,
|
||||
height: size.height,
|
||||
snapshot: props.block.props.snapshot,
|
||||
width: size.width,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
const codeBlock = createCodeBlockSpec(createTolariaCodeBlockOptions())
|
||||
const mathBlock = MathBlock()
|
||||
const mermaidBlock = MermaidBlock()
|
||||
const tldrawBlock = TldrawBlock()
|
||||
|
||||
export const schema = BlockNoteSchema.create({
|
||||
inlineContentSpecs: {
|
||||
@@ -171,6 +227,7 @@ export const schema = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
mathBlock,
|
||||
mermaidBlock,
|
||||
tldrawBlock,
|
||||
codeBlock,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -33,11 +33,35 @@ function installLegacyWebKitRegExp() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('dark')
|
||||
delete document.documentElement.dataset.theme
|
||||
restoreRegExpConstructor()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('editor schema code block highlighting', () => {
|
||||
it('uses the light Shiki theme first in light mode', async () => {
|
||||
vi.resetModules()
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.dataset.theme = 'light'
|
||||
|
||||
const { createTolariaCodeBlockOptions } = await import('./codeBlockOptions')
|
||||
const highlighter = await createTolariaCodeBlockOptions().createHighlighter?.()
|
||||
|
||||
expect(highlighter?.getLoadedThemes()[0]).toBe('github-light')
|
||||
})
|
||||
|
||||
it('uses the dark Shiki theme first in dark mode', async () => {
|
||||
vi.resetModules()
|
||||
document.documentElement.classList.add('dark')
|
||||
document.documentElement.dataset.theme = 'dark'
|
||||
|
||||
const { createTolariaCodeBlockOptions } = await import('./codeBlockOptions')
|
||||
const highlighter = await createTolariaCodeBlockOptions().createHighlighter?.()
|
||||
|
||||
expect(highlighter?.getLoadedThemes()[0]).toBe('github-dark')
|
||||
})
|
||||
|
||||
it('omits the Shiki highlighter when WebKit lacks precompiled regex flags', async () => {
|
||||
installLegacyWebKitRegExp()
|
||||
vi.resetModules()
|
||||
|
||||
@@ -61,6 +61,7 @@ function handleDeleteKeys({
|
||||
onDeleteContent,
|
||||
}: HandleDeleteKeysArgs): boolean {
|
||||
if (isInlineWikilinkCompositionEvent(event, isComposing)) return false
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return false
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -161,12 +161,16 @@ function useViewRowKeyboardActions({
|
||||
startRename: () => void
|
||||
}) {
|
||||
const runKeyboardAction = useCallback((action: RowKeyboardAction) => {
|
||||
const actions: Record<RowKeyboardAction, () => void> = {
|
||||
select: onSelect,
|
||||
rename: startRename,
|
||||
menu: openKeyboardContextMenu,
|
||||
switch (action) {
|
||||
case 'select':
|
||||
onSelect()
|
||||
return
|
||||
case 'rename':
|
||||
startRename()
|
||||
return
|
||||
case 'menu':
|
||||
openKeyboardContextMenu()
|
||||
}
|
||||
actions[action]()
|
||||
}, [onSelect, openKeyboardContextMenu, startRename])
|
||||
|
||||
return useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
type AiAgentDefinition,
|
||||
type AiAgentsStatus,
|
||||
} from '../../lib/aiAgents'
|
||||
import {
|
||||
configuredModelTargets,
|
||||
resolveAiTarget,
|
||||
type AiModelProvider,
|
||||
} from '../../lib/aiTargets'
|
||||
import type { Settings } from '../../types'
|
||||
import {
|
||||
getVaultAiGuidanceSummary,
|
||||
isVaultAiGuidanceStatusChecking,
|
||||
@@ -36,7 +42,10 @@ interface AiAgentsBadgeProps {
|
||||
statuses: AiAgentsStatus
|
||||
guidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAgent: AiAgentId
|
||||
defaultTarget?: string
|
||||
providers?: AiModelProvider[]
|
||||
onSetDefaultAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultTarget?: (target: string) => void
|
||||
onRestoreGuidance?: () => void
|
||||
compact?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -115,8 +124,8 @@ function canShowSwitcherCue(statuses: AiAgentsStatus, defaultAgent: AiAgentId):
|
||||
|
||||
function triggerButtonClassName(compact: boolean): string {
|
||||
return compact
|
||||
? 'h-6 w-6 rounded-sm p-0 text-[11px] font-medium'
|
||||
: 'h-6 px-2 text-[11px] font-medium'
|
||||
? 'h-6 w-6 rounded-sm p-0 text-[12px] font-medium'
|
||||
: 'h-6 px-2 text-[12px] font-medium'
|
||||
}
|
||||
|
||||
function CompactSeparator({ compact }: { compact: boolean }) {
|
||||
@@ -124,11 +133,6 @@ function CompactSeparator({ compact }: { compact: boolean }) {
|
||||
return <span style={SEP_STYLE}>|</span>
|
||||
}
|
||||
|
||||
function TriggerLabel({ compact, defaultAgent }: { compact: boolean; defaultAgent: AiAgentId }) {
|
||||
if (compact) return null
|
||||
return triggerLabel(defaultAgent)
|
||||
}
|
||||
|
||||
function TriggerStateIcon({
|
||||
showWarning,
|
||||
showSwitcherCue,
|
||||
@@ -171,13 +175,17 @@ function AgentMenuContent({
|
||||
statuses,
|
||||
guidanceStatus,
|
||||
defaultAgent,
|
||||
defaultTarget,
|
||||
providers = [],
|
||||
selectedAgentReady,
|
||||
onSetDefaultAgent,
|
||||
onSetDefaultTarget,
|
||||
onRestoreGuidance,
|
||||
locale = 'en',
|
||||
}: AiAgentsBadgeProps & { selectedAgentReady: boolean }) {
|
||||
const installedAgents = installedAgentDefinitions(statuses)
|
||||
const missingAgents = missingAgentDefinitions(statuses)
|
||||
const modelTargets = configuredModelTargets(providers)
|
||||
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
@@ -192,7 +200,10 @@ function AgentMenuContent({
|
||||
) : (
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedAgentReady ? defaultAgent : undefined}
|
||||
onValueChange={(value) => onSetDefaultAgent?.(value as AiAgentId)}
|
||||
onValueChange={(value) => {
|
||||
onSetDefaultAgent?.(value as AiAgentId)
|
||||
onSetDefaultTarget?.(`agent:${value}`)
|
||||
}}
|
||||
>
|
||||
{installedAgents.map((definition) => (
|
||||
<DropdownMenuRadioItem key={definition.id} value={definition.id}>
|
||||
@@ -204,6 +215,12 @@ function AgentMenuContent({
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
)}
|
||||
<ModelTargetMenuSection
|
||||
targets={modelTargets}
|
||||
defaultTarget={defaultTarget}
|
||||
locale={locale}
|
||||
onSetDefaultTarget={onSetDefaultTarget}
|
||||
/>
|
||||
{missingAgents.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -227,19 +244,65 @@ function AgentMenuContent({
|
||||
)
|
||||
}
|
||||
|
||||
function ModelTargetMenuSection({
|
||||
targets,
|
||||
defaultTarget,
|
||||
locale,
|
||||
onSetDefaultTarget,
|
||||
}: {
|
||||
targets: ReturnType<typeof configuredModelTargets>
|
||||
defaultTarget?: string
|
||||
locale: AppLocale
|
||||
onSetDefaultTarget?: (target: string) => void
|
||||
}) {
|
||||
if (targets.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{translate(locale, 'status.ai.modelTargets')}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={defaultTarget}
|
||||
onValueChange={(value) => onSetDefaultTarget?.(value)}
|
||||
>
|
||||
{targets.map((target) => (
|
||||
<DropdownMenuRadioItem key={target.id} value={target.id}>
|
||||
<span>{target.label}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{target.provider.kind === 'ollama' || target.provider.kind === 'lm_studio'
|
||||
? translate(locale, 'status.ai.localChat')
|
||||
: translate(locale, 'status.ai.apiChat')}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiAgentsBadge({
|
||||
statuses,
|
||||
guidanceStatus,
|
||||
defaultAgent,
|
||||
defaultTarget,
|
||||
providers = [],
|
||||
onSetDefaultAgent,
|
||||
onSetDefaultTarget,
|
||||
onRestoreGuidance,
|
||||
compact = false,
|
||||
locale = 'en',
|
||||
}: AiAgentsBadgeProps) {
|
||||
const selectedAgentReady = isAiAgentInstalled(statuses, defaultAgent)
|
||||
const showWarning = hasAiAgentWarning(statuses, defaultAgent, guidanceStatus)
|
||||
const selectedTarget = resolveAiTarget({
|
||||
default_ai_agent: defaultAgent,
|
||||
default_ai_target: defaultTarget,
|
||||
ai_model_providers: providers,
|
||||
} as Settings)
|
||||
const selectedAgentReady = selectedTarget.kind === 'api_model' || isAiAgentInstalled(statuses, defaultAgent)
|
||||
const showWarning = selectedTarget.kind === 'agent' && hasAiAgentWarning(statuses, defaultAgent, guidanceStatus)
|
||||
const showSwitcherCue = !showWarning && canShowSwitcherCue(statuses, defaultAgent)
|
||||
const tooltip = badgeTooltip(locale, statuses, defaultAgent, guidanceStatus)
|
||||
const tooltip = selectedTarget.kind === 'api_model'
|
||||
? translate(locale, 'status.ai.defaultTarget', { target: selectedTarget.label })
|
||||
: badgeTooltip(locale, statuses, defaultAgent, guidanceStatus)
|
||||
|
||||
if (isAiAgentsStatusChecking(statuses)) return null
|
||||
|
||||
@@ -260,7 +323,7 @@ export function AiAgentsBadge({
|
||||
>
|
||||
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
|
||||
<Sparkle size={13} weight="fill" />
|
||||
<TriggerLabel compact={compact} defaultAgent={defaultAgent} />
|
||||
{!compact && (selectedTarget.kind === 'api_model' ? selectedTarget.shortLabel : triggerLabel(defaultAgent))}
|
||||
<TriggerStateIcon showWarning={showWarning} showSwitcherCue={showSwitcherCue} />
|
||||
</span>
|
||||
</Button>
|
||||
@@ -269,7 +332,10 @@ export function AiAgentsBadge({
|
||||
statuses={statuses}
|
||||
guidanceStatus={guidanceStatus}
|
||||
defaultAgent={defaultAgent}
|
||||
defaultTarget={defaultTarget}
|
||||
providers={providers}
|
||||
onSetDefaultAgent={onSetDefaultAgent}
|
||||
onSetDefaultTarget={onSetDefaultTarget}
|
||||
onRestoreGuidance={onRestoreGuidance}
|
||||
selectedAgentReady={selectedAgentReady}
|
||||
locale={locale}
|
||||
|
||||
@@ -156,7 +156,7 @@ function StatusBarAction({
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
||||
'h-auto gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
||||
compact && 'h-6 gap-0.5 px-0.5',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
|
||||
className,
|
||||
@@ -742,7 +742,7 @@ export function ChangesBadge({
|
||||
color: 'var(--text-inverse)',
|
||||
borderRadius: 9,
|
||||
padding: '0 5px',
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
minWidth: 16,
|
||||
lineHeight: '16px',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Moon, Package, Settings, Sun } from 'lucide-react'
|
||||
import { Megaphone } from '@phosphor-icons/react'
|
||||
import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
|
||||
import type { AiModelProvider } from '../../lib/aiTargets'
|
||||
import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
|
||||
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../../hooks/useMcpStatus'
|
||||
@@ -70,7 +71,10 @@ interface StatusBarPrimarySectionProps {
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
@@ -104,8 +108,8 @@ function BuildNumberButton({
|
||||
locale: AppLocale
|
||||
}) {
|
||||
const className = compact
|
||||
? 'h-6 min-w-0 gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
? 'h-6 min-w-0 gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
|
||||
return (
|
||||
<ActionTooltip copy={{ label: translate(locale, 'status.update.check') }} side="top">
|
||||
@@ -132,7 +136,10 @@ function StatusBarAiBadge({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
@@ -143,7 +150,10 @@ function StatusBarAiBadge({
|
||||
| 'aiAgentsStatus'
|
||||
| 'vaultAiGuidanceStatus'
|
||||
| 'defaultAiAgent'
|
||||
| 'defaultAiTarget'
|
||||
| 'aiModelProviders'
|
||||
| 'onSetDefaultAiAgent'
|
||||
| 'onSetDefaultAiTarget'
|
||||
| 'onRestoreVaultAiGuidance'
|
||||
| 'claudeCodeStatus'
|
||||
| 'claudeCodeVersion'
|
||||
@@ -156,7 +166,10 @@ function StatusBarAiBadge({
|
||||
statuses={aiAgentsStatus}
|
||||
guidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAgent={defaultAiAgent}
|
||||
defaultTarget={defaultAiTarget}
|
||||
providers={aiModelProviders}
|
||||
onSetDefaultAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultTarget={onSetDefaultAiTarget}
|
||||
onRestoreGuidance={onRestoreVaultAiGuidance}
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
@@ -189,7 +202,10 @@ function StatusBarPrimaryBadges({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
@@ -217,7 +233,10 @@ function StatusBarPrimaryBadges({
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
vaultAiGuidanceStatus?: VaultAiGuidanceStatus
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: string
|
||||
aiModelProviders?: AiModelProvider[]
|
||||
onSetDefaultAiAgent?: (agent: AiAgentId) => void
|
||||
onSetDefaultAiTarget?: (target: string) => void
|
||||
onRestoreVaultAiGuidance?: () => void
|
||||
claudeCodeStatus?: ClaudeCodeStatus
|
||||
claudeCodeVersion?: string | null
|
||||
@@ -256,7 +275,10 @@ function StatusBarPrimaryBadges({
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
@@ -278,7 +300,7 @@ function FeedbackButton({
|
||||
}) {
|
||||
const className = compact
|
||||
? 'h-6 w-6 rounded-sm p-0 text-muted-foreground hover:text-foreground'
|
||||
: 'h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground'
|
||||
: 'h-6 px-2 text-[12px] font-medium text-muted-foreground hover:text-foreground'
|
||||
|
||||
return (
|
||||
<ActionTooltip copy={{ label: translate(locale, 'status.feedback.contribute') }} side="top">
|
||||
@@ -301,6 +323,24 @@ function FeedbackButton({
|
||||
)
|
||||
}
|
||||
|
||||
function primarySectionStyle(stacked: boolean, compact: boolean) {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: compact ? 8 : 12,
|
||||
rowGap: stacked ? 4 : 0,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
width: stacked ? '100%' : 'auto',
|
||||
flexBasis: stacked ? '100%' : 'auto',
|
||||
flexWrap: stacked ? 'wrap' : 'nowrap',
|
||||
} as const
|
||||
}
|
||||
|
||||
function PrimarySeparator({ compact }: { compact: boolean }) {
|
||||
return compact ? null : <span style={SEP_STYLE}>|</span>
|
||||
}
|
||||
|
||||
export function StatusBarPrimarySection({
|
||||
modifiedCount,
|
||||
vaultPath,
|
||||
@@ -333,7 +373,10 @@ export function StatusBarPrimarySection({
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
aiModelProviders,
|
||||
onSetDefaultAiAgent,
|
||||
onSetDefaultAiTarget,
|
||||
onRestoreVaultAiGuidance,
|
||||
claudeCodeStatus,
|
||||
claudeCodeVersion,
|
||||
@@ -356,17 +399,7 @@ export function StatusBarPrimarySection({
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: compact ? 8 : 12,
|
||||
rowGap: stacked ? 4 : 0,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
width: stacked ? '100%' : 'auto',
|
||||
flexBasis: stacked ? '100%' : 'auto',
|
||||
flexWrap: stacked ? 'wrap' : 'nowrap',
|
||||
}}
|
||||
style={primarySectionStyle(stacked, compact)}
|
||||
>
|
||||
<VaultMenu
|
||||
vaults={vaults}
|
||||
@@ -380,7 +413,7 @@ export function StatusBarPrimarySection({
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
/>
|
||||
{compact ? null : <span style={SEP_STYLE}>|</span>}
|
||||
<PrimarySeparator compact={compact} />
|
||||
<BuildNumberButton buildNumber={buildNumber} onCheckForUpdates={onCheckForUpdates} compact={compact} locale={locale} />
|
||||
<StatusBarPrimaryBadges
|
||||
modifiedCount={modifiedCount}
|
||||
@@ -404,7 +437,10 @@ export function StatusBarPrimarySection({
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
aiModelProviders={aiModelProviders}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onSetDefaultAiTarget={onSetDefaultAiTarget}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
@@ -457,7 +493,7 @@ export function StatusBarSecondarySection({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
className="h-auto rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
onClick={onZoomReset}
|
||||
aria-label={translate(locale, 'status.zoom.reset')}
|
||||
data-testid="status-zoom"
|
||||
|
||||
@@ -54,8 +54,8 @@ function getVaultTriggerClassName(open: boolean, compact: boolean) {
|
||||
}
|
||||
|
||||
return open
|
||||
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[11px] font-medium text-foreground hover:bg-[var(--hover)]'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[12px] font-medium text-foreground hover:bg-[var(--hover)]'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
}
|
||||
|
||||
function buildVaultActions({
|
||||
|
||||
@@ -189,7 +189,9 @@ function blockTextAnchorRect(blockElement: HTMLElement): DOMRect | null {
|
||||
const ownerDocument = inlineContent.ownerDocument
|
||||
const range = ownerDocument.createRange()
|
||||
range.selectNodeContents(inlineContent)
|
||||
const textRect = range.getBoundingClientRect()
|
||||
const firstLineRect = Array.from(range.getClientRects())
|
||||
.find((rect) => rect.width > 0 && rect.height > 0)
|
||||
const textRect = firstLineRect ?? range.getBoundingClientRect()
|
||||
range.detach()
|
||||
|
||||
if (textRect.height > 0) return textRect
|
||||
|
||||
@@ -329,6 +329,157 @@ describe('tolariaEditorFormatting behavior', () => {
|
||||
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('hides the floating toolbar while the editor is composing IME text', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const editorInput = editor.domElement.firstElementChild as HTMLElement
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionStart(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionEnd(editorInput)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const outsideInput = document.createElement('input')
|
||||
document.body.appendChild(outsideInput)
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionStart(outsideInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('binds composition listeners after BlockNote provides its editor element', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const lateEditorElement = editor.domElement
|
||||
const editorInput = lateEditorElement.firstElementChild as HTMLElement
|
||||
|
||||
editor.domElement = undefined as unknown as HTMLElement
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
const { rerender } = render(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
editor.domElement = lateEditorElement
|
||||
rerender(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionStart(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not open the floating toolbar when the editor anchor element is unavailable', () => {
|
||||
const editor = createMockEditor()
|
||||
editor.domElement = document.createElement('div')
|
||||
|
||||
@@ -2,10 +2,13 @@ import { Children, isValidElement, type ReactElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getFormattingToolbarItems } from '@blocknote/react'
|
||||
import {
|
||||
addItemsToMediaGroup,
|
||||
filterTolariaFormattingToolbarItems,
|
||||
filterTolariaSlashMenuItems,
|
||||
getTolariaBlockTypeSelectItems,
|
||||
MERMAID_SLASH_COMMAND_DIAGRAM,
|
||||
} from './tolariaEditorFormattingConfig'
|
||||
import { mermaidFenceSource } from '../utils/mermaidMarkdown'
|
||||
|
||||
describe('tolariaEditorFormatting', () => {
|
||||
it('keeps the markdown-safe toolbar controls and block type select', () => {
|
||||
@@ -106,4 +109,90 @@ describe('tolariaEditorFormatting', () => {
|
||||
'fill',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps custom media slash-menu commands searchable', () => {
|
||||
type TolariaSlashMenuTestItem = {
|
||||
key: string
|
||||
title: string
|
||||
aliases?: string[]
|
||||
onItemClick: () => void
|
||||
}
|
||||
|
||||
const items = filterTolariaSlashMenuItems([
|
||||
{
|
||||
key: 'mermaid',
|
||||
title: 'Mermaid',
|
||||
aliases: ['diagram', 'flowchart', 'graph', 'chart'],
|
||||
onItemClick: () => {},
|
||||
},
|
||||
{
|
||||
key: 'whiteboard',
|
||||
title: 'Whiteboard',
|
||||
aliases: ['tldraw', 'drawing', 'canvas', 'sketch'],
|
||||
onItemClick: () => {},
|
||||
},
|
||||
] satisfies TolariaSlashMenuTestItem[])
|
||||
|
||||
expect(items[0]).toEqual(expect.objectContaining({
|
||||
key: 'mermaid',
|
||||
title: 'Mermaid',
|
||||
aliases: ['diagram', 'flowchart', 'graph', 'chart'],
|
||||
}))
|
||||
expect(items[1]).toEqual(expect.objectContaining({
|
||||
key: 'whiteboard',
|
||||
title: 'Whiteboard',
|
||||
aliases: ['tldraw', 'drawing', 'canvas', 'sketch'],
|
||||
}))
|
||||
expect(isValidElement(items[0]?.icon)).toBe(true)
|
||||
expect(isValidElement(items[1]?.icon)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses a valid placeholder diagram for new Mermaid blocks', () => {
|
||||
expect(MERMAID_SLASH_COMMAND_DIAGRAM).toBe([
|
||||
'flowchart TD',
|
||||
' edit["Switch to the raw editor to edit"]',
|
||||
].join('\n'))
|
||||
expect(mermaidFenceSource({ diagram: MERMAID_SLASH_COMMAND_DIAGRAM })).toBe([
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
' edit["Switch to the raw editor to edit"]',
|
||||
'```',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('places custom media commands before the existing non-media slash-menu group', () => {
|
||||
type TolariaSlashMenuTestItem = {
|
||||
key: string
|
||||
title: string
|
||||
group: string
|
||||
onItemClick: () => void
|
||||
}
|
||||
|
||||
const items = addItemsToMediaGroup([
|
||||
{ key: 'image', title: 'Image', group: 'Media', onItemClick: () => {} },
|
||||
{ key: 'file', title: 'File', group: 'Media', onItemClick: () => {} },
|
||||
{ key: 'emoji', title: 'Emoji', group: 'Others', onItemClick: () => {} },
|
||||
] satisfies TolariaSlashMenuTestItem[], [
|
||||
{
|
||||
key: 'mermaid',
|
||||
title: 'Mermaid',
|
||||
group: 'Media',
|
||||
onItemClick: () => {},
|
||||
},
|
||||
{
|
||||
key: 'whiteboard',
|
||||
title: 'Whiteboard',
|
||||
group: 'Media',
|
||||
onItemClick: () => {},
|
||||
},
|
||||
])
|
||||
|
||||
expect(items.map(item => item.key)).toEqual([
|
||||
'image',
|
||||
'file',
|
||||
'mermaid',
|
||||
'whiteboard',
|
||||
'emoji',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
StyleSchema,
|
||||
} from '@blocknote/core'
|
||||
import { FormattingToolbarExtension } from '@blocknote/core/extensions'
|
||||
import { useEditorComposing } from './useEditorComposing'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -517,6 +518,7 @@ export function TolariaFormattingToolbarController(props: {
|
||||
const show = useExtensionState(FormattingToolbarExtension, {
|
||||
editor,
|
||||
})
|
||||
const isComposing = useEditorComposing(editor)
|
||||
const [toolbarHasFocus, setToolbarHasFocus] = useState(false)
|
||||
const [toolbarHovered, setToolbarHovered] = useState(false)
|
||||
const { closeGraceActive, clearCloseGrace } = useFormattingToolbarCloseGrace({
|
||||
@@ -529,7 +531,8 @@ export function TolariaFormattingToolbarController(props: {
|
||||
show,
|
||||
)
|
||||
|
||||
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
|
||||
const isOpen = !isComposing
|
||||
&& (show || toolbarHasFocus || toolbarHovered || closeGraceActive)
|
||||
const hasFloatingToolbarAnchor = getFormattingToolbarAnchorElement(editor) !== null
|
||||
const shouldRenderFloatingToolbar = isOpen && hasFloatingToolbarAnchor
|
||||
const currentBridgeBlockId = useEditorState({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createElement, type ReactElement } from 'react'
|
||||
import {
|
||||
CodeBlock,
|
||||
File,
|
||||
FlowArrow,
|
||||
ImageSquare,
|
||||
ListBullets,
|
||||
ListChecks,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
Minus,
|
||||
Paragraph,
|
||||
Quotes,
|
||||
ScribbleLoop,
|
||||
Smiley,
|
||||
SpeakerHigh,
|
||||
Table,
|
||||
@@ -26,6 +28,8 @@ import {
|
||||
Video,
|
||||
type Icon as PhosphorIcon,
|
||||
} from '@phosphor-icons/react'
|
||||
import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown'
|
||||
import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown'
|
||||
|
||||
type TolariaSlashMenuItem = DefaultReactSuggestionItem & { key: string }
|
||||
type TolariaBlockTypeSelectItem = {
|
||||
@@ -34,6 +38,22 @@ type TolariaBlockTypeSelectItem = {
|
||||
props?: Record<string, boolean | number | string>
|
||||
icon: PhosphorIcon
|
||||
}
|
||||
type SlashInsertEditor = {
|
||||
getTextCursorPosition: () => { block: unknown }
|
||||
updateBlock: (block: unknown, update: Record<string, unknown>) => void
|
||||
}
|
||||
type BlockSlashMenuItemConfig = {
|
||||
aliases: string[]
|
||||
key: string
|
||||
props: Record<string, unknown>
|
||||
title: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export const MERMAID_SLASH_COMMAND_DIAGRAM = [
|
||||
'flowchart TD',
|
||||
' edit["Switch to the raw editor to edit"]',
|
||||
].join('\n')
|
||||
|
||||
const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([
|
||||
'underlineStyleButton',
|
||||
@@ -80,6 +100,7 @@ const TOLARIA_SLASH_MENU_ICONS: Partial<Record<string, PhosphorIcon>> = {
|
||||
heading_2: TextHTwo,
|
||||
heading_3: TextHThree,
|
||||
image: ImageSquare,
|
||||
mermaid: FlowArrow,
|
||||
numbered_list: ListNumbers,
|
||||
paragraph: Paragraph,
|
||||
quote: Quotes,
|
||||
@@ -89,6 +110,84 @@ const TOLARIA_SLASH_MENU_ICONS: Partial<Record<string, PhosphorIcon>> = {
|
||||
toggle_heading_3: TextHThree,
|
||||
toggle_list: ListBullets,
|
||||
video: Video,
|
||||
whiteboard: ScribbleLoop,
|
||||
}
|
||||
|
||||
function createBoardId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
return `whiteboard-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
function createWhiteboardSlashMenuItem(
|
||||
editor: Parameters<typeof getDefaultReactSlashMenuItems>[0],
|
||||
): TolariaSlashMenuItem {
|
||||
return createBlockSlashMenuItem(editor, {
|
||||
key: 'whiteboard',
|
||||
title: 'Whiteboard',
|
||||
aliases: ['tldraw', 'drawing', 'canvas', 'sketch'],
|
||||
type: TLDRAW_BLOCK_TYPE,
|
||||
props: {
|
||||
boardId: createBoardId(),
|
||||
height: TLDRAW_DEFAULT_HEIGHT,
|
||||
snapshot: '{}',
|
||||
width: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createMermaidSlashMenuItem(
|
||||
editor: Parameters<typeof getDefaultReactSlashMenuItems>[0],
|
||||
): TolariaSlashMenuItem {
|
||||
return createBlockSlashMenuItem(editor, {
|
||||
key: 'mermaid',
|
||||
title: 'Mermaid',
|
||||
aliases: ['diagram', 'flowchart', 'graph', 'chart'],
|
||||
type: MERMAID_BLOCK_TYPE,
|
||||
props: {
|
||||
diagram: MERMAID_SLASH_COMMAND_DIAGRAM,
|
||||
source: mermaidFenceSource({ diagram: MERMAID_SLASH_COMMAND_DIAGRAM }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createBlockSlashMenuItem(
|
||||
editor: Parameters<typeof getDefaultReactSlashMenuItems>[0],
|
||||
config: BlockSlashMenuItemConfig,
|
||||
): TolariaSlashMenuItem {
|
||||
const blockEditor = editor as unknown as SlashInsertEditor
|
||||
|
||||
return {
|
||||
key: config.key,
|
||||
title: config.title,
|
||||
aliases: config.aliases,
|
||||
group: 'Media',
|
||||
onItemClick: () => {
|
||||
const block = blockEditor.getTextCursorPosition().block
|
||||
blockEditor.updateBlock(block, {
|
||||
type: config.type,
|
||||
props: config.props,
|
||||
})
|
||||
},
|
||||
} as TolariaSlashMenuItem
|
||||
}
|
||||
|
||||
export function addItemsToMediaGroup(
|
||||
items: TolariaSlashMenuItem[],
|
||||
mediaItems: TolariaSlashMenuItem[],
|
||||
): TolariaSlashMenuItem[] {
|
||||
const nextItems = [...items]
|
||||
const insertIndex = nextItems.findIndex((item) => item.key === 'emoji')
|
||||
|
||||
if (insertIndex === -1) {
|
||||
nextItems.push(...mediaItems)
|
||||
return nextItems
|
||||
}
|
||||
|
||||
nextItems.splice(insertIndex, 0, ...mediaItems)
|
||||
return nextItems
|
||||
}
|
||||
|
||||
function createTolariaSlashMenuIcon(Icon: PhosphorIcon) {
|
||||
@@ -142,9 +241,17 @@ export function getTolariaSlashMenuItems(
|
||||
editor: Parameters<typeof getDefaultReactSlashMenuItems>[0],
|
||||
query: string,
|
||||
) {
|
||||
const items = addItemsToMediaGroup(
|
||||
getDefaultReactSlashMenuItems(editor) as TolariaSlashMenuItem[],
|
||||
[
|
||||
createMermaidSlashMenuItem(editor),
|
||||
createWhiteboardSlashMenuItem(editor),
|
||||
],
|
||||
)
|
||||
|
||||
return filterSuggestionItems(
|
||||
filterTolariaSlashMenuItems(
|
||||
getDefaultReactSlashMenuItems(editor) as TolariaSlashMenuItem[],
|
||||
items,
|
||||
),
|
||||
query,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { act, render, renderHook, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from './tooltip'
|
||||
import { useZoom } from '@/hooks/useZoom'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -15,7 +16,8 @@ import {
|
||||
const PRESENCE_ANIMATION_CLASS_PARTS = [
|
||||
'animate-',
|
||||
'fade-',
|
||||
'zoom-',
|
||||
'zoom-in-',
|
||||
'zoom-out-',
|
||||
'slide-in-from',
|
||||
]
|
||||
|
||||
@@ -30,6 +32,11 @@ function expectNoPresenceAnimationClasses(element: HTMLElement) {
|
||||
}
|
||||
|
||||
describe('overlay presence stability', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.style.removeProperty('--tolaria-overlay-zoom-factor')
|
||||
document.documentElement.style.removeProperty('--tolaria-overlay-zoom-inverse')
|
||||
})
|
||||
|
||||
it('keeps tooltip content free of Radix presence animation classes', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
@@ -45,6 +52,38 @@ describe('overlay presence stability', () => {
|
||||
expectNoPresenceAnimationClasses(screen.getByTestId('tooltip-content'))
|
||||
})
|
||||
|
||||
it('publishes zoom variables for overlay portal positioning and visual scale', () => {
|
||||
const { result } = renderHook(() => useZoom())
|
||||
|
||||
act(() => {
|
||||
result.current.zoomIn()
|
||||
})
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue('--tolaria-overlay-zoom-factor')).toBe(String(110 / 100))
|
||||
expect(document.documentElement.style.getPropertyValue('--tolaria-overlay-zoom-inverse')).toBe(String(100 / 110))
|
||||
})
|
||||
|
||||
it('compensates tooltip portal positioning without cancelling content zoom', () => {
|
||||
document.documentElement.style.setProperty('--tolaria-overlay-zoom-factor', '1.4')
|
||||
document.documentElement.style.setProperty('--tolaria-overlay-zoom-inverse', String(1 / 1.4))
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button">Tooltip trigger</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent data-testid="tooltip-content">Tooltip copy</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
)
|
||||
|
||||
const positionShell = document.querySelector('[data-slot="tooltip-content"]') as HTMLElement
|
||||
const visualShell = document.querySelector('[data-slot="tooltip-visual-scale"]') as HTMLElement
|
||||
expect(positionShell.className).toContain('[zoom:var(--tolaria-overlay-zoom-inverse,1)]')
|
||||
expect(visualShell.className).toContain('[zoom:var(--tolaria-overlay-zoom-factor,1)]')
|
||||
})
|
||||
|
||||
it('keeps popover content free of Radix presence animation classes', () => {
|
||||
render(
|
||||
<Popover open>
|
||||
|
||||
@@ -33,7 +33,9 @@ function TooltipTrigger({
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
collisionPadding = 8,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
@@ -41,14 +43,23 @@ function TooltipContent({
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"bg-foreground text-background z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
"z-50 w-fit max-w-[min(var(--radix-tooltip-content-available-width,22rem),22rem)] origin-(--radix-tooltip-content-transform-origin) [zoom:var(--tolaria-overlay-zoom-inverse,1)]"
|
||||
)}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<div
|
||||
data-slot="tooltip-visual-scale"
|
||||
className={cn(
|
||||
"bg-foreground text-background max-w-[inherit] rounded-md px-3 py-1.5 text-xs text-balance [zoom:var(--tolaria-overlay-zoom-factor,1)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</div>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import { trackAiAgentPermissionModeChanged } from '../lib/productAnalytics'
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ import { useAiPanelContextSnapshot } from './useAiPanelContextSnapshot'
|
||||
interface UseAiPanelControllerArgs {
|
||||
vaultPath: string
|
||||
defaultAiAgent: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReady: boolean
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
activeEntry?: VaultEntry | null
|
||||
@@ -79,9 +81,65 @@ function useAgentFileCallbacks({
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
}
|
||||
|
||||
function useAiPermissionModeHandler({
|
||||
agent,
|
||||
defaultAiAgent,
|
||||
isActive,
|
||||
locale,
|
||||
permissionMode,
|
||||
}: {
|
||||
agent: ReturnType<typeof useCliAiAgent>
|
||||
defaultAiAgent: AiAgentId
|
||||
isActive: boolean
|
||||
locale: AppLocale
|
||||
permissionMode: AiAgentPermissionMode
|
||||
}) {
|
||||
return useCallback((mode: AiAgentPermissionMode) => {
|
||||
const nextMode = normalizeAiAgentPermissionMode(mode)
|
||||
if (isActive || nextMode === permissionMode) return
|
||||
|
||||
updateVaultConfigField('ai_agent_permission_mode', nextMode)
|
||||
trackAiAgentPermissionModeChanged(defaultAiAgent, nextMode)
|
||||
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode, locale))
|
||||
}, [agent, defaultAiAgent, isActive, locale, permissionMode])
|
||||
}
|
||||
|
||||
function usePanelAgent({
|
||||
vaultPath,
|
||||
contextPrompt,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady,
|
||||
defaultAiAgentReadiness,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: Pick<
|
||||
UseAiPanelControllerArgs,
|
||||
| 'vaultPath'
|
||||
| 'defaultAiAgent'
|
||||
| 'defaultAiTarget'
|
||||
| 'defaultAiAgentReady'
|
||||
| 'defaultAiAgentReadiness'
|
||||
| 'onFileCreated'
|
||||
| 'onFileModified'
|
||||
| 'onVaultChanged'
|
||||
> & { contextPrompt?: string }) {
|
||||
const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged })
|
||||
const permissionMode = useVaultAiAgentPermissionMode()
|
||||
const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, {
|
||||
agent: defaultAiAgent,
|
||||
target: defaultAiTarget,
|
||||
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
|
||||
permissionMode,
|
||||
})
|
||||
return { agent, permissionMode }
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady,
|
||||
defaultAiAgentReadiness,
|
||||
activeEntry,
|
||||
@@ -107,15 +165,7 @@ export function useAiPanelController({
|
||||
noteListFilter,
|
||||
})
|
||||
|
||||
const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged })
|
||||
const permissionMode = useVaultAiAgentPermissionMode()
|
||||
|
||||
const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, {
|
||||
agent: defaultAiAgent,
|
||||
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
|
||||
permissionMode,
|
||||
})
|
||||
const hasContext = !!activeEntry
|
||||
const { agent, permissionMode } = usePanelAgent({ vaultPath, contextPrompt, defaultAiAgent, defaultAiTarget, defaultAiAgentReady, defaultAiAgentReadiness, onFileCreated, onFileModified, onVaultChanged })
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
@@ -128,14 +178,7 @@ export function useAiPanelController({
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handlePermissionModeChange = useCallback((mode: AiAgentPermissionMode) => {
|
||||
const nextMode = normalizeAiAgentPermissionMode(mode)
|
||||
if (isActive || nextMode === permissionMode) return
|
||||
|
||||
updateVaultConfigField('ai_agent_permission_mode', nextMode)
|
||||
trackAiAgentPermissionModeChanged(defaultAiAgent, nextMode)
|
||||
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode, locale))
|
||||
}, [agent, defaultAiAgent, isActive, locale, permissionMode])
|
||||
const handlePermissionModeChange = useAiPermissionModeHandler({ agent, defaultAiAgent, isActive, locale, permissionMode })
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
agent.clearConversation()
|
||||
@@ -147,7 +190,7 @@ export function useAiPanelController({
|
||||
input,
|
||||
setInput,
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
hasContext: !!activeEntry,
|
||||
isActive,
|
||||
permissionMode,
|
||||
handleSend,
|
||||
|
||||
121
src/components/useEditorComposing.ts
Normal file
121
src/components/useEditorComposing.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
BlockNoteEditor,
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
} 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,
|
||||
SSchema extends StyleSchema,
|
||||
>(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 (!compositionEventTargetsEditor(editorElement, event)) return
|
||||
startComposing()
|
||||
}
|
||||
|
||||
const handleCompositionUpdate = (event: CompositionEvent) => {
|
||||
if (!compositionEventTargetsEditor(editorElement, event)) return
|
||||
startComposing()
|
||||
}
|
||||
|
||||
const handleCompositionEnd = (event: CompositionEvent) => {
|
||||
if (
|
||||
!composingRef.current
|
||||
&& !compositionEventTargetsEditor(editorElement, event)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
return isComposing
|
||||
}
|
||||
@@ -113,47 +113,60 @@ type ActiveTabHandlerKey = keyof Pick<
|
||||
'onToggleOrganized' | 'onToggleFavorite' | 'onArchiveNote' | 'onDeleteNote'
|
||||
>
|
||||
|
||||
const SIMPLE_HANDLER_EXECUTORS: Record<SimpleHandlerKey, (handlers: AppCommandHandlers) => void> = {
|
||||
onOpenSettings: (handlers) => handlers.onOpenSettings(),
|
||||
onCheckForUpdates: (handlers) => handlers.onCheckForUpdates?.(),
|
||||
onCreateNote: (handlers) => handlers.onCreateNote(),
|
||||
onCreateType: (handlers) => handlers.onCreateType?.(),
|
||||
onQuickOpen: (handlers) => handlers.onQuickOpen(),
|
||||
onSave: (handlers) => handlers.onSave(),
|
||||
onFindInNote: (handlers) => handlers.onFindInNote?.(),
|
||||
onReplaceInNote: (handlers) => handlers.onReplaceInNote?.(),
|
||||
onPastePlainText: (handlers) => handlers.onPastePlainText(),
|
||||
onSearch: (handlers) => handlers.onSearch(),
|
||||
onToggleRawEditor: (handlers) => handlers.onToggleRawEditor?.(),
|
||||
onToggleDiff: (handlers) => handlers.onToggleDiff?.(),
|
||||
onToggleInspector: (handlers) => handlers.onToggleInspector(),
|
||||
onToggleAIChat: (handlers) => handlers.onToggleAIChat?.(),
|
||||
onCommandPalette: (handlers) => handlers.onCommandPalette(),
|
||||
onZoomIn: (handlers) => handlers.onZoomIn(),
|
||||
onZoomOut: (handlers) => handlers.onZoomOut(),
|
||||
onZoomReset: (handlers) => handlers.onZoomReset(),
|
||||
onGoBack: (handlers) => handlers.onGoBack?.(),
|
||||
onGoForward: (handlers) => handlers.onGoForward?.(),
|
||||
onOpenVault: (handlers) => handlers.onOpenVault?.(),
|
||||
onRemoveActiveVault: (handlers) => handlers.onRemoveActiveVault?.(),
|
||||
onRestoreGettingStarted: (handlers) => handlers.onRestoreGettingStarted?.(),
|
||||
onAddRemote: (handlers) => handlers.onAddRemote?.(),
|
||||
onCommitPush: (handlers) => handlers.onCommitPush?.(),
|
||||
onPull: (handlers) => handlers.onPull?.(),
|
||||
onResolveConflicts: (handlers) => handlers.onResolveConflicts?.(),
|
||||
onViewChanges: (handlers) => handlers.onViewChanges?.(),
|
||||
onInstallMcp: (handlers) => handlers.onInstallMcp?.(),
|
||||
onReloadVault: (handlers) => handlers.onReloadVault?.(),
|
||||
onRepairVault: (handlers) => handlers.onRepairVault?.(),
|
||||
onOpenInNewWindow: (handlers) => handlers.onOpenInNewWindow?.(),
|
||||
onRestoreDeletedNote: (handlers) => handlers.onRestoreDeletedNote?.(),
|
||||
type SimpleHandlerExecutor = (handlers: AppCommandHandlers) => void
|
||||
type ActiveTabHandlerExecutor = (handlers: AppCommandHandlers, path: string) => void
|
||||
|
||||
const SIMPLE_HANDLER_EXECUTORS: readonly [SimpleHandlerKey, SimpleHandlerExecutor][] = [
|
||||
['onOpenSettings', (handlers) => handlers.onOpenSettings()],
|
||||
['onCheckForUpdates', (handlers) => handlers.onCheckForUpdates?.()],
|
||||
['onCreateNote', (handlers) => handlers.onCreateNote()],
|
||||
['onCreateType', (handlers) => handlers.onCreateType?.()],
|
||||
['onQuickOpen', (handlers) => handlers.onQuickOpen()],
|
||||
['onSave', (handlers) => handlers.onSave()],
|
||||
['onFindInNote', (handlers) => handlers.onFindInNote?.()],
|
||||
['onReplaceInNote', (handlers) => handlers.onReplaceInNote?.()],
|
||||
['onPastePlainText', (handlers) => handlers.onPastePlainText()],
|
||||
['onSearch', (handlers) => handlers.onSearch()],
|
||||
['onToggleRawEditor', (handlers) => handlers.onToggleRawEditor?.()],
|
||||
['onToggleDiff', (handlers) => handlers.onToggleDiff?.()],
|
||||
['onToggleInspector', (handlers) => handlers.onToggleInspector()],
|
||||
['onToggleAIChat', (handlers) => handlers.onToggleAIChat?.()],
|
||||
['onCommandPalette', (handlers) => handlers.onCommandPalette()],
|
||||
['onZoomIn', (handlers) => handlers.onZoomIn()],
|
||||
['onZoomOut', (handlers) => handlers.onZoomOut()],
|
||||
['onZoomReset', (handlers) => handlers.onZoomReset()],
|
||||
['onGoBack', (handlers) => handlers.onGoBack?.()],
|
||||
['onGoForward', (handlers) => handlers.onGoForward?.()],
|
||||
['onOpenVault', (handlers) => handlers.onOpenVault?.()],
|
||||
['onRemoveActiveVault', (handlers) => handlers.onRemoveActiveVault?.()],
|
||||
['onRestoreGettingStarted', (handlers) => handlers.onRestoreGettingStarted?.()],
|
||||
['onAddRemote', (handlers) => handlers.onAddRemote?.()],
|
||||
['onCommitPush', (handlers) => handlers.onCommitPush?.()],
|
||||
['onPull', (handlers) => handlers.onPull?.()],
|
||||
['onResolveConflicts', (handlers) => handlers.onResolveConflicts?.()],
|
||||
['onViewChanges', (handlers) => handlers.onViewChanges?.()],
|
||||
['onInstallMcp', (handlers) => handlers.onInstallMcp?.()],
|
||||
['onReloadVault', (handlers) => handlers.onReloadVault?.()],
|
||||
['onRepairVault', (handlers) => handlers.onRepairVault?.()],
|
||||
['onOpenInNewWindow', (handlers) => handlers.onOpenInNewWindow?.()],
|
||||
['onRestoreDeletedNote', (handlers) => handlers.onRestoreDeletedNote?.()],
|
||||
]
|
||||
|
||||
const ACTIVE_TAB_HANDLER_EXECUTORS: readonly [ActiveTabHandlerKey, ActiveTabHandlerExecutor][] = [
|
||||
['onToggleOrganized', (handlers, path) => handlers.onToggleOrganized?.(path)],
|
||||
['onToggleFavorite', (handlers, path) => handlers.onToggleFavorite?.(path)],
|
||||
['onArchiveNote', (handlers, path) => handlers.onArchiveNote(path)],
|
||||
['onDeleteNote', (handlers, path) => handlers.onDeleteNote(path)],
|
||||
]
|
||||
|
||||
function runSimpleHandler(handler: SimpleHandlerKey, handlers: AppCommandHandlers): void {
|
||||
const executor = SIMPLE_HANDLER_EXECUTORS.find(([key]) => key === handler)?.[1]
|
||||
executor?.(handlers)
|
||||
}
|
||||
|
||||
const ACTIVE_TAB_HANDLER_EXECUTORS: Record<ActiveTabHandlerKey, (handlers: AppCommandHandlers, path: string) => void> = {
|
||||
onToggleOrganized: (handlers, path) => handlers.onToggleOrganized?.(path),
|
||||
onToggleFavorite: (handlers, path) => handlers.onToggleFavorite?.(path),
|
||||
onArchiveNote: (handlers, path) => handlers.onArchiveNote(path),
|
||||
onDeleteNote: (handlers, path) => handlers.onDeleteNote(path),
|
||||
function runActiveTabHandler(handler: ActiveTabHandlerKey, handlers: AppCommandHandlers, path: string): void {
|
||||
const executor = ACTIVE_TAB_HANDLER_EXECUTORS.find(([key]) => key === handler)?.[1]
|
||||
executor?.(handlers, path)
|
||||
}
|
||||
|
||||
const SHORTCUT_ECHO_DEDUPE_WINDOW_MS = 150
|
||||
@@ -246,8 +259,7 @@ function dispatchDefinition(
|
||||
handlers.onSelectFilter?.(definition.route.value)
|
||||
return true
|
||||
case 'handler': {
|
||||
const handler = definition.route.handler
|
||||
SIMPLE_HANDLER_EXECUTORS[handler as SimpleHandlerKey](handlers)
|
||||
runSimpleHandler(definition.route.handler as SimpleHandlerKey, handlers)
|
||||
return true
|
||||
}
|
||||
case 'active-tab-handler': {
|
||||
@@ -262,7 +274,7 @@ function dispatchDefinition(
|
||||
|
||||
return dispatchActiveTabCommand(
|
||||
handlers.activeTabPathRef,
|
||||
(path) => ACTIVE_TAB_HANDLER_EXECUTORS[handler as ActiveTabHandlerKey](handlers, path),
|
||||
(path) => runActiveTabHandler(handler as ActiveTabHandlerKey, handlers, path),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createTranslator, type AppLocale, type TranslationKey } from '../../lib
|
||||
import type { CommandAction, CommandGroup } from './types'
|
||||
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
type CommandLabeler = (command: CommandAction, t: Translate) => string
|
||||
|
||||
const GROUP_LABEL_KEYS = {
|
||||
Navigation: 'command.group.navigation',
|
||||
@@ -120,16 +119,20 @@ function localizeMoveSavedViewCommand(command: CommandAction, t: Translate, dire
|
||||
})
|
||||
}
|
||||
|
||||
const VIEW_STATE_LABELERS: Partial<Record<string, CommandLabeler>> = {
|
||||
'zoom-in': (command, t) => t('command.view.zoomIn', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }),
|
||||
'zoom-out': (command, t) => t('command.view.zoomOut', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }),
|
||||
'customize-note-list-columns': localizeColumnsCommand,
|
||||
'move-view-up': (command, t) => localizeMoveSavedViewCommand(command, t, 'Up'),
|
||||
'move-view-down': (command, t) => localizeMoveSavedViewCommand(command, t, 'Down'),
|
||||
}
|
||||
type CommandLocalizer = (command: CommandAction, t: Translate) => string
|
||||
|
||||
const VIEW_STATE_LOCALIZERS: readonly [string, CommandLocalizer][] = [
|
||||
['zoom-in', (command, t) =>
|
||||
t('command.view.zoomIn', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' })],
|
||||
['zoom-out', (command, t) =>
|
||||
t('command.view.zoomOut', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' })],
|
||||
['customize-note-list-columns', localizeColumnsCommand],
|
||||
['move-view-up', (command, t) => localizeMoveSavedViewCommand(command, t, 'Up')],
|
||||
['move-view-down', (command, t) => localizeMoveSavedViewCommand(command, t, 'Down')],
|
||||
]
|
||||
|
||||
function localizeViewStateCommand(command: CommandAction, t: Translate): string | null {
|
||||
return VIEW_STATE_LABELERS[command.id]?.(command, t) ?? null
|
||||
return VIEW_STATE_LOCALIZERS.find(([id]) => id === command.id)?.[1](command, t) ?? null
|
||||
}
|
||||
|
||||
function localizeSettingsStateCommand(command: CommandAction, t: Translate): string | null {
|
||||
|
||||
@@ -2,8 +2,10 @@ 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 { resolveImageUrls } from '../utils/vaultImages'
|
||||
import { repairMalformedEditorBlocks } from './editorBlockRepair'
|
||||
import { inferCodeBlockLanguages } from '../utils/codeBlockLanguage'
|
||||
import {
|
||||
blankParagraphBlocks,
|
||||
extractEditorBody,
|
||||
@@ -132,7 +134,8 @@ async function parseMarkdownBlocks(
|
||||
}
|
||||
|
||||
function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath): PreprocessedMarkdown {
|
||||
const withMermaid = preProcessMermaidMarkdown({ markdown })
|
||||
const withTldraw = preProcessTldrawMarkdown({ markdown })
|
||||
const withMermaid = preProcessMermaidMarkdown({ markdown: withTldraw })
|
||||
const withImages = vaultPath ? resolveImageUrls(withMermaid, vaultPath) : withMermaid
|
||||
const withWikilinks = preProcessWikilinks(withImages)
|
||||
return preProcessMathMarkdown({ markdown: withWikilinks })
|
||||
@@ -141,13 +144,16 @@ function preProcessEditorMarkdown(markdown: MarkdownBody, vaultPath?: VaultPath)
|
||||
function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
|
||||
const withWikilinks = injectWikilinks(blocks)
|
||||
const withMath = injectMathInBlocks(withWikilinks)
|
||||
return injectMermaidInBlocks(withMath) as EditorBlocks
|
||||
const withMermaid = injectMermaidInBlocks(withMath)
|
||||
return injectTldrawInBlocks(withMermaid) as EditorBlocks
|
||||
}
|
||||
|
||||
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {
|
||||
const parseSafeBlocks = repairMalformedEditorBlocks(parsed.blocks) as EditorBlocks
|
||||
if (parsed.usedSourceFallback) return parseSafeBlocks
|
||||
return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks
|
||||
return inferCodeBlockLanguages(
|
||||
repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks,
|
||||
) as EditorBlocks
|
||||
}
|
||||
|
||||
export async function resolveBlocksForTarget(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { markNoteOpenTrace } from '../utils/noteOpenPerformance'
|
||||
import { errorMessage, isActiveVaultUnavailableError } from '../utils/vaultErrors'
|
||||
import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode'
|
||||
|
||||
type NotePath = VaultEntry['path']
|
||||
@@ -273,14 +274,8 @@ export async function loadContentForOpen(options: {
|
||||
return loadNoteContent(entry, forceReload || hasResolvedCachedContent(cachedEntry))
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function isNoActiveVaultSelectedError(error: unknown): boolean {
|
||||
return /no active vault selected/i.test(errorMessage(error))
|
||||
return isActiveVaultUnavailableError(error)
|
||||
}
|
||||
|
||||
export function isUnreadableNoteContentError(error: unknown): boolean {
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('useAiAgentPreferences', () => {
|
||||
expect(saveSettings).toHaveBeenCalledWith({
|
||||
...settings,
|
||||
default_ai_agent: 'codex',
|
||||
default_ai_target: 'agent:codex',
|
||||
})
|
||||
expect(onToast).toHaveBeenCalledWith('Default AI agent: Codex')
|
||||
})
|
||||
|
||||
@@ -3,11 +3,17 @@ import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
getAiAgentDefinition,
|
||||
getNextAiAgentId,
|
||||
resolveDefaultAiAgent,
|
||||
type AiAgentReadiness,
|
||||
type AiAgentId,
|
||||
type AiAgentsStatus,
|
||||
} from '../lib/aiAgents'
|
||||
import {
|
||||
agentTargetId,
|
||||
aiTargetReady,
|
||||
resolveAiTarget,
|
||||
targetAgent,
|
||||
type AiTarget,
|
||||
} from '../lib/aiTargets'
|
||||
import type { Settings } from '../types'
|
||||
|
||||
interface UseAiAgentPreferencesArgs {
|
||||
@@ -18,15 +24,16 @@ interface UseAiAgentPreferencesArgs {
|
||||
onToast?: (message: string) => void
|
||||
}
|
||||
|
||||
function getDefaultAiAgentReadiness(
|
||||
function getDefaultAiTargetReadiness(
|
||||
settingsLoaded: boolean,
|
||||
aiAgentsStatus: AiAgentsStatus,
|
||||
defaultAiAgent: AiAgentId,
|
||||
defaultTarget: AiTarget,
|
||||
): AiAgentReadiness {
|
||||
if (!settingsLoaded) return 'checking'
|
||||
if (defaultTarget.kind === 'api_model') return 'ready'
|
||||
if (!isTauri()) return 'ready'
|
||||
|
||||
const status = aiAgentsStatus[defaultAiAgent].status
|
||||
const status = aiAgentsStatus[defaultTarget.agent].status
|
||||
if (status === 'checking') return 'checking'
|
||||
return status === 'installed' ? 'ready' : 'missing'
|
||||
}
|
||||
@@ -38,16 +45,14 @@ export function useAiAgentPreferences({
|
||||
aiAgentsStatus,
|
||||
onToast,
|
||||
}: UseAiAgentPreferencesArgs) {
|
||||
const defaultAiAgent = useMemo(
|
||||
() => resolveDefaultAiAgent(settings.default_ai_agent),
|
||||
[settings.default_ai_agent],
|
||||
)
|
||||
const defaultAiTarget = useMemo(() => resolveAiTarget(settings), [settings])
|
||||
const targetAgentId = targetAgent(defaultAiTarget)
|
||||
|
||||
const defaultAiAgentLabel = getAiAgentDefinition(defaultAiAgent).label
|
||||
const defaultAiAgentReadiness = getDefaultAiAgentReadiness(
|
||||
const defaultAiAgentLabel = defaultAiTarget.label
|
||||
const defaultAiAgentReadiness = getDefaultAiTargetReadiness(
|
||||
settingsLoaded,
|
||||
aiAgentsStatus,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
)
|
||||
const defaultAiAgentReady = defaultAiAgentReadiness === 'ready'
|
||||
|
||||
@@ -55,20 +60,30 @@ export function useAiAgentPreferences({
|
||||
saveSettings({
|
||||
...settings,
|
||||
default_ai_agent: agent,
|
||||
default_ai_target: agentTargetId(agent),
|
||||
})
|
||||
onToast?.(`Default AI agent: ${getAiAgentDefinition(agent).label}`)
|
||||
}, [onToast, saveSettings, settings])
|
||||
|
||||
const setDefaultAiTarget = useCallback((targetId: string) => {
|
||||
const nextSettings = { ...settings, default_ai_target: targetId }
|
||||
saveSettings(nextSettings)
|
||||
onToast?.(`Default AI target: ${resolveAiTarget(nextSettings).label}`)
|
||||
}, [onToast, saveSettings, settings])
|
||||
|
||||
const cycleDefaultAiAgent = useCallback(() => {
|
||||
setDefaultAiAgent(getNextAiAgentId(defaultAiAgent))
|
||||
}, [defaultAiAgent, setDefaultAiAgent])
|
||||
setDefaultAiAgent(getNextAiAgentId(targetAgentId))
|
||||
}, [setDefaultAiAgent, targetAgentId])
|
||||
|
||||
return {
|
||||
defaultAiAgent,
|
||||
defaultAiAgent: targetAgentId,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentLabel,
|
||||
defaultAiAgentReadiness,
|
||||
defaultAiAgentReady,
|
||||
defaultAiTargetReady: aiTargetReady(defaultAiTarget, aiAgentsStatus),
|
||||
setDefaultAiAgent,
|
||||
setDefaultAiTarget,
|
||||
cycleDefaultAiAgent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,13 @@ describe('useBulkActions', () => {
|
||||
const { result } = renderBulkActions(organizedPaths)
|
||||
|
||||
await act(async () => {
|
||||
await result.current[action](selectedPaths)
|
||||
switch (action) {
|
||||
case 'handleBulkArchive':
|
||||
await result.current.handleBulkArchive(selectedPaths)
|
||||
return
|
||||
case 'handleBulkOrganize':
|
||||
await result.current.handleBulkOrganize(selectedPaths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
|
||||
import type { AiAgentId } from '../lib/aiAgents'
|
||||
import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import {
|
||||
type AgentStatus,
|
||||
@@ -21,6 +22,7 @@ export type { AiAgentMessage } from '../lib/aiAgentConversation'
|
||||
|
||||
interface UseCliAiAgentOptions {
|
||||
agent: AiAgentId
|
||||
target?: AiTarget
|
||||
agentReady: boolean
|
||||
permissionMode: AiAgentPermissionMode
|
||||
}
|
||||
@@ -68,7 +70,7 @@ export function useCliAiAgent(
|
||||
fileCallbacks: AgentFileCallbacks | undefined,
|
||||
options: UseCliAiAgentOptions,
|
||||
) {
|
||||
const { agent, agentReady } = options
|
||||
const { agent, agentReady, target } = options
|
||||
const { permissionMode } = options
|
||||
const runtime = useCliAiAgentRuntime(fileCallbacks)
|
||||
const { messages, status } = runtime
|
||||
@@ -78,6 +80,7 @@ export function useCliAiAgent(
|
||||
runtime,
|
||||
context: {
|
||||
agent,
|
||||
target,
|
||||
ready: agentReady,
|
||||
vaultPath,
|
||||
permissionMode,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user