Compare commits
13 Commits
alpha-v202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3caab862 | ||
|
|
2556907f36 | ||
|
|
8e3f8fbb77 | ||
|
|
501df0e265 | ||
|
|
48eac1e40d | ||
|
|
3d28e21a5a | ||
|
|
191066a3bf | ||
|
|
3a88724d8f | ||
|
|
65ea3f37fc | ||
|
|
0e81f20e31 | ||
|
|
3c8edd8346 | ||
|
|
80ee94da73 | ||
|
|
008e8f3471 |
4
.github/workflows/release-stable.yml
vendored
4
.github/workflows/release-stable.yml
vendored
@@ -67,9 +67,9 @@ jobs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
macos_bundles: ""
|
||||
upload_macos_dmg: true
|
||||
# One-time stable promotion exception while Windows certificate secrets
|
||||
# One-time stable promotion exceptions while Windows certificate secrets
|
||||
# are still being provisioned. Future stable tags must keep Authenticode.
|
||||
require_windows_authenticode: ${{ needs.version.outputs.tag != 'v2026-06-01' }}
|
||||
require_windows_authenticode: ${{ !contains(fromJson('["v2026-06-01","v2026-06-06"]'), needs.version.outputs.tag) }}
|
||||
secrets: inherit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -516,6 +516,7 @@ interface PulseCommit {
|
||||
| `history.rs` | File history | `git log` — last 20 commits per file |
|
||||
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
|
||||
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
|
||||
| `file_url.rs` | File URL | Builds a copyable remote URL from the primary remote, current branch, and vault-relative path without exposing remote credentials |
|
||||
| `commit.rs` | Commit | Ensures a local author fallback when needed, then runs `git add -A && git commit -m "..."`; broken signing helpers trigger one unsigned retry for the same app-managed commit |
|
||||
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
|
||||
| `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe |
|
||||
@@ -644,6 +645,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- 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.
|
||||
- `createImeCompositionKeyGuardExtension()` intercepts composing `Enter` keydown events before BlockNote's list shortcuts see them, so Korean/Japanese/Chinese IMEs can commit text at the start of list items without Tolaria splitting the current bullet. It stops editor shortcut propagation only; it does not prevent the browser/IME default composition action.
|
||||
- `richEditorInputTransform.ts` is the shared execution shell for rich-editor Markdown `beforeinput` transforms. It reads the live ProseMirror view, skips IME composition, resets state when a stale view is detected, dispatches transform transactions, prevents native input only after successful dispatch, and reports recoverable editor-transform errors through the same telemetry path. Arrow ligatures, inline math conversion, and `==highlight==` keep their syntax-specific matching in their feature files and are composed for the main editor by `richEditorInputTransformExtension.ts`.
|
||||
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
|
||||
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
|
||||
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
|
||||
@@ -854,7 +856,7 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
|
||||
- Only shows after vault onboarding has already resolved to a ready state
|
||||
- Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
|
||||
- App-managed Claude Code runs preserve the same user-managed Anthropic/provider env behavior by forwarding selected exported variables from the app process or the user's zsh/bash startup files without persisting those secrets
|
||||
- The shared `useAiAgentsStatus` hook defers that command until after the first render and skips it when AI features are disabled or the current window cannot render AI status surfaces
|
||||
- The shared `useAiAgentsStatus` hook defers that command until after the first render, skips it when AI features are disabled or the current window cannot render AI status surfaces, and falls back to missing-agent statuses if the native probe does not return promptly so first-launch onboarding keeps a recovery path
|
||||
- Persists dismissal locally once the user continues
|
||||
|
||||
### Remote Git Operations
|
||||
@@ -910,7 +912,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/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `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. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `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. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `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; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. 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.
|
||||
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/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `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. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `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. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `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; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. Direct OpenAI-compatible model streams receive the active vault root and may execute Tolaria's native create-only `create_note` tool, but they do not receive shell access or general file-write tools. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. 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
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ flowchart TD
|
||||
|
||||
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Folder creation sends the selected folder's vault-relative path and mounted root to `create_vault_folder`, so a new folder is created under the focused parent instead of defaulting to the active vault root. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. In mounted multi-vault graphs, duplicate type names still render as one sidebar section, but the visibility picker becomes a workspace matrix and writes visibility to the specific vault's Type document, so hidden type definitions suppress only notes of that type from the same workspace.
|
||||
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block` plus lazy direct `@shikijs/langs` registrations for missing common grammars. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block` plus lazy direct `@shikijs/langs` registrations for missing common grammars. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Rich-editor Markdown input transforms for arrows, inline math, and `==highlight==` share one capture-phase `beforeinput` execution path in `src/components/richEditorInputTransform.ts` and are composed by `src/components/richEditorInputTransformExtension.ts`. Navigation history (Cmd+[/]) replaces tabs.
|
||||
Rich-editor copy uses BlockNote's external HTML serializer for selected note content so tables, lists, checklists, and inline formatting paste richly into other apps, with Tolaria keeping fenced-code selections as raw code text and normalized plain text on the clipboard.
|
||||
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and checks the native PDF capability before choosing a platform path. On macOS, the renderer asks for a filesystem PDF destination before the Tauri `export_current_webview_pdf` command saves the current `WKWebView` print operation directly; on Windows/Linux Tauri builds and in browser mode, the same export action falls back to the native/browser print dialog. The export reuses rendered BlockNote output so frontmatter is omitted, while math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified. Markdown notes expose the same export action from Cmd+K, the native Note menu, the breadcrumb overflow menu, and each Markdown row's note-list context menu.
|
||||
- **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
@@ -242,7 +242,7 @@ The main Tauri window derives its minimum width from the visible panes instead o
|
||||
|
||||
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
|
||||
|
||||
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. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, and the Rust command fans per-agent CLI checks across Tokio's blocking pool. 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.
|
||||
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. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, falls back to missing-agent onboarding state if the status IPC does not settle promptly, and the Rust command fans per-agent CLI checks across Tokio's blocking pool with per-agent timeouts. 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.
|
||||
|
||||
Desktop startup registers `tauri-plugin-deep-link` and `tauri-plugin-single-instance` before setup so `tolaria://` links can focus the existing main window and deliver URL events to the renderer. `tauri.conf.json` declares the `tolaria` scheme for bundled desktop builds; Windows and Linux also run `register_all()` as a runtime repair path, while macOS relies on bundle registration.
|
||||
|
||||
@@ -343,7 +343,7 @@ Large active notes are compacted into a head/tail body snapshot before they ente
|
||||
|
||||
### Direct Model Targets
|
||||
|
||||
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `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.
|
||||
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 shell access. OpenAI-compatible direct targets can use Tolaria's narrow native `create_note` tool when an active vault is loaded; the tool calls the same create-only, active-vault-bounded note write command as the UI and emits tool events so the renderer refreshes and opens the created note. 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. Env-backed provider keys are resolved from the app process first, then from exported values in the user's zsh/bash startup files on Unix so GUI-launched sessions can still use shell-managed secrets. Local endpoints can omit authentication.
|
||||
|
||||
@@ -782,6 +782,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `git_pull` | Pull from remote |
|
||||
| `git_push` | Push to remote |
|
||||
| `git_remote_status` | Get branch name + ahead/behind counts |
|
||||
| `git_file_url` | Build a remote-backed browser/git URL for a vault file |
|
||||
| `git_add_remote` | Connect a local-only vault to a compatible remote and start tracking it |
|
||||
| `git_resolve_conflict` | Resolve a merge conflict |
|
||||
| `git_commit_conflict_resolution` | Commit conflict resolution |
|
||||
@@ -921,7 +922,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
| `[[` in editor | Open wikilink suggestion menu |
|
||||
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, the breadcrumb overflow menu, and the note-list context menu, all routed through the same editor export hook. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, the breadcrumb overflow menu, and the note-list context menu, all routed through the same editor export hook. Remote-backed notes expose "Copy git URL" from the breadcrumb overflow and note-list context menu; the renderer gates the action per note workspace via `git_remote_status`, then asks `git_file_url` to build the copied URL from the primary remote, current branch, and vault-relative path. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
|
||||
Shortcut routing is explicit:
|
||||
|
||||
|
||||
58
docs/adr/0137-shared-rich-editor-input-transforms.md
Normal file
58
docs/adr/0137-shared-rich-editor-input-transforms.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0137"
|
||||
title: "Shared rich-editor input transforms"
|
||||
status: active
|
||||
date: 2026-06-07
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria has several Markdown-style conveniences in the rich BlockNote editor:
|
||||
typed arrows become ligatures, completed inline math becomes a math node, and
|
||||
completed `==highlight==` syntax becomes the durable highlight mark.
|
||||
|
||||
These features were added incrementally as separate `beforeinput` extensions.
|
||||
Each extension repeated the same lifecycle work: reading the live ProseMirror
|
||||
view, skipping IME composition, guarding stale views, dispatching transactions,
|
||||
preventing native input only after a successful transform, and recovering known
|
||||
BlockNote/ProseMirror transform failures. The syntax matchers differed, but the
|
||||
execution shell was parallel enough that each new Markdown affordance risked a
|
||||
slightly different edge-case policy.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria routes rich-editor Markdown input transforms through one shared
|
||||
`beforeinput` execution path.**
|
||||
|
||||
`src/components/richEditorInputTransform.ts` owns the common lifecycle,
|
||||
dispatch, and recoverable-error behavior. Feature files such as
|
||||
`arrowLigaturesExtension.ts`, `mathInputExtension.ts`, and
|
||||
`markdownHighlightInputExtension.ts` expose small transform objects that only
|
||||
decide whether the current input event should produce a transaction.
|
||||
|
||||
`src/components/richEditorInputTransformExtension.ts` composes the Markdown
|
||||
transform set used by the main editor and hidden editor probe.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Shared transform primitive with feature-owned matchers** (chosen): removes
|
||||
duplicate listener, dispatch, composition, and recovery code while keeping each
|
||||
syntax rule local and testable.
|
||||
- **One monolithic Markdown input extension**: reduces listener count, but mixes
|
||||
unrelated syntax rules in one file and makes each future input affordance
|
||||
harder to test independently.
|
||||
- **Keep one extension per feature**: preserves local ownership, but keeps the
|
||||
repeated edge-case shell and invites divergence as more transforms are added.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `Editor` mounts one Markdown input-transform extension rather than separate
|
||||
arrow, math, and highlight `beforeinput` listeners.
|
||||
- Feature-specific files remain responsible for their syntax matching and
|
||||
transaction construction only.
|
||||
- Recoverable transform errors use the same telemetry event and fallback policy
|
||||
across Markdown input transforms.
|
||||
- New rich-editor Markdown input affordances should plug into the shared
|
||||
transform primitive instead of adding another capture-phase `beforeinput`
|
||||
extension.
|
||||
@@ -187,3 +187,4 @@ proposed → active → superseded
|
||||
| [0133](0133-request-scoped-ai-stream-events.md) | Request-scoped AI stream event channels | active |
|
||||
| [0134](0134-direct-shiki-language-registrations.md) | Direct Shiki language registrations for code blocks | active |
|
||||
| [0135](0135-clean-active-note-refresh-after-external-edit.md) | Clean active notes refresh immediately after external edits | active |
|
||||
| [0137](0137-shared-rich-editor-input-transforms.md) | Shared rich-editor input transforms | active |
|
||||
|
||||
@@ -588,9 +588,12 @@ files:
|
||||
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
|
||||
editor.toolbar.copyNoteDeepLink: 0fff274a7c47e7c12457c0b7412e86ce
|
||||
editor.toolbar.copyNoteGitUrl: 04a732b98f7585e3c3b5474688e0baac
|
||||
editor.toolbar.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0
|
||||
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
|
||||
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
|
||||
editor.formatting.highlight: 0b90582f4589d84be89f5b847d4d1ed1
|
||||
editor.formatting.highlightTooltip: c27173fea9ec1718030c5fa8440b2c78
|
||||
editor.whiteboard.enterFullscreen: 606bf766125ecff4b33ca98122222c72
|
||||
editor.whiteboard.exitFullscreen: afd0a0414d79d047f00319921e4d4944
|
||||
editor.exportPdf.unavailable: 2fce9834984172b64b66767485d815e9
|
||||
@@ -609,6 +612,9 @@ files:
|
||||
deepLinks.error.unavailableVault: 911dfaf8e855bd8cadefe7df9a98a7e8
|
||||
deepLinks.error.unknownVault: b4940569e884833f50466177dab1f62f
|
||||
deepLinks.error.unsafePath: e09caab43be8c2372bfc94e947ea34e6
|
||||
noteGitUrls.copied: 6832bf85abaa03a6304cd1cce073d747
|
||||
noteGitUrls.error.copyFailed: 1952ee267f473efe8e28ca90659f75f6
|
||||
noteGitUrls.error.unavailable: ed08c134bba26350fd28f780c6f1b55b
|
||||
editor.slash.math: a49950aa047c2292e989e368a97a3aae
|
||||
tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b
|
||||
tableOfContents.close: d3292972a10edd1a06a627f3552f760a
|
||||
|
||||
18
release-notes/v2026-06-06.md
Normal file
18
release-notes/v2026-06-06.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## New Features
|
||||
|
||||
- 📝 **AI Agents Can Create Notes Directly** — OpenAI-backed note creation now works inside Tolaria, making agent-driven capture and drafting feel more complete.
|
||||
- 🧠 **Built-in MCP Note Creation** — Tolaria now exposes a native MCP tool for creating notes, improving automation workflows that operate on the vault.
|
||||
- 🗺️ **Fullscreen Whiteboards** — Whiteboards can now open in a dedicated fullscreen workspace for more focused visual work.
|
||||
|
||||
## Improvements
|
||||
|
||||
- ✨ **Markdown Highlights Render Properly** — Highlight syntax now displays correctly in the editor, with matching toolbar copy and better visual consistency.
|
||||
- 🌍 **Better Localization Coverage** — More menus, feedback text, and Chinese file-manager labels are now translated and aligned with the rest of the app.
|
||||
- 🔧 **Shared MCP Orchestration Internals** — MCP tool execution now runs through a more unified internal path, reducing inconsistency across tools.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Copying from the rich editor is more reliable, inline math sits correctly in text, Mermaid raw-edit access is restored, and table-row parsing is more resilient.
|
||||
- Focus and refresh behavior were hardened across note editing, active-note refreshes, title focus, and view filters across multiple workspaces.
|
||||
- Git identity handling, symlinked gitignored vault paths, sync history surfaces, drag-and-drop guards, Pi agent config recovery, and shell AI-provider inheritance all received targeted fixes.
|
||||
- MCP and editor error recovery were tightened, including null editor transform handling, stale config tolerance, and lockfile synchronization.
|
||||
@@ -1,4 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const AI_AGENT_STATUS_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -104,16 +108,32 @@ pub async fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
let gemini = tokio::task::spawn_blocking(crate::gemini_cli::check_cli);
|
||||
let kiro = tokio::task::spawn_blocking(crate::kiro_cli::check_cli);
|
||||
|
||||
let (claude, codex, opencode, pi, gemini, kiro) =
|
||||
tokio::join!(claude, codex, opencode, pi, gemini, kiro);
|
||||
let (claude, codex, opencode, pi, gemini, kiro) = tokio::join!(
|
||||
availability_or_missing(claude, AI_AGENT_STATUS_PROBE_TIMEOUT),
|
||||
availability_or_missing(codex, AI_AGENT_STATUS_PROBE_TIMEOUT),
|
||||
availability_or_missing(opencode, AI_AGENT_STATUS_PROBE_TIMEOUT),
|
||||
availability_or_missing(pi, AI_AGENT_STATUS_PROBE_TIMEOUT),
|
||||
availability_or_missing(gemini, AI_AGENT_STATUS_PROBE_TIMEOUT),
|
||||
availability_or_missing(kiro, AI_AGENT_STATUS_PROBE_TIMEOUT)
|
||||
);
|
||||
|
||||
AiAgentsStatus {
|
||||
claude_code: claude.unwrap_or_else(|_| missing_availability()),
|
||||
codex: codex.unwrap_or_else(|_| missing_availability()),
|
||||
opencode: opencode.unwrap_or_else(|_| missing_availability()),
|
||||
pi: pi.unwrap_or_else(|_| missing_availability()),
|
||||
gemini: gemini.unwrap_or_else(|_| missing_availability()),
|
||||
kiro: kiro.unwrap_or_else(|_| missing_availability()),
|
||||
claude_code: claude,
|
||||
codex,
|
||||
opencode,
|
||||
pi,
|
||||
gemini,
|
||||
kiro,
|
||||
}
|
||||
}
|
||||
|
||||
async fn availability_or_missing(
|
||||
probe: JoinHandle<AiAgentAvailability>,
|
||||
timeout: Duration,
|
||||
) -> AiAgentAvailability {
|
||||
match tokio::time::timeout(timeout, probe).await {
|
||||
Ok(Ok(availability)) => availability,
|
||||
Ok(Err(_)) | Err(_) => missing_availability(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +315,22 @@ mod tests {
|
||||
.all(|installed| matches!(installed, true | false)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn availability_probe_timeout_returns_missing_status() {
|
||||
let handle = tokio::task::spawn_blocking(|| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: Some("late".into()),
|
||||
}
|
||||
});
|
||||
|
||||
let status = availability_or_missing(handle, std::time::Duration::from_millis(1)).await;
|
||||
|
||||
assert!(!status.installed);
|
||||
assert_eq!(status.version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_claude_done_event_preserves_completion_signal() {
|
||||
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done);
|
||||
|
||||
623
src-tauri/src/ai_model_tools.rs
Normal file
623
src-tauri/src/ai_model_tools.rs
Normal file
@@ -0,0 +1,623 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
use crate::ai_models::{AiModelProviderKind, AiModelStreamRequest};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const CREATE_NOTE_TOOL_NAME: &str = "create_note";
|
||||
const CREATE_NOTE_TOOL_JSON: &str = r#"{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_note",
|
||||
"description": "Create a new markdown note inside the active Tolaria vault without overwriting existing files.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Relative path inside the vault, or an absolute path inside the active vault. Must end in .md."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full markdown note content, including YAML frontmatter and H1 when needed."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Optional title used only when content is omitted."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Optional note type used only when content is omitted."
|
||||
},
|
||||
"is_a": {
|
||||
"type": "string",
|
||||
"description": "Legacy alias for type, used only when content is omitted."
|
||||
},
|
||||
"vaultPath": {
|
||||
"type": "string",
|
||||
"description": "Optional target vault root when multiple vaults are active."
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
struct OpenAiToolCall {
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: serde_json::Value,
|
||||
raw_arguments: String,
|
||||
}
|
||||
|
||||
struct CreatedNoteToolResult {
|
||||
summary: String,
|
||||
output: String,
|
||||
}
|
||||
|
||||
pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json::Value {
|
||||
let mut payload = serde_json::json!({
|
||||
"model": request.model_id,
|
||||
"messages": openai_chat_messages(request),
|
||||
"stream": false
|
||||
});
|
||||
if should_offer_openai_tools(request) {
|
||||
payload["tools"] = serde_json::Value::Array(vec![openai_create_note_tool()]);
|
||||
payload["tool_choice"] = serde_json::Value::String("auto".into());
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
pub(crate) fn execute_openai_tool_calls<F>(
|
||||
request: &AiModelStreamRequest,
|
||||
json: &serde_json::Value,
|
||||
emit: F,
|
||||
) -> Result<Option<String>, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let tool_calls = openai_tool_calls(json)?;
|
||||
if tool_calls.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
run_openai_tool_calls(request, &tool_calls, emit).map(Some)
|
||||
}
|
||||
|
||||
fn openai_chat_messages(request: &AiModelStreamRequest) -> Vec<serde_json::Value> {
|
||||
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 }));
|
||||
messages
|
||||
}
|
||||
|
||||
fn should_offer_openai_tools(request: &AiModelStreamRequest) -> bool {
|
||||
let has_active_vault = non_empty_option(request.vault_path.as_deref()).is_some();
|
||||
has_active_vault
|
||||
&& (request.provider.kind == AiModelProviderKind::OpenAi
|
||||
|| selected_model_supports_tools(request))
|
||||
}
|
||||
|
||||
fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool {
|
||||
request
|
||||
.provider
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.id == request.model_id)
|
||||
.is_some_and(|model| model.capabilities.tools)
|
||||
}
|
||||
|
||||
fn openai_create_note_tool() -> serde_json::Value {
|
||||
serde_json::from_str(CREATE_NOTE_TOOL_JSON).expect("create_note tool schema must be valid JSON")
|
||||
}
|
||||
|
||||
fn run_openai_tool_calls<F>(
|
||||
request: &AiModelStreamRequest,
|
||||
tool_calls: &[OpenAiToolCall],
|
||||
mut emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let mut summaries = Vec::new();
|
||||
for tool_call in tool_calls {
|
||||
summaries.push(execute_openai_tool_call(request, tool_call, &mut emit)?);
|
||||
}
|
||||
Ok(summaries.join("\n"))
|
||||
}
|
||||
|
||||
fn execute_openai_tool_call<F>(
|
||||
request: &AiModelStreamRequest,
|
||||
tool_call: &OpenAiToolCall,
|
||||
emit: &mut F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if tool_call.name != CREATE_NOTE_TOOL_NAME {
|
||||
return Err(format!(
|
||||
"AI provider requested unsupported tool: {}",
|
||||
tool_call.name
|
||||
));
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: CREATE_NOTE_TOOL_NAME.into(),
|
||||
tool_id: tool_call.id.clone(),
|
||||
input: Some(tool_call.raw_arguments.clone()),
|
||||
});
|
||||
|
||||
match create_note_from_tool_args(request, &tool_call.arguments) {
|
||||
Ok(result) => {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: tool_call.id.clone(),
|
||||
output: Some(result.output),
|
||||
});
|
||||
Ok(result.summary)
|
||||
}
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: tool_call.id.clone(),
|
||||
output: Some(format!("Error: {error}")),
|
||||
});
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_tool_calls(json: &serde_json::Value) -> Result<Vec<OpenAiToolCall>, String> {
|
||||
let Some(calls) = json["choices"][0]["message"]["tool_calls"].as_array() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
calls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, call)| openai_tool_call(index, call))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn openai_tool_call(index: usize, call: &serde_json::Value) -> Result<OpenAiToolCall, String> {
|
||||
let function = &call["function"];
|
||||
let name = function["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "AI provider tool call did not include a function name.".to_string())?
|
||||
.to_string();
|
||||
let (arguments, raw_arguments) = parse_tool_arguments(&function["arguments"])?;
|
||||
let id = call["id"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("tool_call_{index}"));
|
||||
Ok(OpenAiToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
raw_arguments,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_tool_arguments(value: &serde_json::Value) -> Result<(serde_json::Value, String), String> {
|
||||
if let Some(raw) = value.as_str() {
|
||||
let parsed = serde_json::from_str(raw)
|
||||
.map_err(|error| format!("Failed to parse AI tool arguments: {error}"))?;
|
||||
return Ok((parsed, raw.to_string()));
|
||||
}
|
||||
if value.is_object() {
|
||||
let raw = serde_json::to_string(value)
|
||||
.map_err(|error| format!("Failed to serialize AI tool arguments: {error}"))?;
|
||||
return Ok((value.clone(), raw));
|
||||
}
|
||||
Ok((serde_json::json!({}), "{}".into()))
|
||||
}
|
||||
|
||||
fn create_note_from_tool_args(
|
||||
request: &AiModelStreamRequest,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<CreatedNoteToolResult, String> {
|
||||
let note_path = required_tool_string(args, "path")?;
|
||||
let content = create_note_tool_content(args, note_path);
|
||||
let vault_path = tool_vault_path(request, args)?;
|
||||
crate::commands::create_note_content(
|
||||
PathBuf::from(note_path),
|
||||
content,
|
||||
Some(PathBuf::from(vault_path)),
|
||||
)?;
|
||||
let output = serde_json::json!({
|
||||
"path": note_path,
|
||||
"vaultPath": vault_path,
|
||||
})
|
||||
.to_string();
|
||||
Ok(CreatedNoteToolResult {
|
||||
summary: format!("Created note: {note_path}"),
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_vault_path<'a>(
|
||||
request: &'a AiModelStreamRequest,
|
||||
args: &'a serde_json::Value,
|
||||
) -> Result<&'a str, String> {
|
||||
if let Some(vault_path) = string_arg(args, "vaultPath") {
|
||||
return active_tool_vault_path(request, vault_path);
|
||||
}
|
||||
request
|
||||
.vault_path
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
.ok_or_else(|| "No active vault is available for create_note.".to_string())
|
||||
}
|
||||
|
||||
fn active_tool_vault_path<'a>(
|
||||
request: &'a AiModelStreamRequest,
|
||||
vault_path: &'a str,
|
||||
) -> Result<&'a str, String> {
|
||||
if active_vault_paths(request).any(|active| active == vault_path) {
|
||||
Ok(vault_path)
|
||||
} else {
|
||||
Err(format!("Vault is not active in Tolaria: {vault_path}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn active_vault_paths(request: &AiModelStreamRequest) -> impl Iterator<Item = &str> {
|
||||
request
|
||||
.vault_path
|
||||
.as_deref()
|
||||
.into_iter()
|
||||
.chain(request.vault_paths.iter().map(String::as_str))
|
||||
.filter_map(non_empty_str)
|
||||
}
|
||||
|
||||
fn create_note_tool_content(args: &serde_json::Value, note_path: &str) -> String {
|
||||
if let Some(content) = content_arg(args, "content") {
|
||||
return content.to_string();
|
||||
}
|
||||
let title = string_arg(args, "title")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| fallback_note_title(note_path));
|
||||
let note_type = string_arg(args, "type")
|
||||
.or_else(|| string_arg(args, "is_a"))
|
||||
.unwrap_or("Note");
|
||||
let note_type_yaml = serde_json::to_string(note_type).unwrap_or_else(|_| "\"Note\"".into());
|
||||
format!("---\ntype: {note_type_yaml}\n---\n\n# {title}\n")
|
||||
}
|
||||
|
||||
fn fallback_note_title(note_path: &str) -> String {
|
||||
let normalized = note_path.replace('\\', "/");
|
||||
Path::new(&normalized)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.filter(|title| !title.trim().is_empty())
|
||||
.unwrap_or("Untitled")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn required_tool_string<'a>(args: &'a serde_json::Value, key: &str) -> Result<&'a str, String> {
|
||||
string_arg(args, key).ok_or_else(|| format!("create_note requires {key}."))
|
||||
}
|
||||
|
||||
fn string_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
args[key].as_str().and_then(non_empty_str)
|
||||
}
|
||||
|
||||
fn content_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
args[key]
|
||||
.as_str()
|
||||
.filter(|content| !content.trim().is_empty())
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_models::{
|
||||
AiModelApiKeyStorage, AiModelCapabilities, AiModelDefinition, AiModelProvider,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
|
||||
const CREATED_NOTE_PATH: &str = "nota-longa-teste-gerada-2.md";
|
||||
const CREATED_NOTE_CONTENT: &str = "---\ntype: Note\n---\n\n# Nota longa de teste - gerada 2\n";
|
||||
|
||||
fn request(vault_path: String) -> AiModelStreamRequest {
|
||||
request_with_provider(provider(), Some(vault_path), Vec::new())
|
||||
}
|
||||
|
||||
fn request_with_provider(
|
||||
provider: AiModelProvider,
|
||||
vault_path: Option<String>,
|
||||
vault_paths: Vec<String>,
|
||||
) -> AiModelStreamRequest {
|
||||
AiModelStreamRequest {
|
||||
provider,
|
||||
model_id: "gpt-5-nano".into(),
|
||||
message: "Create the note".into(),
|
||||
system_prompt: Some("Use create_note for new notes.".into()),
|
||||
vault_path,
|
||||
vault_paths,
|
||||
api_key_override: None,
|
||||
event_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider() -> AiModelProvider {
|
||||
AiModelProvider {
|
||||
id: "openai".into(),
|
||||
name: "OpenAI".into(),
|
||||
kind: AiModelProviderKind::OpenAi,
|
||||
base_url: Some("https://api.openai.com/v1".into()),
|
||||
api_key_storage: Some(AiModelApiKeyStorage::LocalFile),
|
||||
api_key_env_var: None,
|
||||
headers: None,
|
||||
models: vec![AiModelDefinition {
|
||||
id: "gpt-5-nano".into(),
|
||||
display_name: None,
|
||||
context_window: None,
|
||||
max_output_tokens: None,
|
||||
capabilities: AiModelCapabilities {
|
||||
streaming: false,
|
||||
tools: false,
|
||||
vision: false,
|
||||
json_mode: false,
|
||||
reasoning: false,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn create_note_response() -> serde_json::Value {
|
||||
tool_call_response(json!({
|
||||
"id": "call_create",
|
||||
"function": {
|
||||
"name": CREATE_NOTE_TOOL_NAME,
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"path": CREATED_NOTE_PATH,
|
||||
"content": CREATED_NOTE_CONTENT
|
||||
})).unwrap()
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn tool_call_response(tool_call: serde_json::Value) -> serde_json::Value {
|
||||
json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"tool_calls": [tool_call]
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn create_note_response_with_args(arguments: serde_json::Value) -> serde_json::Value {
|
||||
tool_call_response(json!({
|
||||
"function": {
|
||||
"name": CREATE_NOTE_TOOL_NAME,
|
||||
"arguments": arguments,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn create_note_error(arguments: serde_json::Value) -> String {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let request = request(dir.path().to_string_lossy().into_owned());
|
||||
|
||||
execute_openai_tool_calls(&request, &create_note_response_with_args(arguments), |_| {})
|
||||
.unwrap_err()
|
||||
}
|
||||
|
||||
fn assert_note_created(vault_path: &Path) {
|
||||
let actual = fs::read_to_string(vault_path.join(CREATED_NOTE_PATH)).unwrap();
|
||||
assert_eq!(actual, CREATED_NOTE_CONTENT);
|
||||
}
|
||||
|
||||
fn assert_summary(summary: Option<String>) {
|
||||
assert_eq!(
|
||||
summary.as_deref(),
|
||||
Some("Created note: nota-longa-teste-gerada-2.md"),
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_tool_events(events: &[AiAgentStreamEvent]) {
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input: Some(input) }
|
||||
if tool_name == CREATE_NOTE_TOOL_NAME && tool_id == "call_create" && input.contains(CREATED_NOTE_PATH)
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output: Some(output) }
|
||||
if tool_id == "call_create" && output.contains(CREATED_NOTE_PATH)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_payload_offers_create_note_when_active_vault_is_loaded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let payload = openai_chat_payload(&request(dir.path().to_string_lossy().into_owned()));
|
||||
|
||||
assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_payload_offers_create_note_for_tool_capable_custom_models() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut provider = provider();
|
||||
provider.kind = AiModelProviderKind::OpenAiCompatible;
|
||||
provider.models[0].capabilities.tools = true;
|
||||
let request = request_with_provider(
|
||||
provider,
|
||||
Some(dir.path().to_string_lossy().into_owned()),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let payload = openai_chat_payload(&request);
|
||||
|
||||
assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_payload_skips_create_note_when_no_active_vault_is_loaded() {
|
||||
let payload = openai_chat_payload(&request_with_provider(provider(), None, Vec::new()));
|
||||
|
||||
assert!(payload.get("tools").is_none());
|
||||
assert!(payload.get("tool_choice").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_openai_tool_calls_returns_none_without_tool_calls() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let request = request(dir.path().to_string_lossy().into_owned());
|
||||
|
||||
let summary = execute_openai_tool_calls(
|
||||
&request,
|
||||
&json!({ "choices": [{ "message": { "content": "No tools" } }] }),
|
||||
|_| {},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(summary, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executes_openai_create_note_tool_call_inside_active_vault() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let request = request(dir.path().to_string_lossy().into_owned());
|
||||
let mut events = Vec::new();
|
||||
|
||||
let summary = execute_openai_tool_calls(&request, &create_note_response(), |event| {
|
||||
events.push(event)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_note_created(dir.path());
|
||||
assert_summary(summary);
|
||||
assert_tool_events(&events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executes_openai_create_note_with_object_arguments_and_selected_vault() {
|
||||
let primary = tempfile::tempdir().unwrap();
|
||||
let secondary = tempfile::tempdir().unwrap();
|
||||
let secondary_path = secondary.path().to_string_lossy().into_owned();
|
||||
let request = request_with_provider(
|
||||
provider(),
|
||||
Some(primary.path().to_string_lossy().into_owned()),
|
||||
vec![secondary_path.clone()],
|
||||
);
|
||||
let response = tool_call_response(json!({
|
||||
"function": {
|
||||
"name": CREATE_NOTE_TOOL_NAME,
|
||||
"arguments": {
|
||||
"path": "Generated/fallback-note.md",
|
||||
"is_a": "Project",
|
||||
"vaultPath": secondary_path,
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let summary = execute_openai_tool_calls(&request, &response, |_| {}).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(secondary.path().join("Generated/fallback-note.md")).unwrap(),
|
||||
"---\ntype: \"Project\"\n---\n\n# fallback-note\n",
|
||||
);
|
||||
assert_eq!(
|
||||
summary.as_deref(),
|
||||
Some("Created note: Generated/fallback-note.md")
|
||||
);
|
||||
assert!(!primary.path().join("Generated/fallback-note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_openai_tool_calls_rejects_unsupported_tool_before_running_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let request = request(dir.path().to_string_lossy().into_owned());
|
||||
let response = tool_call_response(json!({
|
||||
"id": "call_delete",
|
||||
"function": {
|
||||
"name": "delete_note",
|
||||
"arguments": "{}",
|
||||
}
|
||||
}));
|
||||
let mut events = Vec::new();
|
||||
|
||||
let error =
|
||||
execute_openai_tool_calls(&request, &response, |event| events.push(event)).unwrap_err();
|
||||
|
||||
assert_eq!(error, "AI provider requested unsupported tool: delete_note");
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_openai_tool_calls_rejects_malformed_arguments() {
|
||||
let error = create_note_error(json!("{not-json"));
|
||||
|
||||
assert!(error.contains("Failed to parse AI tool arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_openai_tool_calls_treats_non_object_arguments_as_empty() {
|
||||
let error = create_note_error(json!([]));
|
||||
|
||||
assert_eq!(error, "create_note requires path.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_openai_tool_calls_rejects_existing_note_without_overwriting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let request = request(dir.path().to_string_lossy().into_owned());
|
||||
fs::write(dir.path().join("existing.md"), "# Existing\n").unwrap();
|
||||
let response = tool_call_response(json!({
|
||||
"function": {
|
||||
"name": CREATE_NOTE_TOOL_NAME,
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"path": "existing.md",
|
||||
"content": "# Replacement\n",
|
||||
})).unwrap(),
|
||||
}
|
||||
}));
|
||||
|
||||
let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err();
|
||||
|
||||
assert!(error.contains("already exists"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("existing.md")).unwrap(),
|
||||
"# Existing\n",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_openai_tool_calls_requires_path() {
|
||||
let error = create_note_error(json!("{}"));
|
||||
|
||||
assert_eq!(error, "create_note requires path.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_openai_tool_calls_rejects_inactive_explicit_vault() {
|
||||
let active = tempfile::tempdir().unwrap();
|
||||
let inactive = tempfile::tempdir().unwrap();
|
||||
let request = request(active.path().to_string_lossy().into_owned());
|
||||
let response = tool_call_response(json!({
|
||||
"function": {
|
||||
"name": CREATE_NOTE_TOOL_NAME,
|
||||
"arguments": serde_json::to_string(&json!({
|
||||
"path": "inactive.md",
|
||||
"content": "# Inactive\n",
|
||||
"vaultPath": inactive.path().to_string_lossy(),
|
||||
})).unwrap(),
|
||||
}
|
||||
}));
|
||||
|
||||
let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err();
|
||||
|
||||
assert!(error.starts_with("Vault is not active in Tolaria:"));
|
||||
assert!(!inactive.path().join("inactive.md").exists());
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ pub struct AiModelStreamRequest {
|
||||
pub model_id: String,
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vault_paths: Vec<String>,
|
||||
pub api_key_override: Option<String>,
|
||||
#[serde(default)]
|
||||
pub event_name: Option<String>,
|
||||
@@ -166,7 +169,7 @@ where
|
||||
session_id: format!("api-{}", uuid::Uuid::new_v4()),
|
||||
});
|
||||
|
||||
let text = send_model_message(&request)?;
|
||||
let text = send_model_message(&request, &mut emit)?;
|
||||
|
||||
emit(AiAgentStreamEvent::TextDelta { text });
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
@@ -182,33 +185,39 @@ pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<Str
|
||||
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
|
||||
.into(),
|
||||
),
|
||||
vault_path: None,
|
||||
vault_paths: Vec::new(),
|
||||
api_key_override: normalize_optional_string(request.api_key_override),
|
||||
event_name: None,
|
||||
};
|
||||
send_model_message(&request)
|
||||
send_model_message(&request, &mut |_| {})
|
||||
}
|
||||
|
||||
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
fn send_model_message<F>(request: &AiModelStreamRequest, emit: &mut F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match request.provider.kind {
|
||||
AiModelProviderKind::Anthropic => send_anthropic_message(request),
|
||||
_ => send_openai_compatible_message(request),
|
||||
_ => send_openai_compatible_message(request, emit),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
fn send_openai_compatible_message<F>(
|
||||
request: &AiModelStreamRequest,
|
||||
emit: &mut F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
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 payload = crate::ai_model_tools::openai_chat_payload(request);
|
||||
let json = send_json_request(request, endpoint, payload)?;
|
||||
if let Some(tool_summary) =
|
||||
crate::ai_model_tools::execute_openai_tool_calls(request, &json, emit)?
|
||||
{
|
||||
return Ok(tool_summary);
|
||||
}
|
||||
extract_openai_text(&json)
|
||||
}
|
||||
|
||||
@@ -551,6 +560,8 @@ mod tests {
|
||||
model_id: "demo-model".into(),
|
||||
message: "Hello".into(),
|
||||
system_prompt: Some(" Be concise. ".into()),
|
||||
vault_path: None,
|
||||
vault_paths: Vec::new(),
|
||||
api_key_override: None,
|
||||
event_name: None,
|
||||
}
|
||||
@@ -623,6 +634,51 @@ mod tests {
|
||||
assert_eq!(headers, vec![("X-Demo", "demo")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_builders_apply_auth_and_provider_headers() {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut anthropic_provider = provider(AiModelProviderKind::Anthropic);
|
||||
anthropic_provider.api_key_env_var = None;
|
||||
anthropic_provider.headers = Some(BTreeMap::from([
|
||||
("Authorization".into(), "ignored".into()),
|
||||
("X-Demo".into(), "demo".into()),
|
||||
]));
|
||||
let mut anthropic_request = request(anthropic_provider);
|
||||
anthropic_request.api_key_override = Some(" secret ".into());
|
||||
|
||||
let built = apply_provider_headers(
|
||||
apply_auth_headers(client.post("https://example.test"), &anthropic_request).unwrap(),
|
||||
&anthropic_request,
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let headers = built.headers();
|
||||
assert_eq!(
|
||||
(
|
||||
headers["x-api-key"].to_str().unwrap(),
|
||||
headers["anthropic-version"].to_str().unwrap(),
|
||||
headers["X-Demo"].to_str().unwrap(),
|
||||
headers.get("authorization").is_none(),
|
||||
),
|
||||
("secret", "2023-06-01", "demo", true),
|
||||
);
|
||||
|
||||
let mut openai_provider = provider(AiModelProviderKind::OpenAi);
|
||||
openai_provider.api_key_env_var = None;
|
||||
let mut openai_request = request(openai_provider);
|
||||
openai_request.api_key_override = Some(" openai-secret ".into());
|
||||
let built = apply_auth_headers(client.post("https://example.test"), &openai_request)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
built.headers()["authorization"].to_str().unwrap(),
|
||||
"Bearer openai-secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_provider_catalog_supplies_runtime_base_urls() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1303,6 +1303,7 @@ mod tests {
|
||||
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
|
||||
use std::io::Read;
|
||||
@@ -1321,7 +1322,7 @@ mod tests {
|
||||
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 deadline = Instant::now() + Duration::from_secs(30);
|
||||
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
@@ -1347,6 +1348,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
|
||||
#[test]
|
||||
fn stdin_probe_parent_child() {
|
||||
@@ -1354,25 +1356,15 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
|
||||
let fake_bin = current_test_binary();
|
||||
let args = vec![
|
||||
"stdin_probe_mock_claude_child".to_string(),
|
||||
"--ignored".to_string(),
|
||||
"--nocapture".to_string(),
|
||||
];
|
||||
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(
|
||||
ClaudeSubprocessRequest {
|
||||
bin: &fake_bin,
|
||||
args: &args,
|
||||
fallback_args: &[],
|
||||
stdin_text: None,
|
||||
cwd: None,
|
||||
},
|
||||
&mut |event| events.push(event),
|
||||
);
|
||||
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
|
||||
let (result, events) = run_mock_script(MockClaudeScript(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"stdin=\"$(cat)\"\n",
|
||||
"if [ -n \"$stdin\" ]; then\n",
|
||||
" echo \"stdin was not closed\" >&2\n",
|
||||
" exit 9\n",
|
||||
"fi\n",
|
||||
"printf '%s\\n' '{\"type\":\"result\",\"result\":\"stdin closed\",\"session_id\":\"stdin-ok\"}'\n",
|
||||
)));
|
||||
|
||||
assert_eq!(result.unwrap(), "stdin-ok");
|
||||
assert!(matches!(
|
||||
@@ -1383,28 +1375,6 @@ mod tests {
|
||||
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[ignore = "spawned by stdin_probe_parent_child"]
|
||||
#[test]
|
||||
fn stdin_probe_mock_claude_child() {
|
||||
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
let mut stdin = String::new();
|
||||
std::io::stdin().read_to_string(&mut stdin).unwrap();
|
||||
assert!(stdin.is_empty(), "stdin was not EOF");
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "result",
|
||||
"result": "stdin closed",
|
||||
"session_id": "stdin-ok"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_skips_blank_and_non_json_lines() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
const OUTPUT_PREFIX: &str = "__TOLARIA_ENV__:";
|
||||
|
||||
@@ -110,6 +110,7 @@ fn user_shell_bindings_from_shell(shell: &Path, names: &[EnvName<'_>]) -> Option
|
||||
let output = crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(shell_probe_script(shell, names))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
@@ -191,6 +192,80 @@ fn parse_probe_line(line: ProbeLine<'_>, names: &[EnvName<'_>]) -> Option<EnvBin
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn env_value_uses_trimmed_process_value() {
|
||||
let key = "TOLARIA_TEST_SHELL_ENV_PROCESS_VALUE";
|
||||
std::env::set_var(key, " process-secret ");
|
||||
|
||||
let value = env_value_from_process_or_user_shell(EnvName::trusted(key));
|
||||
|
||||
std::env::remove_var(key);
|
||||
assert_eq!(value.as_deref(), Some("process-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_value_marks_name_as_already_available() {
|
||||
let mut command = Command::new("demo");
|
||||
command.env("TOLARIA_TEST_COMMAND_VALUE", "command-secret");
|
||||
|
||||
apply_user_shell_env_vars_if_missing(
|
||||
&mut command,
|
||||
&[EnvName::trusted("TOLARIA_TEST_COMMAND_VALUE")],
|
||||
);
|
||||
|
||||
let values = command
|
||||
.get_envs()
|
||||
.map(|(key, value)| (key.to_string_lossy().to_string(), value.is_some()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(values, vec![("TOLARIA_TEST_COMMAND_VALUE".into(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_probe_output_filters_invalid_unexpected_and_blank_values() {
|
||||
let names = [
|
||||
EnvName::trusted("GOOD_VALUE"),
|
||||
EnvName::trusted("OTHER_VALUE"),
|
||||
];
|
||||
|
||||
let bindings = parse_probe_output(
|
||||
"__TOLARIA_ENV__:GOOD_VALUE=kept\n\
|
||||
ignored\n\
|
||||
__TOLARIA_ENV__:BAD-NAME=bad\n\
|
||||
__TOLARIA_ENV__:OTHER_VALUE= \n\
|
||||
__TOLARIA_ENV__:UNEXPECTED=value\n",
|
||||
&names,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
bindings,
|
||||
vec![EnvBinding {
|
||||
name: "GOOD_VALUE".into(),
|
||||
value: "kept".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn user_shell_bindings_from_shell_returns_none_for_failing_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shell = dir.path().join("zsh");
|
||||
std::fs::write(&shell, "#!/bin/sh\nexit 7\n").unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let values = user_shell_bindings_from_shell(&shell, &[EnvName::trusted("MISSING")]);
|
||||
|
||||
assert_eq!(values, None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rc_source_command_ignores_unknown_shells() {
|
||||
assert_eq!(rc_source_command(Path::new("fish")), "");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn user_shell_bindings_from_shell_reads_zshrc_exports_for_requested_keys() {
|
||||
|
||||
@@ -150,6 +150,19 @@ pub async fn git_remote_status(vault_path: VaultPathArg) -> Result<GitRemoteStat
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_file_url(
|
||||
vault_path: VaultPathArg,
|
||||
path: NotePathArg,
|
||||
) -> Result<Option<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
let path = expand_tilde(&path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_file_url(&vault_path, &path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_discard_file(
|
||||
@@ -344,6 +357,15 @@ pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result<GitRemoteSta
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_file_url(
|
||||
_vault_path: VaultPathArg,
|
||||
_path: NotePathArg,
|
||||
) -> Result<Option<String>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_discard_file(
|
||||
|
||||
403
src-tauri/src/git/file_url.rs
Normal file
403
src-tauri/src/git/file_url.rs
Normal file
@@ -0,0 +1,403 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::vault::path_identity::vault_relative_path_string;
|
||||
|
||||
use super::command::{git_output, stderr_or_failure, stdout_text};
|
||||
use super::remote_config::primary_remote_url;
|
||||
|
||||
enum RemoteWebKind {
|
||||
Bitbucket,
|
||||
Gitea,
|
||||
GitLab,
|
||||
Generic,
|
||||
}
|
||||
|
||||
struct RemoteWebBase {
|
||||
base_url: String,
|
||||
kind: RemoteWebKind,
|
||||
}
|
||||
|
||||
struct GitFileLocation {
|
||||
branch: BranchName,
|
||||
relative_path: RelativeGitPath,
|
||||
}
|
||||
|
||||
struct BranchName(String);
|
||||
|
||||
struct RelativeGitPath(String);
|
||||
|
||||
struct RemoteUrl(String);
|
||||
|
||||
struct RemoteParts {
|
||||
host: RemoteHost,
|
||||
repo_path: RepoPath,
|
||||
}
|
||||
|
||||
struct RemoteHost(String);
|
||||
|
||||
struct RepoPath(String);
|
||||
|
||||
pub fn git_file_url(vault_path: &str, file_path: &str) -> Result<Option<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
let Some(relative_path) = RelativeGitPath::from_paths(vault, file)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(remote_url) = primary_remote_url(vault)?.map(RemoteUrl::new) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let location = GitFileLocation::new(current_ref_name(vault)?, relative_path);
|
||||
|
||||
let url = match remote_web_base(&remote_url) {
|
||||
Some(remote) => remote.file_url(&location),
|
||||
None => remote_url.git_fragment_url(&location),
|
||||
};
|
||||
Ok(Some(url))
|
||||
}
|
||||
|
||||
fn current_ref_name(vault: &Path) -> Result<BranchName, String> {
|
||||
let output = git_output(vault, &["branch", "--show-current"])
|
||||
.map_err(|e| format!("Failed to get branch: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(stderr_or_failure("git branch", &output));
|
||||
}
|
||||
|
||||
let branch = stdout_text(&output);
|
||||
Ok(BranchName::new(branch))
|
||||
}
|
||||
|
||||
impl GitFileLocation {
|
||||
fn new(branch: BranchName, relative_path: RelativeGitPath) -> Self {
|
||||
Self {
|
||||
branch,
|
||||
relative_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BranchName {
|
||||
fn new(value: String) -> Self {
|
||||
if value.is_empty() {
|
||||
return Self("HEAD".to_string());
|
||||
}
|
||||
Self(value)
|
||||
}
|
||||
|
||||
fn encoded_fragment(&self) -> String {
|
||||
encode_fragment_part(&self.0)
|
||||
}
|
||||
|
||||
fn encoded_path(&self) -> String {
|
||||
encode_path(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl RelativeGitPath {
|
||||
fn from_paths(vault: &Path, file: &Path) -> Result<Option<Self>, String> {
|
||||
let value = vault_relative_path_string(vault, file)?;
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Self(value)))
|
||||
}
|
||||
|
||||
fn encoded_fragment(&self) -> String {
|
||||
encode_fragment_part(&self.0)
|
||||
}
|
||||
|
||||
fn encoded_path(&self) -> String {
|
||||
encode_path(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteWebBase {
|
||||
fn file_url(&self, location: &GitFileLocation) -> String {
|
||||
let branch = location.branch.encoded_path();
|
||||
let path = location.relative_path.encoded_path();
|
||||
match self.kind {
|
||||
RemoteWebKind::Bitbucket => format!("{}/src/{}/{}", self.base_url, branch, path),
|
||||
RemoteWebKind::Gitea => format!("{}/src/branch/{}/{}", self.base_url, branch, path),
|
||||
RemoteWebKind::GitLab => format!("{}/-/blob/{}/{}", self.base_url, branch, path),
|
||||
RemoteWebKind::Generic => format!("{}/blob/{}/{}", self.base_url, branch, path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteUrl {
|
||||
fn new(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
fn trimmed(&self) -> &str {
|
||||
self.0.trim()
|
||||
}
|
||||
|
||||
fn git_fragment_url(&self, location: &GitFileLocation) -> String {
|
||||
format!(
|
||||
"{}#{}:{}",
|
||||
self.trimmed(),
|
||||
location.branch.encoded_fragment(),
|
||||
location.relative_path.encoded_fragment(),
|
||||
)
|
||||
}
|
||||
|
||||
fn host_and_path(&self) -> Option<RemoteParts> {
|
||||
self.http_parts()
|
||||
.or_else(|| self.scheme_parts())
|
||||
.or_else(|| self.scp_parts())
|
||||
}
|
||||
|
||||
fn http_parts(&self) -> Option<RemoteParts> {
|
||||
let rest = self
|
||||
.trimmed()
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| self.trimmed().strip_prefix("http://"))?;
|
||||
RemoteParts::from_authority_path(rest)
|
||||
}
|
||||
|
||||
fn scheme_parts(&self) -> Option<RemoteParts> {
|
||||
let rest = self
|
||||
.trimmed()
|
||||
.strip_prefix("ssh://")
|
||||
.or_else(|| self.trimmed().strip_prefix("git://"))?;
|
||||
RemoteParts::from_authority_path(rest)
|
||||
}
|
||||
|
||||
fn scp_parts(&self) -> Option<RemoteParts> {
|
||||
let (_, target) = self.trimmed().split_once('@')?;
|
||||
let (host, path) = target.split_once(':')?;
|
||||
Some(RemoteParts::new(RemoteHost::new(host), RepoPath::new(path)))
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteParts {
|
||||
fn new(host: RemoteHost, repo_path: RepoPath) -> Self {
|
||||
Self { host, repo_path }
|
||||
}
|
||||
|
||||
fn from_authority_path(value: &str) -> Option<Self> {
|
||||
let (authority, path) = value.split_once('/')?;
|
||||
Some(Self::new(
|
||||
RemoteHost::from_authority(authority),
|
||||
RepoPath::new(path),
|
||||
))
|
||||
}
|
||||
|
||||
fn into_web_base(self) -> Option<RemoteWebBase> {
|
||||
let clean_path = self.repo_path.clean();
|
||||
if self.host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if clean_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base_url = format!("https://{}/{clean_path}", self.host.as_str());
|
||||
Some(RemoteWebBase {
|
||||
kind: remote_web_kind(&self.host),
|
||||
base_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteHost {
|
||||
fn new(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
|
||||
fn from_authority(authority: &str) -> Self {
|
||||
Self::new(authority.rsplit('@').next().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
fn lower(&self) -> String {
|
||||
self.0.to_ascii_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoPath {
|
||||
fn new(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
|
||||
fn clean(&self) -> &str {
|
||||
let trimmed = self.0.trim_matches('/');
|
||||
trimmed.strip_suffix(".git").unwrap_or(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_web_base(remote_url: &RemoteUrl) -> Option<RemoteWebBase> {
|
||||
remote_url.host_and_path()?.into_web_base()
|
||||
}
|
||||
|
||||
fn remote_web_kind(host: &RemoteHost) -> RemoteWebKind {
|
||||
let lower_host = host.lower();
|
||||
if lower_host.contains("gitlab") {
|
||||
return RemoteWebKind::GitLab;
|
||||
}
|
||||
if lower_host.contains("bitbucket") {
|
||||
return RemoteWebKind::Bitbucket;
|
||||
}
|
||||
if lower_host.contains("gitea") {
|
||||
return RemoteWebKind::Gitea;
|
||||
}
|
||||
if lower_host.contains("forgejo") {
|
||||
return RemoteWebKind::Gitea;
|
||||
}
|
||||
if lower_host == "codeberg.org" {
|
||||
return RemoteWebKind::Gitea;
|
||||
}
|
||||
RemoteWebKind::Generic
|
||||
}
|
||||
|
||||
fn encode_path(path: &str) -> String {
|
||||
path.split('/')
|
||||
.map(encode_segment)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn encode_fragment_part(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b' ' => encoded.push_str("%20"),
|
||||
b'#' => encoded.push_str("%23"),
|
||||
b'%' => encoded.push_str("%25"),
|
||||
_ => encoded.push(char::from(byte)),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn encode_segment(segment: &str) -> String {
|
||||
segment
|
||||
.bytes()
|
||||
.flat_map(|byte| {
|
||||
if is_unreserved_url_byte(byte) {
|
||||
vec![byte]
|
||||
} else {
|
||||
format!("%{byte:02X}").into_bytes()
|
||||
}
|
||||
})
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_unreserved_url_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_command;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
struct RemoteFixture {
|
||||
name: &'static str,
|
||||
url: &'static str,
|
||||
}
|
||||
|
||||
struct GitUrlCase {
|
||||
remote: RemoteFixture,
|
||||
note_path: &'static str,
|
||||
expected_url: &'static str,
|
||||
}
|
||||
|
||||
fn add_remote(vault: &Path, remote: RemoteFixture) {
|
||||
git_command()
|
||||
.args(["remote", "add", remote.name, remote.url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_note(vault: &Path, relative_path: &str) -> String {
|
||||
let file = vault.join(relative_path);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
fs::write(&file, "# Note\n").unwrap();
|
||||
file.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn assert_git_file_url(test_case: GitUrlCase) {
|
||||
let dir = setup_git_repo();
|
||||
let note = write_note(dir.path(), test_case.note_path);
|
||||
add_remote(dir.path(), test_case.remote);
|
||||
|
||||
let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap();
|
||||
|
||||
assert_eq!(url.as_deref(), Some(test_case.expected_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_without_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let note = write_note(dir.path(), "note.md");
|
||||
|
||||
let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap();
|
||||
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_outside_git_repository() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let note = write_note(dir.path(), "note.md");
|
||||
|
||||
let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap();
|
||||
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_remote_note_urls() {
|
||||
[
|
||||
GitUrlCase {
|
||||
remote: RemoteFixture {
|
||||
name: "origin",
|
||||
url: "git@github.com:owner/repo.git",
|
||||
},
|
||||
note_path: "Notes/Project Plan.md",
|
||||
expected_url: "https://github.com/owner/repo/blob/main/Notes/Project%20Plan.md",
|
||||
},
|
||||
GitUrlCase {
|
||||
remote: RemoteFixture {
|
||||
name: "origin",
|
||||
url: "https://gho_secret@github.com/owner/repo.git",
|
||||
},
|
||||
note_path: "private.md",
|
||||
expected_url: "https://github.com/owner/repo/blob/main/private.md",
|
||||
},
|
||||
GitUrlCase {
|
||||
remote: RemoteFixture {
|
||||
name: "origin",
|
||||
url: "https://gitlab.com/group/repo.git",
|
||||
},
|
||||
note_path: "notes/topic.md",
|
||||
expected_url: "https://gitlab.com/group/repo/-/blob/main/notes/topic.md",
|
||||
},
|
||||
GitUrlCase {
|
||||
remote: RemoteFixture {
|
||||
name: "upstream",
|
||||
url: "https://github.com/team/vault.git",
|
||||
},
|
||||
note_path: "shared.md",
|
||||
expected_url: "https://github.com/team/vault/blob/main/shared.md",
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.for_each(assert_git_file_url);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod conflict;
|
||||
mod connect;
|
||||
mod credentials;
|
||||
mod dates;
|
||||
mod file_url;
|
||||
mod history;
|
||||
mod pulse;
|
||||
mod remote;
|
||||
@@ -27,6 +28,7 @@ pub use conflict::{
|
||||
};
|
||||
pub use connect::{disconnect_all_remotes, git_add_remote, GitAddRemoteResult};
|
||||
pub use dates::{get_all_file_dates, GitDates};
|
||||
pub use file_url::git_file_url;
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{
|
||||
|
||||
@@ -7,11 +7,33 @@ const REMOTE_URL_CONFIG_PATTERN: &str = r"^remote\..*\.url$";
|
||||
const ORIGIN_URL_CONFIG_KEY: &str = "remote.origin.url";
|
||||
const ORIGIN_FETCH_CONFIG_KEY: &str = "remote.origin.fetch";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ConfiguredRemote {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
pub(super) fn has_configured_remote(vault: &Path) -> Result<bool, String> {
|
||||
Ok(!list_configured_remotes(vault)?.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn list_configured_remotes(vault: &Path) -> Result<Vec<String>, String> {
|
||||
Ok(list_configured_remote_urls(vault)?
|
||||
.into_iter()
|
||||
.map(|remote| remote.name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) fn primary_remote_url(vault: &Path) -> Result<Option<String>, String> {
|
||||
let remotes = list_configured_remote_urls(vault)?;
|
||||
Ok(remotes
|
||||
.iter()
|
||||
.find(|remote| remote.name == "origin")
|
||||
.or_else(|| remotes.first())
|
||||
.map(|remote| remote.url.clone()))
|
||||
}
|
||||
|
||||
fn list_configured_remote_urls(vault: &Path) -> Result<Vec<ConfiguredRemote>, String> {
|
||||
let output = git_output(
|
||||
vault,
|
||||
&["config", "--get-regexp", REMOTE_URL_CONFIG_PATTERN],
|
||||
@@ -27,19 +49,26 @@ pub(super) fn list_configured_remotes(vault: &Path) -> Result<Vec<String>, Strin
|
||||
|
||||
Ok(stdout_lines(&output)
|
||||
.into_iter()
|
||||
.filter_map(|line| remote_name_from_url_config(&line))
|
||||
.filter_map(|line| remote_from_url_config(&line))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn remote_name_from_url_config(line: &str) -> Option<String> {
|
||||
fn remote_from_url_config(line: &str) -> Option<ConfiguredRemote> {
|
||||
let (key, value) = line.split_once(' ')?;
|
||||
if value.trim().is_empty() {
|
||||
let url = value.trim();
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
key.strip_prefix("remote.")
|
||||
let name = key
|
||||
.strip_prefix("remote.")
|
||||
.and_then(|name| name.strip_suffix(".url"))
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.map(ToString::to_string)?;
|
||||
|
||||
Some(ConfiguredRemote {
|
||||
name,
|
||||
url: url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn configure_origin_remote(vault: &Path, remote_url: &str) -> Result<(), String> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod ai_agents;
|
||||
mod ai_model_tools;
|
||||
pub mod ai_models;
|
||||
mod app_icon;
|
||||
pub mod app_updater;
|
||||
@@ -500,6 +501,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::git_file_url,
|
||||
commands::git_add_remote,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
|
||||
@@ -18,8 +18,12 @@ pub(crate) fn build_command(
|
||||
request.permission_mode,
|
||||
)?;
|
||||
|
||||
let mut command = crate::hidden_command(binary);
|
||||
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
|
||||
let mut command = crate::hidden_command(&target.program);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
||||
if let Some(first_arg) = target.first_arg {
|
||||
command.arg(first_arg);
|
||||
}
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
@@ -300,10 +304,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_pi_json_mode_args(args: &[String]) {
|
||||
assert_eq!(args[0], "--mode");
|
||||
assert_eq!(args[1], "json");
|
||||
assert!(args.contains(&"--no-session".to_string()));
|
||||
assert!(args.contains(&"--extension".to_string()));
|
||||
assert_eq!(
|
||||
(
|
||||
args.first().map(String::as_str),
|
||||
args.get(1).map(String::as_str),
|
||||
args.iter().any(|arg| arg == "--no-session"),
|
||||
args.iter().any(|arg| arg == "--extension"),
|
||||
),
|
||||
(Some("--mode"), Some("json"), true, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -324,10 +333,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_command_identity(command: &std::process::Command, actual_args: &[&OsStr]) {
|
||||
assert_eq!(command.get_program(), OsStr::new("pi"));
|
||||
assert_eq!(actual_args[0], OsStr::new("--mode"));
|
||||
assert_eq!(actual_args[1], OsStr::new("json"));
|
||||
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
|
||||
assert_eq!(
|
||||
(
|
||||
command.get_program(),
|
||||
actual_args.first().copied(),
|
||||
actual_args.get(1).copied(),
|
||||
actual_args.last().copied(),
|
||||
),
|
||||
(
|
||||
OsStr::new("pi"),
|
||||
Some(OsStr::new("--mode")),
|
||||
Some(OsStr::new("json")),
|
||||
Some(OsStr::new("Rename the note")),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_command_vault_scope(
|
||||
@@ -340,6 +359,50 @@ mod tests {
|
||||
assert!(agent_dir.join("mcp.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_avoids_windows_cmd_shim_for_prompt_args() {
|
||||
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
|
||||
let source_agent_dir = tempfile::tempdir().unwrap();
|
||||
let agent_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path());
|
||||
let shim = agent_dir.path().join("pi.cmd");
|
||||
let launcher = agent_dir
|
||||
.path()
|
||||
.join("node_modules")
|
||||
.join("@withpi")
|
||||
.join("pi")
|
||||
.join("bin")
|
||||
.join("pi.exe");
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&launcher, "native pi launcher").unwrap();
|
||||
std::fs::write(
|
||||
&shim,
|
||||
r#"@ECHO off
|
||||
"%~dp0\node_modules\@withpi\pi\bin\pi.exe" %*
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let command = build_command(&shim, &request(), agent_dir.path()).unwrap();
|
||||
let actual_args = command.get_args().collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
command.get_program() != shim.as_os_str(),
|
||||
command.get_program(),
|
||||
actual_args.first().copied(),
|
||||
actual_args.last().copied(),
|
||||
),
|
||||
(
|
||||
true,
|
||||
launcher.as_os_str(),
|
||||
Some(OsStr::new("--mode")),
|
||||
Some(OsStr::new("Rename the note")),
|
||||
),
|
||||
"Pi npm .cmd shims cannot be spawned directly on Windows"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_seeds_temp_agent_dir_from_existing_pi_config() {
|
||||
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
|
||||
@@ -429,10 +492,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_base_mcp_config(json: &serde_json::Value) {
|
||||
assert_eq!(json["settings"]["toolPrefix"], "none");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
|
||||
assert_eq!(
|
||||
(
|
||||
&json["settings"]["toolPrefix"],
|
||||
&json["mcpServers"]["tolaria"]["command"],
|
||||
&json["mcpServers"]["tolaria"]["lifecycle"],
|
||||
&json["mcpServers"]["tolaria"]["directTools"],
|
||||
),
|
||||
(
|
||||
&serde_json::json!("none"),
|
||||
&serde_json::json!("node"),
|
||||
&serde_json::json!("lazy"),
|
||||
&serde_json::json!(true),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_tolaria_mcp_env(json: &serde_json::Value) {
|
||||
|
||||
@@ -88,12 +88,19 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
first_existing_path_for_platform(stdout, cfg!(windows))
|
||||
}
|
||||
|
||||
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
if windows && !crate::cli_agent_runtime::has_windows_cli_extension(&candidate) {
|
||||
return None;
|
||||
}
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
@@ -358,6 +365,18 @@ mod tests {
|
||||
assert_eq!(first_existing_path(&stdout), Some(pi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_windows_path_skips_extensionless_npm_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wrapper = dir.path().join("pi");
|
||||
let shim = dir.path().join("pi.cmd");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&shim, "@ECHO off\n").unwrap();
|
||||
let stdout = format!("{}\n{}\n", wrapper.display(), shim.display());
|
||||
|
||||
assert_eq!(first_existing_path_for_platform(&stdout, true), Some(shim));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_pi_from_interactive_login_shell() {
|
||||
|
||||
10
src/App.tsx
10
src/App.tsx
@@ -53,6 +53,7 @@ import { useDeleteActions } from './hooks/useDeleteActions'
|
||||
import { useFolderActions } from './hooks/useFolderActions'
|
||||
import { useFileActions } from './hooks/useFileActions'
|
||||
import { useDeepLinks } from './hooks/useDeepLinks'
|
||||
import { useNoteGitUrls } from './hooks/useNoteGitUrls'
|
||||
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import { useConflictFlow } from './hooks/useConflictFlow'
|
||||
import { useAppSave } from './hooks/useAppSave'
|
||||
@@ -1379,6 +1380,12 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
vaults: vaultSwitcher.allVaults,
|
||||
})
|
||||
const activeEditorVaultPath = activeTab ? vaultPathForEntry(activeTab.entry, resolvedPath) : resolvedPath
|
||||
const noteGitUrls = useNoteGitUrls({
|
||||
currentVaultPath: resolvedPath,
|
||||
locale: appLocale,
|
||||
remoteStatusForRepository: gitSurfaces.remoteStatusForRepository,
|
||||
setToastMessage,
|
||||
})
|
||||
const commandAiActions = useAppCommandAiActions(aiFeaturesEnabled, dialogs, aiAgentsStatus, vaultAiGuidanceStatus, restoreVaultAiGuidanceCommand, aiAgentPreferences)
|
||||
const undoCommand = useCallback(() => {
|
||||
if (runNativeTextHistoryCommand('undo')) return
|
||||
@@ -1594,7 +1601,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} refreshKey={gitHistoryRefreshKey} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
|
||||
) : (
|
||||
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onExportPdf={handleExportNotePdfFromList} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
|
||||
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onExportPdf={handleExportNotePdfFromList} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} canCopyGitUrl={noteGitUrls.canCopyEntryGitUrl} onCopyGitUrl={noteGitUrls.copyEntryGitUrl} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -1645,6 +1652,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
onRevealFile={fileActions.revealFile}
|
||||
onCopyFilePath={fileActions.copyFilePath}
|
||||
onCopyDeepLink={activeDeletedFile ? undefined : deepLinks.copyEntryDeepLink}
|
||||
onCopyGitUrl={activeDeletedFile || !activeTabEntry || !noteGitUrls.canCopyEntryGitUrl(activeTabEntry) ? undefined : noteGitUrls.copyEntryGitUrl}
|
||||
onOpenExternalFile={fileActions.openExternalFile}
|
||||
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
||||
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
||||
|
||||
@@ -249,6 +249,24 @@ describe('BreadcrumbBar — file actions', () => {
|
||||
expect(onCopyDeepLink).toHaveBeenCalledWith(baseEntry)
|
||||
})
|
||||
|
||||
it('copies the current note git URL from the overflow menu when available', async () => {
|
||||
const onCopyGitUrl = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onCopyGitUrl={onCopyGitUrl} />)
|
||||
|
||||
const menu = await openOverflowMenu()
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Copy git URL' }))
|
||||
|
||||
expect(onCopyGitUrl).toHaveBeenCalledWith(baseEntry)
|
||||
})
|
||||
|
||||
it('does not show the note git URL action without a remote-backed handler', async () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
|
||||
const menu = await openOverflowMenu()
|
||||
|
||||
expect(within(menu).queryByRole('menuitem', { name: 'Copy git URL' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exports the current note as PDF from the overflow menu', async () => {
|
||||
const onExportPdf = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onExportPdf={onExportPdf} />)
|
||||
|
||||
@@ -60,6 +60,7 @@ interface BreadcrumbBarProps {
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onCopyDeepLink?: (entry: VaultEntry) => void
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
onExportPdf?: () => void
|
||||
onDelete?: () => void
|
||||
onArchive?: () => void
|
||||
@@ -819,6 +820,7 @@ function BreadcrumbActions({
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onCopyDeepLink,
|
||||
onCopyGitUrl,
|
||||
onExportPdf,
|
||||
onDelete,
|
||||
onArchive,
|
||||
@@ -868,6 +870,7 @@ function BreadcrumbActions({
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onCopyDeepLink={onCopyDeepLink}
|
||||
onCopyGitUrl={onCopyGitUrl}
|
||||
onExportPdf={onExportPdf}
|
||||
onArchive={onArchive}
|
||||
onUnarchive={onUnarchive}
|
||||
@@ -892,6 +895,7 @@ function BreadcrumbOverflowMenu({
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onCopyDeepLink,
|
||||
onCopyGitUrl,
|
||||
onExportPdf,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
@@ -911,6 +915,7 @@ function BreadcrumbOverflowMenu({
|
||||
| 'onRevealFile'
|
||||
| 'onCopyFilePath'
|
||||
| 'onCopyDeepLink'
|
||||
| 'onCopyGitUrl'
|
||||
| 'onExportPdf'
|
||||
| 'onArchive'
|
||||
| 'onUnarchive'
|
||||
@@ -986,6 +991,7 @@ function BreadcrumbOverflowMenu({
|
||||
<Link size={16} />
|
||||
{translate(locale, 'editor.toolbar.copyNoteDeepLink')}
|
||||
</DropdownMenuItem>
|
||||
<CopyGitUrlMenuItem action={entryAction(onCopyGitUrl, entry)} locale={locale} />
|
||||
<DropdownMenuItem disabled={!runArchiveAction} onSelect={runArchiveAction}>
|
||||
<ArchiveMenuIcon archived={entry.archived} />
|
||||
{archiveLabel}
|
||||
@@ -999,6 +1005,22 @@ function BreadcrumbOverflowMenu({
|
||||
)
|
||||
}
|
||||
|
||||
function CopyGitUrlMenuItem({
|
||||
action,
|
||||
locale,
|
||||
}: {
|
||||
action: (() => void) | undefined
|
||||
locale: AppLocale
|
||||
}) {
|
||||
if (!action) return null
|
||||
return (
|
||||
<DropdownMenuItem onSelect={action}>
|
||||
<GitBranch size={16} />
|
||||
{translate(locale, 'editor.toolbar.copyNoteGitUrl')}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator() {
|
||||
return <span aria-hidden="true" className="shrink-0 text-border">›</span>
|
||||
}
|
||||
|
||||
@@ -278,6 +278,28 @@ describe('CommandPalette', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps keyboard selection stable when the mouse is already over a row', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Commit & Push'))
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(commands[1].execute).toHaveBeenCalledOnce()
|
||||
expect(commands[2].execute).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets real mouse movement take over command selection', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
fireEvent.mouseMove(screen.getByText('Commit & Push'))
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(commands[2].execute).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a short query keyboard-selectable after ArrowDown and Enter', () => {
|
||||
const changeNoteType = makeCommand({
|
||||
id: 'change-note-type',
|
||||
|
||||
@@ -484,7 +484,7 @@ function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) {
|
||||
selected ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHover}
|
||||
onMouseMove={onHover}
|
||||
>
|
||||
<span className="text-sm text-foreground">{command.label}</span>
|
||||
{command.shortcut && (
|
||||
|
||||
@@ -31,9 +31,8 @@ import {
|
||||
} from './editorRawModeSync'
|
||||
import { useRegisterEditorContentFlushes } from './editorContentFlushRegistration'
|
||||
import { useRawModeWithFlush } from './useRawModeWithFlush'
|
||||
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
|
||||
import { createImeCompositionKeyGuardExtension } from './imeCompositionKeyGuardExtension'
|
||||
import { createMathInputExtension } from './mathInputExtension'
|
||||
import { createRichEditorMarkdownInputTransformExtension } from './richEditorInputTransformExtension'
|
||||
import { createRichEditorTransformErrorRecoveryExtension } from './richEditorTransformErrorRecoveryExtension'
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
import { useEditorPdfExport } from './useEditorPdfExport'
|
||||
@@ -95,6 +94,7 @@ interface EditorProps {
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onCopyDeepLink?: (entry: VaultEntry) => void
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
onOpenExternalFile?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
@@ -225,8 +225,7 @@ function useEditorSetup({
|
||||
extensions: [
|
||||
createRichEditorTransformErrorRecoveryExtension(),
|
||||
createImeCompositionKeyGuardExtension(),
|
||||
createArrowLigaturesExtension(),
|
||||
createMathInputExtension(),
|
||||
createRichEditorMarkdownInputTransformExtension(),
|
||||
],
|
||||
})
|
||||
useFilenameAutolinkGuard(editor)
|
||||
@@ -365,6 +364,7 @@ function EditorLayout({
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onCopyDeepLink,
|
||||
onCopyGitUrl,
|
||||
onExportPdf,
|
||||
onOpenExternalFile,
|
||||
onDeleteNote,
|
||||
@@ -439,6 +439,7 @@ function EditorLayout({
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onCopyDeepLink?: (entry: VaultEntry) => void
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
onOpenExternalFile?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
@@ -531,6 +532,7 @@ function EditorLayout({
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onCopyDeepLink={onCopyDeepLink}
|
||||
onCopyGitUrl={onCopyGitUrl}
|
||||
onExportPdf={() => onExportPdf?.('breadcrumb')}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
|
||||
import { createMathInputExtension } from './mathInputExtension'
|
||||
import { createRichEditorMarkdownInputTransformExtension } from './richEditorInputTransformExtension'
|
||||
import { schema } from './editorSchema'
|
||||
import type { ProbeTarget } from './editorMemoryProbeTypes'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
@@ -16,7 +15,7 @@ function useProbeEditor(target: ProbeTarget, vaultPath?: string) {
|
||||
schema,
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPath),
|
||||
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
|
||||
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
|
||||
extensions: [createRichEditorMarkdownInputTransformExtension()],
|
||||
})
|
||||
useEditorTabSwap({
|
||||
tabs: [{ entry: target.entry, content: target.content }],
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('NoteList context menu', () => {
|
||||
const onToggleOrganized = vi.fn()
|
||||
const onRevealFile = vi.fn()
|
||||
const onCopyFilePath = vi.fn()
|
||||
const canCopyGitUrl = vi.fn(() => true)
|
||||
const onCopyGitUrl = vi.fn()
|
||||
|
||||
renderNoteList({
|
||||
onOpenInNewWindow,
|
||||
@@ -25,6 +27,8 @@ describe('NoteList context menu', () => {
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
@@ -58,6 +62,11 @@ describe('NoteList context menu', () => {
|
||||
fireEvent.click(screen.getByText('Copy file path'))
|
||||
expect(onCopyFilePath).toHaveBeenCalledWith(mockEntries[0].path)
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Copy git URL'))
|
||||
expect(canCopyGitUrl).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(onCopyGitUrl).toHaveBeenCalledWith(mockEntries[0])
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Export note as PDF'))
|
||||
expect(onExportPdf).toHaveBeenCalledWith(mockEntries[0])
|
||||
@@ -90,4 +99,16 @@ describe('NoteList context menu', () => {
|
||||
expect(screen.getByText('Remove from Favorites')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mark as Unorganized')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the git URL action for notes without a remote', () => {
|
||||
renderNoteList({
|
||||
canCopyGitUrl: () => false,
|
||||
onCopyGitUrl: vi.fn(),
|
||||
onCopyFilePath: vi.fn(),
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
|
||||
expect(screen.queryByText('Copy git URL')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,34 @@ describe('NoteSearchList', () => {
|
||||
const onItemClick = vi.fn()
|
||||
const onItemHover = vi.fn()
|
||||
|
||||
const renderList = ({
|
||||
listItems = items,
|
||||
selectedIndex = 0,
|
||||
getItemKey = (item: TestItem) => item.id,
|
||||
itemClick = onItemClick,
|
||||
itemHover,
|
||||
activateOnMouseDown,
|
||||
emptyMessage,
|
||||
}: {
|
||||
listItems?: TestItem[]
|
||||
selectedIndex?: number
|
||||
getItemKey?: (item: TestItem, index: number) => string
|
||||
itemClick?: (item: TestItem, index: number) => void
|
||||
itemHover?: (index: number) => void
|
||||
activateOnMouseDown?: boolean
|
||||
emptyMessage?: string
|
||||
} = {}) => render(
|
||||
<NoteSearchList
|
||||
items={listItems}
|
||||
selectedIndex={selectedIndex}
|
||||
getItemKey={getItemKey}
|
||||
onItemClick={itemClick}
|
||||
onItemHover={itemHover}
|
||||
activateOnMouseDown={activateOnMouseDown}
|
||||
emptyMessage={emptyMessage}
|
||||
/>,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -112,39 +140,17 @@ describe('NoteSearchList', () => {
|
||||
})
|
||||
|
||||
it('shows empty message when no items', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={[]}
|
||||
selectedIndex={0}
|
||||
getItemKey={() => ''}
|
||||
onItemClick={onItemClick}
|
||||
emptyMessage="No matching notes"
|
||||
/>,
|
||||
)
|
||||
renderList({ listItems: [], getItemKey: () => '', emptyMessage: 'No matching notes' })
|
||||
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows default empty message', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={[]}
|
||||
selectedIndex={0}
|
||||
getItemKey={() => ''}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
renderList({ listItems: [], getItemKey: () => '' })
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onItemClick when an item is clicked', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
renderList()
|
||||
fireEvent.click(screen.getByText('Beta Notes'))
|
||||
expect(onItemClick).toHaveBeenCalledWith(items[1], 1)
|
||||
})
|
||||
@@ -184,17 +190,15 @@ describe('NoteSearchList', () => {
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onItemHover when mouse enters an item', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
onItemHover={onItemHover}
|
||||
/>,
|
||||
)
|
||||
it('does not call onItemHover for mouse enter alone', () => {
|
||||
renderList({ itemHover: onItemHover })
|
||||
fireEvent.mouseEnter(screen.getByText('Gamma Experiment'))
|
||||
expect(onItemHover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onItemHover when the mouse actually moves over an item', () => {
|
||||
renderList({ itemHover: onItemHover })
|
||||
fireEvent.mouseMove(screen.getByText('Gamma Experiment'))
|
||||
expect(onItemHover).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -36,6 +36,47 @@ interface NoteSearchListItemProps<T extends NoteSearchResultItem> {
|
||||
activateOnMouseDown?: boolean
|
||||
}
|
||||
|
||||
type SearchItemPressEvent = MouseEvent<HTMLButtonElement> | PointerEvent<HTMLButtonElement>
|
||||
|
||||
function useSearchItemActivation<T extends NoteSearchResultItem>({
|
||||
item,
|
||||
index,
|
||||
onItemClick,
|
||||
activateOnMouseDown,
|
||||
}: Pick<NoteSearchListItemProps<T>, 'item' | 'index' | 'onItemClick' | 'activateOnMouseDown'>) {
|
||||
const pressActivatedRef = useRef(false)
|
||||
|
||||
const activateItem = () => onItemClick(item, index)
|
||||
|
||||
const clearPressActivation = () => {
|
||||
pressActivatedRef.current = false
|
||||
}
|
||||
|
||||
const activateFromPress = (event: SearchItemPressEvent) => {
|
||||
event.preventDefault()
|
||||
if (!activateOnMouseDown) return
|
||||
|
||||
event.stopPropagation()
|
||||
if (pressActivatedRef.current) return
|
||||
|
||||
pressActivatedRef.current = true
|
||||
window.setTimeout(clearPressActivation, 0)
|
||||
activateItem()
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!activateOnMouseDown) {
|
||||
activateItem()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
return { activateFromPress, handleClick }
|
||||
}
|
||||
|
||||
function NoteSearchListItem<T extends NoteSearchResultItem>({
|
||||
item,
|
||||
index,
|
||||
@@ -44,31 +85,12 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
|
||||
onItemHover,
|
||||
activateOnMouseDown,
|
||||
}: NoteSearchListItemProps<T>) {
|
||||
const pressActivatedRef = useRef(false)
|
||||
|
||||
const activateFromPress = (event: MouseEvent<HTMLButtonElement> | PointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
if (!activateOnMouseDown) return
|
||||
|
||||
event.stopPropagation()
|
||||
if (pressActivatedRef.current) return
|
||||
|
||||
pressActivatedRef.current = true
|
||||
window.setTimeout(() => {
|
||||
pressActivatedRef.current = false
|
||||
}, 0)
|
||||
onItemClick(item, index)
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (activateOnMouseDown) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
onItemClick(item, index)
|
||||
}
|
||||
const { activateFromPress, handleClick } = useSearchItemActivation({
|
||||
item,
|
||||
index,
|
||||
onItemClick,
|
||||
activateOnMouseDown,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -83,7 +105,7 @@ function NoteSearchListItem<T extends NoteSearchResultItem>({
|
||||
onPointerDownCapture={activateFromPress}
|
||||
onMouseDownCapture={activateFromPress}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => onItemHover?.(index)}
|
||||
onMouseMove={() => onItemHover?.(index)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm text-foreground">
|
||||
{item.TypeIcon && (
|
||||
|
||||
@@ -161,6 +161,17 @@ describe('QuickOpenPalette', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps keyboard selection stable when the mouse is already over a result', () => {
|
||||
render(<QuickOpenPalette open={true} entries={entries} onSelect={onSelect} onClose={onClose} />)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Gamma Experiment'))
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(entries[1])
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not go below the last item with ArrowDown', () => {
|
||||
render(<QuickOpenPalette open={true} entries={entries} onSelect={onSelect} onClose={onClose} />)
|
||||
|
||||
|
||||
@@ -1111,6 +1111,7 @@ function useSuggestionMenuItems(options: {
|
||||
}
|
||||
|
||||
type EditorInteractionControllersProps = ReturnType<typeof useSuggestionMenuItems> & {
|
||||
locale: AppLocale
|
||||
runEditorAction: (action: SuggestionAction) => void
|
||||
vaultPath?: string
|
||||
}
|
||||
@@ -1120,6 +1121,7 @@ function EditorInteractionControllers({
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
getWikilinkItems,
|
||||
locale,
|
||||
runEditorAction,
|
||||
vaultPath,
|
||||
}: EditorInteractionControllersProps) {
|
||||
@@ -1128,7 +1130,7 @@ function EditorInteractionControllers({
|
||||
<SideMenuController sideMenu={TolariaSideMenu} />
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={(props) => (
|
||||
<TolariaFormattingToolbar {...props} vaultPath={vaultPath} />
|
||||
<TolariaFormattingToolbar {...props} locale={locale} vaultPath={vaultPath} />
|
||||
)}
|
||||
floatingUIOptions={{
|
||||
elementProps: {
|
||||
@@ -1372,6 +1374,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
>
|
||||
<EditorInteractionControllers
|
||||
{...suggestionMenuItems}
|
||||
locale={locale}
|
||||
runEditorAction={runEditorAction}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createExtension } from '@blocknote/core'
|
||||
import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
|
||||
import {
|
||||
createRichEditorInputTransformExtension,
|
||||
type RichEditorInputTransform,
|
||||
} from './richEditorInputTransform'
|
||||
|
||||
const PREFIX_CONTEXT_LENGTH = 2
|
||||
|
||||
@@ -17,12 +20,6 @@ interface CodeContextSelection {
|
||||
}
|
||||
}
|
||||
|
||||
interface ArrowLigatureView {
|
||||
composing?: boolean
|
||||
dom?: { isConnected?: boolean }
|
||||
isDestroyed?: boolean
|
||||
}
|
||||
|
||||
interface ArrowLigatureTransactionArgs<Transaction> {
|
||||
event: InputEvent & { data: string }
|
||||
literalAsciiCursor: number | null
|
||||
@@ -68,23 +65,6 @@ function getWritableCursor(selection: CodeContextSelection): number | null {
|
||||
return from === to ? from : null
|
||||
}
|
||||
|
||||
function isLiveEditorView(view: ArrowLigatureView): boolean {
|
||||
if (view.isDestroyed) return false
|
||||
if (view.dom?.isConnected === false) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isComposingInput({
|
||||
event,
|
||||
view,
|
||||
}: {
|
||||
event: InputEvent
|
||||
view: { composing?: boolean }
|
||||
}): boolean {
|
||||
return event.isComposing || Boolean(view.composing)
|
||||
}
|
||||
|
||||
function withoutTransaction<Transaction>(
|
||||
nextLiteralAsciiCursor: number | null,
|
||||
): ArrowLigatureTransactionResult<Transaction> {
|
||||
@@ -130,47 +110,33 @@ function buildArrowLigatureTransaction<Transaction>({
|
||||
}
|
||||
}
|
||||
|
||||
export const createArrowLigaturesExtension = createExtension(({ editor }) => {
|
||||
export function createArrowLigatureInputTransform(): RichEditorInputTransform {
|
||||
let literalAsciiCursor: number | null = null
|
||||
|
||||
const handleBeforeInput = (event: InputEvent) => {
|
||||
if (!isInsertedCharacter(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const view = editor._tiptapEditor?.view ?? editor.prosemirrorView
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
if (!isLiveEditorView(view)) {
|
||||
literalAsciiCursor = null
|
||||
return
|
||||
}
|
||||
if (isComposingInput({ event, view })) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = buildArrowLigatureTransaction({ event, literalAsciiCursor, view })
|
||||
literalAsciiCursor = result.nextLiteralAsciiCursor
|
||||
if (result.transaction === null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
view.dispatch(result.transaction)
|
||||
event.preventDefault()
|
||||
} catch {
|
||||
literalAsciiCursor = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'arrowLigatures',
|
||||
mount: ({ dom, signal }) => {
|
||||
dom.addEventListener('beforeinput', handleBeforeInput as EventListener, {
|
||||
capture: true,
|
||||
signal,
|
||||
})
|
||||
handleBeforeInput(event, { view }) {
|
||||
if (!isInsertedCharacter(event)) return null
|
||||
|
||||
const result = buildArrowLigatureTransaction({ event, literalAsciiCursor, view })
|
||||
literalAsciiCursor = result.nextLiteralAsciiCursor
|
||||
if (result.transaction === null) return null
|
||||
|
||||
return {
|
||||
ignoreDispatchError: true,
|
||||
onDispatchError: () => {
|
||||
literalAsciiCursor = null
|
||||
},
|
||||
preventDefault: true,
|
||||
transaction: result.transaction,
|
||||
}
|
||||
},
|
||||
} as const
|
||||
reset() {
|
||||
literalAsciiCursor = null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const createArrowLigaturesExtension = createRichEditorInputTransformExtension({
|
||||
createTransforms: () => [createArrowLigatureInputTransform()],
|
||||
key: 'arrowLigatures',
|
||||
})
|
||||
|
||||
@@ -41,6 +41,20 @@ describe('blockNoteRenderRecovery', () => {
|
||||
expect(isRecoverableBlockNoteRenderError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes production paragraph index render errors from slash input', () => {
|
||||
const error = new RangeError('Index 1 out of range for <paragraph("/")>')
|
||||
|
||||
expect(blockNoteRenderRecoveryReason(error)).toBe('paragraph_index_out_of_range')
|
||||
expect(isRecoverableBlockNoteRenderError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes production paragraph index render errors that are plain Error instances', () => {
|
||||
const error = new Error('Index 1 out of range for <paragraph("/")>')
|
||||
|
||||
expect(blockNoteRenderRecoveryReason(error)).toBe('paragraph_index_out_of_range')
|
||||
expect(isRecoverableBlockNoteRenderError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes recovered BlockNote block type mismatch render errors', () => {
|
||||
const error = new Error('Block type does not match')
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ const BLOCKNOTE_BLOCK_TYPE_MISMATCH_ERROR = 'Block type does not match'
|
||||
const BLOCKNOTE_RECOVERY_BOUNDARY_NAME = 'BlockNoteRenderRecoveryBoundary'
|
||||
const RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK = '__tolariaRecoveredBlockNoteRenderError'
|
||||
const BLOCKNOTE_TABLE_ROW_INDEX_ERROR = /^Index \d+ out of range for <tableRow\(/
|
||||
const BLOCKNOTE_PARAGRAPH_INDEX_ERROR = /^Index \d+ out of range for <paragraph\(/
|
||||
|
||||
export type BlockNoteRenderRecoveryReason =
|
||||
| 'block_type_mismatch'
|
||||
| 'block_missing_id'
|
||||
| 'paragraph_index_out_of_range'
|
||||
| 'table_row_index_out_of_range'
|
||||
|
||||
type MarkedRecoveredBlockNoteRenderError = Error & {
|
||||
@@ -29,6 +31,9 @@ export function blockNoteRenderRecoveryReason(error: unknown): BlockNoteRenderRe
|
||||
if (BLOCKNOTE_TABLE_ROW_INDEX_ERROR.test(error.message)) {
|
||||
return 'table_row_index_out_of_range'
|
||||
}
|
||||
if (BLOCKNOTE_PARAGRAPH_INDEX_ERROR.test(error.message)) {
|
||||
return 'paragraph_index_out_of_range'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type BreadcrumbActions = Pick<
|
||||
| 'onRevealFile'
|
||||
| 'onCopyFilePath'
|
||||
| 'onCopyDeepLink'
|
||||
| 'onCopyGitUrl'
|
||||
| 'onExportPdf'
|
||||
| 'onDeleteNote'
|
||||
| 'onArchiveNote'
|
||||
@@ -204,6 +205,7 @@ function ActiveTabBreadcrumb({
|
||||
onRevealFile={actions.onRevealFile}
|
||||
onCopyFilePath={actions.onCopyFilePath}
|
||||
onCopyDeepLink={actions.onCopyDeepLink}
|
||||
onCopyGitUrl={actions.onCopyGitUrl}
|
||||
onExportPdf={actions.onExportPdf}
|
||||
onDelete={bindPath(actions.onDeleteNote, path)}
|
||||
onArchive={bindPath(actions.onArchiveNote, path)}
|
||||
@@ -271,6 +273,7 @@ function buildBreadcrumbActions(model: EditorContentModel): BreadcrumbActions {
|
||||
onRevealFile: model.onRevealFile,
|
||||
onCopyFilePath: model.onCopyFilePath,
|
||||
onCopyDeepLink: model.onCopyDeepLink,
|
||||
onCopyGitUrl: model.onCopyGitUrl,
|
||||
onExportPdf: model.onExportPdf,
|
||||
onDeleteNote: model.onDeleteNote,
|
||||
onArchiveNote: model.onArchiveNote,
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface EditorContentProps {
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onCopyDeepLink?: (entry: VaultEntry) => void
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
onExportPdf?: () => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
|
||||
211
src/components/markdownHighlightInputExtension.test.ts
Normal file
211
src/components/markdownHighlightInputExtension.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
|
||||
import {
|
||||
createMarkdownHighlightInputExtension,
|
||||
readMarkdownHighlightInputReplacement,
|
||||
} from './markdownHighlightInputExtension'
|
||||
|
||||
function createTransaction() {
|
||||
const transaction = {
|
||||
addMark: vi.fn(() => transaction),
|
||||
delete: vi.fn(() => transaction),
|
||||
scrollIntoView: vi.fn(() => transaction),
|
||||
}
|
||||
return transaction
|
||||
}
|
||||
|
||||
function createView(beforeText: string, parentStart = 0) {
|
||||
const cursor = parentStart + beforeText.length
|
||||
const transaction = createTransaction()
|
||||
const highlightMark = { type: { name: MARKDOWN_HIGHLIGHT_STYLE } }
|
||||
const highlightMarkType = { create: vi.fn(() => highlightMark) }
|
||||
const docNodes: Array<{
|
||||
node: {
|
||||
isText?: boolean
|
||||
marks?: Array<{ type: { name: string } }>
|
||||
nodeSize?: number
|
||||
}
|
||||
pos: number
|
||||
}> = []
|
||||
const view = {
|
||||
composing: false,
|
||||
dispatch: vi.fn(),
|
||||
state: {
|
||||
doc: {
|
||||
nodesBetween: vi.fn((
|
||||
from: number,
|
||||
to: number,
|
||||
visit: (
|
||||
node: { isText?: boolean; marks?: Array<{ type: { name: string } }>; nodeSize?: number },
|
||||
pos: number,
|
||||
) => boolean | void,
|
||||
) => {
|
||||
for (const item of docNodes) {
|
||||
const nodeEnd = item.pos + (item.node.nodeSize ?? 1)
|
||||
if (nodeEnd < from || item.pos > to) continue
|
||||
if (visit(item.node, item.pos) === false) return
|
||||
}
|
||||
}),
|
||||
},
|
||||
schema: {
|
||||
marks: {
|
||||
[MARKDOWN_HIGHLIGHT_STYLE]: highlightMarkType,
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
from: cursor,
|
||||
to: cursor,
|
||||
$from: {
|
||||
parent: {
|
||||
isTextblock: true,
|
||||
textBetween: vi.fn(() => beforeText),
|
||||
},
|
||||
parentOffset: beforeText.length,
|
||||
marks: vi.fn(() => []),
|
||||
},
|
||||
},
|
||||
storedMarks: null as Array<{ type: { name: string } }> | null,
|
||||
tr: transaction,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
cursor,
|
||||
docNodes,
|
||||
highlightMark,
|
||||
highlightMarkType,
|
||||
transaction,
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture(beforeText = 'Plain ==marked=', parentStart = 0) {
|
||||
let beforeInputListener: EventListener | null = null
|
||||
const { docNodes, highlightMark, highlightMarkType, transaction, view } = createView(beforeText, parentStart)
|
||||
const dom = {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === 'beforeinput') {
|
||||
beforeInputListener = listener
|
||||
}
|
||||
}),
|
||||
}
|
||||
const editor = {
|
||||
_tiptapEditor: { view },
|
||||
prosemirrorView: view,
|
||||
}
|
||||
const extension = createMarkdownHighlightInputExtension()({ editor: editor as never })
|
||||
|
||||
return {
|
||||
docNodes,
|
||||
dom,
|
||||
extension,
|
||||
fireInput(event: Partial<InputEvent> = {}) {
|
||||
if (!beforeInputListener) {
|
||||
throw new Error('Markdown highlight input extension did not register beforeinput')
|
||||
}
|
||||
|
||||
const inputEvent = {
|
||||
data: '=',
|
||||
inputType: 'insertText',
|
||||
isComposing: false,
|
||||
preventDefault: vi.fn(),
|
||||
...event,
|
||||
}
|
||||
|
||||
beforeInputListener(inputEvent as InputEvent)
|
||||
return inputEvent
|
||||
},
|
||||
highlightMark,
|
||||
highlightMarkType,
|
||||
mount() {
|
||||
const controller = new AbortController()
|
||||
extension.mount?.({
|
||||
dom: dom as never,
|
||||
root: document,
|
||||
signal: controller.signal,
|
||||
})
|
||||
return controller
|
||||
},
|
||||
transaction,
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
function expectNoHighlightTransform(fixture: ReturnType<typeof createFixture>, event: Partial<InputEvent> = {}) {
|
||||
const inputEvent = fixture.fireInput(event)
|
||||
|
||||
expect(fixture.transaction.delete).not.toHaveBeenCalled()
|
||||
expect(fixture.transaction.addMark).not.toHaveBeenCalled()
|
||||
expect(fixture.view.dispatch).not.toHaveBeenCalled()
|
||||
expect(inputEvent.preventDefault).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
describe('createMarkdownHighlightInputExtension', () => {
|
||||
it('reads a completed highlight pair before the final equals is inserted', () => {
|
||||
expect(readMarkdownHighlightInputReplacement({
|
||||
beforeText: 'Plain ==marked=',
|
||||
cursor: 15,
|
||||
parentStart: 0,
|
||||
})).toEqual({
|
||||
closingFrom: 14,
|
||||
closingTo: 15,
|
||||
contentFrom: 8,
|
||||
contentTo: 14,
|
||||
openingFrom: 6,
|
||||
openingTo: 8,
|
||||
})
|
||||
})
|
||||
|
||||
it('registers a beforeinput listener when the editor mounts', () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
fixture.mount()
|
||||
|
||||
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
|
||||
'beforeinput',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
capture: true,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('turns typed ==highlight== syntax into the durable highlight mark', () => {
|
||||
const fixture = createFixture('Plain ==marked=', 20)
|
||||
fixture.mount()
|
||||
|
||||
const event = fixture.fireInput()
|
||||
|
||||
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(1, 34, 35)
|
||||
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(2, 26, 28)
|
||||
expect(fixture.highlightMarkType.create).toHaveBeenCalledWith()
|
||||
expect(fixture.transaction.addMark).toHaveBeenCalledWith(26, 32, fixture.highlightMark)
|
||||
expect(fixture.transaction.scrollIntoView).toHaveBeenCalled()
|
||||
expect(fixture.view.dispatch).toHaveBeenCalledWith(fixture.transaction)
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves highlight-looking syntax literal inside inline code', () => {
|
||||
const fixture = createFixture()
|
||||
fixture.view.state.storedMarks = [{ type: { name: 'code' } }]
|
||||
fixture.mount()
|
||||
|
||||
expectNoHighlightTransform(fixture)
|
||||
})
|
||||
|
||||
it('leaves highlight-looking syntax literal when existing content has code marks', () => {
|
||||
const fixture = createFixture()
|
||||
fixture.docNodes.push({
|
||||
node: {
|
||||
isText: true,
|
||||
marks: [{ type: { name: 'code' } }],
|
||||
nodeSize: 6,
|
||||
},
|
||||
pos: 8,
|
||||
})
|
||||
fixture.mount()
|
||||
|
||||
expectNoHighlightTransform(fixture)
|
||||
})
|
||||
})
|
||||
161
src/components/markdownHighlightInputExtension.ts
Normal file
161
src/components/markdownHighlightInputExtension.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
|
||||
import {
|
||||
createRichEditorInputTransformExtension,
|
||||
type RichEditorInputTransform,
|
||||
} from './richEditorInputTransform'
|
||||
|
||||
const MARKDOWN_HIGHLIGHT_DELIMITER = '=='
|
||||
const MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH = MARKDOWN_HIGHLIGHT_DELIMITER.length
|
||||
const FINAL_MARKDOWN_HIGHLIGHT_INPUT = '='
|
||||
const CODE_MARK_TYPE = 'code'
|
||||
|
||||
type EditorViewLike = NonNullable<ReturnType<typeof useCreateBlockNote>['prosemirrorView']>
|
||||
type MarkLike = { type: { name: string } }
|
||||
type EditorMark = Parameters<EditorViewLike['state']['tr']['addMark']>[2]
|
||||
type MarkTypeLike = { create: () => EditorMark }
|
||||
|
||||
interface MarkdownHighlightCursorText {
|
||||
beforeText: string
|
||||
cursor: number
|
||||
parentStart: number
|
||||
}
|
||||
|
||||
export interface MarkdownHighlightInputReplacement {
|
||||
closingFrom: number
|
||||
closingTo: number
|
||||
contentFrom: number
|
||||
contentTo: number
|
||||
openingFrom: number
|
||||
openingTo: number
|
||||
}
|
||||
|
||||
function isInsertedFinalEquals(event: InputEvent): event is InputEvent & { data: string } {
|
||||
return event.inputType === 'insertText'
|
||||
&& event.data === FINAL_MARKDOWN_HIGHLIGHT_INPUT
|
||||
}
|
||||
|
||||
function hasCodeMark(marks: readonly MarkLike[] | null | undefined): boolean {
|
||||
return Boolean(marks?.some((mark) => mark.type.name === CODE_MARK_TYPE))
|
||||
}
|
||||
|
||||
function selectionHasCodeMark(view: EditorViewLike): boolean {
|
||||
const marks = view.state.storedMarks ?? view.state.selection.$from.marks()
|
||||
return hasCodeMark(marks)
|
||||
}
|
||||
|
||||
function rangeHasCodeMark(
|
||||
view: EditorViewLike,
|
||||
from: number,
|
||||
to: number,
|
||||
): boolean {
|
||||
let containsCode = false
|
||||
|
||||
view.state.doc.nodesBetween(from, to, (node: {
|
||||
isText?: boolean
|
||||
marks?: readonly MarkLike[]
|
||||
}) => {
|
||||
if (!node.isText) return true
|
||||
|
||||
containsCode = hasCodeMark(node.marks)
|
||||
return containsCode ? false : true
|
||||
})
|
||||
|
||||
return containsCode
|
||||
}
|
||||
|
||||
function readCursorText(view: EditorViewLike): MarkdownHighlightCursorText | null {
|
||||
const { from, to, $from } = view.state.selection
|
||||
if (from !== to) return null
|
||||
if (!$from.parent.isTextblock) return null
|
||||
|
||||
return {
|
||||
beforeText: $from.parent.textBetween(0, $from.parentOffset, '', ''),
|
||||
cursor: from,
|
||||
parentStart: from - $from.parentOffset,
|
||||
}
|
||||
}
|
||||
|
||||
function hasValidHighlightContent(content: string): boolean {
|
||||
if (content.trim().length === 0) return false
|
||||
if (/^\s|\s$/.test(content)) return false
|
||||
return !/[\r\n]/.test(content)
|
||||
}
|
||||
|
||||
export function readMarkdownHighlightInputReplacement({
|
||||
beforeText,
|
||||
cursor,
|
||||
parentStart,
|
||||
}: MarkdownHighlightCursorText): MarkdownHighlightInputReplacement | null {
|
||||
const candidateText = `${beforeText}${FINAL_MARKDOWN_HIGHLIGHT_INPUT}`
|
||||
if (!candidateText.endsWith(MARKDOWN_HIGHLIGHT_DELIMITER)) return null
|
||||
|
||||
const closingStart = candidateText.length - MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH
|
||||
const openingStart = candidateText.lastIndexOf(
|
||||
MARKDOWN_HIGHLIGHT_DELIMITER,
|
||||
closingStart - 1,
|
||||
)
|
||||
if (openingStart === -1) return null
|
||||
|
||||
const contentStart = openingStart + MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH
|
||||
const content = candidateText.slice(contentStart, closingStart)
|
||||
if (!hasValidHighlightContent(content)) return null
|
||||
|
||||
const closingFrom = parentStart + closingStart
|
||||
if (cursor !== closingFrom + 1) return null
|
||||
|
||||
return {
|
||||
closingFrom,
|
||||
closingTo: cursor,
|
||||
contentFrom: parentStart + contentStart,
|
||||
contentTo: parentStart + closingStart,
|
||||
openingFrom: parentStart + openingStart,
|
||||
openingTo: parentStart + contentStart,
|
||||
}
|
||||
}
|
||||
|
||||
function readHighlightMarkType(view: EditorViewLike): MarkTypeLike | null {
|
||||
const markType = Reflect.get(view.state.schema.marks, MARKDOWN_HIGHLIGHT_STYLE) as MarkTypeLike | undefined
|
||||
return markType ?? null
|
||||
}
|
||||
|
||||
function replaceCompletedMarkdownHighlight(
|
||||
view: EditorViewLike,
|
||||
): EditorViewLike['state']['tr'] | null {
|
||||
if (selectionHasCodeMark(view)) return null
|
||||
|
||||
const cursorText = readCursorText(view)
|
||||
if (!cursorText) return null
|
||||
|
||||
const replacement = readMarkdownHighlightInputReplacement(cursorText)
|
||||
const highlightMarkType = readHighlightMarkType(view)
|
||||
if (!replacement || !highlightMarkType) return null
|
||||
if (rangeHasCodeMark(view, replacement.contentFrom, replacement.contentTo)) return null
|
||||
|
||||
const highlightedFrom = replacement.contentFrom - MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH
|
||||
const highlightedTo = replacement.contentTo - MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH
|
||||
|
||||
return view.state.tr
|
||||
.delete(replacement.closingFrom, replacement.closingTo)
|
||||
.delete(replacement.openingFrom, replacement.openingTo)
|
||||
.addMark(highlightedFrom, highlightedTo, highlightMarkType.create())
|
||||
.scrollIntoView()
|
||||
}
|
||||
|
||||
export function createMarkdownHighlightInputTransform(): RichEditorInputTransform {
|
||||
return {
|
||||
handleBeforeInput(event, { view }) {
|
||||
if (!isInsertedFinalEquals(event)) return null
|
||||
|
||||
const transaction = replaceCompletedMarkdownHighlight(view)
|
||||
if (!transaction) return null
|
||||
|
||||
return { preventDefault: true, transaction }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const createMarkdownHighlightInputExtension = createRichEditorInputTransformExtension({
|
||||
createTransforms: () => [createMarkdownHighlightInputTransform()],
|
||||
key: 'markdownHighlightInput',
|
||||
})
|
||||
@@ -3,9 +3,10 @@ import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, readCompletedInlineMathAtEnd } from '../utils/mathMarkdown'
|
||||
import {
|
||||
isRecoverableEditorTransformError,
|
||||
reportRecoveredEditorTransformError,
|
||||
} from './richEditorTransformErrorRecoveryExtension'
|
||||
dispatchRichEditorInputTransaction,
|
||||
mountRichEditorInputTransforms,
|
||||
type RichEditorInputTransform,
|
||||
} from './richEditorInputTransform'
|
||||
|
||||
const INLINE_WHITESPACE_RE = /^[^\S\r\n]$/
|
||||
const NEWLINE_INPUT_TYPES = new Set(['insertParagraph', 'insertLineBreak'])
|
||||
@@ -59,12 +60,6 @@ function shouldHandleInput(event: InputEvent): boolean {
|
||||
return isInsertedInlineWhitespace(event) || NEWLINE_INPUT_TYPES.has(event.inputType)
|
||||
}
|
||||
|
||||
function shouldSkipInput(event: InputEvent, view: EditorViewLike): boolean {
|
||||
if (event.isComposing) return true
|
||||
if (view.composing) return true
|
||||
return !shouldHandleInput(event)
|
||||
}
|
||||
|
||||
function selectionHasCodeMark(view: EditorViewLike): boolean {
|
||||
const marks = view.state.storedMarks ?? view.state.selection.$from.marks()
|
||||
return marks.some((mark: { type: { name: string } }) => mark.type.name === 'code')
|
||||
@@ -115,25 +110,6 @@ function replaceCompletedInlineMath(
|
||||
return transaction.scrollIntoView()
|
||||
}
|
||||
|
||||
function recoverTransformError(error: unknown): boolean {
|
||||
if (!isRecoverableEditorTransformError(error)) return false
|
||||
|
||||
reportRecoveredEditorTransformError('transform_error', error)
|
||||
return true
|
||||
}
|
||||
|
||||
function readMathInputTransaction(
|
||||
view: EditorViewLike,
|
||||
trailingText?: string,
|
||||
): EditorViewLike['state']['tr'] | null {
|
||||
try {
|
||||
return replaceCompletedInlineMath(view, trailingText)
|
||||
} catch (error) {
|
||||
if (!recoverTransformError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mathSource({ latex }: { latex: string }): string {
|
||||
return `$${latex}$`
|
||||
}
|
||||
@@ -287,7 +263,7 @@ function restoreMathSource({
|
||||
.replaceWith(location.from, location.to, replacement)
|
||||
.scrollIntoView()
|
||||
|
||||
if (!dispatchMathInputTransaction(view, transaction)) return false
|
||||
if (!dispatchRichEditorInputTransaction(view, { transaction })) return false
|
||||
|
||||
editor._tiptapEditor?.commands?.setTextSelection?.(
|
||||
mathLatexSelectionRange(location),
|
||||
@@ -299,20 +275,6 @@ function restoreMathSource({
|
||||
return true
|
||||
}
|
||||
|
||||
function handleBeforeInputEvent(event: InputEvent, readView: ReadEditorView) {
|
||||
const view = readView()
|
||||
if (!view || shouldSkipInput(event, view)) return
|
||||
|
||||
const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined
|
||||
const transaction = readMathInputTransaction(view, trailingText)
|
||||
if (!transaction) return
|
||||
|
||||
if (!dispatchMathInputTransaction(view, transaction)) return
|
||||
if (trailingText !== undefined) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenderedMathDoubleClick(
|
||||
event: MouseEvent,
|
||||
{ editor, readView }: MathExtensionContext,
|
||||
@@ -346,16 +308,20 @@ function handleMathKeyDown(
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function dispatchMathInputTransaction(
|
||||
view: EditorViewLike,
|
||||
transaction: EditorViewLike['state']['tr'],
|
||||
): boolean {
|
||||
try {
|
||||
view.dispatch(transaction)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!recoverTransformError(error)) throw error
|
||||
return false
|
||||
export function createMathInputTransform(): RichEditorInputTransform {
|
||||
return {
|
||||
handleBeforeInput(event, { view }) {
|
||||
if (!shouldHandleInput(event)) return null
|
||||
|
||||
const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined
|
||||
const transaction = replaceCompletedInlineMath(view, trailingText)
|
||||
if (!transaction) return null
|
||||
|
||||
return {
|
||||
preventDefault: trailingText !== undefined,
|
||||
transaction,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,11 +333,11 @@ export const createMathInputExtension = createExtension(({ editor }) => {
|
||||
mount: ({ dom, signal }) => {
|
||||
const context = { editor, readView }
|
||||
|
||||
dom.addEventListener('beforeinput', ((event: InputEvent) => {
|
||||
handleBeforeInputEvent(event, readView)
|
||||
}) as EventListener, {
|
||||
capture: true,
|
||||
mountRichEditorInputTransforms({
|
||||
dom,
|
||||
readView,
|
||||
signal,
|
||||
transforms: [createMathInputTransform()],
|
||||
})
|
||||
dom.addEventListener('dblclick', (event) => {
|
||||
handleRenderedMathDoubleClick(event, context)
|
||||
|
||||
@@ -28,6 +28,8 @@ interface NoteListContextMenuParams {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
canCopyGitUrl?: (entry: VaultEntry) => boolean
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
}
|
||||
|
||||
function hasNoteListContextActions({
|
||||
@@ -41,6 +43,8 @@ function hasNoteListContextActions({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
}: NoteListContextMenuParams & { entry: VaultEntry }) {
|
||||
return [
|
||||
onOpenInNewWindow,
|
||||
@@ -52,6 +56,7 @@ function hasNoteListContextActions({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onCopyGitUrl && canCopyGitUrl?.(entry),
|
||||
].some(Boolean)
|
||||
}
|
||||
|
||||
@@ -66,6 +71,8 @@ export function useNoteListContextMenu({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
}: NoteListContextMenuParams) {
|
||||
const [ctxMenu, setCtxMenu] = useState<NoteListContextMenuState | null>(null)
|
||||
const ctxMenuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -101,6 +108,8 @@ export function useNoteListContextMenu({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
})) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -109,10 +118,12 @@ export function useNoteListContextMenu({
|
||||
}, [
|
||||
onArchivePaths,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onDeletePaths,
|
||||
onEnterNeighborhood,
|
||||
onExportPdf,
|
||||
onOpenInNewWindow,
|
||||
onCopyGitUrl,
|
||||
onRevealFile,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
@@ -132,6 +143,8 @@ export function useNoteListContextMenu({
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
canCopyGitUrl={canCopyGitUrl}
|
||||
onCopyGitUrl={onCopyGitUrl}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ClipboardText,
|
||||
FilePdf,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
MapTrifold,
|
||||
Star,
|
||||
Trash,
|
||||
@@ -43,6 +44,8 @@ interface NoteListContextMenuNodeProps {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
canCopyGitUrl?: (entry: VaultEntry) => boolean
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -58,6 +61,8 @@ type BuildContextMenuItemsParams = Pick<
|
||||
| 'onToggleOrganized'
|
||||
| 'onRevealFile'
|
||||
| 'onCopyFilePath'
|
||||
| 'canCopyGitUrl'
|
||||
| 'onCopyGitUrl'
|
||||
>
|
||||
|
||||
function openWindowItem(
|
||||
@@ -149,6 +154,21 @@ function copyFilePathItem(
|
||||
}]
|
||||
}
|
||||
|
||||
function copyGitUrlItem(
|
||||
entry: VaultEntry,
|
||||
locale: AppLocale,
|
||||
canCopyGitUrl: ((entry: VaultEntry) => boolean) | undefined,
|
||||
onCopyGitUrl: ((entry: VaultEntry) => void) | undefined,
|
||||
selectAction: SelectContextAction,
|
||||
) {
|
||||
if (!onCopyGitUrl || !canCopyGitUrl?.(entry)) return []
|
||||
return [{
|
||||
icon: GitBranch,
|
||||
label: translate(locale, 'editor.toolbar.copyNoteGitUrl'),
|
||||
onSelect: () => selectAction('copy_git_url', () => onCopyGitUrl(entry)),
|
||||
}]
|
||||
}
|
||||
|
||||
function exportPdfItem(
|
||||
entry: VaultEntry,
|
||||
locale: AppLocale,
|
||||
@@ -206,6 +226,7 @@ function buildContextMenuItems(
|
||||
...neighborhoodItem(entry, props.locale, props.onEnterNeighborhood, selectAction),
|
||||
...revealFileItem(entry, props.locale, props.onRevealFile, selectAction),
|
||||
...copyFilePathItem(entry, props.locale, props.onCopyFilePath, selectAction),
|
||||
...copyGitUrlItem(entry, props.locale, props.canCopyGitUrl, props.onCopyGitUrl, selectAction),
|
||||
...exportPdfItem(entry, props.locale, props.onExportPdf, selectAction),
|
||||
...archiveItem(entry, props.locale, props.onArchivePaths, selectAction),
|
||||
...deleteItem(entry, props.locale, props.onDeletePaths, selectAction),
|
||||
@@ -241,6 +262,8 @@ export function NoteListContextMenuNode({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
onClose,
|
||||
}: NoteListContextMenuNodeProps) {
|
||||
if (!ctxMenu) return null
|
||||
@@ -262,6 +285,8 @@ export function NoteListContextMenuNode({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
}, entry, selectAction)
|
||||
|
||||
return (
|
||||
|
||||
@@ -345,6 +345,8 @@ interface UseNoteListInteractionStateParams {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
canCopyGitUrl?: (entry: VaultEntry) => boolean
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
onAutoTriggerDiff?: () => void
|
||||
onDiscardFile?: (relativePath: string) => Promise<void>
|
||||
onCreateNote: (type?: string, options?: ImmediateCreateOptions) => void
|
||||
@@ -374,6 +376,8 @@ function useNoteListInteractionState({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
onAutoTriggerDiff,
|
||||
onDiscardFile,
|
||||
onCreateNote,
|
||||
@@ -393,6 +397,8 @@ function useNoteListInteractionState({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
})
|
||||
const {
|
||||
collapsedGroups,
|
||||
@@ -555,6 +561,8 @@ export interface NoteListProps {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
canCopyGitUrl?: (entry: VaultEntry) => boolean
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
onDiscardFile?: (relativePath: string) => Promise<void>
|
||||
onAutoTriggerDiff?: () => void
|
||||
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
|
||||
@@ -684,6 +692,8 @@ export function useNoteListModel({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
onDiscardFile,
|
||||
onAutoTriggerDiff,
|
||||
onOpenDeletedNote,
|
||||
@@ -743,6 +753,8 @@ export function useNoteListModel({
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
onAutoTriggerDiff,
|
||||
onDiscardFile,
|
||||
onCreateNote,
|
||||
|
||||
184
src/components/richEditorInputTransform.ts
Normal file
184
src/components/richEditorInputTransform.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { createExtension } from '@blocknote/core'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import {
|
||||
isRecoverableEditorTransformError,
|
||||
reportRecoveredEditorTransformError,
|
||||
} from './richEditorTransformErrorRecoveryExtension'
|
||||
|
||||
export type RichEditorInputView = NonNullable<ReturnType<typeof useCreateBlockNote>['prosemirrorView']>
|
||||
export type RichEditorInputTransaction = Parameters<RichEditorInputView['dispatch']>[0]
|
||||
|
||||
export interface RichEditorInputTransformContext {
|
||||
view: RichEditorInputView
|
||||
}
|
||||
|
||||
export interface RichEditorInputTransformResult {
|
||||
ignoreDispatchError?: boolean
|
||||
onDispatchError?: (error: unknown) => void
|
||||
preventDefault?: boolean
|
||||
transaction: RichEditorInputTransaction
|
||||
}
|
||||
|
||||
export interface RichEditorInputTransform {
|
||||
handleBeforeInput: (
|
||||
event: InputEvent,
|
||||
context: RichEditorInputTransformContext
|
||||
) => RichEditorInputTransformResult | null
|
||||
reset?: () => void
|
||||
}
|
||||
|
||||
interface RichEditorInputTransformExtensionOptions {
|
||||
createTransforms: () => RichEditorInputTransform[]
|
||||
key: string
|
||||
}
|
||||
|
||||
interface MountRichEditorInputTransformsOptions {
|
||||
dom: HTMLElement
|
||||
readView: () => RichEditorInputView | undefined
|
||||
signal: AbortSignal
|
||||
transforms: RichEditorInputTransform[]
|
||||
}
|
||||
|
||||
const RECOVERED_INPUT_TRANSFORM_ERROR = Symbol('recoveredInputTransformError')
|
||||
type TransformReadResult = RichEditorInputTransformResult | null | typeof RECOVERED_INPUT_TRANSFORM_ERROR
|
||||
|
||||
function resetInputTransforms(transforms: RichEditorInputTransform[]): void {
|
||||
transforms.forEach((transform) => transform.reset?.())
|
||||
}
|
||||
|
||||
function isLiveEditorView(view: RichEditorInputView): boolean {
|
||||
if (view.isDestroyed) return false
|
||||
if (view.dom?.isConnected === false) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isComposingInput(event: InputEvent, view: RichEditorInputView): boolean {
|
||||
return event.isComposing || Boolean(view.composing)
|
||||
}
|
||||
|
||||
export function recoverRichEditorInputTransformError(error: unknown): boolean {
|
||||
if (!isRecoverableEditorTransformError(error)) return false
|
||||
|
||||
reportRecoveredEditorTransformError('transform_error', error)
|
||||
return true
|
||||
}
|
||||
|
||||
function readTransformResult(
|
||||
transform: RichEditorInputTransform,
|
||||
event: InputEvent,
|
||||
context: RichEditorInputTransformContext,
|
||||
): TransformReadResult {
|
||||
try {
|
||||
return transform.handleBeforeInput(event, context)
|
||||
} catch (error) {
|
||||
if (!recoverRichEditorInputTransformError(error)) throw error
|
||||
return RECOVERED_INPUT_TRANSFORM_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
function readReadyInputView({
|
||||
readView,
|
||||
transforms,
|
||||
}: Omit<MountRichEditorInputTransformsOptions, 'dom' | 'signal'>): RichEditorInputView | null {
|
||||
const view = readView()
|
||||
if (!view) return null
|
||||
if (isLiveEditorView(view)) return view
|
||||
|
||||
resetInputTransforms(transforms)
|
||||
return null
|
||||
}
|
||||
|
||||
export function dispatchRichEditorInputTransaction(
|
||||
view: RichEditorInputView,
|
||||
result: RichEditorInputTransformResult,
|
||||
): boolean {
|
||||
try {
|
||||
view.dispatch(result.transaction)
|
||||
return true
|
||||
} catch (error) {
|
||||
result.onDispatchError?.(error)
|
||||
if (recoverRichEditorInputTransformError(error)) return false
|
||||
if (result.ignoreDispatchError) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function completeInputTransform(
|
||||
event: InputEvent,
|
||||
view: RichEditorInputView,
|
||||
result: TransformReadResult,
|
||||
): boolean {
|
||||
if (result === RECOVERED_INPUT_TRANSFORM_ERROR) return true
|
||||
if (!result) return false
|
||||
if (!dispatchRichEditorInputTransaction(view, result)) return true
|
||||
if (result.preventDefault) event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function runInputTransform(
|
||||
transform: RichEditorInputTransform,
|
||||
event: InputEvent,
|
||||
view: RichEditorInputView,
|
||||
): boolean {
|
||||
return completeInputTransform(
|
||||
event,
|
||||
view,
|
||||
readTransformResult(transform, event, { view }),
|
||||
)
|
||||
}
|
||||
|
||||
function runInputTransforms(
|
||||
event: InputEvent,
|
||||
view: RichEditorInputView,
|
||||
transforms: RichEditorInputTransform[],
|
||||
): void {
|
||||
transforms.some((transform) => runInputTransform(transform, event, view))
|
||||
}
|
||||
|
||||
function handleRichEditorBeforeInput(
|
||||
event: InputEvent,
|
||||
{ readView, transforms }: Omit<MountRichEditorInputTransformsOptions, 'dom' | 'signal'>,
|
||||
): void {
|
||||
const view = readReadyInputView({ readView, transforms })
|
||||
if (!view) return
|
||||
if (isComposingInput(event, view)) return
|
||||
|
||||
runInputTransforms(event, view, transforms)
|
||||
}
|
||||
|
||||
export function mountRichEditorInputTransforms({
|
||||
dom,
|
||||
readView,
|
||||
signal,
|
||||
transforms,
|
||||
}: MountRichEditorInputTransformsOptions): void {
|
||||
dom.addEventListener('beforeinput', ((event: InputEvent) => {
|
||||
handleRichEditorBeforeInput(event, { readView, transforms })
|
||||
}) as EventListener, {
|
||||
capture: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function createRichEditorInputTransformExtension({
|
||||
createTransforms,
|
||||
key,
|
||||
}: RichEditorInputTransformExtensionOptions) {
|
||||
return createExtension(({ editor }) => {
|
||||
const readView = () => editor._tiptapEditor?.view ?? editor.prosemirrorView
|
||||
const transforms = createTransforms()
|
||||
|
||||
return {
|
||||
key,
|
||||
mount: ({ dom, signal }) => {
|
||||
mountRichEditorInputTransforms({
|
||||
dom,
|
||||
readView,
|
||||
signal,
|
||||
transforms,
|
||||
})
|
||||
},
|
||||
} as const
|
||||
})
|
||||
}
|
||||
167
src/components/richEditorInputTransformExtension.test.ts
Normal file
167
src/components/richEditorInputTransformExtension.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
|
||||
import { createRichEditorMarkdownInputTransformExtension } from './richEditorInputTransformExtension'
|
||||
|
||||
function createTransaction() {
|
||||
const transaction = {
|
||||
addMark: vi.fn(() => transaction),
|
||||
delete: vi.fn(() => transaction),
|
||||
insertText: vi.fn(() => transaction),
|
||||
replaceWith: vi.fn(() => transaction),
|
||||
scrollIntoView: vi.fn(() => transaction),
|
||||
}
|
||||
return transaction
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
let beforeInputListener: EventListener | null = null
|
||||
let beforeText = ''
|
||||
let parentStart = 0
|
||||
let arrowPrefix = ''
|
||||
const transaction = createTransaction()
|
||||
const mathNode = { nodeSize: 1, type: 'mathInline' }
|
||||
const highlightMark = { type: { name: MARKDOWN_HIGHLIGHT_STYLE } }
|
||||
const highlightMarkType = { create: vi.fn(() => highlightMark) }
|
||||
const view = {
|
||||
composing: false,
|
||||
dispatch: vi.fn(),
|
||||
dom: { isConnected: true },
|
||||
state: {
|
||||
doc: {
|
||||
nodesBetween: vi.fn(),
|
||||
textBetween: vi.fn(() => arrowPrefix),
|
||||
},
|
||||
schema: {
|
||||
marks: {
|
||||
[MARKDOWN_HIGHLIGHT_STYLE]: highlightMarkType,
|
||||
},
|
||||
nodes: {
|
||||
mathInline: {
|
||||
createChecked: vi.fn(() => mathNode),
|
||||
},
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
$from: {
|
||||
depth: 0,
|
||||
marks: vi.fn(() => []),
|
||||
node: vi.fn(() => ({ type: { name: 'paragraph', spec: {} } })),
|
||||
parent: {
|
||||
isTextblock: true,
|
||||
textBetween: vi.fn(() => beforeText),
|
||||
},
|
||||
parentOffset: 0,
|
||||
},
|
||||
},
|
||||
storedMarks: null,
|
||||
tr: transaction,
|
||||
},
|
||||
}
|
||||
const dom = {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === 'beforeinput') beforeInputListener = listener
|
||||
}),
|
||||
}
|
||||
const editor = {
|
||||
_tiptapEditor: { view },
|
||||
prosemirrorView: view,
|
||||
}
|
||||
const extension = createRichEditorMarkdownInputTransformExtension()({ editor: editor as never })
|
||||
|
||||
function setCursorText(nextBeforeText: string, nextParentStart = 0) {
|
||||
beforeText = nextBeforeText
|
||||
parentStart = nextParentStart
|
||||
view.state.selection.from = parentStart + beforeText.length
|
||||
view.state.selection.to = parentStart + beforeText.length
|
||||
view.state.selection.$from.parentOffset = beforeText.length
|
||||
}
|
||||
|
||||
return {
|
||||
dom,
|
||||
fireBeforeInput(event: Partial<InputEvent>) {
|
||||
if (!beforeInputListener) throw new Error('Combined input transform did not mount beforeinput')
|
||||
const inputEvent = {
|
||||
data: null,
|
||||
inputType: 'insertText',
|
||||
isComposing: false,
|
||||
preventDefault: vi.fn(),
|
||||
...event,
|
||||
}
|
||||
|
||||
beforeInputListener(inputEvent as InputEvent)
|
||||
return inputEvent
|
||||
},
|
||||
highlightMark,
|
||||
highlightMarkType,
|
||||
mathNode,
|
||||
mount() {
|
||||
const controller = new AbortController()
|
||||
extension.mount?.({
|
||||
dom: dom as never,
|
||||
root: document,
|
||||
signal: controller.signal,
|
||||
})
|
||||
return controller
|
||||
},
|
||||
setArrowPrefix(nextArrowPrefix: string) {
|
||||
arrowPrefix = nextArrowPrefix
|
||||
},
|
||||
setCursorText,
|
||||
transaction,
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createRichEditorMarkdownInputTransformExtension', () => {
|
||||
it('mounts one beforeinput listener for all markdown input transforms', () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
fixture.mount()
|
||||
|
||||
expect(fixture.dom.addEventListener).toHaveBeenCalledTimes(1)
|
||||
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
|
||||
'beforeinput',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
capture: true,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('routes arrow, inline math, and highlight syntax through the shared listener', () => {
|
||||
const fixture = createFixture()
|
||||
fixture.mount()
|
||||
|
||||
fixture.setArrowPrefix('-')
|
||||
fixture.setCursorText('-', 0)
|
||||
const arrowEvent = fixture.fireBeforeInput({ data: '>' })
|
||||
|
||||
expect(fixture.transaction.insertText).toHaveBeenCalledWith('→', 0, 1)
|
||||
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
|
||||
expect(arrowEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.clearAllMocks()
|
||||
fixture.setCursorText('Inline $x^2$', 0)
|
||||
const mathEvent = fixture.fireBeforeInput({ data: ' ' })
|
||||
|
||||
expect(fixture.view.state.schema.nodes.mathInline.createChecked).toHaveBeenCalledWith({ latex: 'x^2' })
|
||||
expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(7, 12, fixture.mathNode)
|
||||
expect(fixture.transaction.insertText).toHaveBeenCalledWith(' ', 8)
|
||||
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
|
||||
expect(mathEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.clearAllMocks()
|
||||
fixture.setCursorText('Plain ==marked=', 20)
|
||||
const highlightEvent = fixture.fireBeforeInput({ data: '=' })
|
||||
|
||||
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(1, 34, 35)
|
||||
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(2, 26, 28)
|
||||
expect(fixture.highlightMarkType.create).toHaveBeenCalledWith()
|
||||
expect(fixture.transaction.addMark).toHaveBeenCalledWith(26, 32, fixture.highlightMark)
|
||||
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
|
||||
expect(highlightEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
13
src/components/richEditorInputTransformExtension.ts
Normal file
13
src/components/richEditorInputTransformExtension.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createArrowLigatureInputTransform } from './arrowLigaturesExtension'
|
||||
import { createMarkdownHighlightInputTransform } from './markdownHighlightInputExtension'
|
||||
import { createMathInputTransform } from './mathInputExtension'
|
||||
import { createRichEditorInputTransformExtension } from './richEditorInputTransform'
|
||||
|
||||
export const createRichEditorMarkdownInputTransformExtension = createRichEditorInputTransformExtension({
|
||||
createTransforms: () => [
|
||||
createArrowLigatureInputTransform(),
|
||||
createMarkdownHighlightInputTransform(),
|
||||
createMathInputTransform(),
|
||||
],
|
||||
key: 'richEditorMarkdownInputTransform',
|
||||
})
|
||||
@@ -95,6 +95,12 @@ describe('isRecoverableEditorTransformError', () => {
|
||||
expect(isRecoverableEditorTransformError(new Error(
|
||||
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
|
||||
))).toBe(true)
|
||||
expect(isRecoverableEditorTransformError(new RangeError(
|
||||
'Index 1 out of range for <paragraph("/")>',
|
||||
))).toBe(true)
|
||||
expect(isRecoverableEditorTransformError(new Error(
|
||||
'Index 1 out of range for <paragraph("/")>',
|
||||
))).toBe(true)
|
||||
expect(isRecoverableEditorTransformError(new Error(
|
||||
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
|
||||
))).toBe(true)
|
||||
@@ -106,9 +112,6 @@ describe('isRecoverableEditorTransformError', () => {
|
||||
expect(isRecoverableEditorTransformError(new TypeError(
|
||||
"Cannot read properties of null (reading 'append')",
|
||||
))).toBe(false)
|
||||
expect(isRecoverableEditorTransformError(new RangeError(
|
||||
'Index 1 out of range for <paragraph("A")>',
|
||||
))).toBe(false)
|
||||
expect(isRecoverableEditorTransformError(new Error('unrelated'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -218,6 +221,13 @@ describe('installRichEditorTransformErrorRecovery', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('recovers production paragraph index transactions from stale slash input', () => {
|
||||
expectDocumentRepairRecovery(
|
||||
new RangeError('Index 1 out of range for <paragraph("/")>'),
|
||||
'paragraph_position_out_of_range',
|
||||
)
|
||||
})
|
||||
|
||||
it('recovers invalid insertion-depth transactions after note switching and saves', () => {
|
||||
expectDocumentRepairRecovery(
|
||||
new RangeError('Inserted content deeper than insertion position'),
|
||||
|
||||
@@ -49,11 +49,17 @@ type RecoveryReason =
|
||||
| 'invalid_insertion_depth'
|
||||
| 'mismatched_transaction'
|
||||
| 'null_fragment_append'
|
||||
| 'paragraph_position_out_of_range'
|
||||
| 'stale_block_reference'
|
||||
| 'stale_transaction'
|
||||
| 'table_position_out_of_range'
|
||||
| 'transform_error'
|
||||
|
||||
interface RecoveryReasonMatcher {
|
||||
matches: (error: unknown) => boolean
|
||||
reason: RecoveryReason
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
@@ -92,6 +98,10 @@ function isTablePositionOutOfRangeError(error: unknown): boolean {
|
||||
return error instanceof Error && /^Index \d+ out of range for <tableRow\(/.test(error.message)
|
||||
}
|
||||
|
||||
function isParagraphPositionOutOfRangeError(error: unknown): boolean {
|
||||
return error instanceof Error && /^Index \d+ out of range for <paragraph\(/.test(error.message)
|
||||
}
|
||||
|
||||
function isInvalidBlockJoinError(error: unknown): boolean {
|
||||
return isTransformError(error) && /^Cannot join (blockGroup|tableCell) onto blockContainer$/.test(error.message)
|
||||
}
|
||||
@@ -114,6 +124,7 @@ function isTransformError(error: unknown): error is Error {
|
||||
function isRecoverableRangeError(error: unknown): boolean {
|
||||
return isInvalidContentTransactionError(error)
|
||||
|| isInvalidInsertionDepthError(error)
|
||||
|| isParagraphPositionOutOfRangeError(error)
|
||||
|| isTablePositionOutOfRangeError(error)
|
||||
}
|
||||
|
||||
@@ -125,6 +136,16 @@ const RECOVERABLE_EDITOR_ERROR_PREDICATES = [
|
||||
isStaleBlockReferenceError,
|
||||
]
|
||||
|
||||
const RECOVERY_REASON_MATCHERS: RecoveryReasonMatcher[] = [
|
||||
{ matches: isMismatchedTransactionError, reason: 'mismatched_transaction' },
|
||||
{ matches: isStaleBlockReferenceError, reason: 'stale_block_reference' },
|
||||
{ matches: isInvalidBlockJoinError, reason: 'invalid_block_join' },
|
||||
{ matches: isInvalidInsertionDepthError, reason: 'invalid_insertion_depth' },
|
||||
{ matches: isNullFragmentAppendError, reason: 'null_fragment_append' },
|
||||
{ matches: isParagraphPositionOutOfRangeError, reason: 'paragraph_position_out_of_range' },
|
||||
{ matches: isTablePositionOutOfRangeError, reason: 'table_position_out_of_range' },
|
||||
]
|
||||
|
||||
export function isRecoverableEditorTransformError(error: unknown): boolean {
|
||||
return RECOVERABLE_EDITOR_ERROR_PREDICATES.some((predicate) => predicate(error))
|
||||
}
|
||||
@@ -135,13 +156,7 @@ function recoveryReason(
|
||||
view: RichEditorDispatchView,
|
||||
): RecoveryReason {
|
||||
if (transactionDocIsStale(transaction, view)) return 'stale_transaction'
|
||||
if (isMismatchedTransactionError(error)) return 'mismatched_transaction'
|
||||
if (isStaleBlockReferenceError(error)) return 'stale_block_reference'
|
||||
if (isInvalidBlockJoinError(error)) return 'invalid_block_join'
|
||||
if (isInvalidInsertionDepthError(error)) return 'invalid_insertion_depth'
|
||||
if (isNullFragmentAppendError(error)) return 'null_fragment_append'
|
||||
if (isTablePositionOutOfRangeError(error)) return 'table_position_out_of_range'
|
||||
return 'transform_error'
|
||||
return RECOVERY_REASON_MATCHERS.find(({ matches }) => matches(error))?.reason ?? 'transform_error'
|
||||
}
|
||||
|
||||
function shouldRepairEditorDocument(error: unknown): boolean {
|
||||
|
||||
@@ -105,6 +105,7 @@ vi.mock('@phosphor-icons/react', () => ({
|
||||
ArrowSquareOut: MockIcon,
|
||||
CaretDown: MockIcon,
|
||||
Code: MockIcon,
|
||||
Highlighter: MockIcon,
|
||||
TextB: MockIcon,
|
||||
TextItalic: MockIcon,
|
||||
TextStrikethrough: MockIcon,
|
||||
@@ -155,6 +156,7 @@ function createMockEditor(blockType = 'image', props: Record<string, unknown> =
|
||||
italic: { type: 'italic', propSchema: 'boolean' },
|
||||
strike: { type: 'strike', propSchema: 'boolean' },
|
||||
code: { type: 'code', propSchema: 'boolean' },
|
||||
highlight: { type: 'highlight', propSchema: 'boolean' },
|
||||
},
|
||||
},
|
||||
prosemirrorState: { selection: { from: 1, to: 5 } },
|
||||
@@ -187,11 +189,13 @@ describe('tolariaEditorFormatting behavior', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /inline code/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /highlight/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ bold: true })
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
|
||||
expect(editor.toggleStyles).toHaveBeenCalledWith({ highlight: true })
|
||||
expect(editor.transact).toHaveBeenCalledTimes(1)
|
||||
expect(editor.updateBlock).toHaveBeenCalledWith(
|
||||
'file-block',
|
||||
|
||||
@@ -48,15 +48,18 @@ import {
|
||||
ArrowSquareOut as ExternalLink,
|
||||
CaretDown as ChevronDown,
|
||||
Code as Code2,
|
||||
Highlighter,
|
||||
TextB as Bold,
|
||||
TextItalic as Italic,
|
||||
TextStrikethrough as Strikethrough,
|
||||
type Icon as PhosphorIcon,
|
||||
} from '@phosphor-icons/react'
|
||||
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
|
||||
import {
|
||||
filterTolariaFormattingToolbarItems,
|
||||
getTolariaBlockTypeSelectItems,
|
||||
} from './tolariaEditorFormattingConfig'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
|
||||
import { openEditorAttachmentOrUrl } from './editorAttachmentActions'
|
||||
import {
|
||||
@@ -64,7 +67,12 @@ import {
|
||||
reportRecoveredEditorTransformError,
|
||||
} from './richEditorTransformErrorRecoveryExtension'
|
||||
|
||||
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
|
||||
type TolariaBasicTextStyle =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'strike'
|
||||
| 'code'
|
||||
| typeof MARKDOWN_HIGHLIGHT_STYLE
|
||||
|
||||
const FORMATTER_CLOSE_GRACE_MS = 160
|
||||
const FORMATTER_VIEWPORT_PADDING_PX = 8
|
||||
@@ -184,7 +192,7 @@ const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
|
||||
secondaryTooltip: '`code`',
|
||||
},
|
||||
} satisfies Record<
|
||||
TolariaBasicTextStyle,
|
||||
Exclude<TolariaBasicTextStyle, typeof MARKDOWN_HIGHLIGHT_STYLE>,
|
||||
{ label: string; mainTooltip: string; secondaryTooltip: string }
|
||||
>
|
||||
|
||||
@@ -193,6 +201,7 @@ const TOLARIA_BASIC_TEXT_STYLE_ICONS = {
|
||||
italic: Italic,
|
||||
strike: Strikethrough,
|
||||
code: Code2,
|
||||
[MARKDOWN_HIGHLIGHT_STYLE]: Highlighter,
|
||||
} satisfies Record<TolariaBasicTextStyle, PhosphorIcon>
|
||||
|
||||
type TolariaSelectedBlock = ReturnType<
|
||||
@@ -484,8 +493,10 @@ function updateSelectedBlocksToType(
|
||||
|
||||
function TolariaBasicTextStyleButton({
|
||||
basicTextStyle,
|
||||
locale = 'en',
|
||||
}: {
|
||||
basicTextStyle: TolariaBasicTextStyle
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
const Components = useComponentsContext()!
|
||||
const editor = useBlockNoteEditor<
|
||||
@@ -506,11 +517,7 @@ function TolariaBasicTextStyleButton({
|
||||
if (buttonState === undefined) return null
|
||||
|
||||
const Icon = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_ICONS, basicTextStyle) as PhosphorIcon
|
||||
const copy = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS, basicTextStyle) as {
|
||||
label: string
|
||||
mainTooltip: string
|
||||
secondaryTooltip: string
|
||||
}
|
||||
const copy = basicTextStyleCopy(basicTextStyle, locale)
|
||||
|
||||
return (
|
||||
<Components.FormattingToolbar.Button
|
||||
@@ -526,6 +533,25 @@ function TolariaBasicTextStyleButton({
|
||||
)
|
||||
}
|
||||
|
||||
function basicTextStyleCopy(
|
||||
basicTextStyle: TolariaBasicTextStyle,
|
||||
locale: AppLocale,
|
||||
) {
|
||||
if (basicTextStyle === MARKDOWN_HIGHLIGHT_STYLE) {
|
||||
return {
|
||||
label: translate(locale, 'editor.formatting.highlight'),
|
||||
mainTooltip: translate(locale, 'editor.formatting.highlightTooltip'),
|
||||
secondaryTooltip: '==highlight==',
|
||||
}
|
||||
}
|
||||
|
||||
return Reflect.get(TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS, basicTextStyle) as {
|
||||
label: string
|
||||
mainTooltip: string
|
||||
secondaryTooltip: string
|
||||
}
|
||||
}
|
||||
|
||||
function TolariaBlockTypeSelect() {
|
||||
const editor = useBlockNoteEditor<
|
||||
BlockSchema,
|
||||
@@ -649,7 +675,7 @@ function replaceToolbarControls(items: ReactElement[], vaultPath?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function insertInlineCodeButton(items: ReactElement[]) {
|
||||
function insertExtraTextStyleButtons(items: ReactElement[], locale: AppLocale) {
|
||||
const strikeButtonIndex = items.findIndex(
|
||||
(item) => String(item.key) === 'strikeStyleButton',
|
||||
)
|
||||
@@ -658,23 +684,35 @@ function insertInlineCodeButton(items: ReactElement[]) {
|
||||
return [
|
||||
...items.slice(0, strikeButtonIndex + 1),
|
||||
<TolariaBasicTextStyleButton basicTextStyle="code" key="codeStyleButton" />,
|
||||
<TolariaBasicTextStyleButton
|
||||
basicTextStyle={MARKDOWN_HIGHLIGHT_STYLE}
|
||||
key="highlightStyleButton"
|
||||
locale={locale}
|
||||
/>,
|
||||
...items.slice(strikeButtonIndex + 1),
|
||||
]
|
||||
}
|
||||
|
||||
function getTolariaFormattingToolbarItems(vaultPath?: string) {
|
||||
return insertInlineCodeButton(
|
||||
function getTolariaFormattingToolbarItems(vaultPath: string | undefined, locale: AppLocale) {
|
||||
return insertExtraTextStyleButtons(
|
||||
replaceToolbarControls(
|
||||
filterTolariaFormattingToolbarItems(
|
||||
getFormattingToolbarItems(),
|
||||
),
|
||||
vaultPath,
|
||||
),
|
||||
locale,
|
||||
)
|
||||
}
|
||||
|
||||
export function TolariaFormattingToolbar({ vaultPath }: { vaultPath?: string } = {}) {
|
||||
return <FormattingToolbar>{getTolariaFormattingToolbarItems(vaultPath)}</FormattingToolbar>
|
||||
export function TolariaFormattingToolbar({
|
||||
locale = 'en',
|
||||
vaultPath,
|
||||
}: {
|
||||
locale?: AppLocale
|
||||
vaultPath?: string
|
||||
} = {}) {
|
||||
return <FormattingToolbar>{getTolariaFormattingToolbarItems(vaultPath, locale)}</FormattingToolbar>
|
||||
}
|
||||
|
||||
export function TolariaFormattingToolbarController(props: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useAiAgentsStatus } from './useAiAgentsStatus'
|
||||
import { AI_AGENTS_STATUS_PROBE_TIMEOUT_MS, useAiAgentsStatus } from './useAiAgentsStatus'
|
||||
import { AI_AGENT_DEFINITIONS, type AiAgentsStatus } from '../lib/aiAgents'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
@@ -141,5 +141,23 @@ describe('useAiAgentsStatus', () => {
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to missing when the status probe never resolves', async () => {
|
||||
mockInvoke.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { result } = renderHook(() => useAiAgentsStatus({ enabled: true }))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_ai_agents_status')
|
||||
expectStatuses(result.current, 'checking')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AI_AGENTS_STATUS_PROBE_TIMEOUT_MS + 1)
|
||||
})
|
||||
|
||||
expectStatuses(result.current, 'missing')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
|
||||
type RawAiAgentsStatus = Partial<Record<AiAgentId, { installed?: boolean | null; version?: string | null }>>
|
||||
|
||||
export const AI_AGENTS_STATUS_PROBE_TIMEOUT_MS = 5000
|
||||
|
||||
interface UseAiAgentsStatusOptions {
|
||||
/**
|
||||
* When false, the hook stays in its initial state and never calls the
|
||||
@@ -65,17 +67,32 @@ export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsS
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearProbeTimeout = () => {
|
||||
if (timeoutId === null) return
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
|
||||
const fire = () => {
|
||||
if (cancelled) return
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
setStatuses(createMissingAiAgentsStatus())
|
||||
}
|
||||
}, AI_AGENTS_STATUS_PROBE_TIMEOUT_MS)
|
||||
|
||||
tauriCall<RawAiAgentsStatus>('get_ai_agents_status')
|
||||
.then((result) => {
|
||||
clearProbeTimeout()
|
||||
if (!cancelled) {
|
||||
setStatuses(normalizeAiAgentsStatus(result))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
clearProbeTimeout()
|
||||
if (!cancelled) {
|
||||
setStatuses(createMissingAiAgentsStatus())
|
||||
}
|
||||
@@ -89,6 +106,7 @@ export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsS
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearProbeTimeout()
|
||||
cancelIdle(handle)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
108
src/hooks/useNoteGitUrls.test.ts
Normal file
108
src/hooks/useNoteGitUrls.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
import type { GitRemoteStatus, VaultEntry } from '../types'
|
||||
import { writeClipboardText } from '../utils/clipboardText'
|
||||
import { useNoteGitUrls } from './useNoteGitUrls'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
vi.mock('../utils/clipboardText', () => ({
|
||||
writeClipboardText: vi.fn(),
|
||||
}))
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
function remoteStatus(hasRemote: boolean): GitRemoteStatus {
|
||||
return {
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote,
|
||||
}
|
||||
}
|
||||
|
||||
function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/notes/share.md',
|
||||
filename: 'share.md',
|
||||
title: 'Share',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 0,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNoteGitUrls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(writeClipboardText).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('copies the backend git URL for remote-backed notes', async () => {
|
||||
const setToastMessage = vi.fn()
|
||||
const note = entry()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('https://github.com/team/vault/blob/main/notes/share.md')
|
||||
const { result } = renderHook(() => useNoteGitUrls({
|
||||
currentVaultPath: '/vault',
|
||||
remoteStatusForRepository: () => remoteStatus(true),
|
||||
setToastMessage,
|
||||
}))
|
||||
|
||||
expect(result.current.canCopyEntryGitUrl(note)).toBe(true)
|
||||
|
||||
act(() => result.current.copyEntryGitUrl(note))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/team/vault/blob/main/notes/share.md')
|
||||
})
|
||||
expect(mockInvoke).toHaveBeenCalledWith('git_file_url', { vaultPath: '/vault', path: note.path })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Git URL copied')
|
||||
})
|
||||
|
||||
it('does not request a URL for local-only notes', () => {
|
||||
const note = entry()
|
||||
const { result } = renderHook(() => useNoteGitUrls({
|
||||
currentVaultPath: '/vault',
|
||||
remoteStatusForRepository: () => remoteStatus(false),
|
||||
setToastMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.canCopyEntryGitUrl(note)).toBe(false)
|
||||
|
||||
act(() => result.current.copyEntryGitUrl(note))
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
expect(writeClipboardText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
70
src/hooks/useNoteGitUrls.ts
Normal file
70
src/hooks/useNoteGitUrls.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitRemoteStatus, VaultEntry } from '../types'
|
||||
import { writeClipboardText } from '../utils/clipboardText'
|
||||
import { vaultPathForEntry } from '../utils/workspaces'
|
||||
|
||||
interface UseNoteGitUrlsConfig {
|
||||
currentVaultPath: string
|
||||
locale?: AppLocale
|
||||
remoteStatusForRepository: (path: string) => GitRemoteStatus | null
|
||||
setToastMessage: (message: string) => void
|
||||
}
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function hasRemote(remoteStatus: GitRemoteStatus | null): boolean {
|
||||
return remoteStatus?.hasRemote === true
|
||||
}
|
||||
|
||||
async function loadNoteGitUrl(vaultPath: string, path: string): Promise<string | null> {
|
||||
return tauriCall<string | null>('git_file_url', { vaultPath, path })
|
||||
}
|
||||
|
||||
export function useNoteGitUrls({
|
||||
currentVaultPath,
|
||||
locale = 'en',
|
||||
remoteStatusForRepository,
|
||||
setToastMessage,
|
||||
}: UseNoteGitUrlsConfig) {
|
||||
const canCopyEntryGitUrl = useCallback((entry: VaultEntry) => (
|
||||
hasRemote(remoteStatusForRepository(vaultPathForEntry(entry, currentVaultPath)))
|
||||
), [currentVaultPath, remoteStatusForRepository])
|
||||
|
||||
const copyEntryGitUrl = useCallback((entry: VaultEntry) => {
|
||||
const vaultPath = vaultPathForEntry(entry, currentVaultPath)
|
||||
if (!hasRemote(remoteStatusForRepository(vaultPath))) return
|
||||
|
||||
void loadNoteGitUrl(vaultPath, entry.path)
|
||||
.then((url) => {
|
||||
if (!url) {
|
||||
setToastMessage(translate(locale, 'noteGitUrls.error.unavailable'))
|
||||
trackEvent('note_git_url_copied', { outcome: 'failed', reason: 'unavailable' })
|
||||
return
|
||||
}
|
||||
|
||||
return writeClipboardText(url).then(() => {
|
||||
setToastMessage(translate(locale, 'noteGitUrls.copied'))
|
||||
trackEvent('note_git_url_copied', { outcome: 'success' })
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
setToastMessage(translate(locale, 'noteGitUrls.error.copyFailed', { detail: errorDetail(error) }))
|
||||
trackEvent('note_git_url_copied', { outcome: 'failed', reason: 'copy_failed' })
|
||||
})
|
||||
}, [currentVaultPath, locale, remoteStatusForRepository, setToastMessage])
|
||||
|
||||
return useMemo(() => ({
|
||||
canCopyEntryGitUrl,
|
||||
copyEntryGitUrl,
|
||||
}), [canCopyEntryGitUrl, copyEntryGitUrl])
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
|
||||
import type { AiModelDefinition, AiModelProvider, AiTarget } from './aiTargets'
|
||||
|
||||
const {
|
||||
buildAgentSystemPromptMock,
|
||||
@@ -8,6 +9,7 @@ const {
|
||||
hydrateNoteReferencesMock,
|
||||
nextMessageIdMock,
|
||||
streamAiAgentMock,
|
||||
streamAiModelMock,
|
||||
trackEventMock,
|
||||
trimHistoryMock,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -17,6 +19,7 @@ const {
|
||||
hydrateNoteReferencesMock: vi.fn(async (references: unknown) => references),
|
||||
nextMessageIdMock: vi.fn(),
|
||||
streamAiAgentMock: vi.fn(async () => {}),
|
||||
streamAiModelMock: vi.fn(async () => {}),
|
||||
trackEventMock: vi.fn(),
|
||||
trimHistoryMock: vi.fn((history: unknown) => history),
|
||||
}))
|
||||
@@ -40,6 +43,10 @@ vi.mock('../utils/streamAiAgent', () => ({
|
||||
streamAiAgent: streamAiAgentMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/streamAiModel', () => ({
|
||||
streamAiModel: streamAiModelMock,
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-reference-content', () => ({
|
||||
hydrateNoteReferences: hydrateNoteReferencesMock,
|
||||
}))
|
||||
@@ -109,6 +116,36 @@ const expectedChatHistory = [
|
||||
{ role: 'user', content: 'Previous question', id: 'msg-1' },
|
||||
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
|
||||
]
|
||||
const apiModelProvider: AiModelProvider = {
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
kind: 'open_ai',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key_storage: 'local_file',
|
||||
api_key_env_var: null,
|
||||
models: [],
|
||||
}
|
||||
const apiModel: AiModelDefinition = {
|
||||
id: 'gpt-5-nano',
|
||||
display_name: 'GPT-5 nano',
|
||||
context_window: null,
|
||||
max_output_tokens: null,
|
||||
capabilities: {
|
||||
streaming: false,
|
||||
tools: false,
|
||||
vision: false,
|
||||
json_mode: false,
|
||||
reasoning: false,
|
||||
},
|
||||
}
|
||||
const apiTarget: AiTarget = {
|
||||
kind: 'api_model',
|
||||
provider: apiModelProvider,
|
||||
model: apiModel,
|
||||
id: 'model:openai/gpt-5-nano',
|
||||
label: 'OpenAI · GPT-5 nano',
|
||||
shortLabel: 'GPT-5 nano',
|
||||
}
|
||||
|
||||
function expectStreamingRuntimeState(session: RuntimeFixture): void {
|
||||
expect(session.runtime.abortRef.current).toEqual({ aborted: false })
|
||||
@@ -153,6 +190,24 @@ function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void {
|
||||
})
|
||||
}
|
||||
|
||||
function expectApiModelStreamingRequest(runtime: RuntimeFixture['runtime']): void {
|
||||
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
messageId: 'msg-stream',
|
||||
vaultPath: '/vault',
|
||||
setMessages: runtime.setMessages,
|
||||
setStatus: runtime.setStatus,
|
||||
}))
|
||||
expect(streamAiModelMock).toHaveBeenCalledWith({
|
||||
provider: apiModelProvider,
|
||||
model: apiModel,
|
||||
message: expect.stringContaining('formatted:Latest question'),
|
||||
systemPrompt: 'SYSTEM',
|
||||
vaultPath: '/vault',
|
||||
vaultPaths: ['/vault', '/team-vault'],
|
||||
callbacks: { stream: 'callbacks' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('aiAgentSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -286,6 +341,35 @@ describe('aiAgentSession', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes vault roots to api model streams for native note tools', async () => {
|
||||
nextMessageIdMock.mockReturnValue('msg-stream')
|
||||
const session = createRuntime([
|
||||
completedHistory,
|
||||
streamingHistory,
|
||||
])
|
||||
|
||||
await sendAgentMessage({
|
||||
runtime: session.runtime,
|
||||
context: {
|
||||
agent: 'codex',
|
||||
target: apiTarget,
|
||||
ready: true,
|
||||
vaultPath: '/vault',
|
||||
vaultPaths: ['/vault', '/team-vault'],
|
||||
permissionMode: 'safe',
|
||||
},
|
||||
prompt: {
|
||||
text: ' Latest question ',
|
||||
references: [{ path: '/vault/ref.md', title: 'Ref' }],
|
||||
},
|
||||
})
|
||||
|
||||
expectStreamingRuntimeState(session)
|
||||
expectFormattedHistoryUsed()
|
||||
expectApiModelStreamingRequest(session.runtime)
|
||||
expect(streamAiAgentMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the conversation and resets runtime refs', () => {
|
||||
const { runtime } = createRuntime([
|
||||
{ id: 'msg-1', userMessage: 'Question', actions: [] },
|
||||
|
||||
@@ -82,6 +82,8 @@ async function streamWithSelectedTarget(
|
||||
model: context.target.model,
|
||||
message: formattedMessage,
|
||||
systemPrompt,
|
||||
vaultPath: context.vaultPath,
|
||||
vaultPaths: context.vaultPaths,
|
||||
callbacks,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Паказаць у Finder",
|
||||
"editor.toolbar.copyFilePath": "Капіяваць шлях",
|
||||
"editor.toolbar.copyNoteDeepLink": "Капіяваць дэплінк нататкі",
|
||||
"editor.toolbar.copyNoteGitUrl": "Капіраваць URL git",
|
||||
"editor.toolbar.exportPdf": "Экспартаваць нататку ў фармаце PDF",
|
||||
"editor.toolbar.moreActions": "Больш дзеянняў",
|
||||
"editor.toolbar.openProperties": "Адкрыць уласцівасці",
|
||||
"editor.formatting.highlight": "Вылучэнне",
|
||||
"editor.formatting.highlightTooltip": "Вылучэнне (захоўваецца ў Markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Разгарнуць дошку",
|
||||
"editor.whiteboard.exitFullscreen": "Выйсці з поўнаэкраннай дошкі",
|
||||
"editor.exportPdf.unavailable": "Адкрыйце нататку Markdown перад экспартам у PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Сховішча глыбокіх спасылак недаступнае на гэтай прыладзе.",
|
||||
"deepLinks.error.unknownVault": "Глыбокі спасылка накіравана на невядомы сховішча.",
|
||||
"deepLinks.error.unsafePath": "Шлях глыбокай спасылкі не можа выходзіць за межы сховішча.",
|
||||
"noteGitUrls.copied": "URL Git скапіяваны",
|
||||
"noteGitUrls.error.copyFailed": "Не атрымалася скапіяваць URL git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "URL Git недаступны для гэтай нататкі.",
|
||||
"editor.slash.math": "Матэматыка",
|
||||
"tableOfContents.title": "Змест",
|
||||
"tableOfContents.close": "Закрыць змест",
|
||||
|
||||
@@ -588,9 +588,12 @@
|
||||
"editor.toolbar.revealFile": "Pakazać u Finder",
|
||||
"editor.toolbar.copyFilePath": "Kapijavać šliach",
|
||||
"editor.toolbar.copyNoteDeepLink": "Kapijavać deep-link natatki",
|
||||
"editor.toolbar.copyNoteGitUrl": "Kapijavać Git URL",
|
||||
"editor.toolbar.exportPdf": "Ekspartavać natatku ŭ farmacie PDF",
|
||||
"editor.toolbar.moreActions": "Boĺš dziejanniaŭ",
|
||||
"editor.toolbar.openProperties": "Adkryć ulascivasci",
|
||||
"editor.formatting.highlight": "Vylučennie",
|
||||
"editor.formatting.highlightTooltip": "Vylučennie (zachoŭvajecca ŭ Markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Pašyryć došku",
|
||||
"editor.whiteboard.exitFullscreen": "Vyjsci z poŭnaekrannaj doški",
|
||||
"editor.exportPdf.unavailable": "Adkryjcie natatku Markdown pierad ekspartam u PDF.",
|
||||
@@ -609,6 +612,9 @@
|
||||
"deepLinks.error.unavailableVault": "Schovišča z deep-link niedastupnaje na hetaj pryladzie.",
|
||||
"deepLinks.error.unknownVault": "Deep-link viadzie da nieviadomaha schovišča.",
|
||||
"deepLinks.error.unsafePath": "Šliach deep-link nie moža vychodzić za miežy schovišča.",
|
||||
"noteGitUrls.copied": "Git URL skapijavany",
|
||||
"noteGitUrls.error.copyFailed": "Nie ŭdalosia skapijavać Git URL: {detail}",
|
||||
"noteGitUrls.error.unavailable": "Git URL niedastupny dla hetaj natatki.",
|
||||
"tableOfContents.title": "Zmiest",
|
||||
"tableOfContents.close": "Zakryć zmiest",
|
||||
"tableOfContents.empty": "Niama zahaloŭkaŭ",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Im Finder anzeigen",
|
||||
"editor.toolbar.copyFilePath": "Dateipfad kopieren",
|
||||
"editor.toolbar.copyNoteDeepLink": "Deeplink der Notiz kopieren",
|
||||
"editor.toolbar.copyNoteGitUrl": "Git-URL kopieren",
|
||||
"editor.toolbar.exportPdf": "Notiz als PDF exportieren",
|
||||
"editor.toolbar.moreActions": "Weitere Notizaktionen",
|
||||
"editor.toolbar.openProperties": "Eigenschaften-Panel öffnen",
|
||||
"editor.formatting.highlight": "Hervorheben",
|
||||
"editor.formatting.highlightTooltip": "Hervorhebung (bleibt in Markdown erhalten)",
|
||||
"editor.whiteboard.enterFullscreen": "Whiteboard erweitern",
|
||||
"editor.whiteboard.exitFullscreen": "Vollbild-Whiteboard verlassen",
|
||||
"editor.exportPdf.unavailable": "Öffne eine Markdown-Notiz, bevor du sie als PDF exportierst.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Der Deep-Link-Tresor ist auf diesem Gerät nicht verfügbar.",
|
||||
"deepLinks.error.unknownVault": "Der Deep Link verweist auf einen unbekannten Vault.",
|
||||
"deepLinks.error.unsafePath": "Der Deep-Link-Pfad darf den Vault nicht verlassen.",
|
||||
"noteGitUrls.copied": "Git-URL kopiert",
|
||||
"noteGitUrls.error.copyFailed": "Git-URL konnte nicht kopiert werden: {detail}",
|
||||
"noteGitUrls.error.unavailable": "Für diese Notiz ist keine Git-URL verfügbar.",
|
||||
"editor.slash.math": "Mathematik",
|
||||
"tableOfContents.title": "Inhaltsverzeichnis",
|
||||
"tableOfContents.close": "Inhaltsverzeichnis schließen",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Reveal in Finder",
|
||||
"editor.toolbar.copyFilePath": "Copy file path",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copy note deeplink",
|
||||
"editor.toolbar.copyNoteGitUrl": "Copy git URL",
|
||||
"editor.toolbar.exportPdf": "Export note as PDF",
|
||||
"editor.toolbar.moreActions": "More note actions",
|
||||
"editor.toolbar.openProperties": "Open the properties panel",
|
||||
"editor.formatting.highlight": "Highlight",
|
||||
"editor.formatting.highlightTooltip": "Highlight (persists in markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Expand whiteboard",
|
||||
"editor.whiteboard.exitFullscreen": "Exit fullscreen whiteboard",
|
||||
"editor.exportPdf.unavailable": "Open a Markdown note before exporting a PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Deep link vault is not available on this device.",
|
||||
"deepLinks.error.unknownVault": "Deep link targets an unknown vault.",
|
||||
"deepLinks.error.unsafePath": "Deep link path cannot leave the vault.",
|
||||
"noteGitUrls.copied": "Git URL copied",
|
||||
"noteGitUrls.error.copyFailed": "Failed to copy git URL: {detail}",
|
||||
"noteGitUrls.error.unavailable": "Git URL is not available for this note.",
|
||||
"editor.slash.math": "Math",
|
||||
"tableOfContents.title": "Table of Contents",
|
||||
"tableOfContents.close": "Close table of contents",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Mostrar en el Finder",
|
||||
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copiar el enlace profundo de la nota",
|
||||
"editor.toolbar.copyNoteGitUrl": "Copiar URL de Git",
|
||||
"editor.toolbar.exportPdf": "Exportar nota como PDF",
|
||||
"editor.toolbar.moreActions": "Más acciones de nota",
|
||||
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
|
||||
"editor.formatting.highlight": "Resaltar",
|
||||
"editor.formatting.highlightTooltip": "Resaltado (persiste en Markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Expandir pizarra",
|
||||
"editor.whiteboard.exitFullscreen": "Salir de la pizarra en pantalla completa",
|
||||
"editor.exportPdf.unavailable": "Abre una nota de Markdown antes de exportar un PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "El repositorio del enlace profundo no está disponible en este dispositivo.",
|
||||
"deepLinks.error.unknownVault": "El enlace profundo apunta a una bóveda desconocida.",
|
||||
"deepLinks.error.unsafePath": "La ruta del enlace profundo no puede salir de la bóveda.",
|
||||
"noteGitUrls.copied": "URL de Git copiada",
|
||||
"noteGitUrls.error.copyFailed": "Error al copiar la URL de Git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "La URL de Git no está disponible para esta nota.",
|
||||
"editor.slash.math": "Matemáticas",
|
||||
"tableOfContents.title": "Índice",
|
||||
"tableOfContents.close": "Cerrar índice",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Mostrar en el Finder",
|
||||
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copiar el enlace profundo de la nota",
|
||||
"editor.toolbar.copyNoteGitUrl": "Copiar URL de Git",
|
||||
"editor.toolbar.exportPdf": "Exportar nota como PDF",
|
||||
"editor.toolbar.moreActions": "Más acciones de nota",
|
||||
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
|
||||
"editor.formatting.highlight": "Resaltado",
|
||||
"editor.formatting.highlightTooltip": "Resaltado (persiste en Markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Expandir pizarra",
|
||||
"editor.whiteboard.exitFullscreen": "Salir de la pizarra en pantalla completa",
|
||||
"editor.exportPdf.unavailable": "Abre una nota de Markdown antes de exportar un PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "El almacén del enlace profundo no está disponible en este dispositivo.",
|
||||
"deepLinks.error.unknownVault": "El enlace profundo apunta a un almacén desconocido.",
|
||||
"deepLinks.error.unsafePath": "La ruta del enlace profundo no puede salir de la caja fuerte.",
|
||||
"noteGitUrls.copied": "URL de Git copiada",
|
||||
"noteGitUrls.error.copyFailed": "Error al copiar la URL de Git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "La URL de Git no está disponible para esta nota.",
|
||||
"editor.slash.math": "Matemáticas",
|
||||
"tableOfContents.title": "Índice",
|
||||
"tableOfContents.close": "Cerrar el índice",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Afficher dans le Finder",
|
||||
"editor.toolbar.copyFilePath": "Copier le chemin du fichier",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copier le deeplink de la note",
|
||||
"editor.toolbar.copyNoteGitUrl": "Copier l'URL Git",
|
||||
"editor.toolbar.exportPdf": "Exporter la note au format PDF",
|
||||
"editor.toolbar.moreActions": "Autres actions de note",
|
||||
"editor.toolbar.openProperties": "Ouvrir le panneau des propriétés",
|
||||
"editor.formatting.highlight": "Surligner",
|
||||
"editor.formatting.highlightTooltip": "Surlignage (persiste dans le Markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Agrandir le tableau blanc",
|
||||
"editor.whiteboard.exitFullscreen": "Quitter le tableau blanc en plein écran",
|
||||
"editor.exportPdf.unavailable": "Ouvrez une note Markdown avant d'exporter un PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Le coffre-fort du lien profond n'est pas disponible sur cet appareil.",
|
||||
"deepLinks.error.unknownVault": "Le lien profond cible un coffre inconnu.",
|
||||
"deepLinks.error.unsafePath": "Le chemin du lien profond ne peut pas sortir du coffre.",
|
||||
"noteGitUrls.copied": "URL Git copiée",
|
||||
"noteGitUrls.error.copyFailed": "Échec de la copie de l'URL Git : {detail}",
|
||||
"noteGitUrls.error.unavailable": "L'URL Git n'est pas disponible pour cette note.",
|
||||
"editor.slash.math": "Mathématiques",
|
||||
"tableOfContents.title": "Table des matières",
|
||||
"tableOfContents.close": "Fermer la table des matières",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Tampilkan di Finder",
|
||||
"editor.toolbar.copyFilePath": "Salin jalur file",
|
||||
"editor.toolbar.copyNoteDeepLink": "Salin deeplink catatan",
|
||||
"editor.toolbar.copyNoteGitUrl": "Salin URL git",
|
||||
"editor.toolbar.exportPdf": "Ekspor catatan sebagai PDF",
|
||||
"editor.toolbar.moreActions": "Tindakan catatan lainnya",
|
||||
"editor.toolbar.openProperties": "Buka panel properti",
|
||||
"editor.formatting.highlight": "Sorot",
|
||||
"editor.formatting.highlightTooltip": "Sorot (bertahan dalam markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Perluas papan tulis",
|
||||
"editor.whiteboard.exitFullscreen": "Keluar dari papan tulis layar penuh",
|
||||
"editor.exportPdf.unavailable": "Buka catatan Markdown sebelum mengekspor PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Vault tautan dalam tidak tersedia di perangkat ini.",
|
||||
"deepLinks.error.unknownVault": "Tautan dalam mengarah ke vault yang tidak dikenal.",
|
||||
"deepLinks.error.unsafePath": "Jalur tautan dalam tidak boleh keluar dari vault.",
|
||||
"noteGitUrls.copied": "URL Git disalin",
|
||||
"noteGitUrls.error.copyFailed": "Gagal menyalin URL git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "URL Git tidak tersedia untuk catatan ini.",
|
||||
"editor.slash.math": "Matematika",
|
||||
"tableOfContents.title": "Daftar Isi",
|
||||
"tableOfContents.close": "Tutup daftar isi",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Mostra nel Finder",
|
||||
"editor.toolbar.copyFilePath": "Copia il percorso del file",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copia deeplink della nota",
|
||||
"editor.toolbar.copyNoteGitUrl": "Copia URL Git",
|
||||
"editor.toolbar.exportPdf": "Esporta nota in PDF",
|
||||
"editor.toolbar.moreActions": "Altre azioni della nota",
|
||||
"editor.toolbar.openProperties": "Apri il pannello delle proprietà",
|
||||
"editor.formatting.highlight": "Evidenzia",
|
||||
"editor.formatting.highlightTooltip": "Evidenziazione (persiste in markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Espandi la lavagna",
|
||||
"editor.whiteboard.exitFullscreen": "Esci dalla lavagna a schermo intero",
|
||||
"editor.exportPdf.unavailable": "Apri una nota Markdown prima di esportare un PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Il vault del deep link non è disponibile su questo dispositivo.",
|
||||
"deepLinks.error.unknownVault": "Il deep link punta a un vault sconosciuto.",
|
||||
"deepLinks.error.unsafePath": "Il percorso del deep link non può uscire dal vault.",
|
||||
"noteGitUrls.copied": "URL Git copiato",
|
||||
"noteGitUrls.error.copyFailed": "Impossibile copiare l'URL Git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "L'URL Git non è disponibile per questa nota.",
|
||||
"editor.slash.math": "Matematica",
|
||||
"tableOfContents.title": "Indice",
|
||||
"tableOfContents.close": "Chiudi l'indice",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Finderで表示",
|
||||
"editor.toolbar.copyFilePath": "ファイルパスをコピー",
|
||||
"editor.toolbar.copyNoteDeepLink": "ノートのディープリンクをコピー",
|
||||
"editor.toolbar.copyNoteGitUrl": "Git URLをコピー",
|
||||
"editor.toolbar.exportPdf": "ノートをPDFとしてエクスポート",
|
||||
"editor.toolbar.moreActions": "その他のノート操作",
|
||||
"editor.toolbar.openProperties": "プロパティパネルを開く",
|
||||
"editor.formatting.highlight": "ハイライト",
|
||||
"editor.formatting.highlightTooltip": "ハイライト(Markdownで保持)",
|
||||
"editor.whiteboard.enterFullscreen": "ホワイトボードを展開",
|
||||
"editor.whiteboard.exitFullscreen": "フルスクリーンホワイトボードを終了",
|
||||
"editor.exportPdf.unavailable": "PDFをエクスポートする前に、Markdownノートを開いてください。",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "このデバイスではディープリンクのボールトは利用できません。",
|
||||
"deepLinks.error.unknownVault": "ディープリンクのターゲットは不明なボールトです。",
|
||||
"deepLinks.error.unsafePath": "ディープリンクのパスは、ボールトの外に出ることはできません。",
|
||||
"noteGitUrls.copied": "Git URLをコピーしました",
|
||||
"noteGitUrls.error.copyFailed": "Git URLのコピーに失敗しました:{detail}",
|
||||
"noteGitUrls.error.unavailable": "このノートではGit URLは利用できません。",
|
||||
"editor.slash.math": "数学",
|
||||
"tableOfContents.title": "目次",
|
||||
"tableOfContents.close": "目次を閉じる",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Finder에서 표시",
|
||||
"editor.toolbar.copyFilePath": "파일 경로 복사",
|
||||
"editor.toolbar.copyNoteDeepLink": "노트 딥링크 복사",
|
||||
"editor.toolbar.copyNoteGitUrl": "Git URL 복사",
|
||||
"editor.toolbar.exportPdf": "노트를 PDF로 내보내기",
|
||||
"editor.toolbar.moreActions": "추가 노트 작업",
|
||||
"editor.toolbar.openProperties": "속성 패널 열기",
|
||||
"editor.formatting.highlight": "강조",
|
||||
"editor.formatting.highlightTooltip": "강조 표시(마크다운에 유지)",
|
||||
"editor.whiteboard.enterFullscreen": "화이트보드 펼치기",
|
||||
"editor.whiteboard.exitFullscreen": "전체 화면 화이트보드에서 나가기",
|
||||
"editor.exportPdf.unavailable": "PDF로 내보내기 전에 Markdown 노트를 여세요.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "이 장치에서는 딥 링크 보관을 사용할 수 없습니다.",
|
||||
"deepLinks.error.unknownVault": "딥 링크가 알 수 없는 볼트를 대상으로 합니다.",
|
||||
"deepLinks.error.unsafePath": "딥 링크 경로는 볼트를 벗어날 수 없습니다.",
|
||||
"noteGitUrls.copied": "Git URL 복사됨",
|
||||
"noteGitUrls.error.copyFailed": "Git URL 복사에 실패했습니다: {detail}",
|
||||
"noteGitUrls.error.unavailable": "이 노트에 대한 Git URL을 사용할 수 없습니다.",
|
||||
"editor.slash.math": "수학",
|
||||
"tableOfContents.title": "목차",
|
||||
"tableOfContents.close": "목차 닫기",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Pokaż w Finderze",
|
||||
"editor.toolbar.copyFilePath": "Kopiuj ścieżkę pliku",
|
||||
"editor.toolbar.copyNoteDeepLink": "Skopiuj deeplink do notatki",
|
||||
"editor.toolbar.copyNoteGitUrl": "Skopiuj adres URL Git",
|
||||
"editor.toolbar.exportPdf": "Eksportuj notatkę jako PDF",
|
||||
"editor.toolbar.moreActions": "Więcej działań notatki",
|
||||
"editor.toolbar.openProperties": "Otwórz panel właściwości",
|
||||
"editor.formatting.highlight": "Wyróżnienie",
|
||||
"editor.formatting.highlightTooltip": "Wyróżnienie (zachowuje się w Markdownie)",
|
||||
"editor.whiteboard.enterFullscreen": "Rozwiń tablicę",
|
||||
"editor.whiteboard.exitFullscreen": "Wyjdź z tablicy pełnoekranowej",
|
||||
"editor.exportPdf.unavailable": "Otwórz notatkę Markdown przed wyeksportowaniem pliku PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Magazyn deep linków nie jest dostępny na tym urządzeniu.",
|
||||
"deepLinks.error.unknownVault": "Głęboki link wskazuje na nieznany skarbiec.",
|
||||
"deepLinks.error.unsafePath": "Ścieżka deep linku nie może wychodzić poza skarbiec.",
|
||||
"noteGitUrls.copied": "Skopiowano adres URL Git",
|
||||
"noteGitUrls.error.copyFailed": "Nie udało się skopiować adresu URL Git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "Adres URL Git nie jest dostępny dla tej notatki.",
|
||||
"editor.slash.math": "Matematyka",
|
||||
"tableOfContents.title": "Spis treści",
|
||||
"tableOfContents.close": "Zamknij spis treści",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Exibir no Finder",
|
||||
"editor.toolbar.copyFilePath": "Copiar caminho do arquivo",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copiar deeplink da nota",
|
||||
"editor.toolbar.copyNoteGitUrl": "Copiar URL do Git",
|
||||
"editor.toolbar.exportPdf": "Exportar nota como PDF",
|
||||
"editor.toolbar.moreActions": "Mais ações da nota",
|
||||
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
|
||||
"editor.formatting.highlight": "Destaque",
|
||||
"editor.formatting.highlightTooltip": "Destaque (persiste no markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Expandir quadro branco",
|
||||
"editor.whiteboard.exitFullscreen": "Sair do quadro branco em tela cheia",
|
||||
"editor.exportPdf.unavailable": "Abra uma nota Markdown antes de exportar um PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "O cofre do deep link não está disponível neste dispositivo.",
|
||||
"deepLinks.error.unknownVault": "O deep link aponta para um cofre desconhecido.",
|
||||
"deepLinks.error.unsafePath": "O caminho do deep link não pode sair do cofre.",
|
||||
"noteGitUrls.copied": "URL do Git copiada",
|
||||
"noteGitUrls.error.copyFailed": "Falha ao copiar o URL do Git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "O URL do Git não está disponível para esta nota.",
|
||||
"editor.slash.math": "Matemática",
|
||||
"tableOfContents.title": "Índice",
|
||||
"tableOfContents.close": "Fechar índice",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Mostrar no Finder",
|
||||
"editor.toolbar.copyFilePath": "Copiar caminho do ficheiro",
|
||||
"editor.toolbar.copyNoteDeepLink": "Copiar deeplink da nota",
|
||||
"editor.toolbar.copyNoteGitUrl": "Copiar URL do Git",
|
||||
"editor.toolbar.exportPdf": "Exportar nota como PDF",
|
||||
"editor.toolbar.moreActions": "Mais ações da nota",
|
||||
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
|
||||
"editor.formatting.highlight": "Destaque",
|
||||
"editor.formatting.highlightTooltip": "Destaque (persiste em markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Expandir quadro branco",
|
||||
"editor.whiteboard.exitFullscreen": "Sair do quadro branco em ecrã inteiro",
|
||||
"editor.exportPdf.unavailable": "Abra uma nota Markdown antes de exportar um PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "O cofre da ligação profunda não está disponível neste dispositivo.",
|
||||
"deepLinks.error.unknownVault": "O deep link aponta para um cofre desconhecido.",
|
||||
"deepLinks.error.unsafePath": "O caminho do deep link não pode sair do cofre.",
|
||||
"noteGitUrls.copied": "URL do Git copiado",
|
||||
"noteGitUrls.error.copyFailed": "Falha ao copiar o URL do Git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "O URL do Git não está disponível para esta nota.",
|
||||
"editor.slash.math": "Matemática",
|
||||
"tableOfContents.title": "Índice",
|
||||
"tableOfContents.close": "Fechar índice",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Показать в Finder",
|
||||
"editor.toolbar.copyFilePath": "Копировать путь к файлу",
|
||||
"editor.toolbar.copyNoteDeepLink": "Копировать диплинк заметки",
|
||||
"editor.toolbar.copyNoteGitUrl": "Копировать URL Git",
|
||||
"editor.toolbar.exportPdf": "Экспортировать заметку в формате PDF",
|
||||
"editor.toolbar.moreActions": "Другие действия с заметкой",
|
||||
"editor.toolbar.openProperties": "Открыть панель свойств",
|
||||
"editor.formatting.highlight": "Выделение",
|
||||
"editor.formatting.highlightTooltip": "Выделение (сохраняется в Markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Развернуть доску",
|
||||
"editor.whiteboard.exitFullscreen": "Выйти из полноэкранной доски",
|
||||
"editor.exportPdf.unavailable": "Откройте заметку Markdown перед экспортом в PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Хранилище для глубокой ссылки недоступно на этом устройстве.",
|
||||
"deepLinks.error.unknownVault": "Глубинная ссылка указывает на неизвестное хранилище.",
|
||||
"deepLinks.error.unsafePath": "Путь глубокой ссылки не может выходить за пределы хранилища.",
|
||||
"noteGitUrls.copied": "URL Git скопирован",
|
||||
"noteGitUrls.error.copyFailed": "Не удалось скопировать URL-адрес Git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "URL-адрес Git недоступен для этой заметки.",
|
||||
"editor.slash.math": "Математика",
|
||||
"tableOfContents.title": "Содержание",
|
||||
"tableOfContents.close": "Закрыть оглавление",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "Hiển thị trong Finder",
|
||||
"editor.toolbar.copyFilePath": "Sao chép đường dẫn tệp",
|
||||
"editor.toolbar.copyNoteDeepLink": "Sao chép liên kết sâu của ghi chú",
|
||||
"editor.toolbar.copyNoteGitUrl": "Sao chép URL git",
|
||||
"editor.toolbar.exportPdf": "Xuất ghi chú dưới dạng PDF",
|
||||
"editor.toolbar.moreActions": "Thêm hành động ghi chú",
|
||||
"editor.toolbar.openProperties": "Mở bảng thuộc tính",
|
||||
"editor.formatting.highlight": "Đánh dấu",
|
||||
"editor.formatting.highlightTooltip": "Đánh dấu (duy trì trong markdown)",
|
||||
"editor.whiteboard.enterFullscreen": "Mở rộng bảng trắng",
|
||||
"editor.whiteboard.exitFullscreen": "Thoát chế độ bảng trắng toàn màn hình",
|
||||
"editor.exportPdf.unavailable": "Mở ghi chú Markdown trước khi xuất PDF.",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "Kho liên kết sâu không khả dụng trên thiết bị này.",
|
||||
"deepLinks.error.unknownVault": "Liên kết sâu trỏ đến một vault không xác định.",
|
||||
"deepLinks.error.unsafePath": "Đường dẫn liên kết sâu không thể nằm ngoài vault.",
|
||||
"noteGitUrls.copied": "Đã sao chép URL Git",
|
||||
"noteGitUrls.error.copyFailed": "Không thể sao chép URL git: {detail}",
|
||||
"noteGitUrls.error.unavailable": "URL Git không khả dụng cho ghi chú này.",
|
||||
"editor.slash.math": "Toán học",
|
||||
"tableOfContents.title": "Mục lục",
|
||||
"tableOfContents.close": "Đóng mục lục",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "在文件管理器中显示",
|
||||
"editor.toolbar.copyFilePath": "复制文件路径",
|
||||
"editor.toolbar.copyNoteDeepLink": "复制笔记深层链接",
|
||||
"editor.toolbar.copyNoteGitUrl": "复制 Git URL",
|
||||
"editor.toolbar.exportPdf": "将笔记导出为 PDF",
|
||||
"editor.toolbar.moreActions": "更多笔记操作",
|
||||
"editor.toolbar.openProperties": "打开属性面板",
|
||||
"editor.formatting.highlight": "高亮",
|
||||
"editor.formatting.highlightTooltip": "高亮(在 Markdown 中保留)",
|
||||
"editor.whiteboard.enterFullscreen": "展开白板",
|
||||
"editor.whiteboard.exitFullscreen": "退出全屏白板",
|
||||
"editor.exportPdf.unavailable": "在导出 PDF 之前,请先打开 Markdown 笔记。",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "此设备上没有深层链接保管库。",
|
||||
"deepLinks.error.unknownVault": "深层链接指向未知的保管库。",
|
||||
"deepLinks.error.unsafePath": "深层链接路径不能离开保管库。",
|
||||
"noteGitUrls.copied": "Git URL 已复制",
|
||||
"noteGitUrls.error.copyFailed": "复制 Git URL 失败:{detail}",
|
||||
"noteGitUrls.error.unavailable": "此笔记没有 Git URL。",
|
||||
"editor.slash.math": "数学",
|
||||
"tableOfContents.title": "目录",
|
||||
"tableOfContents.close": "关闭目录",
|
||||
|
||||
@@ -586,9 +586,12 @@
|
||||
"editor.toolbar.revealFile": "在檔案管理器中顯示",
|
||||
"editor.toolbar.copyFilePath": "複製檔案路徑",
|
||||
"editor.toolbar.copyNoteDeepLink": "複製筆記深層連結",
|
||||
"editor.toolbar.copyNoteGitUrl": "複製 Git URL",
|
||||
"editor.toolbar.exportPdf": "將筆記匯出為 PDF",
|
||||
"editor.toolbar.moreActions": "更多筆記操作",
|
||||
"editor.toolbar.openProperties": "開啟屬性面板",
|
||||
"editor.formatting.highlight": "醒目提示",
|
||||
"editor.formatting.highlightTooltip": "醒目提示(在 Markdown 中保留)",
|
||||
"editor.whiteboard.enterFullscreen": "展開白板",
|
||||
"editor.whiteboard.exitFullscreen": "退出全螢幕白板",
|
||||
"editor.exportPdf.unavailable": "在匯出 PDF 之前,請先開啟 Markdown 筆記。",
|
||||
@@ -607,6 +610,9 @@
|
||||
"deepLinks.error.unavailableVault": "此裝置無法使用深層連結保管庫。",
|
||||
"deepLinks.error.unknownVault": "深層連結指向未知的保管庫。",
|
||||
"deepLinks.error.unsafePath": "深層連結路徑不能離開保管庫。",
|
||||
"noteGitUrls.copied": "Git URL 已複製",
|
||||
"noteGitUrls.error.copyFailed": "無法複製 Git URL:{detail}",
|
||||
"noteGitUrls.error.unavailable": "此筆記無法使用 Git URL。",
|
||||
"editor.slash.math": "數學",
|
||||
"tableOfContents.title": "目錄",
|
||||
"tableOfContents.close": "關閉目錄",
|
||||
|
||||
@@ -460,6 +460,12 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
|
||||
return { branch: 'main', ahead: 0, behind: 0, hasRemote: getMockRemoteState(vaultPath) }
|
||||
},
|
||||
git_file_url: (args?: { vaultPath?: string; vault_path?: string; path?: string }): string | null => {
|
||||
const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
|
||||
if (!getMockRemoteState(vaultPath)) return null
|
||||
const path = args?.path?.replace(/^.*?\/Laputa\//, '') ?? 'note.md'
|
||||
return `https://github.com/lucaong/laputa-vault/blob/main/${encodeURI(path)}`
|
||||
},
|
||||
git_add_remote: (args?: {
|
||||
request?: { vaultPath?: string; vault_path?: string; remoteUrl?: string }
|
||||
vaultPath?: string
|
||||
|
||||
@@ -64,7 +64,9 @@ describe('release workflow macOS artifact names', () => {
|
||||
)
|
||||
|
||||
expect(alphaWorkflow).toContain('require_windows_authenticode: false')
|
||||
expect(stableWorkflow).toContain("require_windows_authenticode: ${{ needs.version.outputs.tag != 'v2026-06-01' }}")
|
||||
expect(stableWorkflow).toContain(
|
||||
`require_windows_authenticode: \${{ !contains(fromJson('["v2026-06-01","v2026-06-06"]'), needs.version.outputs.tag) }}`,
|
||||
)
|
||||
expect(artifactWorkflow).toContain('require_windows_authenticode:')
|
||||
expect(artifactWorkflow).toContain('WINDOWS_CODE_SIGNING_CERTIFICATE')
|
||||
expect(artifactWorkflow).toContain('WINDOWS_CERTIFICATE')
|
||||
|
||||
@@ -86,4 +86,29 @@ describe('streamAiModel', () => {
|
||||
expect(callbacks.onDone).toHaveBeenCalledTimes(1)
|
||||
expect(unlisten).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('passes active vault roots to native model streams for note tools', async () => {
|
||||
isTauriState.value = true
|
||||
const unlisten = vi.fn()
|
||||
listenMock.mockResolvedValue(unlisten)
|
||||
invokeMock.mockResolvedValue('session')
|
||||
|
||||
const callbacks = createCallbacks()
|
||||
|
||||
await streamAiModel({
|
||||
provider,
|
||||
model,
|
||||
message: 'create a note',
|
||||
vaultPath: '/vault',
|
||||
vaultPaths: ['/vault', '/team-vault'],
|
||||
callbacks,
|
||||
})
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith('stream_ai_model', {
|
||||
request: expect.objectContaining({
|
||||
vault_path: '/vault',
|
||||
vault_paths: ['/vault', '/team-vault'],
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,9 +18,22 @@ interface StreamAiModelRequest {
|
||||
model: AiModelDefinition
|
||||
message: string
|
||||
systemPrompt?: string
|
||||
vaultPath?: string
|
||||
vaultPaths?: string[]
|
||||
callbacks: AgentStreamCallbacks
|
||||
}
|
||||
|
||||
interface NativeAiModelStreamRequest {
|
||||
provider: AiModelProvider
|
||||
model_id: string
|
||||
message: string
|
||||
system_prompt: string | null
|
||||
vault_path: string | null
|
||||
vault_paths: string[] | null
|
||||
api_key_override: null
|
||||
event_name: string
|
||||
}
|
||||
|
||||
function mockModelResponse(provider: AiModelProvider, model: AiModelDefinition, message: string): string {
|
||||
const displayName = model.display_name || model.id
|
||||
return `[mock-${provider.name} ${displayName}] You asked: "${message.slice(0, 160)}"`
|
||||
@@ -49,55 +62,79 @@ function handleStreamEvent(data: AiModelStreamEvent, callbacks: AgentStreamCallb
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamAiModel({
|
||||
provider,
|
||||
model,
|
||||
message,
|
||||
systemPrompt,
|
||||
callbacks,
|
||||
}: StreamAiModelRequest): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText(mockModelResponse(provider, model, message))
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
function streamMockAiModel({ provider, model, message, callbacks }: StreamAiModelRequest): void {
|
||||
setTimeout(() => {
|
||||
callbacks.onText(mockModelResponse(provider, model, message))
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
const eventName = createScopedStreamEventName('ai-model-stream')
|
||||
function nativeVaultPaths(vaultPaths: string[] | undefined): string[] | null {
|
||||
return vaultPaths && vaultPaths.length > 0 ? vaultPaths : null
|
||||
}
|
||||
|
||||
function nativeAiModelRequest(request: StreamAiModelRequest, eventName: string): NativeAiModelStreamRequest {
|
||||
return {
|
||||
provider: request.provider,
|
||||
model_id: request.model.id,
|
||||
message: request.message,
|
||||
system_prompt: request.systemPrompt || null,
|
||||
vault_path: request.vaultPath || null,
|
||||
vault_paths: nativeVaultPaths(request.vaultPaths),
|
||||
api_key_override: null,
|
||||
event_name: eventName,
|
||||
}
|
||||
}
|
||||
|
||||
function createStreamCloser(callbacks: AgentStreamCallbacks) {
|
||||
let closed = false
|
||||
const closeStream = (): void => {
|
||||
return (): void => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
callbacks.onDone()
|
||||
}
|
||||
}
|
||||
|
||||
function handleNativeStreamEvent(
|
||||
data: AiModelStreamEvent,
|
||||
callbacks: AgentStreamCallbacks,
|
||||
closeStream: () => void,
|
||||
): void {
|
||||
if (data.kind === 'Done') {
|
||||
closeStream()
|
||||
return
|
||||
}
|
||||
handleStreamEvent(data, callbacks)
|
||||
}
|
||||
|
||||
async function streamNativeAiModel(request: StreamAiModelRequest): Promise<void> {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
const eventName = createScopedStreamEventName('ai-model-stream')
|
||||
const closeStream = createStreamCloser(request.callbacks)
|
||||
|
||||
const unlisten = await listen<AiModelStreamEvent>(eventName, (event) => {
|
||||
if (event.payload.kind === 'Done') {
|
||||
closeStream()
|
||||
return
|
||||
}
|
||||
handleStreamEvent(event.payload, callbacks)
|
||||
handleNativeStreamEvent(event.payload, request.callbacks, closeStream)
|
||||
})
|
||||
|
||||
try {
|
||||
await invoke<string>('stream_ai_model', {
|
||||
request: {
|
||||
provider,
|
||||
model_id: model.id,
|
||||
message,
|
||||
system_prompt: systemPrompt || null,
|
||||
api_key_override: null,
|
||||
event_name: eventName,
|
||||
},
|
||||
request: nativeAiModelRequest(request, eventName),
|
||||
})
|
||||
closeStream()
|
||||
} catch (err) {
|
||||
callbacks.onError(err instanceof Error ? err.message : String(err))
|
||||
request.callbacks.onError(err instanceof Error ? err.message : String(err))
|
||||
closeStream()
|
||||
} finally {
|
||||
cleanupTauriEventListener(unlisten)
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamAiModel(request: StreamAiModelRequest): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
streamMockAiModel(request)
|
||||
return
|
||||
}
|
||||
|
||||
await streamNativeAiModel(request)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('../lib/appUpdater', () => ({
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE:
|
||||
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
|
||||
isRestartRequiredAfterUpdate: vi.fn(() => false),
|
||||
markRestartRequiredAfterUpdate: vi.fn(),
|
||||
}))
|
||||
|
||||
const openMock = vi.fn()
|
||||
@@ -21,12 +22,16 @@ import { pickFolder } from './vault-dialog'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
isRestartRequiredAfterUpdate,
|
||||
markRestartRequiredAfterUpdate,
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
|
||||
} from '../lib/appUpdater'
|
||||
|
||||
describe('pickFolder', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
openMock.mockReset()
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns user input from prompt in browser mode', async () => {
|
||||
@@ -70,6 +75,15 @@ describe('pickFolder', () => {
|
||||
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
|
||||
})
|
||||
|
||||
it('translates an NSOpenPanel panic into the restart-required folder picker error', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
openMock.mockRejectedValue('panic: unexpected NULL returned from +[NSOpenPanel openPanel]')
|
||||
|
||||
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
|
||||
expect(markRestartRequiredAfterUpdate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('normalizes a native single-selection array to its first folder path', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
@@ -98,6 +112,7 @@ describe('pickFolder', () => {
|
||||
const secondRequest = pickFolder('Open vault folder')
|
||||
|
||||
await expect(secondRequest).resolves.toBeNull()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(openMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveOpen?.('/Users/test/restored-vault')
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
isRestartRequiredAfterUpdate,
|
||||
markRestartRequiredAfterUpdate,
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
|
||||
} from '../lib/appUpdater'
|
||||
|
||||
const NS_OPEN_PANEL_UNAVAILABLE_MARKER = 'unexpected NULL returned from +[NSOpenPanel openPanel]'
|
||||
|
||||
export class NativeFolderPickerBlockedError extends Error {
|
||||
constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) {
|
||||
super(message)
|
||||
@@ -23,6 +26,16 @@ export function isNativeFolderPickerBlockedError(
|
||||
return error instanceof NativeFolderPickerBlockedError
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
return ''
|
||||
}
|
||||
|
||||
function isUnavailableNativeFolderPicker(error: unknown): boolean {
|
||||
return errorMessage(error).includes(NS_OPEN_PANEL_UNAVAILABLE_MARKER)
|
||||
}
|
||||
|
||||
export function formatFolderPickerActionError(
|
||||
action: string,
|
||||
error: unknown,
|
||||
@@ -31,12 +44,7 @@ export function formatFolderPickerActionError(
|
||||
return error.message
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: ''
|
||||
const message = errorMessage(error)
|
||||
|
||||
return message ? `${action}: ${message}` : action
|
||||
}
|
||||
@@ -77,6 +85,28 @@ function normalizePickedFolderPath(selected: string | string[] | null): string |
|
||||
|
||||
let folderPickerRequestInFlight = false
|
||||
|
||||
async function pickNativeFolder(title?: string): Promise<string | null> {
|
||||
if (isRestartRequiredAfterUpdate()) {
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: title ?? 'Select folder',
|
||||
})
|
||||
return normalizePickedFolderPath(selected)
|
||||
} catch (error) {
|
||||
if (isUnavailableNativeFolderPicker(error)) {
|
||||
markRestartRequiredAfterUpdate()
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
|
||||
* Returns the selected folder path, or null if the user cancelled.
|
||||
@@ -87,17 +117,7 @@ export async function pickFolder(title?: string): Promise<string | null> {
|
||||
folderPickerRequestInFlight = true
|
||||
try {
|
||||
if (isTauri()) {
|
||||
if (isRestartRequiredAfterUpdate()) {
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: title ?? 'Select folder',
|
||||
})
|
||||
return normalizePickedFolderPath(selected)
|
||||
return await pickNativeFolder(title)
|
||||
}
|
||||
return normalizePickedFolderPath(prompt(title ?? 'Enter folder path:'))
|
||||
} finally {
|
||||
|
||||
@@ -207,6 +207,7 @@ test('toolbar only exposes audited markdown-safe formatting controls', async ({
|
||||
await expect(page.locator('.bn-formatting-toolbar [data-test="bold"]')).toBeVisible()
|
||||
await expect(page.locator('.bn-formatting-toolbar [data-test="italic"]')).toBeVisible()
|
||||
await expect(page.locator('.bn-formatting-toolbar [data-test="code"]')).toBeVisible()
|
||||
await expect(page.locator('.bn-formatting-toolbar [data-test="highlight"]')).toBeVisible()
|
||||
await expect(page.locator('.bn-formatting-toolbar [data-test="strike"]')).toBeVisible()
|
||||
await expect(page.locator('.bn-formatting-toolbar [data-test="createLink"]')).toBeVisible()
|
||||
|
||||
@@ -258,6 +259,46 @@ test('Obsidian-style highlight markdown renders and persists', async ({ page })
|
||||
expect(await getRawEditorContent(page)).toContain(highlightedLine)
|
||||
})
|
||||
|
||||
test('Obsidian-style highlight markdown typed in rich mode renders and persists', async ({ page }) => {
|
||||
await openNote(page, 'Note B')
|
||||
|
||||
const block = page.locator('.bn-block-content').nth(1)
|
||||
await block.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.type(' Plain ==rich-marked==')
|
||||
|
||||
await expect(page.locator('.bn-editor mark.markdown-highlight', { hasText: 'rich-marked' })).toBeVisible()
|
||||
await expect(block).not.toContainText('==rich-marked==')
|
||||
|
||||
await roundTripThroughAnotherNote(page)
|
||||
await openRawMode(page)
|
||||
|
||||
expect(await getRawEditorContent(page)).toContain('Plain ==rich-marked==')
|
||||
})
|
||||
|
||||
test('toolbar highlight button toggles selected text and persists removal', async ({ page }) => {
|
||||
await openNote(page, 'Note B')
|
||||
await selectWord(page, 1, 'referenced')
|
||||
|
||||
await page.locator('.bn-formatting-toolbar [data-test="highlight"]').click()
|
||||
await expect(page.locator('.bn-editor mark.markdown-highlight', { hasText: 'referenced' })).toBeVisible()
|
||||
|
||||
await roundTripThroughAnotherNote(page)
|
||||
await openRawMode(page)
|
||||
expect(await getRawEditorContent(page)).toContain('This is Note B, ==referenced== by Alpha Project.')
|
||||
|
||||
await openBlockNoteMode(page)
|
||||
await selectWord(page, 1, 'referenced')
|
||||
await page.locator('.bn-formatting-toolbar [data-test="highlight"]').click()
|
||||
await expect(page.locator('.bn-editor mark.markdown-highlight', { hasText: 'referenced' })).toHaveCount(0)
|
||||
|
||||
await roundTripThroughAnotherNote(page)
|
||||
await openRawMode(page)
|
||||
const raw = await getRawEditorContent(page)
|
||||
expect(raw).toContain('This is Note B, referenced by Alpha Project.')
|
||||
expect(raw).not.toContain('==referenced==')
|
||||
})
|
||||
|
||||
test('toolbar block-type commands persist numbered lists', async ({ page }) => {
|
||||
await openNote(page, 'Note B')
|
||||
await selectWord(page, 1, 'This')
|
||||
|
||||
@@ -75,6 +75,28 @@ async function dispatchAppCommand(page: Page, id: string): Promise<void> {
|
||||
}, id)
|
||||
}
|
||||
|
||||
async function openQuickOpenFromKeyboard(page: Page): Promise<void> {
|
||||
await dispatchShortcutEvent(page, {
|
||||
key: 'p',
|
||||
code: 'KeyP',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
function selectedQuickOpenTitle(page: Page) {
|
||||
return page.getByTestId('quick-open-palette').locator('div.bg-accent > button:has([data-testid="note-search-item-icon"])').first()
|
||||
}
|
||||
|
||||
function quickOpenTitleAt(page: Page, index: number) {
|
||||
return page.getByTestId('quick-open-palette').locator('button:has([data-testid="note-search-item-icon"])').nth(index)
|
||||
}
|
||||
|
||||
async function installGlobalSearchResultsHarness(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => Boolean(window.__mockHandlers?.search_vault))
|
||||
await page.evaluate((results) => {
|
||||
@@ -134,17 +156,7 @@ test.describe('keyboard command routing', () => {
|
||||
test('desktop shortcut bridge opens quick open through both Cmd+P and Cmd+O @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
|
||||
await dispatchShortcutEvent(page, {
|
||||
key: 'p',
|
||||
code: 'KeyP',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
|
||||
await openQuickOpenFromKeyboard(page)
|
||||
await expect(page.locator('input[placeholder="Search notes..."]')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
@@ -164,6 +176,31 @@ test.describe('keyboard command routing', () => {
|
||||
await expect(page.locator('input[placeholder="Search notes..."]')).toBeFocused()
|
||||
})
|
||||
|
||||
test('quick open ignores a stationary pointer until it moves @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await openQuickOpenFromKeyboard(page)
|
||||
|
||||
const initialTitle = await selectedQuickOpenTitle(page).textContent()
|
||||
const secondTitle = quickOpenTitleAt(page, 1)
|
||||
const targetTitle = await secondTitle.textContent()
|
||||
const secondTitleBox = await secondTitle.boundingBox()
|
||||
if (!initialTitle) throw new Error('Quick open did not select an initial note')
|
||||
if (!targetTitle) throw new Error('Quick open did not render a second note title')
|
||||
if (!secondTitleBox) throw new Error('Quick open did not render a second note box')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByTestId('quick-open-palette')).not.toBeVisible({ timeout: 5_000 })
|
||||
await page.mouse.move(
|
||||
secondTitleBox.x + secondTitleBox.width / 2,
|
||||
secondTitleBox.y + secondTitleBox.height / 2,
|
||||
)
|
||||
|
||||
await openQuickOpenFromKeyboard(page)
|
||||
await expect(selectedQuickOpenTitle(page)).toHaveText(initialTitle)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect(selectedQuickOpenTitle(page)).toHaveText(targetTitle)
|
||||
})
|
||||
|
||||
test('global search arrow keys move one result at a time @smoke', async ({ page }) => {
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await installGlobalSearchResultsHarness(page)
|
||||
|
||||
Reference in New Issue
Block a user