Compare commits
50 Commits
alpha-v202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3caab862 | ||
|
|
2556907f36 | ||
|
|
8e3f8fbb77 | ||
|
|
501df0e265 | ||
|
|
48eac1e40d | ||
|
|
3d28e21a5a | ||
|
|
191066a3bf | ||
|
|
3a88724d8f | ||
|
|
65ea3f37fc | ||
|
|
0e81f20e31 | ||
|
|
3c8edd8346 | ||
|
|
80ee94da73 | ||
|
|
008e8f3471 | ||
|
|
6aaaca8e24 | ||
|
|
c5dc4a9300 | ||
|
|
25c7ba0926 | ||
|
|
2b1f4395b3 | ||
|
|
e3ef3f6073 | ||
|
|
8d47b8d7d2 | ||
|
|
8fc366c8c0 | ||
|
|
42d7a3ac44 | ||
|
|
3e349fa017 | ||
|
|
8138925bb0 | ||
|
|
cc4203ab1c | ||
|
|
401087a8ef | ||
|
|
0053a36b6e | ||
|
|
762ac4ff48 | ||
|
|
4716003ca1 | ||
|
|
af9cc118e7 | ||
|
|
95bdbc90c7 | ||
|
|
12a229e604 | ||
|
|
cc52a52216 | ||
|
|
057c73376c | ||
|
|
a194763847 | ||
|
|
6945de0060 | ||
|
|
4e81ab8aa3 | ||
|
|
1309cf322e | ||
|
|
c9467711ad | ||
|
|
82b2ff2ac4 | ||
|
|
34a22c485d | ||
|
|
c12aa4f4fd | ||
|
|
6c979addb3 | ||
|
|
7fa284f0d0 | ||
|
|
74891e0283 | ||
|
|
f003b38518 | ||
|
|
b84d6579da | ||
|
|
4f52f3e803 | ||
|
|
83cd7bc20d | ||
|
|
0cdf758f7d | ||
|
|
cba038766c |
4
.github/workflows/release-stable.yml
vendored
4
.github/workflows/release-stable.yml
vendored
@@ -67,7 +67,9 @@ jobs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
macos_bundles: ""
|
||||
upload_macos_dmg: true
|
||||
require_windows_authenticode: true
|
||||
# One-time stable promotion exceptions while Windows certificate secrets
|
||||
# are still being provisioned. Future stable tags must keep Authenticode.
|
||||
require_windows_authenticode: ${{ !contains(fromJson('["v2026-06-01","v2026-06-06"]'), needs.version.outputs.tag) }}
|
||||
secrets: inherit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -175,7 +175,7 @@ interface WorkspaceIdentity {
|
||||
|
||||
The status-bar workspace manager edits installation-local identity and mount state. The alias is the durable user-facing namespace for cross-workspace links such as `[[team/projects/alpha]]`; labels and colors are display affordances only. The default workspace controls where new notes and Type files are created; it is not a claim that only one vault is active. When multiple workspaces are enabled, every mounted available workspace participates in the graph and the active Git repository set.
|
||||
|
||||
Git-facing renderer code must pass an explicit repository path instead of assuming a single active vault. Changes and Pulse/history display one selected repository at a time, manual commit selects one target repository, and AutoGit checkpoints iterate every active repository. Diff, file history, note saves, and discarded changes resolve the repository from the note's workspace provenance or from the selected Git surface.
|
||||
Git-facing renderer code must pass an explicit repository path instead of assuming a single active vault. Changes and Pulse/history display one selected repository at a time, manual commit selects one target repository, and AutoGit checkpoints iterate every active repository. Diff, file history, note saves, and discarded changes resolve the repository from the note's workspace provenance or from the selected Git surface. Manual Sync refreshes vault-derived sidebar state and bumps a shared Git history refresh key after successful pulls, including `up_to_date` pulls, while automatic up-to-date checks avoid that heavier reload path.
|
||||
|
||||
`useGitFileWorkflows` is the renderer abstraction for note-scoped Git file actions. It translates active tabs, visible entries, and modified-file surfaces into the correct repository path for diff/history commands, deleted-note previews, queued editor diff requests, and discard refresh behavior.
|
||||
|
||||
@@ -199,7 +199,7 @@ Deep links are navigation-only. Opening one can focus Tolaria, switch to a regis
|
||||
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. On Linux AppImage builds, `should_use_external_media_preview` can disable in-webview audio/video rendering so the same file blocks show filename/external-open fallback controls instead of triggering unstable WebKitGTK media playback. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
|
||||
Markdown note PDF export is not a stored file-kind transformation. `src/utils/notePdfExport.ts` temporarily marks the current webview for print and invokes Tauri's native webview print command, and `src/components/useEditorPdfExport.ts` ensures the rich rendered note is active before the system print/save-to-PDF dialog opens. The PDF reflects the current rendered editor DOM and leaves the vault file unchanged.
|
||||
Markdown note PDF export is not a stored file-kind transformation. `src/utils/notePdfExport.ts` temporarily marks the current webview for print-only rendering, asks for a `.pdf` filesystem destination only when the native capability reports direct save support, and invokes Tauri's native `WKWebView` PDF export command on macOS. Windows/Linux Tauri builds and browser mode keep print-dialog fallback behavior. `src/components/useEditorPdfExport.ts` ensures the rich rendered note is active before export, so frontmatter is ignored and the PDF reflects the current rendered editor DOM while leaving the vault file unchanged.
|
||||
|
||||
### Note Content Freshness
|
||||
|
||||
@@ -373,6 +373,8 @@ In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-o
|
||||
|
||||
`useMcpSetupDialogController()` owns MCP setup dialog state, busy actions, and manual config callbacks so `App.tsx` only passes the controller into settings/status surfaces. `useAiWorkspaceWindowBridgeEvents()` owns native AI-workspace event subscriptions and listener cleanup for popped-out workspace windows.
|
||||
|
||||
`createCrossWindowPersistedStore()` is the shared renderer primitive for AI workspace state that must stay synchronized across the main window and popped-out workspace windows. It owns localStorage reads/writes, BroadcastChannel publishing, storage-event synchronization, and external-store subscribers; domain modules such as `aiWorkspaceSessionStore` and `aiWorkspaceWindowSharedContext` provide sanitizers and mutations around that shell.
|
||||
|
||||
The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar treats saved View rows like Type rows for direct customization: double-click starts inline rename, right-click opens edit/rename/icon-color/delete actions, and keyboard users can open that same menu from the focused row while command-palette actions remain responsible for saved View ordering.
|
||||
|
||||
### Neighborhood Mode
|
||||
@@ -429,7 +431,7 @@ Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts`
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. `mcp-server/tool-service.js` owns the shared tool semantics for active-vault resolution, cross-vault lookup/search, note-creation defaults, vault listing, and UI action intents; `mcp-server/index.js` and `mcp-server/ws-bridge.js` remain transport adapters around that service. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
@@ -514,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 |
|
||||
@@ -529,7 +532,7 @@ interface PulseCommit {
|
||||
- Refreshes aggregate remote status after a pull, and avoids a separate startup status fetch when the initial pull will already refresh it
|
||||
- Pushes the active repository set during divergence recovery
|
||||
- Awaits the post-pull vault refreshes so toasts land after note-list state is fresh
|
||||
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
|
||||
- Reopens the clean active tab from disk only when the pull changed that active note, then restores editor focus if the editor owned focus before the remount
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks aggregate remote status (ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
@@ -538,7 +541,7 @@ interface PulseCommit {
|
||||
|
||||
### External Vault Refresh
|
||||
|
||||
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note only when the changed-path list includes that note, and closes the active tab if the file disappeared. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
|
||||
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note when the changed-path list includes that note, and closes the active tab if the file disappeared. Editor focus does not block the clean active note from converging to disk when its own file changed externally; if the active editor owned focus before that remount, the app requests editor focus again after the fresh tab is mounted. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
|
||||
|
||||
`useGitRepositories` is the commit-time companion to `useAutoSync`:
|
||||
- Owns repository picker validation plus `get_modified_files` and `git_remote_status` loading for active Git repositories
|
||||
@@ -604,7 +607,7 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
|
||||
|
||||
- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes.
|
||||
- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note.
|
||||
- Double-clicking rendered math, or pressing `Enter`/`F2` when a math node is selected, restores the Markdown source and selects only the formula body for editing.
|
||||
- Double-clicking rendered display math edits the math block's `latex` property in-place; Markdown delimiters remain owned by serialization. Inline math can still be reopened as source text for direct editing.
|
||||
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
|
||||
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
|
||||
|
||||
@@ -627,6 +630,7 @@ Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdow
|
||||
- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file.
|
||||
- Whiteboard prop writes re-resolve the live BlockNote block by id before mutating it, and disappear as no-ops if a note reload or mode switch has already removed that block.
|
||||
- The tldraw runtime receives Tolaria's resolved light/dark mode as its user color scheme, so embedded whiteboards follow the app appearance and update while mounted.
|
||||
- Embedded whiteboards expose a session-only full-window workspace that reuses the same tldraw store and Markdown snapshot; expanding or closing it does not persist camera, tool, or size state.
|
||||
- Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner.
|
||||
- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts.
|
||||
|
||||
@@ -636,10 +640,12 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
|
||||
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
|
||||
- `SingleEditorView` owns a whitespace mouse-selection bridge around BlockNote and its rich-editor scroll area: drag starts that land outside the editable text DOM are remapped through the ProseMirror view with clamped coordinates, while drags below the rendered document fall back to the document end. Drags that begin inside BlockNote's contenteditable surface, toolbars, side menu, dialogs, or non-primary mouse buttons stay on BlockNote/native handling.
|
||||
- `editorRichCopy.ts` owns rich-editor copy serialization for external apps. Normal selections use BlockNote's external clipboard HTML so tables, lists, checklists, and inline marks paste as rich content outside Tolaria, while `SingleEditorView` still normalizes `text/plain` and keeps fenced code-block selections on raw code text.
|
||||
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
|
||||
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
|
||||
- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
|
||||
- `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.
|
||||
@@ -849,7 +855,8 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
|
||||
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
|
||||
- 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
|
||||
- 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
|
||||
- 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, 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
|
||||
@@ -905,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. `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
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ flowchart LR
|
||||
|
||||
#### External Change Detection
|
||||
|
||||
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
|
||||
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and clean active-editor content refresh under the ADR-0135 unsaved-edit rules. When that clean active-editor remount starts from a focused editor, the main app dispatches a post-replacement editor focus request so typing can continue in the refreshed tab. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
|
||||
|
||||
#### Progressive Vault Loading
|
||||
|
||||
@@ -229,11 +229,12 @@ 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.
|
||||
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and opens the platform print/save-to-PDF dialog through the Tauri `print_current_webview` command, with `window.print()` as the browser fallback. The export reuses rendered BlockNote output so 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.
|
||||
- **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).
|
||||
|
||||
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat.
|
||||
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat orchestration, sidebar tabs, installed-only target picker, permission-mode picker, and dock/pop-out controls. Header/guidance chrome lives in `AiWorkspaceChrome`, edge-resize handles in `AiWorkspaceResizeHandles`, conversation metadata/settings persistence in `aiWorkspaceConversations`, and sizing/class/style helpers in `aiWorkspaceSizing`. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior.
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
|
||||
|
||||
@@ -241,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.
|
||||
|
||||
@@ -279,7 +280,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous permission-bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Codex, OpenCode, Pi, Gemini, and Kiro all launch from the active vault cwd with transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault.
|
||||
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path. Claude Code launches also copy a narrow set of exported provider/auth environment variables from the app process or the user's zsh/bash startup files, including Anthropic API/base URL values, so company proxy and API-key setups that work in Terminal also work when Tolaria is opened from Finder or Dock. Windows npm `.cmd` shims are not spawned directly; the shared CLI runtime resolves them to their quoted Node script or native executable target first so prompt arguments do not hit batch-file argument validation.
|
||||
|
||||
CLI-agent system prompts also include a local Tolaria docs orientation when the bundled docs resource is present. `scripts/build-agent-docs.mjs` generates `src-tauri/resources/agent-docs/` from the public VitePress Markdown sources, including `index.md`, `AGENTS.md`, per-section bundles, `all.md`, `search-index.json`, and generated per-page files. Tauri bundles that folder as `agent-docs/`; `get_agent_docs_path` resolves the installed resource path, with a repository fallback for development, and `getAgentDocsPath()` caches it before each agent run. Agents are instructed to read the active vault's `AGENTS.md` for local conventions and search the bundled docs for Tolaria product behavior.
|
||||
|
||||
@@ -342,17 +343,18 @@ 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. Local endpoints can omit authentication.
|
||||
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.
|
||||
|
||||
### Authentication
|
||||
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup.
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login or user-managed Anthropic/provider environment variables; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup.
|
||||
|
||||
## MCP Server
|
||||
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
|
||||
The stdio entrypoint and desktop WebSocket bridge share `mcp-server/tool-service.js` for mounted-vault resolution, note lookup/search, note creation defaults, vault listing, and UI action intents; `index.js` and `ws-bridge.js` only adapt those semantics to their transport-specific request and response shapes.
|
||||
|
||||
### Tool Surface
|
||||
|
||||
@@ -360,7 +362,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
|------|--------|-------------|
|
||||
| `open_note` | `path` | Open and read a note by relative path |
|
||||
| `read_note` | `path` | Read note content (alias for `open_note`) |
|
||||
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
|
||||
| `create_note` | `path, content, [title], [type], [vaultPath]` | Create a new markdown note inside an active vault without overwriting existing files |
|
||||
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
|
||||
| `list_vaults` | — | List active mounted vaults and whether each has root `AGENTS.md` instructions |
|
||||
| `append_to_note` | `path, text` | Append text to end of existing note |
|
||||
@@ -398,12 +400,15 @@ That setup is user-initiated through the status bar / command palette flow, not
|
||||
flowchart TD
|
||||
subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"]
|
||||
IDX["index.js"]
|
||||
SVC["tool-service.js\n(vault resolution, note operations,\nUI action intents)"]
|
||||
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
|
||||
WSB["ws-bridge.js"]
|
||||
|
||||
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"]
|
||||
IDX --> VAULT
|
||||
IDX --> WSB
|
||||
IDX --> SVC
|
||||
SVC --> VAULT
|
||||
IDX -.->|"UI action WebSocket client"| WSB
|
||||
WSB --> SVC
|
||||
WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"]
|
||||
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
|
||||
end
|
||||
@@ -540,7 +545,7 @@ If the selected vault disappears after startup, `useVaultLoader` re-checks `chec
|
||||
|
||||
When an opened folder is not yet a git repo, Tolaria can show a Git setup dialog with Initialize, Not now, and Never for this vault actions. The Never choice stores a local per-vault `git_setup_preference` so the automatic dialog does not return for that vault, while manual initialization remains reachable from Git commands when global Git features are enabled. Markdown scanning, note browsing, note editing, and search continue normally in plain folders. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures Git can resolve an author identity without overriding one the user configured: it heals the legacy repo-local `vault@tolaria.md` email earlier versions wrote, respects identities resolvable from local, global, or system scope, skips the legacy email wherever it resolves, and only when nothing resolves writes a repo-local `Tolaria <vault@tolaria.default>` fallback. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
|
||||
@@ -649,8 +654,10 @@ flowchart TD
|
||||
RV --> TAB{"clean active tab?"}
|
||||
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
|
||||
TAB -->|No| DONE["idle"]
|
||||
RT --> DONE
|
||||
PC -->|Up to date| DONE["idle"]
|
||||
RT --> RF["restore editor focus\nwhen previously focused"]
|
||||
RF --> DONE
|
||||
PC -->|Manual up to date| RV
|
||||
PC -->|Auto up to date| DONE["idle"]
|
||||
|
||||
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
|
||||
RS --> RCHK["invoke('git_remote_status')"]
|
||||
@@ -671,6 +678,8 @@ flowchart TD
|
||||
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
|
||||
```
|
||||
|
||||
Manual Sync forces a visible-state refresh even when `git_pull` reports `up_to_date`, because the working tree may have already changed through another process while the app still holds stale vault and History state. Updated pulls refresh the vault index, folders, saved views, clean active-editor content, and Git history surfaces; manual up-to-date pulls refresh the vault/sidebar surfaces with unknown changed files and bump the History refresh key without showing a "Pulled 0 updates" toast. Automatic mount/focus/interval up-to-date checks stay cheap and do not reload the vault.
|
||||
|
||||
`useGitRemoteStatus` re-checks `git_remote_status` for the default repository, and `useCommitFlow` can resolve remote status for an explicit selected repository when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps that repository's flow local-only: the status bar shows a neutral `No remote` chip for the default repository, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
|
||||
|
||||
If the current vault is not a Git repository, Tolaria treats Git as unavailable instead of degraded. With global Git features enabled, the status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup unless the user has chosen not to be prompted automatically for that vault. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
|
||||
@@ -773,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 |
|
||||
@@ -912,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, and the breadcrumb overflow 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:
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ tolaria/
|
||||
│ ├── theme.json # Editor typography theme configuration
|
||||
│ ├── index.css # Semantic app theme variables + Tailwind setup
|
||||
│ │
|
||||
│ ├── components/ # UI components (~98 files)
|
||||
│ ├── components/ # UI components (~100 files)
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
|
||||
│ │ ├── SidebarParts.tsx # Sidebar subcomponents
|
||||
│ │ ├── NoteList.tsx # Second panel: filtered note list
|
||||
@@ -120,7 +120,9 @@ tolaria/
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace (docked or native window)
|
||||
│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace orchestration (docked or native window)
|
||||
│ │ ├── AiWorkspaceChrome.tsx # AI workspace header and vault-guidance chrome
|
||||
│ │ ├── AiWorkspaceResizeHandles.tsx # AI workspace edge resize handles
|
||||
│ │ ├── AiPanel.tsx # AI transcript/composer surface (selected target + per-vault permission mode)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
@@ -247,7 +249,7 @@ tolaria/
|
||||
│ └── icons/ # App icons
|
||||
│
|
||||
├── mcp-server/ # MCP bridge (Node.js or Bun)
|
||||
│ ├── index.js # MCP server entry (stdio, 14 tools)
|
||||
│ ├── index.js # MCP server entry (stdio tools)
|
||||
│ ├── vault.js # Vault file operations
|
||||
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
|
||||
│ ├── test.js # MCP server tests
|
||||
@@ -333,7 +335,11 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace — sidebar chats, installed-only target picker, permission picker, and dock/pop-out controls. |
|
||||
| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace orchestration — chat sessions, sidebar tabs, target/permission controls, and dock/pop-out wiring. |
|
||||
| `src/components/AiWorkspaceChrome.tsx` | Header and vault-guidance chrome shared by docked and popped-out AI workspace modes. |
|
||||
| `src/components/AiWorkspaceResizeHandles.tsx` | Edge resize affordances for docked/side AI workspace layouts. |
|
||||
| `src/components/aiWorkspaceConversations.ts` | Conversation metadata state, settings persistence, default title generation, and target resolution. |
|
||||
| `src/components/aiWorkspaceSizing.ts` | AI workspace sizing, localStorage persistence, class names, and layout style helpers. |
|
||||
| `src/components/AiPanel.tsx` | Reusable AI transcript/composer surface — selected target with tool execution, reasoning, actions, and per-vault permission mode. |
|
||||
| `src/utils/openAiWorkspaceWindow.ts` | Native Tauri AI workspace window creation, focus, and dock-back traffic-light handling. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# Large Vault Loading QA
|
||||
|
||||
Use this when validating startup responsiveness for large vaults. The goal is to make the bottleneck reproducible without using a real user vault.
|
||||
|
||||
## Synthetic Vault
|
||||
|
||||
Create a disposable vault with many markdown files:
|
||||
|
||||
```bash
|
||||
VAULT="$(mktemp -d /tmp/tolaria-large-vault.XXXXXX)"
|
||||
mkdir -p "$VAULT/type" "$VAULT/archive" "$VAULT/assets"
|
||||
cat > "$VAULT/type/project.md" <<'EOF'
|
||||
---
|
||||
is_a: Type
|
||||
---
|
||||
# Project
|
||||
EOF
|
||||
|
||||
for i in $(seq -w 1 20000); do
|
||||
cat > "$VAULT/project-$i.md" <<EOF
|
||||
---
|
||||
is_a: Project
|
||||
status: Active
|
||||
related_to:
|
||||
- "[[project-00001]]"
|
||||
---
|
||||
# Project $i
|
||||
|
||||
Synthetic body $i with enough text to exercise parsing and snippets.
|
||||
EOF
|
||||
done
|
||||
|
||||
git -C "$VAULT" init
|
||||
git -C "$VAULT" config user.email qa@example.invalid
|
||||
git -C "$VAULT" config user.name "Tolaria QA"
|
||||
git -C "$VAULT" add .
|
||||
git -C "$VAULT" commit -m "seed large vault"
|
||||
echo "$VAULT"
|
||||
```
|
||||
|
||||
## Manual QA
|
||||
|
||||
1. Start Tolaria with `pnpm tauri dev`.
|
||||
2. Open the synthetic vault path printed above.
|
||||
3. Verify the main shell renders before the full note list finishes indexing.
|
||||
4. Confirm the status bar shows vault activity while indexing is still in progress.
|
||||
5. Use keyboard-only flows while indexing continues:
|
||||
- Cmd+K opens the command palette.
|
||||
- Cmd+P opens quick open; results may be partial or empty until indexing finishes.
|
||||
- Create a new note with Cmd+N and type in the editor.
|
||||
6. Wait for indexing to finish and verify the note list/search state is consistent.
|
||||
|
||||
The synthetic vault lives under `/tmp`; remove it after QA if it is no longer needed.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0135"
|
||||
title: "Clean active notes refresh immediately after external edits"
|
||||
status: active
|
||||
date: 2026-05-30
|
||||
supersedes: "0111"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0111 made external vault refreshes path-aware and preserved focused editor mounts so unrelated watcher events would not disrupt cursor state. That avoided needless remount churn, but it also meant a clean active note edited by Codex or another external process could remain visibly stale while the editor stayed focused. Because no later safe-remount trigger was guaranteed, users could see the old content until a full app restart.
|
||||
|
||||
Tolaria's filesystem-first model requires clean in-memory editor state to converge to the file on disk during the current session. Unsaved local editor buffers still need protection, but editor focus alone is not enough reason to keep showing stale content when the changed-path batch identifies the active file.
|
||||
|
||||
## Decision
|
||||
|
||||
**External vault refreshes now remount a clean active note immediately when the external changed-path batch includes that note, regardless of editor focus.**
|
||||
|
||||
The shared `refreshPulledVaultState()` path applies these rules:
|
||||
|
||||
1. Reload vault entries, folders, and saved views together for every external change batch.
|
||||
2. If there is no active note, stop after the shared reload.
|
||||
3. If the active note changed during the async reload, stop rather than reopening stale context.
|
||||
4. If the active note has unsaved local edits, keep the current editor buffer mounted.
|
||||
5. If the active file disappeared, close the tab instead of leaving a stale editor behind.
|
||||
6. If the changed-path batch includes the clean active file, close and reopen the active tab from disk even when focus is inside the rich or raw editor.
|
||||
7. Unknown or unrelated change batches refresh vault-derived state without remounting the active editor.
|
||||
|
||||
Git pulls, AI-agent refresh callbacks, and filesystem-watcher batches continue to converge through this single reconciliation helper instead of adding separate reload policies.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Immediate clean active-note remount** (chosen): restores filesystem convergence for Codex and other external note edits while preserving unsaved local edits. Cons: a focused clean editor can lose cursor state when its own file changes externally.
|
||||
- **Keep focused-editor preservation from ADR-0111**: avoids cursor disruption, but can leave the active editor stale indefinitely.
|
||||
- **Defer active-note reload until blur**: reduces focus disruption, but adds another pending-refresh state machine and still allows the active editor to show stale disk content for an unbounded editing session.
|
||||
|
||||
## Consequences
|
||||
|
||||
- External edits to the currently open clean note become visible without restarting Tolaria.
|
||||
- Unsaved local content remains authoritative and is not replaced by watcher, pull, or agent refreshes.
|
||||
- The changed-path batch remains part of the external-refresh contract; callers should pass specific file paths whenever available.
|
||||
- Unrelated watcher events still avoid active-editor remounts, so broad vault churn does not disturb the editor unless the active file itself changed.
|
||||
- ADR-0111 is superseded by this stronger filesystem-convergence rule.
|
||||
31
docs/adr/0136-macos-webview-pdf-export.md
Normal file
31
docs/adr/0136-macos-webview-pdf-export.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# ADR-0136: macOS Webview PDF Export
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The first note PDF export implementation reused the native webview print command. On macOS that opens the full printer dialog, which is not the product behavior expected from "Export note as PDF"; users should choose a filesystem destination and get a PDF directly.
|
||||
|
||||
Tolaria already renders the exportable note in the live BlockNote DOM and applies print-only CSS so math, Mermaid, images, code blocks, tables, links, and custom blocks follow the same rendering path users see in the editor. Introducing a second Markdown-to-PDF renderer would duplicate that rendering logic and create drift.
|
||||
|
||||
## Decision
|
||||
|
||||
Use the existing Tauri webview and WebKit's own print operation to save the current webview directly to a chosen PDF path. The renderer remains responsible for export preparation:
|
||||
|
||||
- exit raw/diff modes
|
||||
- apply the PDF export body class
|
||||
- ask the user for a `.pdf` destination
|
||||
- invoke the native `export_current_webview_pdf` command
|
||||
|
||||
The native command uses direct `objc2`, `objc2-app-kit`, `objc2-foundation`, and `objc2-web-kit` dependencies on macOS only. These crates are already part of Tauri's platform stack; declaring them directly lets Tolaria ask `WKWebView` for a WebKit-aware `NSPrintOperation` without adding a separate PDF rendering engine.
|
||||
|
||||
Windows, Linux, and browser mode keep print-dialog fallback behavior because they do not have the macOS WebKit/AppKit direct PDF save path yet. The renderer checks the native capability before opening a filesystem save dialog, so unsupported platforms do not ask for a destination that cannot be used.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The macOS path opens a save-file dialog, not the printer dialog.
|
||||
- The exported PDF keeps using the rendered note DOM, so frontmatter stays excluded by the existing rich-editor body extraction.
|
||||
- The feature depends on macOS WebKit/AppKit behavior for direct PDF output. Other desktop platforms use the existing native print dialog until they get a platform-specific direct PDF path.
|
||||
- The new direct dependencies must stay target-scoped to macOS so Linux and Windows builds do not compile AppKit crates.
|
||||
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.
|
||||
@@ -164,7 +164,7 @@ proposed → active → superseded
|
||||
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
|
||||
| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active |
|
||||
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | superseded → [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) |
|
||||
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active |
|
||||
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | superseded → [0135](0135-clean-active-note-refresh-after-external-edit.md) |
|
||||
| [0112](0112-system-theme-mode.md) | System theme mode | active |
|
||||
| [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active |
|
||||
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
|
||||
@@ -186,3 +186,5 @@ proposed → active → superseded
|
||||
| [0132](0132-alpha-authenticode-soft-gate.md) | Alpha Authenticode soft gate | active |
|
||||
| [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 |
|
||||
|
||||
71
lara.lock
71
lara.lock
@@ -17,6 +17,63 @@ files:
|
||||
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
|
||||
command.contribute: 9887a4451812854f0f1b6f669a874307
|
||||
command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f
|
||||
menu.application: bc16b1fbe2e90ace4991cafc5149955c
|
||||
menu.file: 0b27918290ff5323bea1e3b78a9cf04e
|
||||
menu.edit: 7dce122004969d56ae2e0245cb754d35
|
||||
menu.view: 4351cfebe4b61d8aa5efa1d020710005
|
||||
menu.go: 5f075ae3e1f9d0382bb8c4632991f96f
|
||||
menu.note: 3b0649c72650c313a357338dcdfb64ec
|
||||
menu.vault: 5b70a213f150f01f0776fa9481ef2ddf
|
||||
menu.window: c89686a387d2b12b3c729ce35a0bcb5b
|
||||
menu.file.quickOpen: d79cba7ca4badcccc081bdcba22abd32
|
||||
menu.file.quickOpenCmdO: 2f1c2a963833b85008562cd615515234
|
||||
menu.file.quickOpenCtrlO: 8aaba3ce15c61798ce80bda311fc1e2d
|
||||
menu.file.save: c9cc8cce247e49bae79f15173ce97354
|
||||
menu.edit.pasteWithoutFormatting: 0219dac5b3b9c457aa65eb19fdd3d22c
|
||||
menu.edit.findInVault: 7dad10e092a9fe1bfbb4a96c377bc417
|
||||
menu.edit.toggleNoteListSearch: f6e0cd9529f571609033958a55f18d78
|
||||
menu.view.allPanels: d33bf99e0756e1d5008cb78902e38ca9
|
||||
menu.view.zoomIn: f5ca4abce85e2dddb0342d0bae3a7270
|
||||
menu.view.zoomOut: 30850b501f98539b1aabaa29fabe41ef
|
||||
menu.view.actualSize: a5dac79ddad1cbdb8b60c5b347ee047e
|
||||
menu.view.commandPalette: c0a2436cd8b2dc5085c869e4a052572f
|
||||
menu.go.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5
|
||||
menu.go.archived: 7d69b3cb4cada18ae61811304f8fbcb5
|
||||
menu.go.changes: c112bb3542e98308d12d5ecb10a67abc
|
||||
menu.go.inbox: 3882d32c66e7e768145ecd8f104b0c08
|
||||
menu.note.toggleOrganized: 003f5648f585407c894e85543a5fda47
|
||||
menu.note.toggleTableOfContents: c464111f97490e65699fe18970fc00e1
|
||||
menu.vault.addRemote: 4d9d0f784920d6ef7f9f3b33266dd00e
|
||||
feedback.title: 96ab5025e38aef43fdb73c9830795264
|
||||
feedback.description: e900af4efb9167033d4ecc8a02cfe463
|
||||
feedback.sponsor.title: 2f6a1db54812f51924c3324a178125ed
|
||||
feedback.sponsor.description: 6517e0c5d4e4801814a6034dd54862b6
|
||||
feedback.sponsor.cta: a2c71ec076796ab6af0ba0ea882303fc
|
||||
feedback.sponsor.linkLabel: 7fbe24a8f025219f4d165aeb75352125
|
||||
feedback.featureRequests.title: 73ea5953d68b2c20a82bb94240f6fe17
|
||||
feedback.featureRequests.description: 9583575bdef16e12927b0a5acfa84307
|
||||
feedback.featureRequests.cta: c19ef0158349da249920b0d9f67d7d48
|
||||
feedback.featureRequests.linkLabel: d125f38d1dc8dbdc7dae3f1778df3a3a
|
||||
feedback.discussions.title: 560f23c77d499c21038c0b4487f03cb2
|
||||
feedback.discussions.description: 2e4da4c5f54c7af3b8db9f3d93ed63c5
|
||||
feedback.discussions.cta: 61ccb6a90ec8037f2926c48c0a90caa3
|
||||
feedback.discussions.linkLabel: 4f85e3343c5e21d2b11812b2baa8ed83
|
||||
feedback.contributeCode.title: 8f93fd72b63620711cc118403b85133e
|
||||
feedback.contributeCode.description: 53230626356951578842a8cd7233d1f1
|
||||
feedback.contributeCode.cta: c73dd5e7d432cd18c109b333e0b55299
|
||||
feedback.contributeCode.linkLabel: bfe9dd9816bb39f82fc003de794a075d
|
||||
feedback.contributingGuide.cta: 6cc1fbcbbc69a70a70a2020c979d8e22
|
||||
feedback.contributingGuide.linkLabel: b1310938ecab4c502a8b3af2ca1ce188
|
||||
feedback.reportBug.title: 3406147c3ce729ce17ed76d45c2896f9
|
||||
feedback.reportBug.description: 6da172412f774e022b0f32ba17305491
|
||||
feedback.reportBug.cta: 1f5aa905b40956ff5ea8b4cc5ea29315
|
||||
feedback.reportBug.linkLabel: ffaa2eaa0f015cf037d2b5fe8af6b813
|
||||
feedback.linkFallback.title: c5396ec50cec23a09977180747737264
|
||||
feedback.linkFallback.description: 91d1e18130765119606f095ac473d9cd
|
||||
feedback.copyDiagnostics: dedf5388697bba1fd53173b04823761a
|
||||
feedback.diagnosticsCopied: 9c7f3f37df9c000fc67c1a6ee35f40e5
|
||||
feedback.diagnosticsCopiedSentence: 6f8ebe492dbb95827ccf7536965dc672
|
||||
feedback.clipboardUnavailable: 8e25a424420687ed8e0dc2b1be8c7406
|
||||
command.group.navigation: 846495f9ceed11accf8879f555936a7d
|
||||
command.group.note: 3b0649c72650c313a357338dcdfb64ec
|
||||
command.group.git: 0bcc70105ad279503e31fe7b3f47b665
|
||||
@@ -317,6 +374,8 @@ files:
|
||||
ai.message.regenerate: e464a5330788a9f79d06c96989f840cb
|
||||
ai.message.copy: 53fa249ce3d02c8a686b657b31d02ee7
|
||||
ai.message.fork: f5aace5e5b5e7b0954651a2da44b6755
|
||||
ai.message.reasoning: 87a222577e9c7b7a0739d92337f4fe46
|
||||
ai.message.toolUse: d6093cfe0c95f805875e5724eecd20aa
|
||||
ai.workspace.title: 375099f0c804e924c1dc275370710d92
|
||||
ai.workspace.newChat: a917baaf1484ec5dc85f19ab8fc4f4e9
|
||||
ai.workspace.collapseSidebar: 9ecc763686b7c61547281f3e05ffd06f
|
||||
@@ -358,6 +417,10 @@ files:
|
||||
ai.workspace.target.installedVersion: 8677756d761e4cce240a637805482946
|
||||
ai.workspace.target.localModel: d34f4b997ce78da5aa57d657a5d6fcb5
|
||||
ai.workspace.target.apiModel: 589a257cbd265eaa7de93de8b4084dff
|
||||
window.minimize: d27532d90ecd513e97ab811c0f34dbfd
|
||||
window.maximize: 9369ba148ee259473fc1fb4939b6c2e8
|
||||
window.restore: 2bd339d85ee3b33e513359ce781b60cc
|
||||
window.close: d3d2e617335f08df83599665eef8a418
|
||||
mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01
|
||||
mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c
|
||||
mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa
|
||||
@@ -525,9 +588,14 @@ 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
|
||||
editor.exportPdf.failed: bf3fa78ff5725f5cbde8979b67ec2e2a
|
||||
filePreview.copyDeepLink: b75833669968adbef634495636e3fe50
|
||||
@@ -544,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
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* - search_notes: full-text search across vault notes
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
* - get_note: parsed frontmatter + content (convenience over raw cat)
|
||||
* - create_note: create a new markdown note without overwriting existing files
|
||||
* - open_note: signal Tolaria UI to open a note as a tab
|
||||
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
|
||||
* - refresh_vault: trigger vault rescan so new/modified files appear
|
||||
@@ -19,10 +20,7 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import WebSocket from 'ws'
|
||||
import { searchNotes, getNote } from './vault.js'
|
||||
import { requireVaultPaths } from './vault-path.js'
|
||||
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
|
||||
import path from 'node:path'
|
||||
import { createMcpToolService } from './tool-service.js'
|
||||
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
@@ -32,6 +30,12 @@ const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
})
|
||||
const LOCAL_CREATE_TOOL_ANNOTATIONS = Object.freeze({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
})
|
||||
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
@@ -40,10 +44,6 @@ let reconnectTimer = null
|
||||
let shutdownStarted = false
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function activeVaultPaths() {
|
||||
return requireVaultPaths()
|
||||
}
|
||||
|
||||
function connectUiBridge() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
@@ -109,6 +109,8 @@ function broadcastUiAction(action, payload) {
|
||||
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
|
||||
}
|
||||
|
||||
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'search_notes',
|
||||
@@ -156,6 +158,23 @@ const TOOLS = [
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_note',
|
||||
description: 'Create a new markdown note inside an active Tolaria vault. Does not overwrite existing files. Use content for the full markdown including YAML frontmatter and H1.',
|
||||
annotations: LOCAL_CREATE_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path inside the vault, or an absolute path inside an active vault. Must end in .md.' },
|
||||
content: { type: 'string', description: 'Full markdown note content, including YAML frontmatter 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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
@@ -196,80 +215,8 @@ const TOOLS = [
|
||||
},
|
||||
]
|
||||
|
||||
function requestedVaultPath(args = {}) {
|
||||
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
|
||||
if (!requested) return null
|
||||
if (!activeVaultPaths().includes(requested)) {
|
||||
throw new Error(`Vault is not active in Tolaria: ${requested}`)
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
function vaultLabel(vaultPath) {
|
||||
return path.basename(vaultPath) || vaultPath
|
||||
}
|
||||
|
||||
function withVaultMetadata(note, vaultPath) {
|
||||
return {
|
||||
...note,
|
||||
vaultPath,
|
||||
vaultLabel: vaultLabel(vaultPath),
|
||||
}
|
||||
}
|
||||
|
||||
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
|
||||
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
|
||||
const matches = []
|
||||
const errors = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate))
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
|
||||
}
|
||||
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
|
||||
}
|
||||
|
||||
async function searchActiveVaults(query, limit = 10) {
|
||||
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
|
||||
const results = []
|
||||
|
||||
for (const vaultPath of activeVaultPaths()) {
|
||||
const vaultResults = await searchNotes(vaultPath, query, requestedLimit)
|
||||
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
|
||||
if (results.length >= requestedLimit) break
|
||||
}
|
||||
|
||||
return results.slice(0, requestedLimit)
|
||||
}
|
||||
|
||||
async function activeVaultContext(targetVaultPath = null) {
|
||||
const roots = activeVaultPaths()
|
||||
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
|
||||
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
|
||||
|
||||
return {
|
||||
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
|
||||
}
|
||||
}
|
||||
|
||||
function uiPath(args = {}) {
|
||||
const notePath = typeof args.path === 'string' ? args.path : ''
|
||||
if (path.isAbsolute(notePath)) return notePath
|
||||
const roots = activeVaultPaths()
|
||||
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
|
||||
return vaultPath ? path.join(vaultPath, notePath) : notePath
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
const results = await searchActiveVaults(args.query, args.limit)
|
||||
const results = await toolService.searchNotes(args)
|
||||
const text = results.length === 0
|
||||
? 'No matching notes found.'
|
||||
: results.map(r => `**${r.title}** (${r.vaultLabel} / ${r.path})\n${r.snippet}`).join('\n\n')
|
||||
@@ -277,45 +224,43 @@ async function handleSearchNotes(args) {
|
||||
}
|
||||
|
||||
async function handleVaultContext(args = {}) {
|
||||
const ctx = await activeVaultContext(requestedVaultPath(args))
|
||||
const ctx = await toolService.vaultContext(args)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
|
||||
}
|
||||
|
||||
async function handleListVaults() {
|
||||
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => {
|
||||
const agentInstructions = await readAgentInstructions(vaultPath)
|
||||
return {
|
||||
path: vaultPath,
|
||||
label: vaultLabel(vaultPath),
|
||||
agentInstructionsPath: agentInstructions?.path ?? null,
|
||||
hasAgentInstructions: agentInstructions !== null,
|
||||
}
|
||||
}))
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ vaults }, null, 2) }] }
|
||||
return { content: [{ type: 'text', text: JSON.stringify(await toolService.listVaults(), null, 2) }] }
|
||||
}
|
||||
|
||||
async function handleGetNote(args) {
|
||||
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
|
||||
const note = await toolService.readNote(args)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
|
||||
}
|
||||
|
||||
async function handleCreateNote(args = {}) {
|
||||
const note = await toolService.createNote(args)
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify(note, null, 2),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenNote(args) {
|
||||
// Refresh vault first so the new/modified note appears in the note list,
|
||||
// then signal the UI to open it in a tab.
|
||||
const targetPath = uiPath(args)
|
||||
broadcastUiAction('vault_changed', { path: targetPath })
|
||||
broadcastUiAction('open_tab', { path: targetPath })
|
||||
const { targetPath } = toolService.openNoteAsTab(args)
|
||||
return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] }
|
||||
}
|
||||
|
||||
function handleHighlightEditor(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
toolService.highlightEditor(args)
|
||||
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
|
||||
}
|
||||
|
||||
function handleRefreshVault(args) {
|
||||
broadcastUiAction('vault_changed', { path: uiPath(args) })
|
||||
toolService.refreshVault(args)
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
@@ -324,6 +269,7 @@ const TOOL_HANDLERS = new Map([
|
||||
['get_vault_context', handleVaultContext],
|
||||
['list_vaults', handleListVaults],
|
||||
['get_note', handleGetNote],
|
||||
['create_note', handleCreateNote],
|
||||
['open_note', handleOpenNote],
|
||||
['highlight_editor', handleHighlightEditor],
|
||||
['refresh_vault', handleRefreshVault],
|
||||
|
||||
20
mcp-server/package-lock.json
generated
20
mcp-server/package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
"ws": "^8.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
@@ -619,9 +619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"version": "4.12.21",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
|
||||
"integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -914,9 +914,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -1227,9 +1227,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -6,18 +6,19 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "node --test test.js"
|
||||
"test": "node --test test.js tool-service.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
"ws": "^8.20.1"
|
||||
},
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"fast-uri": "3.1.2",
|
||||
"hono": "4.12.18",
|
||||
"hono": "4.12.21",
|
||||
"qs": "6.15.2",
|
||||
"ip-address": "10.1.1",
|
||||
"path-to-regexp": "8.4.0"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import {
|
||||
mkdtemp, mkdir, open, rm, writeFile,
|
||||
access, mkdtemp, mkdir, open, readFile, rm, writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
createNote, findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { requireVaultPath, requireVaultPaths } from './vault-path.js'
|
||||
import { vaultContextWithInstructions } from './agent-instructions.js'
|
||||
@@ -126,6 +126,74 @@ describe('getNote', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNote', () => {
|
||||
it('creates a new markdown note inside the vault', async () => {
|
||||
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-'))
|
||||
const content = `---
|
||||
type: Note
|
||||
---
|
||||
|
||||
# MCP Created
|
||||
`
|
||||
|
||||
try {
|
||||
const note = await createNote(vaultDir, 'note/mcp-created.md', content)
|
||||
assert.equal(note.path, 'note/mcp-created.md')
|
||||
assert.equal(await readFile(path.join(vaultDir, note.path), 'utf-8'), content)
|
||||
} finally {
|
||||
await rm(vaultDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not overwrite an existing note', async () => {
|
||||
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-existing-'))
|
||||
const notePath = path.join(vaultDir, 'existing.md')
|
||||
await writeFile(notePath, '# Existing\n', 'utf-8')
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => createNote(vaultDir, 'existing.md', '# Replacement\n'),
|
||||
{ code: 'EEXIST' },
|
||||
)
|
||||
assert.equal(await readFile(notePath, 'utf-8'), '# Existing\n')
|
||||
} finally {
|
||||
await rm(vaultDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects absolute paths outside the vault', async () => {
|
||||
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-'))
|
||||
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-'))
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => createNote(vaultDir, path.join(outsideDir, 'outside.md'), '# Outside\n'),
|
||||
{ message: ACTIVE_VAULT_ERROR },
|
||||
)
|
||||
} finally {
|
||||
await rm(vaultDir, { recursive: true, force: true })
|
||||
await rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects outside paths before creating missing parent folders', async () => {
|
||||
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-'))
|
||||
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-'))
|
||||
const outsideParent = path.join(outsideDir, 'missing-parent')
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => createNote(vaultDir, path.join(outsideParent, 'outside.md'), '# Outside\n'),
|
||||
{ message: ACTIVE_VAULT_ERROR },
|
||||
)
|
||||
await assert.rejects(() => access(outsideParent), { code: 'ENOENT' })
|
||||
} finally {
|
||||
await rm(vaultDir, { recursive: true, force: true })
|
||||
await rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should find notes matching title', async () => {
|
||||
const results = await searchNotes(tmpDir, 'Test Project')
|
||||
@@ -330,11 +398,43 @@ describe('stdio process lifecycle', () => {
|
||||
assert.equal(tool.annotations?.destructiveHint, false, `${name} should not be treated as destructive`)
|
||||
assert.equal(tool.annotations?.openWorldHint, false, `${name} should stay scoped to local active vaults`)
|
||||
}
|
||||
|
||||
const createTool = toolsByName.get('create_note')
|
||||
assert.ok(createTool, 'Missing MCP tool: create_note')
|
||||
assert.equal(createTool.annotations?.readOnlyHint, false)
|
||||
assert.equal(createTool.annotations?.destructiveHint, false)
|
||||
assert.equal(createTool.annotations?.openWorldHint, false)
|
||||
} finally {
|
||||
await closeMcpClient(client, stderr)
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a note through the MCP create_note tool', async () => {
|
||||
const { client, stderr } = await connectMcpClient()
|
||||
const relativePath = 'note/mcp-tool-created.md'
|
||||
const absolutePath = path.join(tmpDir, relativePath)
|
||||
const content = `---
|
||||
type: Note
|
||||
---
|
||||
|
||||
# MCP Tool Created
|
||||
`
|
||||
|
||||
try {
|
||||
await rm(absolutePath, { force: true })
|
||||
const result = await client.callTool({
|
||||
name: 'create_note',
|
||||
arguments: { path: relativePath, content },
|
||||
})
|
||||
|
||||
assert.equal(await readFile(absolutePath, 'utf-8'), content)
|
||||
assert.match(JSON.stringify(result.content), /mcp-tool-created\.md/)
|
||||
} finally {
|
||||
await rm(absolutePath, { force: true })
|
||||
await closeMcpClient(client, stderr)
|
||||
}
|
||||
})
|
||||
|
||||
it('exits when the MCP client closes stdin', async () => {
|
||||
const child = spawn(process.execPath, ['index.js'], {
|
||||
cwd: MCP_SERVER_DIR,
|
||||
|
||||
213
mcp-server/tool-service.js
Normal file
213
mcp-server/tool-service.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import path from 'node:path'
|
||||
import {
|
||||
createNote as createVaultNote,
|
||||
getNote,
|
||||
searchNotes as searchVaultNotes,
|
||||
} from './vault.js'
|
||||
import { requireVaultPaths } from './vault-path.js'
|
||||
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
|
||||
|
||||
export function createMcpToolService({
|
||||
resolveVaultPaths = () => requireVaultPaths(),
|
||||
emitUiAction = () => {},
|
||||
} = {}) {
|
||||
function activeVaultPaths() {
|
||||
return resolveVaultPaths()
|
||||
}
|
||||
|
||||
function requestedVaultPath(args = {}) {
|
||||
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
|
||||
if (!requested) return null
|
||||
if (!activeVaultPaths().includes(requested)) {
|
||||
throw new Error(`Vault is not active in Tolaria: ${requested}`)
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
function resolveUiPath(args = {}) {
|
||||
const notePath = typeof args.path === 'string' ? args.path : ''
|
||||
if (path.isAbsolute(notePath)) return notePath
|
||||
const roots = activeVaultPaths()
|
||||
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
|
||||
return vaultPath ? path.join(vaultPath, notePath) : notePath
|
||||
}
|
||||
|
||||
async function readNote(args = {}) {
|
||||
return getNoteFromActiveVaults(notePathArg(args), requestedVaultPath(args))
|
||||
}
|
||||
|
||||
async function searchNotes(args = {}) {
|
||||
const requestedLimit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 10
|
||||
const results = []
|
||||
|
||||
for (const vaultPath of activeVaultPaths()) {
|
||||
const vaultResults = await searchVaultNotes(vaultPath, args.query, requestedLimit)
|
||||
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
|
||||
if (results.length >= requestedLimit) break
|
||||
}
|
||||
|
||||
return results.slice(0, requestedLimit)
|
||||
}
|
||||
|
||||
async function vaultContext(args = {}) {
|
||||
const targetVaultPath = requestedVaultPath(args)
|
||||
const roots = activeVaultPaths()
|
||||
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
|
||||
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
|
||||
|
||||
return {
|
||||
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
|
||||
}
|
||||
}
|
||||
|
||||
async function listVaults() {
|
||||
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => {
|
||||
const agentInstructions = await readAgentInstructions(vaultPath)
|
||||
return {
|
||||
path: vaultPath,
|
||||
label: vaultLabel(vaultPath),
|
||||
agentInstructionsPath: agentInstructions?.path ?? null,
|
||||
hasAgentInstructions: agentInstructions !== null,
|
||||
}
|
||||
}))
|
||||
|
||||
return { vaults }
|
||||
}
|
||||
|
||||
async function createNote(args = {}) {
|
||||
const vaultPath = writableVaultPath(args)
|
||||
const notePath = writableNotePath(args, vaultPath)
|
||||
const note = await createVaultNote(vaultPath, notePath, createNoteContent(args))
|
||||
const targetPath = resolveUiPath({ ...args, path: note.path, vaultPath })
|
||||
emitUiAction('vault_changed', { path: targetPath })
|
||||
emitUiAction('open_tab', { path: targetPath })
|
||||
return { path: note.path, absolutePath: note.absolutePath, vaultPath }
|
||||
}
|
||||
|
||||
function openNoteAsTab(args = {}) {
|
||||
const targetPath = resolveUiPath(args)
|
||||
emitUiAction('vault_changed', { path: targetPath })
|
||||
emitUiAction('open_tab', { path: targetPath })
|
||||
return { targetPath }
|
||||
}
|
||||
|
||||
function openNoteInEditor(args = {}) {
|
||||
const targetPath = resolveUiPath(args)
|
||||
emitUiAction('vault_changed', { path: targetPath })
|
||||
emitUiAction('open_note', { path: targetPath })
|
||||
return { targetPath }
|
||||
}
|
||||
|
||||
function highlightEditor(args = {}) {
|
||||
emitUiAction('highlight', { element: args.element, path: args.path })
|
||||
}
|
||||
|
||||
function setFilter(args = {}) {
|
||||
emitUiAction('set_filter', { filterType: args.type })
|
||||
}
|
||||
|
||||
function refreshVault(args = {}) {
|
||||
emitUiAction('vault_changed', { path: resolveUiPath(args) })
|
||||
}
|
||||
|
||||
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
|
||||
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
|
||||
const matches = []
|
||||
const errors = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate))
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
|
||||
}
|
||||
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
|
||||
}
|
||||
|
||||
function writableVaultPath(args = {}) {
|
||||
const requested = requestedVaultPath(args)
|
||||
if (requested) return requested
|
||||
|
||||
const roots = activeVaultPaths()
|
||||
const notePath = notePathArg(args)
|
||||
if (path.isAbsolute(notePath)) {
|
||||
const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath))
|
||||
if (root) return root
|
||||
}
|
||||
if (roots.length === 1) return roots[0]
|
||||
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
|
||||
}
|
||||
|
||||
return {
|
||||
activeVaultPaths,
|
||||
createNote,
|
||||
highlightEditor,
|
||||
listVaults,
|
||||
openNoteAsTab,
|
||||
openNoteInEditor,
|
||||
readNote,
|
||||
refreshVault,
|
||||
requestedVaultPath,
|
||||
resolveUiPath,
|
||||
searchNotes,
|
||||
setFilter,
|
||||
vaultContext,
|
||||
}
|
||||
}
|
||||
|
||||
function writableNotePath(args, vaultPath) {
|
||||
const notePath = notePathArg(args)
|
||||
if (!path.isAbsolute(notePath) || !isInsideVaultRoot(vaultPath, notePath)) return notePath
|
||||
return path.relative(vaultPath, notePath)
|
||||
}
|
||||
|
||||
function withVaultMetadata(note, vaultPath) {
|
||||
return {
|
||||
...note,
|
||||
vaultPath,
|
||||
vaultLabel: vaultLabel(vaultPath),
|
||||
}
|
||||
}
|
||||
|
||||
function vaultLabel(vaultPath) {
|
||||
return path.basename(vaultPath) || vaultPath
|
||||
}
|
||||
|
||||
function isInsideVaultRoot(vaultPath, notePath) {
|
||||
const relative = path.relative(vaultPath, notePath)
|
||||
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
function notePathArg(args = {}) {
|
||||
const notePath = typeof args.path === 'string' ? args.path.trim() : ''
|
||||
if (!notePath) throw new Error('Note path is required')
|
||||
return notePath
|
||||
}
|
||||
|
||||
function yamlScalar(value) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function fallbackCreateNoteContent(args = {}) {
|
||||
const title = typeof args.title === 'string' && args.title.trim()
|
||||
? args.title.trim()
|
||||
: path.basename(notePathArg(args), '.md')
|
||||
const type = typeof args.type === 'string' && args.type.trim()
|
||||
? args.type.trim()
|
||||
: typeof args.is_a === 'string' && args.is_a.trim()
|
||||
? args.is_a.trim()
|
||||
: 'Note'
|
||||
return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n`
|
||||
}
|
||||
|
||||
function createNoteContent(args = {}) {
|
||||
return typeof args.content === 'string' && args.content.trim()
|
||||
? args.content
|
||||
: fallbackCreateNoteContent(args)
|
||||
}
|
||||
156
mcp-server/tool-service.test.js
Normal file
156
mcp-server/tool-service.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { createMcpToolService } from './tool-service.js'
|
||||
|
||||
let tmpDir
|
||||
let firstVault
|
||||
let secondVault
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-service-'))
|
||||
firstVault = path.join(tmpDir, 'First Vault')
|
||||
secondVault = path.join(tmpDir, 'Second Vault')
|
||||
|
||||
await seedVault(firstVault, {
|
||||
'note/shared.md': noteFixture('Shared Note', 'Shared content from the first vault.'),
|
||||
'note/alpha.md': noteFixture('Alpha Project', 'Project planning in the first vault.'),
|
||||
})
|
||||
await seedVault(secondVault, {
|
||||
'AGENTS.md': '# Second Vault Rules\n',
|
||||
'note/shared.md': noteFixture('Shared Note', 'Shared content from the second vault.'),
|
||||
'note/beta.md': noteFixture('Beta Project', 'Project planning in the second vault.'),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('createMcpToolService', () => {
|
||||
it('requires vaultPath when reading an ambiguous note path', async () => {
|
||||
const service = makeService()
|
||||
|
||||
await assert.rejects(
|
||||
() => service.readNote({ path: 'note/shared.md' }),
|
||||
/Note path is ambiguous across active vaults/,
|
||||
)
|
||||
|
||||
const note = await service.readNote({
|
||||
path: 'note/shared.md',
|
||||
vaultPath: secondVault,
|
||||
})
|
||||
|
||||
assert.equal(note.vaultPath, secondVault)
|
||||
assert.equal(note.vaultLabel, 'Second Vault')
|
||||
assert.match(note.content, /second vault/)
|
||||
})
|
||||
|
||||
it('creates notes with fallback markdown and emits refresh and tab actions', async () => {
|
||||
const emittedActions = []
|
||||
const service = makeService({ emittedActions })
|
||||
const absolutePath = path.join(secondVault, 'note/created.md')
|
||||
|
||||
const note = await service.createNote({
|
||||
path: absolutePath,
|
||||
title: 'Created From MCP',
|
||||
type: 'Project',
|
||||
})
|
||||
|
||||
assert.equal(note.path, 'note/created.md')
|
||||
assert.equal(note.vaultPath, secondVault)
|
||||
assert.equal(path.basename(note.absolutePath), 'created.md')
|
||||
assert.equal(
|
||||
await readFile(note.absolutePath, 'utf-8'),
|
||||
'---\ntype: "Project"\n---\n\n# Created From MCP\n',
|
||||
)
|
||||
assert.deepEqual(emittedActions, [
|
||||
{ action: 'vault_changed', payload: { path: absolutePath } },
|
||||
{ action: 'open_tab', payload: { path: absolutePath } },
|
||||
])
|
||||
})
|
||||
|
||||
it('searches active vaults with consistent vault metadata', async () => {
|
||||
const service = makeService()
|
||||
|
||||
const results = await service.searchNotes({ query: 'Project', limit: 2 })
|
||||
|
||||
assert.equal(results.length, 2)
|
||||
assert.deepEqual(
|
||||
results.map(({ path: notePath, vaultPath, vaultLabel }) => ({
|
||||
notePath,
|
||||
vaultPath,
|
||||
vaultLabel,
|
||||
})),
|
||||
[
|
||||
{ notePath: 'note/alpha.md', vaultPath: firstVault, vaultLabel: 'First Vault' },
|
||||
{ notePath: 'note/beta.md', vaultPath: secondVault, vaultLabel: 'Second Vault' },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it('lists active vaults with agent-instruction metadata', async () => {
|
||||
const service = makeService()
|
||||
|
||||
assert.deepEqual(await service.listVaults(), {
|
||||
vaults: [
|
||||
{
|
||||
path: firstVault,
|
||||
label: 'First Vault',
|
||||
agentInstructionsPath: null,
|
||||
hasAgentInstructions: false,
|
||||
},
|
||||
{
|
||||
path: secondVault,
|
||||
label: 'Second Vault',
|
||||
agentInstructionsPath: path.join(secondVault, 'AGENTS.md'),
|
||||
hasAgentInstructions: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('emits transport-neutral UI intents for note opening and filters', () => {
|
||||
const emittedActions = []
|
||||
const service = makeService({ emittedActions })
|
||||
|
||||
service.openNoteAsTab({ path: 'note/beta.md', vaultPath: secondVault })
|
||||
service.openNoteInEditor({ path: 'note/beta.md', vaultPath: secondVault })
|
||||
service.highlightEditor({ element: 'editor', path: 'note/beta.md' })
|
||||
service.setFilter({ type: 'Project' })
|
||||
service.refreshVault({ path: 'note/beta.md', vaultPath: secondVault })
|
||||
|
||||
assert.deepEqual(emittedActions, [
|
||||
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
|
||||
{ action: 'open_tab', payload: { path: path.join(secondVault, 'note/beta.md') } },
|
||||
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
|
||||
{ action: 'open_note', payload: { path: path.join(secondVault, 'note/beta.md') } },
|
||||
{ action: 'highlight', payload: { element: 'editor', path: 'note/beta.md' } },
|
||||
{ action: 'set_filter', payload: { filterType: 'Project' } },
|
||||
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function makeService({ emittedActions = [] } = {}) {
|
||||
return createMcpToolService({
|
||||
resolveVaultPaths: () => [firstVault, secondVault],
|
||||
emitUiAction: (action, payload) => {
|
||||
emittedActions.push({ action, payload })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function seedVault(vaultPath, files) {
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const filePath = path.join(vaultPath, relativePath)
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
function noteFixture(title, body) {
|
||||
return `---\ntitle: ${JSON.stringify(title)}\ntype: Note\n---\n\n# ${title}\n\n${body}\n`
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Vault operations — read-only helpers for Tolaria markdown vault.
|
||||
* Write operations are handled by the app-managed agent's active permission
|
||||
* profile and native file-edit tools when available.
|
||||
* Most write operations are handled by the app-managed agent's active
|
||||
* permission profile and native file-edit tools; createNote is intentionally
|
||||
* narrow so read-only agents can create a new Markdown file without overwrite.
|
||||
*/
|
||||
import { open, opendir, realpath } from 'node:fs/promises'
|
||||
import { mkdir, open, opendir, realpath } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
@@ -60,6 +61,22 @@ export async function getNote(vaultPath, notePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new markdown note inside the vault without overwriting an existing file.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @param {string} content
|
||||
* @returns {Promise<{path: string, absolutePath: string}>}
|
||||
*/
|
||||
export async function createNote(vaultPath, notePath, content) {
|
||||
const { requestedPath, relativePath } = await resolveNewVaultNotePath(vaultPath, notePath)
|
||||
await writeNewUtf8File(requestedPath, content)
|
||||
return {
|
||||
path: relativePath,
|
||||
absolutePath: requestedPath,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search notes by title or content substring.
|
||||
* @param {string} vaultPath
|
||||
@@ -145,6 +162,61 @@ function resolveRequestedNotePath(vaultRoot, notePath) {
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function resolveNewVaultNotePath(vaultPath, notePath) {
|
||||
const requestedNotePath = validateNewNotePath(notePath)
|
||||
const vaultRoot = await realpath(vaultPath)
|
||||
const requestedPath = resolveRequestedNotePath(vaultRoot, requestedNotePath)
|
||||
const relativePath = relativeNotePathInsideVault(vaultRoot, requestedPath)
|
||||
await ensureWritableParentInsideVault(vaultRoot, requestedPath)
|
||||
return { requestedPath, relativePath }
|
||||
}
|
||||
|
||||
function validateNewNotePath(notePath) {
|
||||
const trimmedPath = typeof notePath === 'string' ? notePath.trim() : ''
|
||||
if (!trimmedPath) {
|
||||
throw new Error('Note path is required')
|
||||
}
|
||||
if (!trimmedPath.endsWith('.md')) {
|
||||
throw new Error('New notes must be markdown files ending in .md')
|
||||
}
|
||||
return trimmedPath
|
||||
}
|
||||
|
||||
async function ensureWritableParentInsideVault(vaultRoot, requestedPath) {
|
||||
const parentPath = path.dirname(requestedPath)
|
||||
const existingAncestor = await nearestExistingAncestor(parentPath)
|
||||
assertInsideVault(vaultRoot, existingAncestor)
|
||||
await mkdir(parentPath, { recursive: true })
|
||||
assertInsideVault(vaultRoot, await realpath(parentPath))
|
||||
}
|
||||
|
||||
async function nearestExistingAncestor(targetPath) {
|
||||
let currentPath = targetPath
|
||||
while (currentPath && currentPath !== path.dirname(currentPath)) {
|
||||
try {
|
||||
return await realpath(currentPath)
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
currentPath = path.dirname(currentPath)
|
||||
}
|
||||
}
|
||||
return realpath(currentPath)
|
||||
}
|
||||
|
||||
function assertInsideVault(vaultRoot, targetPath) {
|
||||
if (!isVaultRelativePath(path.relative(vaultRoot, targetPath))) {
|
||||
throw new Error(ACTIVE_VAULT_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
function relativeNotePathInsideVault(vaultRoot, requestedPath) {
|
||||
const relativePath = path.relative(vaultRoot, requestedPath)
|
||||
if (!isVaultRelativePath(relativePath) || !relativePath) {
|
||||
throw new Error(ACTIVE_VAULT_ERROR)
|
||||
}
|
||||
return relativePath
|
||||
}
|
||||
|
||||
function resolveInside(root, target) {
|
||||
const resolved = path.resolve(root, target)
|
||||
const relative = path.relative(root, resolved)
|
||||
@@ -338,6 +410,15 @@ async function readUtf8File(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeNewUtf8File(filePath, content) {
|
||||
const handle = await open(filePath, 'wx')
|
||||
try {
|
||||
await handle.writeFile(content, 'utf-8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function statFile(filePath) {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
|
||||
@@ -21,12 +21,7 @@
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import {
|
||||
getNote, searchNotes,
|
||||
} from './vault.js'
|
||||
import { requireVaultPaths } from './vault-path.js'
|
||||
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
|
||||
import path from 'node:path'
|
||||
import { createMcpToolService } from './tool-service.js'
|
||||
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
@@ -41,66 +36,6 @@ const TRUSTED_UI_ORIGINS = new Set([
|
||||
let uiBridge = null
|
||||
const UNKNOWN_TOOL = Symbol('unknown tool')
|
||||
|
||||
function activeVaultPaths() {
|
||||
return requireVaultPaths()
|
||||
}
|
||||
|
||||
function requestedVaultPath(args = {}) {
|
||||
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
|
||||
if (!requested) return null
|
||||
if (!activeVaultPaths().includes(requested)) {
|
||||
throw new Error(`Vault is not active in Tolaria: ${requested}`)
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
function uiPath(args = {}) {
|
||||
const notePath = typeof args.path === 'string' ? args.path : ''
|
||||
if (path.isAbsolute(notePath)) return notePath
|
||||
const roots = activeVaultPaths()
|
||||
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
|
||||
return vaultPath ? path.join(vaultPath, notePath) : notePath
|
||||
}
|
||||
|
||||
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
|
||||
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
|
||||
const matches = []
|
||||
const errors = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
matches.push({ ...(await getNote(candidate, notePath)), vaultPath: candidate })
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
|
||||
}
|
||||
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
|
||||
}
|
||||
|
||||
async function searchActiveVaults(query, limit = 10) {
|
||||
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
|
||||
const results = []
|
||||
|
||||
for (const vaultPath of activeVaultPaths()) {
|
||||
const vaultResults = await searchNotes(vaultPath, query, requestedLimit)
|
||||
results.push(...vaultResults.map((result) => ({ ...result, vaultPath })))
|
||||
if (results.length >= requestedLimit) break
|
||||
}
|
||||
|
||||
return results.slice(0, requestedLimit)
|
||||
}
|
||||
|
||||
async function activeVaultContext() {
|
||||
const roots = activeVaultPaths()
|
||||
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
|
||||
return { vaults: await Promise.all(roots.map(vaultContextWithInstructions)) }
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
|
||||
@@ -109,61 +44,49 @@ function broadcastUiAction(action, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
|
||||
|
||||
async function readNoteTool(args) {
|
||||
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
|
||||
const note = await toolService.readNote(args)
|
||||
return { content: note.content, frontmatter: note.frontmatter }
|
||||
}
|
||||
|
||||
function uiOpenNoteTool(args) {
|
||||
const targetPath = uiPath(args)
|
||||
broadcastUiAction('vault_changed', { path: targetPath })
|
||||
broadcastUiAction('open_note', { path: targetPath })
|
||||
toolService.openNoteInEditor(args)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function uiOpenTabTool(args) {
|
||||
const targetPath = uiPath(args)
|
||||
broadcastUiAction('vault_changed', { path: targetPath })
|
||||
broadcastUiAction('open_tab', { path: targetPath })
|
||||
toolService.openNoteAsTab(args)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function createNoteTool(args = {}) {
|
||||
return { ok: true, ...(await toolService.createNote(args)) }
|
||||
}
|
||||
|
||||
function highlightTool(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
toolService.highlightEditor(args)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function uiSetFilterTool(args) {
|
||||
broadcastUiAction('set_filter', { filterType: args.type })
|
||||
toolService.setFilter(args)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function refreshVaultTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: uiPath(args) })
|
||||
toolService.refreshVault(args)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function listVaultsTool() {
|
||||
return {
|
||||
vaults: await Promise.all(activeVaultPaths().map(async (vaultPath) => {
|
||||
const agentInstructions = await readAgentInstructions(vaultPath)
|
||||
return {
|
||||
path: vaultPath,
|
||||
label: path.basename(vaultPath) || vaultPath,
|
||||
agentInstructionsPath: agentInstructions?.path ?? null,
|
||||
hasAgentInstructions: agentInstructions !== null,
|
||||
}
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_EXECUTORS = [
|
||||
['open_note', readNoteTool],
|
||||
['read_note', readNoteTool],
|
||||
['search_notes', (args) => searchActiveVaults(args.query, args.limit)],
|
||||
['vault_context', () => activeVaultContext()],
|
||||
['list_vaults', () => listVaultsTool()],
|
||||
['create_note', createNoteTool],
|
||||
['search_notes', (args) => toolService.searchNotes(args)],
|
||||
['vault_context', (args) => toolService.vaultContext(args)],
|
||||
['list_vaults', () => toolService.listVaults()],
|
||||
['ui_open_note', uiOpenNoteTool],
|
||||
['ui_open_tab', uiOpenTabTool],
|
||||
['ui_highlight', highlightTool],
|
||||
@@ -279,7 +202,7 @@ export function startUiBridge(port = WS_UI_PORT) {
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
const currentVaultPaths = activeVaultPaths()
|
||||
const currentVaultPaths = toolService.activeVaultPaths()
|
||||
const wss = new WebSocketServer({
|
||||
port,
|
||||
host: LOOPBACK_HOST,
|
||||
@@ -309,7 +232,7 @@ export function startBridge(port = WS_PORT) {
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
try {
|
||||
activeVaultPaths()
|
||||
toolService.activeVaultPaths()
|
||||
startUiBridge().then(() => startBridge())
|
||||
} catch (err) {
|
||||
console.error(`[ws-bridge] ${err.message}`)
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -301,8 +301,8 @@ importers:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
specifier: ^8.20.1
|
||||
version: 8.21.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -5898,6 +5898,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -12235,6 +12247,8 @@ snapshots:
|
||||
|
||||
ws@8.19.0: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
17
release-notes/v2026-06-01.md
Normal file
17
release-notes/v2026-06-01.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## New Features
|
||||
|
||||
- 📄 **Export Notes to PDF** — Export the active note directly to a PDF from Tolaria, with the release build carrying the native desktop export path.
|
||||
- 🧩 **More Code Block Languages** — Rich-editor code blocks now recognize more common grammars, including shell, config, infrastructure, and scripting formats.
|
||||
|
||||
## Improvements
|
||||
|
||||
- 🧮 **Editable Math Source Panel** — Double-click rendered display formulas to edit the underlying math source without turning the formula into normal Markdown text.
|
||||
- ✍️ **Cleaner Math Editing UX** — Selected math source text stays readable in the editor panel, and the focused panel now avoids the doubled active-border treatment.
|
||||
- 🤖 **More Focused AI Workspace Internals** — The AI workspace keeps shared sessions and orchestration logic cleaner while preserving the existing side-panel behavior.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Active notes refresh more reliably after external edits without disturbing clean in-editor state.
|
||||
- Windows command-shim handling is steadier for npm-launched agent commands.
|
||||
- PDF export, display-math editing, KaTeX source selection, AI workspace title persistence, and Tauri listener cleanup were hardened before promotion.
|
||||
- Stable release signing and artifact workflows were tightened so the stable channel can publish the expected macOS, Windows, and Linux bundles.
|
||||
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.
|
||||
4
src-tauri/Cargo.lock
generated
4
src-tauri/Cargo.lock
generated
@@ -5440,6 +5440,10 @@ dependencies = [
|
||||
"gray_matter",
|
||||
"log",
|
||||
"notify",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-web-kit",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"sentry",
|
||||
|
||||
@@ -43,4 +43,10 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.3"
|
||||
objc2-app-kit = "0.3.2"
|
||||
objc2-foundation = "0.3.2"
|
||||
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -423,18 +432,32 @@ fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<Stri
|
||||
}
|
||||
|
||||
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
api_key_from_env_with_lookup(
|
||||
request,
|
||||
crate::cli_agent_runtime::env_value_from_process_or_user_shell,
|
||||
)
|
||||
}
|
||||
|
||||
fn api_key_from_env_with_lookup(
|
||||
request: &AiModelStreamRequest,
|
||||
lookup: impl Fn(crate::cli_agent_runtime::EnvName<'_>) -> Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(name) = request
|
||||
.provider
|
||||
.api_key_env_var
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
.and_then(crate::cli_agent_runtime::EnvName::new)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
std::env::var(name)
|
||||
.map(Some)
|
||||
.map_err(|_| format!("Environment variable {name} is not set for this AI provider."))
|
||||
lookup(name).map(Some).ok_or_else(|| {
|
||||
format!(
|
||||
"Environment variable {} is not set for this AI provider.",
|
||||
name.as_str()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn api_key_from_provider(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
@@ -537,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,
|
||||
}
|
||||
@@ -609,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!(
|
||||
@@ -636,6 +706,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_api_key_uses_shell_lookup_when_process_env_is_missing() {
|
||||
let mut provider = provider(AiModelProviderKind::Anthropic);
|
||||
provider.api_key_storage = Some(AiModelApiKeyStorage::Env);
|
||||
provider.api_key_env_var = Some("ANTHROPIC_API_KEY".into());
|
||||
let request = request(provider);
|
||||
|
||||
let api_key =
|
||||
api_key_from_env_with_lookup(&request, |_| Some("shell-secret".to_string())).unwrap();
|
||||
|
||||
assert_eq!(api_key.as_deref(), Some("shell-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_provider_text_payloads_and_reports_empty_responses() {
|
||||
let openai = json!({
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use crate::cli_agent_runtime::EnvName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
|
||||
const CLAUDE_PROVIDER_ENV_KEYS: &[EnvName<'static>] = &[
|
||||
EnvName::trusted("ANTHROPIC_API_KEY"),
|
||||
EnvName::trusted("ANTHROPIC_AUTH_TOKEN"),
|
||||
EnvName::trusted("ANTHROPIC_BASE_URL"),
|
||||
EnvName::trusted("ANTHROPIC_CUSTOM_HEADERS"),
|
||||
EnvName::trusted("ANTHROPIC_MODEL"),
|
||||
EnvName::trusted("ANTHROPIC_SMALL_FAST_MODEL"),
|
||||
EnvName::trusted("CLAUDE_CODE_USE_BEDROCK"),
|
||||
EnvName::trusted("CLAUDE_CODE_USE_VERTEX"),
|
||||
EnvName::trusted("AWS_ACCESS_KEY_ID"),
|
||||
EnvName::trusted("AWS_SECRET_ACCESS_KEY"),
|
||||
EnvName::trusted("AWS_SESSION_TOKEN"),
|
||||
EnvName::trusted("AWS_PROFILE"),
|
||||
EnvName::trusted("AWS_REGION"),
|
||||
EnvName::trusted("AWS_DEFAULT_REGION"),
|
||||
EnvName::trusted("GOOGLE_APPLICATION_CREDENTIALS"),
|
||||
EnvName::trusted("CLOUD_ML_REGION"),
|
||||
EnvName::trusted("VERTEX_REGION"),
|
||||
EnvName::trusted("HTTPS_PROXY"),
|
||||
EnvName::trusted("HTTP_PROXY"),
|
||||
EnvName::trusted("NO_PROXY"),
|
||||
EnvName::trusted("SSL_CERT_FILE"),
|
||||
EnvName::trusted("SSL_CERT_DIR"),
|
||||
EnvName::trusted("NODE_EXTRA_CA_CERTS"),
|
||||
];
|
||||
|
||||
/// Status returned by `check_claude_cli`.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ClaudeCliStatus {
|
||||
@@ -393,7 +420,7 @@ fn build_claude_command(
|
||||
) -> Result<std::process::Command, String> {
|
||||
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(request.bin)?;
|
||||
let mut cmd = crate::hidden_command(&target.program);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, request.bin);
|
||||
configure_claude_command_environment(&mut cmd, request.bin);
|
||||
if let Some(first_arg) = target.first_arg {
|
||||
cmd.arg(first_arg);
|
||||
}
|
||||
@@ -408,6 +435,11 @@ fn build_claude_command(
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
fn configure_claude_command_environment(cmd: &mut std::process::Command, bin: &Path) {
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(cmd, bin);
|
||||
crate::cli_agent_runtime::apply_user_shell_env_vars_if_missing(cmd, CLAUDE_PROVIDER_ENV_KEYS);
|
||||
}
|
||||
|
||||
fn format_failed_claude_exit(failure: ClaudeFailure<'_>) -> String {
|
||||
if is_claude_auth_error(failure.stderr) {
|
||||
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
|
||||
@@ -1148,6 +1180,12 @@ mod tests {
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_provider_env_keys_include_reported_anthropic_overrides() {
|
||||
assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_API_KEY")));
|
||||
assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_BASE_URL")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct MockClaudeScript<'a>(&'a str);
|
||||
@@ -1265,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;
|
||||
@@ -1283,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() {
|
||||
@@ -1309,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() {
|
||||
@@ -1316,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!(
|
||||
@@ -1345,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() {
|
||||
|
||||
@@ -5,6 +5,13 @@ use std::io::{BufRead, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus};
|
||||
|
||||
mod shell_env;
|
||||
mod windows_cmd_shim;
|
||||
|
||||
pub(crate) use shell_env::{
|
||||
apply_user_shell_env_vars_if_missing, env_value_from_process_or_user_shell, EnvName,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
@@ -107,13 +114,8 @@ pub(crate) fn version_for_binary(binary: &Path) -> Option<String> {
|
||||
pub(crate) fn command_target_avoiding_windows_cmd_shim(
|
||||
binary: &Path,
|
||||
) -> Result<AgentCommandTarget, String> {
|
||||
if is_windows_batch_shim(binary) {
|
||||
if let Some(script) = node_script_from_windows_cmd_shim(binary) {
|
||||
return Ok(AgentCommandTarget {
|
||||
program: crate::mcp::find_node()?,
|
||||
first_arg: Some(script),
|
||||
});
|
||||
}
|
||||
if let Some(target) = windows_cmd_shim::command_target(binary)? {
|
||||
return Ok(target);
|
||||
}
|
||||
|
||||
Ok(AgentCommandTarget {
|
||||
@@ -244,43 +246,6 @@ pub(crate) fn has_windows_cli_extension(path: &Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_windows_batch_shim(binary: &Path) -> bool {
|
||||
binary
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
|
||||
})
|
||||
}
|
||||
|
||||
fn node_script_from_windows_cmd_shim(binary: &Path) -> Option<PathBuf> {
|
||||
let contents = std::fs::read_to_string(binary).ok()?;
|
||||
contents
|
||||
.split('"')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.filter(|token| is_node_script_token(token))
|
||||
.find_map(|token| resolve_cmd_shim_script_path(binary, token))
|
||||
}
|
||||
|
||||
fn is_node_script_token(token: &str) -> bool {
|
||||
let lower = token.to_ascii_lowercase();
|
||||
(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"))
|
||||
&& (lower.starts_with("%dp0%") || lower.starts_with("%~dp0"))
|
||||
}
|
||||
|
||||
fn resolve_cmd_shim_script_path(binary: &Path, token: &str) -> Option<PathBuf> {
|
||||
let relative = token
|
||||
.strip_prefix("%dp0%")
|
||||
.or_else(|| token.strip_prefix("%~dp0"))?
|
||||
.trim_start_matches(['\\', '/']);
|
||||
let mut script = binary.parent()?.to_path_buf();
|
||||
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
|
||||
script.push(part);
|
||||
}
|
||||
script.is_file().then_some(script)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_json_line(
|
||||
line: Result<String, std::io::Error>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
|
||||
313
src-tauri/src/cli_agent_runtime/shell_env.rs
Normal file
313
src-tauri/src/cli_agent_runtime/shell_env.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
const OUTPUT_PREFIX: &str = "__TOLARIA_ENV__:";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct EnvName<'a>(&'a str);
|
||||
|
||||
impl<'a> EnvName<'a> {
|
||||
pub(crate) fn new(raw: &'a str) -> Option<Self> {
|
||||
is_valid_name(raw).then_some(Self(raw))
|
||||
}
|
||||
|
||||
pub(crate) const fn trusted(raw: &'a str) -> Self {
|
||||
Self(raw)
|
||||
}
|
||||
|
||||
pub(crate) fn as_str(self) -> &'a str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_user_shell_env_vars_if_missing(command: &mut Command, names: &[EnvName<'_>]) {
|
||||
let missing = valid_unique_names(names)
|
||||
.into_iter()
|
||||
.filter(|name| !process_has_value(name) && !command_has_value(command, name))
|
||||
.collect::<Vec<_>>();
|
||||
for binding in user_shell_bindings(&missing) {
|
||||
command.env(binding.name, binding.value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn env_value_from_process_or_user_shell(name: EnvName<'_>) -> Option<String> {
|
||||
process_value(name).or_else(|| user_shell_value(name))
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct EnvBinding {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
fn process_has_value(name: &EnvName<'_>) -> bool {
|
||||
std::env::var_os(name.as_str()).is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn command_has_value(command: &Command, name: &EnvName<'_>) -> bool {
|
||||
command.get_envs().any(|(key, value)| {
|
||||
key == OsStr::new(name.as_str()) && value.is_some_and(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn process_value(name: EnvName<'_>) -> Option<String> {
|
||||
std::env::var(name.as_str())
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn user_shell_value(name: EnvName<'_>) -> Option<String> {
|
||||
user_shell_bindings(&[name])
|
||||
.into_iter()
|
||||
.find_map(|binding| (binding.name == name.as_str()).then_some(binding.value))
|
||||
}
|
||||
|
||||
fn user_shell_bindings(names: &[EnvName<'_>]) -> Vec<EnvBinding> {
|
||||
let names = valid_unique_names(names);
|
||||
if names.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
user_shell_bindings_for_platform(&names)
|
||||
}
|
||||
|
||||
fn valid_unique_names<'a>(names: &[EnvName<'a>]) -> Vec<EnvName<'a>> {
|
||||
let mut unique = Vec::new();
|
||||
for name in names.iter().copied() {
|
||||
if !unique.iter().any(|existing| existing == &name) {
|
||||
unique.push(name);
|
||||
}
|
||||
}
|
||||
unique
|
||||
}
|
||||
|
||||
fn is_valid_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first == '_' || first.is_ascii_alphabetic())
|
||||
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn user_shell_bindings_for_platform(names: &[EnvName<'_>]) -> Vec<EnvBinding> {
|
||||
shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| user_shell_bindings_from_shell(&shell, names))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn user_shell_bindings_for_platform(_names: &[EnvName<'_>]) -> Vec<EnvBinding> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn user_shell_bindings_from_shell(shell: &Path, names: &[EnvName<'_>]) -> Option<Vec<EnvBinding>> {
|
||||
let output = crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(shell_probe_script(shell, names))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let bindings = parse_probe_output(&String::from_utf8_lossy(&output.stdout), names);
|
||||
(!bindings.is_empty()).then_some(bindings)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn shell_probe_script(shell: &Path, names: &[EnvName<'_>]) -> String {
|
||||
format!(
|
||||
"{}\nfor name in {}; do\n value=$(printenv \"$name\" 2>/dev/null || true)\n if [ -n \"$value\" ]; then\n printf '{}%s=%s\\n' \"$name\" \"$value\"\n fi\ndone\n",
|
||||
rc_source_command(shell),
|
||||
joined_names(names),
|
||||
OUTPUT_PREFIX
|
||||
)
|
||||
}
|
||||
|
||||
fn joined_names(names: &[EnvName<'_>]) -> String {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn rc_source_command(shell: &Path) -> &'static str {
|
||||
let name = shell
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or_default();
|
||||
if name.contains("zsh") {
|
||||
return "if [ -n \"${ZDOTDIR:-}\" ] && [ -r \"${ZDOTDIR}/.zshrc\" ]; then . \"${ZDOTDIR}/.zshrc\" >/dev/null 2>&1 || true; elif [ -r \"$HOME/.zshrc\" ]; then . \"$HOME/.zshrc\" >/dev/null 2>&1 || true; fi";
|
||||
}
|
||||
if name.contains("bash") {
|
||||
return "if [ -r \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\" >/dev/null 2>&1 || true; fi";
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
struct ProbeOutput<'a>(&'a str);
|
||||
|
||||
struct ProbeLine<'a>(&'a str);
|
||||
|
||||
fn parse_probe_output(stdout: &str, names: &[EnvName<'_>]) -> Vec<EnvBinding> {
|
||||
ProbeOutput(stdout)
|
||||
.0
|
||||
.lines()
|
||||
.filter_map(|line| parse_probe_line(ProbeLine(line), names))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_probe_line(line: ProbeLine<'_>, names: &[EnvName<'_>]) -> Option<EnvBinding> {
|
||||
let (name, value) = line.0.strip_prefix(OUTPUT_PREFIX)?.split_once('=')?;
|
||||
let name = EnvName::new(name)?;
|
||||
let value = value.trim();
|
||||
(names.iter().any(|expected| expected == &name) && !value.is_empty()).then(|| EnvBinding {
|
||||
name: name.as_str().to_string(),
|
||||
value: value.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shell = dir.path().join("zsh");
|
||||
let zshrc = dir.path().join(".zshrc");
|
||||
std::fs::write(
|
||||
&shell,
|
||||
"#!/bin/sh\nexport HOME=$(dirname \"$0\")\nexec /bin/sh -c \"$2\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&zshrc,
|
||||
"export ANTHROPIC_API_KEY=from-zshrc\nexport ANTHROPIC_BASE_URL=https://proxy.example.test\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let values = user_shell_bindings_from_shell(
|
||||
&shell,
|
||||
&[
|
||||
EnvName::trusted("ANTHROPIC_API_KEY"),
|
||||
EnvName::trusted("ANTHROPIC_BASE_URL"),
|
||||
EnvName::trusted("IGNORED_SECRET"),
|
||||
],
|
||||
)
|
||||
.expect("zshrc exports should be readable");
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![
|
||||
EnvBinding {
|
||||
name: "ANTHROPIC_API_KEY".to_string(),
|
||||
value: "from-zshrc".to_string(),
|
||||
},
|
||||
EnvBinding {
|
||||
name: "ANTHROPIC_BASE_URL".to_string(),
|
||||
value: "https://proxy.example.test".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
115
src-tauri/src/cli_agent_runtime/windows_cmd_shim.rs
Normal file
115
src-tauri/src/cli_agent_runtime/windows_cmd_shim.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use super::AgentCommandTarget;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn command_target(binary: &Path) -> Result<Option<AgentCommandTarget>, String> {
|
||||
if !is_batch_shim(binary) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(target) = target_path(binary) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if is_node_script(&target) {
|
||||
return Ok(Some(AgentCommandTarget {
|
||||
program: crate::mcp::find_node()?,
|
||||
first_arg: Some(target),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Some(AgentCommandTarget {
|
||||
program: target,
|
||||
first_arg: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_batch_shim(binary: &Path) -> bool {
|
||||
has_extension(binary, &["cmd", "bat"])
|
||||
}
|
||||
|
||||
fn target_path(binary: &Path) -> Option<PathBuf> {
|
||||
let contents = std::fs::read_to_string(binary).ok()?;
|
||||
contents
|
||||
.split('"')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.find_map(|token| resolve_target_path(binary, token))
|
||||
}
|
||||
|
||||
fn resolve_target_path(binary: &Path, token: &str) -> Option<PathBuf> {
|
||||
let relative = token
|
||||
.strip_prefix("%dp0%")
|
||||
.or_else(|| token.strip_prefix("%~dp0"))?
|
||||
.trim_start_matches(['\\', '/']);
|
||||
let mut target = binary.parent()?.to_path_buf();
|
||||
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
|
||||
target.push(part);
|
||||
}
|
||||
is_supported_target(&target)
|
||||
.then_some(target)
|
||||
.filter(|target| target.is_file())
|
||||
}
|
||||
|
||||
fn is_supported_target(path: &Path) -> bool {
|
||||
is_node_script(path) || is_native_executable(path)
|
||||
}
|
||||
|
||||
fn is_node_script(path: &Path) -> bool {
|
||||
has_extension(path, &["js", "mjs", "cjs"])
|
||||
}
|
||||
|
||||
fn is_native_executable(path: &Path) -> bool {
|
||||
has_extension(path, &["exe", "com"]) && !has_file_name(path, "node.exe")
|
||||
}
|
||||
|
||||
fn has_extension(path: &Path, expected_extensions: &[&str]) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
expected_extensions
|
||||
.iter()
|
||||
.any(|expected| extension.eq_ignore_ascii_case(expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn has_file_name(path: &Path, expected_name: &str) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_target_uses_native_exe_from_windows_cmd_shim() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shim = dir.path().join("claude.cmd");
|
||||
let native_exe = dir
|
||||
.path()
|
||||
.join("node_modules")
|
||||
.join("@anthropic-ai")
|
||||
.join("claude-code")
|
||||
.join("bin")
|
||||
.join("claude.exe");
|
||||
std::fs::create_dir_all(native_exe.parent().unwrap()).unwrap();
|
||||
std::fs::write(dir.path().join("node.exe"), "node runtime").unwrap();
|
||||
std::fs::write(&native_exe, "native claude launcher").unwrap();
|
||||
std::fs::write(
|
||||
&shim,
|
||||
r#"@ECHO off
|
||||
IF EXIST "%~dp0\node.exe" (
|
||||
SET "_prog=%~dp0\node.exe"
|
||||
)
|
||||
"%~dp0\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %*
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let target = command_target(&shim).unwrap().unwrap();
|
||||
|
||||
assert_eq!(target.program, native_exe);
|
||||
assert_eq!(target.first_arg, None);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -7,6 +7,7 @@ mod git;
|
||||
pub mod git_clone;
|
||||
mod git_connect;
|
||||
mod memory;
|
||||
mod pdf_export;
|
||||
mod runtime;
|
||||
mod system;
|
||||
mod vault;
|
||||
@@ -22,6 +23,7 @@ pub use folders::*;
|
||||
pub use git::*;
|
||||
pub use git_connect::*;
|
||||
pub use memory::*;
|
||||
pub use pdf_export::*;
|
||||
pub use runtime::*;
|
||||
pub use system::*;
|
||||
pub use vault::*;
|
||||
|
||||
141
src-tauri/src/commands/pdf_export.rs
Normal file
141
src-tauri/src/commands/pdf_export.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
#[tauri::command]
|
||||
pub fn export_current_webview_pdf(
|
||||
window: tauri::WebviewWindow,
|
||||
output_path: String,
|
||||
) -> Result<(), String> {
|
||||
native::export_current_webview_pdf(window, output_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn can_export_current_webview_pdf() -> bool {
|
||||
native::can_export_current_webview_pdf()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native {
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use objc2::runtime::ProtocolObject;
|
||||
use objc2::ClassType;
|
||||
use objc2_app_kit::{NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob};
|
||||
use objc2_foundation::{NSString, NSURL};
|
||||
use objc2_web_kit::WKWebView;
|
||||
|
||||
const PDF_EXPORT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub fn can_export_current_webview_pdf() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn export_current_webview_pdf(
|
||||
window: tauri::WebviewWindow,
|
||||
output_path: String,
|
||||
) -> Result<(), String> {
|
||||
validate_output_path(&output_path)?;
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
|
||||
window
|
||||
.with_webview(move |webview| {
|
||||
let result = save_webview_pdf(webview, &output_path);
|
||||
let _ = sender.send(result);
|
||||
})
|
||||
.map_err(|error| format!("Failed to access the current webview: {error}"))?;
|
||||
|
||||
receiver
|
||||
.recv_timeout(PDF_EXPORT_TIMEOUT)
|
||||
.map_err(|_| "Timed out while exporting the current note as PDF".to_string())?
|
||||
}
|
||||
|
||||
fn validate_output_path(output_path: &str) -> Result<(), String> {
|
||||
if output_path.trim().is_empty() {
|
||||
return Err("Missing PDF export path".to_string());
|
||||
}
|
||||
|
||||
let path = Path::new(output_path);
|
||||
if path.file_name().is_none() {
|
||||
return Err("PDF export path must include a file name".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_webview_pdf(
|
||||
webview: tauri::webview::PlatformWebview,
|
||||
output_path: &str,
|
||||
) -> Result<(), String> {
|
||||
let output = NSString::from_str(output_path);
|
||||
let output_url = NSURL::fileURLWithPath(&output);
|
||||
let print_info = NSPrintInfo::sharedPrintInfo();
|
||||
let print_settings = unsafe { print_info.dictionary() };
|
||||
let previous_job_disposition = print_info.jobDisposition();
|
||||
|
||||
print_info.setJobDisposition(unsafe { NSPrintSaveJob });
|
||||
unsafe {
|
||||
print_settings.setObject_forKey(
|
||||
output_url.as_super().as_super(),
|
||||
ProtocolObject::from_ref(NSPrintJobSavingURL),
|
||||
);
|
||||
}
|
||||
|
||||
let webview: &WKWebView = unsafe { &*webview.inner().cast() };
|
||||
let window = webview
|
||||
.window()
|
||||
.ok_or_else(|| "Failed to access the webview window for PDF export".to_string())?;
|
||||
let operation = unsafe { webview.printOperationWithPrintInfo(&print_info) };
|
||||
operation.setShowsPrintPanel(false);
|
||||
operation.setShowsProgressPanel(false);
|
||||
operation.setCanSpawnSeparateThread(true);
|
||||
|
||||
unsafe {
|
||||
operation.runOperationModalForWindow_delegate_didRunSelector_contextInfo(
|
||||
&window,
|
||||
None,
|
||||
None,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
print_info.setJobDisposition(&previous_job_disposition);
|
||||
unsafe {
|
||||
print_settings.removeObjectForKey(NSPrintJobSavingURL);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_output_path;
|
||||
|
||||
#[test]
|
||||
fn output_path_requires_a_non_blank_value() {
|
||||
assert!(validate_output_path("").is_err());
|
||||
assert!(validate_output_path(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_path_requires_a_file_name() {
|
||||
assert!(validate_output_path("/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_path_accepts_a_pdf_file_path() {
|
||||
assert!(validate_output_path("/tmp/tolaria-note.pdf").is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod native {
|
||||
pub fn can_export_current_webview_pdf() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn export_current_webview_pdf(
|
||||
_window: tauri::WebviewWindow,
|
||||
_output_path: String,
|
||||
) -> Result<(), String> {
|
||||
Err("Direct PDF export is currently only supported on macOS".to_string())
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ fn is_commit_signing_failure(detail: &str) -> bool {
|
||||
mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use crate::git::tests::{setup_git_repo, GitConfigEnvGuard};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -138,6 +138,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_git_commit_sets_missing_local_author_identity() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
unset_local_author_config(vault);
|
||||
@@ -156,7 +158,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
local_config_value(vault, "user.email").as_deref(),
|
||||
Some("vault@tolaria.md")
|
||||
Some("vault@tolaria.default")
|
||||
);
|
||||
|
||||
let author = git_command()
|
||||
@@ -166,7 +168,39 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&author.stdout).trim(),
|
||||
"Tolaria <vault@tolaria.md>"
|
||||
"Tolaria <vault@tolaria.default>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_commit_respects_global_author_identity() {
|
||||
let _env =
|
||||
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
|
||||
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
unset_local_author_config(vault);
|
||||
|
||||
fs::write(vault.join("global-identity.md"), "# Global identity\n").unwrap();
|
||||
|
||||
let result = git_commit(vault.to_str().unwrap(), "Commit with global identity");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"commit should use the global identity: {result:?}"
|
||||
);
|
||||
|
||||
// The global identity resolves, so no local override is written.
|
||||
assert_eq!(local_config_value(vault, "user.name"), None);
|
||||
assert_eq!(local_config_value(vault, "user.email"), None);
|
||||
|
||||
let author = git_command()
|
||||
.args(["log", "-1", "--format=%an <%ae>"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&author.stdout).trim(),
|
||||
"Global User <global@test.com>"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair, GitConfigEnvGuard};
|
||||
use crate::git::{git_commit, git_pull, git_push};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -310,6 +310,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_sets_missing_local_author_identity() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vault = clone_b.path();
|
||||
let vp_b = vault.to_str().unwrap();
|
||||
@@ -328,7 +330,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
local_config_value(vault, "user.email").as_deref(),
|
||||
Some("vault@tolaria.md")
|
||||
Some("vault@tolaria.default")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ fn concise_git_detail(stderr: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use crate::git::tests::{setup_git_repo, GitConfigEnvGuard};
|
||||
use crate::git::{git_commit, git_remote_status};
|
||||
use std::fs;
|
||||
use std::process::Command as StdCommand;
|
||||
@@ -471,6 +471,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_sets_local_identity_when_existing_repo_has_none() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let local = setup_git_repo();
|
||||
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
|
||||
clear_local_author(local.path());
|
||||
@@ -487,6 +489,16 @@ mod tests {
|
||||
|
||||
assert_eq!(result.status, "connected");
|
||||
assert!(local_author_is_configured(local.path()));
|
||||
|
||||
let email = StdCommand::new("git")
|
||||
.args(["config", "--local", "user.email"])
|
||||
.current_dir(local.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&email.stdout).trim(),
|
||||
"vault@tolaria.default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
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;
|
||||
@@ -16,6 +17,9 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[cfg(test)]
|
||||
use std::cell::RefCell;
|
||||
|
||||
pub use clone::clone_repo;
|
||||
pub use commit::git_commit;
|
||||
pub use conflict::{
|
||||
@@ -24,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::{
|
||||
@@ -82,10 +87,34 @@ pub(crate) fn git_command() -> Command {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
sanitize_linux_appimage_git_env(&mut command);
|
||||
#[cfg(test)]
|
||||
apply_test_git_config_env(&mut command);
|
||||
command.args(["-c", "core.quotePath=false"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone)]
|
||||
struct TestGitConfigEnv {
|
||||
global: PathBuf,
|
||||
system: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_GIT_CONFIG_ENV: RefCell<Option<TestGitConfigEnv>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn apply_test_git_config_env(command: &mut Command) {
|
||||
TEST_GIT_CONFIG_ENV.with(|env| {
|
||||
if let Some(config) = env.borrow().as_ref() {
|
||||
command.env("GIT_CONFIG_GLOBAL", &config.global);
|
||||
command.env("GIT_CONFIG_SYSTEM", &config.system);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn git_launch_config() -> &'static GitLaunchConfig {
|
||||
static CONFIG: OnceLock<GitLaunchConfig> = OnceLock::new();
|
||||
CONFIG.get_or_init(detect_git_launch_config)
|
||||
@@ -287,17 +316,44 @@ fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
/// Fallback author name written when no identity is configured anywhere.
|
||||
const FALLBACK_AUTHOR_NAME: &str = "Tolaria";
|
||||
|
||||
/// Fallback author email written when no identity is configured anywhere.
|
||||
const FALLBACK_AUTHOR_EMAIL: &str = "vault@tolaria.default";
|
||||
|
||||
/// Email previously hardcoded by Tolaria. GitHub may attribute it to a real
|
||||
/// account, so it is treated as "no identity": healed from the local scope and
|
||||
/// skipped wherever it resolves from.
|
||||
const LEGACY_FALLBACK_EMAIL: &str = "vault@tolaria.md";
|
||||
|
||||
/// Ensure git can resolve an author identity for the vault, without ever
|
||||
/// overriding one the user configured themselves.
|
||||
///
|
||||
/// 1. Heal: earlier Tolaria versions unconditionally wrote
|
||||
/// `Tolaria <vault@tolaria.md>` into the repo-local config, shadowing the
|
||||
/// user's own global/system identity. If that legacy email is still present
|
||||
/// locally, remove it so the user's identity resolves again.
|
||||
/// 2. Respect: if git resolves a value from any scope, keep it. A resolved email
|
||||
/// equal to the legacy fallback is skipped, since it misattributes commits.
|
||||
/// 3. Fallback: only when nothing resolves, write a repo-local Tolaria fallback
|
||||
/// identity so app-managed commits still work.
|
||||
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [("user.name", "Tolaria"), ("user.email", "vault@tolaria.md")] {
|
||||
let local = git_command()
|
||||
.args(["config", "--local", key])
|
||||
heal_legacy_local_identity(dir)?;
|
||||
|
||||
for (key, fallback, skip_legacy) in [
|
||||
("user.name", FALLBACK_AUTHOR_NAME, false),
|
||||
("user.email", FALLBACK_AUTHOR_EMAIL, true),
|
||||
] {
|
||||
let resolved = git_command()
|
||||
.args(["config", key])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check git config {key}: {e}"))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&local.stdout);
|
||||
if local.status.success() && !value.trim().is_empty() {
|
||||
let value = String::from_utf8_lossy(&resolved.stdout);
|
||||
let value = value.trim();
|
||||
if resolved.status.success() && resolved_author_value_is_usable(value, skip_legacy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -306,6 +362,42 @@ pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolved_author_value_is_usable(value: &str, skip_legacy: bool) -> bool {
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
!skip_legacy || value != LEGACY_FALLBACK_EMAIL
|
||||
}
|
||||
|
||||
/// Remove the local `vault@tolaria.md` email that earlier versions wrote into
|
||||
/// repo-local config. A local name the user set themselves is left untouched.
|
||||
fn heal_legacy_local_identity(dir: &Path) -> Result<(), String> {
|
||||
let local_email = local_config_value(dir, "user.email")?;
|
||||
if local_email.as_deref() != Some(LEGACY_FALLBACK_EMAIL) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_git(dir, &["config", "--local", "--unset-all", "user.email"])?;
|
||||
if local_config_value(dir, "user.name")?.as_deref() == Some(FALLBACK_AUTHOR_NAME) {
|
||||
run_git(dir, &["config", "--local", "--unset-all", "user.name"])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a repo-local config value, or `None` when it is not set.
|
||||
fn local_config_value(dir: &Path, key: &str) -> Result<Option<String>, String> {
|
||||
let output = git_command()
|
||||
.args(["config", "--local", key])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check git config {key}: {e}"))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&output.stdout);
|
||||
let value = value.trim();
|
||||
Ok((output.status.success() && !value.is_empty()).then(|| value.to_string()))
|
||||
}
|
||||
|
||||
/// Extract "owner/repo" from a GitHub remote URL.
|
||||
/// Supports HTTPS (https://github.com/owner/repo.git) and
|
||||
/// SSH (git@github.com:owner/repo.git) formats.
|
||||
@@ -340,6 +432,52 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Redirect global and system git config to files under a TempDir so
|
||||
/// identity tests are hermetic with respect to the developer's own
|
||||
/// gitconfig.
|
||||
pub(crate) struct GitConfigEnvGuard {
|
||||
previous: Option<TestGitConfigEnv>,
|
||||
_dir: TempDir,
|
||||
}
|
||||
|
||||
impl GitConfigEnvGuard {
|
||||
/// No identity resolvable outside the repo's local config.
|
||||
pub(crate) fn isolated() -> Self {
|
||||
Self::with_global_identity(None)
|
||||
}
|
||||
|
||||
/// Optionally expose a global identity to spawned git commands.
|
||||
pub(crate) fn with_global_identity(identity: Option<(&str, &str)>) -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let global = dir.path().join("gitconfig-global");
|
||||
if let Some((name, email)) = identity {
|
||||
fs::write(
|
||||
&global,
|
||||
format!("[user]\n\tname = {name}\n\temail = {email}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let system = dir.path().join("gitconfig-system");
|
||||
|
||||
let config = TestGitConfigEnv { global, system };
|
||||
let previous = TEST_GIT_CONFIG_ENV.with(|env| env.replace(Some(config)));
|
||||
|
||||
Self {
|
||||
previous,
|
||||
_dir: dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GitConfigEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.previous.take();
|
||||
TEST_GIT_CONFIG_ENV.with(|env| {
|
||||
env.replace(previous);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_repo_path(url: &str, expected: Option<&str>) {
|
||||
assert_eq!(
|
||||
parse_github_repo_path(url),
|
||||
@@ -420,6 +558,160 @@ mod tests {
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
fn init_plain_repo() -> TempDir {
|
||||
let dir = TempDir::new().unwrap();
|
||||
git_command()
|
||||
.args(["init", "--initial-branch=main"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn set_local_identity(dir: &Path, name: &str, email: &str) {
|
||||
for (key, value) in [("user.name", name), ("user.email", email)] {
|
||||
git_command()
|
||||
.args(["config", "--local", key, value])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_local_identity(dir: &Path, name: Option<&str>, email: Option<&str>) {
|
||||
assert_eq!(
|
||||
local_config_value(dir, "user.name").unwrap().as_deref(),
|
||||
name
|
||||
);
|
||||
assert_eq!(
|
||||
local_config_value(dir, "user.email").unwrap().as_deref(),
|
||||
email
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_author_config_respects_existing_global_identity() {
|
||||
let _env =
|
||||
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
|
||||
|
||||
let dir = init_plain_repo();
|
||||
|
||||
ensure_author_config(dir.path()).unwrap();
|
||||
|
||||
// The globally configured identity resolves, so no local override
|
||||
// should be written.
|
||||
assert_local_identity(dir.path(), None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_author_config_sets_fallback_without_any_identity() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let dir = init_plain_repo();
|
||||
|
||||
ensure_author_config(dir.path()).unwrap();
|
||||
|
||||
assert_local_identity(
|
||||
dir.path(),
|
||||
Some(FALLBACK_AUTHOR_NAME),
|
||||
Some(FALLBACK_AUTHOR_EMAIL),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_author_config_heals_legacy_identity_when_global_exists() {
|
||||
let _env =
|
||||
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
|
||||
|
||||
let dir = init_plain_repo();
|
||||
set_local_identity(dir.path(), FALLBACK_AUTHOR_NAME, LEGACY_FALLBACK_EMAIL);
|
||||
|
||||
ensure_author_config(dir.path()).unwrap();
|
||||
|
||||
// The legacy pair is removed so the global identity resolves again.
|
||||
assert_local_identity(dir.path(), None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_author_config_replaces_legacy_identity_without_global() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let dir = init_plain_repo();
|
||||
set_local_identity(dir.path(), FALLBACK_AUTHOR_NAME, LEGACY_FALLBACK_EMAIL);
|
||||
|
||||
ensure_author_config(dir.path()).unwrap();
|
||||
|
||||
// No user identity anywhere: the legacy email is replaced with the
|
||||
// fallback so commits keep working.
|
||||
assert_local_identity(
|
||||
dir.path(),
|
||||
Some(FALLBACK_AUTHOR_NAME),
|
||||
Some(FALLBACK_AUTHOR_EMAIL),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_author_config_keeps_user_set_local_identity() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let dir = init_plain_repo();
|
||||
set_local_identity(dir.path(), "Vault Owner", "owner@example.com");
|
||||
|
||||
ensure_author_config(dir.path()).unwrap();
|
||||
|
||||
// A local identity the user set themselves is never touched.
|
||||
assert_local_identity(dir.path(), Some("Vault Owner"), Some("owner@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_author_config_preserves_user_name_when_healing_legacy_email() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let dir = init_plain_repo();
|
||||
set_local_identity(dir.path(), "Vault Owner", LEGACY_FALLBACK_EMAIL);
|
||||
|
||||
ensure_author_config(dir.path()).unwrap();
|
||||
|
||||
assert_local_identity(dir.path(), Some("Vault Owner"), Some(FALLBACK_AUTHOR_EMAIL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_author_config_skips_legacy_email_resolved_from_global() {
|
||||
let _env =
|
||||
GitConfigEnvGuard::with_global_identity(Some(("Someone", LEGACY_FALLBACK_EMAIL)));
|
||||
|
||||
let dir = init_plain_repo();
|
||||
|
||||
ensure_author_config(dir.path()).unwrap();
|
||||
|
||||
// The name resolves globally; the legacy email is skipped and the
|
||||
// fallback is written locally instead.
|
||||
assert_local_identity(dir.path(), None, Some(FALLBACK_AUTHOR_EMAIL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_respects_global_author_identity_for_initial_commit() {
|
||||
let _env =
|
||||
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
|
||||
|
||||
init_repo(dir.path()).unwrap();
|
||||
|
||||
assert_local_identity(dir.path(), None, None);
|
||||
|
||||
let author = git_command()
|
||||
.args(["log", "-1", "--format=%an <%ae>"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&author.stdout).trim(),
|
||||
"Global User <global@test.com>"
|
||||
);
|
||||
}
|
||||
|
||||
fn command_envs(command: &Command) -> HashMap<String, Option<String>> {
|
||||
command
|
||||
.get_envs()
|
||||
|
||||
@@ -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,
|
||||
@@ -565,6 +567,8 @@ macro_rules! app_invoke_handler {
|
||||
commands::reinit_telemetry,
|
||||
commands::should_use_external_media_preview,
|
||||
commands::print_current_webview,
|
||||
commands::can_export_current_webview_pdf,
|
||||
commands::export_current_webview_pdf,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use crate::pi_cli::AgentStreamRequest;
|
||||
use serde_json::{Map, Value};
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
@@ -17,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))
|
||||
@@ -56,9 +61,18 @@ fn seed_agent_dir(source_dir: &Path, agent_dir: &Path) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(source_dir)
|
||||
.map_err(|error| format!("Failed to read Pi agent directory: {error}"))?
|
||||
{
|
||||
let entries = match std::fs::read_dir(source_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Failed to read Pi agent directory at {}: {error}",
|
||||
source_dir.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| format!("Failed to read Pi agent file: {error}"))?;
|
||||
let target = agent_dir.join(entry.file_name());
|
||||
copy_agent_entry(&entry.path(), &target)?;
|
||||
@@ -75,8 +89,16 @@ fn same_directory(left: &Path, right: &Path) -> bool {
|
||||
}
|
||||
|
||||
fn copy_agent_entry(source: &Path, target: &Path) -> Result<(), String> {
|
||||
let metadata = std::fs::metadata(source)
|
||||
.map_err(|error| format!("Failed to inspect Pi agent config: {error}"))?;
|
||||
let metadata = match std::fs::metadata(source) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Failed to inspect Pi agent config at {}: {error}",
|
||||
source.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if metadata.is_dir() {
|
||||
seed_agent_dir(source, target)
|
||||
@@ -93,13 +115,30 @@ fn copy_agent_file(
|
||||
metadata: std::fs::Metadata,
|
||||
) -> Result<(), String> {
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("Failed to create Pi agent config parent: {error}"))?;
|
||||
std::fs::create_dir_all(parent).map_err(|error| {
|
||||
format!(
|
||||
"Failed to create Pi agent config parent at {}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
std::fs::copy(source, target)
|
||||
.map_err(|error| format!("Failed to copy Pi agent config: {error}"))?;
|
||||
std::fs::set_permissions(target, metadata.permissions())
|
||||
.map_err(|error| format!("Failed to preserve Pi agent config permissions: {error}"))
|
||||
match std::fs::copy(source, target) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"Failed to copy Pi agent config from {} to {}: {error}",
|
||||
source.display(),
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
std::fs::set_permissions(target, metadata.permissions()).map_err(|error| {
|
||||
format!(
|
||||
"Failed to preserve Pi agent config permissions at {}: {error}",
|
||||
target.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_args() -> Vec<String> {
|
||||
@@ -265,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]
|
||||
@@ -289,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(
|
||||
@@ -305,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();
|
||||
@@ -367,6 +465,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_pi_agent_config_entry_is_ignored() {
|
||||
let source_agent_dir = tempfile::tempdir().unwrap();
|
||||
let agent_dir = tempfile::tempdir().unwrap();
|
||||
let stale_source = source_agent_dir.path().join("stale.json");
|
||||
let target = agent_dir.path().join("stale.json");
|
||||
|
||||
copy_agent_entry(&stale_source, &target).unwrap();
|
||||
|
||||
assert!(!target.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_includes_tolaria_server_for_active_vault() {
|
||||
if let Ok(config) = build_mcp_config(
|
||||
@@ -382,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() {
|
||||
|
||||
@@ -12,12 +12,20 @@ fn normalize_relative_path(path: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
|
||||
fn stripped_relative_path(vault_path: &Path, path: &Path) -> Option<String> {
|
||||
let relative = path.strip_prefix(vault_path).ok()?;
|
||||
let normalized = normalize_relative_path(relative.to_string_lossy().as_ref());
|
||||
(!normalized.is_empty()).then_some(normalized)
|
||||
}
|
||||
|
||||
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
|
||||
stripped_relative_path(vault_path, path).or_else(|| {
|
||||
let canonical_vault_path = vault_path.canonicalize().ok()?;
|
||||
let canonical_path = path.canonicalize().ok()?;
|
||||
stripped_relative_path(&canonical_vault_path, &canonical_path)
|
||||
})
|
||||
}
|
||||
|
||||
fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
|
||||
entry.depth() == 0 || entry.file_name().to_string_lossy() != ".git"
|
||||
}
|
||||
@@ -334,4 +342,29 @@ mod tests {
|
||||
let filtered = filter_gitignored_entries(dir.path(), entries, true);
|
||||
assert_eq!(entry_paths(dir.path(), &filtered), vec!["notes/local.md"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn filters_entries_with_real_paths_when_vault_root_is_symlinked() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let real_root = dir.path().join("real-vault");
|
||||
let symlink_root = dir.path().join("linked-vault");
|
||||
fs::create_dir_all(&real_root).unwrap();
|
||||
std::os::unix::fs::symlink(&real_root, &symlink_root).unwrap();
|
||||
init_git_repo(&symlink_root);
|
||||
write_file(&real_root, ".gitignore", "tmp/\n");
|
||||
write_file(&real_root, "visible.md", "# Visible\n");
|
||||
write_file(&real_root, "tmp/hidden.md", "# Hidden\n");
|
||||
|
||||
let filtered = filter_gitignored_entries(
|
||||
&symlink_root,
|
||||
vec![
|
||||
entry(&real_root, "visible.md"),
|
||||
entry(&real_root, "tmp/hidden.md"),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(entry_paths(&real_root, &filtered), vec!["visible.md"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +369,7 @@ vi.mock('@blocknote/core', () => ({
|
||||
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
||||
createCodeBlockSpec: vi.fn(() => ({})),
|
||||
createExtension: (factory: unknown) => () => factory,
|
||||
createStyleSpec: vi.fn(() => ({})),
|
||||
createVideoBlockConfig: vi.fn(() => ({})), defaultInlineContentSpecs: {},
|
||||
filterSuggestionItems: vi.fn(() => []), videoParse: vi.fn(() => undefined),
|
||||
}))
|
||||
|
||||
71
src/App.tsx
71
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'
|
||||
@@ -78,7 +79,7 @@ import type { AiWorkspaceConversationSetting, GitSetupPreference, SidebarSelecti
|
||||
import { initializeNoteProperties } from './utils/initializeNoteProperties'
|
||||
import { type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { getPulledVaultUpdateOptions, refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
||||
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
||||
import { viewMatchesSelection } from './utils/viewIdentity'
|
||||
import { isAiWorkspaceWindow, isNoteWindow, getNoteWindowParams, type NoteWindowParams } from './utils/windowMode'
|
||||
import { GitSetupDialog } from './components/GitRequiredModal'
|
||||
@@ -111,6 +112,7 @@ import {
|
||||
vaultPathForEntry,
|
||||
} from './utils/workspaces'
|
||||
import { activeGitRepositories } from './utils/gitRepositories'
|
||||
import { isMarkdownEntry } from './utils/typeDefinitions'
|
||||
import { useVisibleWorkspaceEntries, useWorkspaceGraphState } from './hooks/useWorkspaceGraphState'
|
||||
import { useGitSetupState } from './hooks/useGitSetupState'
|
||||
import { AppPreferencesProvider, useAppPreferences } from './hooks/useAppPreferences'
|
||||
@@ -160,6 +162,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
const aiWorkspaceWindow = false
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
||||
const [pendingNoteListPdfExportPath, setPendingNoteListPdfExportPath] = useState<string | null>(null)
|
||||
const selectionRef = useRef<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const neighborhoodHistoryRef = useRef<SidebarSelection[]>([])
|
||||
const inboxPeriod: InboxPeriod = 'all'
|
||||
@@ -181,6 +184,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const multiSelectionCommandRef = useRef<NoteListMultiSelectionCommands | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const [gitHistoryRefreshKey, setGitHistoryRefreshKey] = useState(0)
|
||||
const dialogs = useDialogs()
|
||||
const { closeAIChat, openAIChat, showAIChat } = dialogs
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
@@ -523,6 +527,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
} = notes
|
||||
const noteActiveTabPath = notes.activeTabPath
|
||||
const noteActiveTabPathRef = notes.activeTabPathRef
|
||||
const refocusActiveEditor = useCallback((path: string) => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path } }))
|
||||
}, [])
|
||||
useNoteWindowLifecycle({
|
||||
activeTabPath: notes.activeTabPath,
|
||||
handleSelectNote,
|
||||
@@ -533,7 +540,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
})
|
||||
const handleVaultUpdate = useCallback(async (
|
||||
updatedFiles: string[],
|
||||
options: { preserveFocusedEditor?: boolean; vaultPath?: string } = {},
|
||||
options: { vaultPath?: string } = {},
|
||||
) => {
|
||||
const updateVaultPath = options.vaultPath ?? resolvedPath
|
||||
await refreshPulledVaultState({
|
||||
@@ -541,13 +548,12 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
closeAllTabs,
|
||||
getActiveTabPath: () => noteActiveTabPathRef.current,
|
||||
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
||||
shouldKeepActiveEditorMounted: options.preserveFocusedEditor
|
||||
? isActiveElementInsideEditorSurface
|
||||
: undefined,
|
||||
reloadFolders: vault.reloadFolders,
|
||||
reloadVault: vault.reloadVault,
|
||||
reloadViews: vault.reloadViews,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
shouldRefocusActiveEditor: isActiveElementInsideEditorSurface,
|
||||
updatedFiles,
|
||||
vaultPath: updateVaultPath,
|
||||
})
|
||||
@@ -557,6 +563,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
handleReplaceActiveTab,
|
||||
noteActiveTabPath,
|
||||
noteActiveTabPathRef,
|
||||
refocusActiveEditor,
|
||||
refreshGitModifiedFiles,
|
||||
resolvedPath,
|
||||
vault.reloadFolders,
|
||||
@@ -565,14 +572,14 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
vault.unsavedPaths,
|
||||
])
|
||||
const handlePulledVaultUpdate = useCallback(
|
||||
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, {
|
||||
...getPulledVaultUpdateOptions(),
|
||||
vaultPath,
|
||||
}),
|
||||
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, { vaultPath }),
|
||||
[handleVaultUpdate],
|
||||
)
|
||||
const refreshGitHistorySurfaces = useCallback(() => {
|
||||
setGitHistoryRefreshKey((key) => key + 1)
|
||||
}, [])
|
||||
const handleFocusedVaultUpdate = useCallback(
|
||||
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles, { preserveFocusedEditor: true }),
|
||||
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles),
|
||||
[handleVaultUpdate],
|
||||
)
|
||||
useEffect(() => {
|
||||
@@ -599,6 +606,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
vaultPaths: activeGitRepositoryPaths,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: handlePulledVaultUpdate,
|
||||
onSyncUpdated: refreshGitHistorySurfaces,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
@@ -641,8 +649,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
reloadViews: vault.reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
||||
shouldKeepActiveEditorMounted: isActiveElementInsideEditorSurface,
|
||||
shouldRefocusActiveEditor: isActiveElementInsideEditorSurface,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
getActiveTabPath: () => notes.activeTabPathRef.current,
|
||||
@@ -1074,7 +1083,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
}, [deleteActions, visibleEntries])
|
||||
|
||||
const shouldLoadGitHistory = !layout.inspectorCollapsed && !effectiveShowAIChat
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, loadGitHistoryForPath, shouldLoadGitHistory)
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, loadGitHistoryForPath, shouldLoadGitHistory, gitHistoryRefreshKey)
|
||||
|
||||
const {
|
||||
availableFields,
|
||||
@@ -1313,6 +1322,31 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
updateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
setToastMessage,
|
||||
})
|
||||
const activeTabEntry = activeTab?.entry ?? null
|
||||
const activeTabPath = activeTabEntry?.path
|
||||
const handleSelectNoteForPdfExport = notes.handleSelectNote
|
||||
const handleExportNotePdfFromList = useCallback((entry: VaultEntry) => {
|
||||
if (!isMarkdownEntry(entry)) return
|
||||
|
||||
if (activeTabPath === entry.path) {
|
||||
pdfExportRef.current?.('note_list_context_menu')
|
||||
return
|
||||
}
|
||||
|
||||
setPendingNoteListPdfExportPath(entry.path)
|
||||
handleSelectNoteForPdfExport(entry)
|
||||
}, [activeTabPath, handleSelectNoteForPdfExport, pdfExportRef])
|
||||
useEffect(() => {
|
||||
if (!pendingNoteListPdfExportPath) return
|
||||
if (!activeTabEntry || activeTabPath !== pendingNoteListPdfExportPath) return
|
||||
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (isMarkdownEntry(activeTabEntry)) pdfExportRef.current?.('note_list_context_menu')
|
||||
setPendingNoteListPdfExportPath(null)
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frameId)
|
||||
}, [activeTabEntry, activeTabPath, pendingNoteListPdfExportPath, pdfExportRef])
|
||||
|
||||
const {
|
||||
isStartupLoading,
|
||||
@@ -1346,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
|
||||
@@ -1559,9 +1599,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
|
||||
<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} 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} />
|
||||
@@ -1612,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}
|
||||
@@ -1709,7 +1750,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={gitRepoState !== 'missing'} onSave={saveSettings} onCopyMcpConfig={mcpSetupDialog.copyManualConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} locale={appLocale} />
|
||||
<McpSetupDialog open={mcpSetupDialog.open} status={mcpSetupDialog.status} busyAction={mcpSetupDialog.busyAction} manualConfigSnippet={mcpSetupDialog.manualConfigSnippet} manualConfigLoading={mcpSetupDialog.manualConfigLoading} manualConfigError={mcpSetupDialog.manualConfigError} locale={appLocale} onClose={mcpSetupDialog.closeDialog} onConnect={mcpSetupDialog.connect} onCopyManualConfig={mcpSetupDialog.copyManualConfig} onDisconnect={mcpSetupDialog.disconnect} onLoadManualConfig={mcpSetupDialog.loadManualConfig} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
{deleteActions.confirmDelete && (
|
||||
|
||||
@@ -38,6 +38,21 @@ describe('AiMessage', () => {
|
||||
expect(screen.getByRole('button', { name: 'Fork chat from here' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('localizes reasoning and tool use chrome', () => {
|
||||
render(
|
||||
<AiMessage
|
||||
userMessage="Fai qualcosa"
|
||||
locale="it-IT"
|
||||
reasoning="Sto pensando..."
|
||||
reasoningDone
|
||||
actions={[{ tool: 'search_notes', toolId: 't1', label: 'Cercato', status: 'done' }]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /ragionamento/i })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /uso degli strumenti/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows reasoning expanded while streaming (reasoningDone=false)', () => {
|
||||
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." reasoningDone={false} actions={[]} />)
|
||||
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
|
||||
|
||||
@@ -106,8 +106,8 @@ function UserBubble({ content, references, onOpenNote }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningBlock({ text, expanded, onToggle }: {
|
||||
text: string; expanded: boolean; onToggle: () => void
|
||||
function ReasoningBlock({ locale, text, expanded, onToggle }: {
|
||||
locale: AppLocale; text: string; expanded: boolean; onToggle: () => void
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -127,7 +127,7 @@ function ReasoningBlock({ text, expanded, onToggle }: {
|
||||
data-testid="reasoning-toggle"
|
||||
>
|
||||
<Brain size={14} />
|
||||
<span>Reasoning</span>
|
||||
<span>{translate(locale, 'ai.message.reasoning')}</span>
|
||||
{expanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
|
||||
</button>
|
||||
{expanded && (
|
||||
@@ -174,6 +174,7 @@ function ToolUseBlock({
|
||||
actions,
|
||||
expanded,
|
||||
expandedActionIds,
|
||||
locale,
|
||||
onOpenNote,
|
||||
onToggle,
|
||||
onToggleAction,
|
||||
@@ -181,6 +182,7 @@ function ToolUseBlock({
|
||||
actions: AiAction[]
|
||||
expanded: boolean
|
||||
expandedActionIds: Set<string>
|
||||
locale: AppLocale
|
||||
onOpenNote?: (path: string) => void
|
||||
onToggle: () => void
|
||||
onToggleAction: (toolId: string) => void
|
||||
@@ -198,7 +200,7 @@ function ToolUseBlock({
|
||||
data-testid="tool-use-toggle"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<span>Tool use</span>
|
||||
<span>{translate(locale, 'ai.message.toolUse')}</span>
|
||||
<span
|
||||
className={`inline-flex h-4 min-w-4 items-center justify-center rounded-full ${pending ? 'animate-pulse' : ''}`}
|
||||
style={{
|
||||
@@ -372,6 +374,7 @@ function ConversationMessage({ userMessage, references, locale = 'en', messageId
|
||||
<UserBubble content={userMessage} references={references} onOpenNote={onOpenNote} />
|
||||
{reasoning && (
|
||||
<ReasoningBlock
|
||||
locale={locale}
|
||||
text={reasoning}
|
||||
expanded={reasoningExpanded}
|
||||
onToggle={() => setUserOverride(prev => !prev)}
|
||||
@@ -382,6 +385,7 @@ function ConversationMessage({ userMessage, references, locale = 'en', messageId
|
||||
actions={actions}
|
||||
expanded={toolUseExpanded}
|
||||
expandedActionIds={expandedActions}
|
||||
locale={locale}
|
||||
onOpenNote={onOpenNote}
|
||||
onToggle={() => setToolUseExpanded((current) => !current)}
|
||||
onToggleAction={toggleAction}
|
||||
|
||||
@@ -559,9 +559,14 @@ describe('AiWorkspace', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ id: 'stored-chat', title: 'Quarterly sponsor outreach' }),
|
||||
])
|
||||
await waitFor(() => {
|
||||
expect(onConversationSettingsChange.mock.calls.some(([settings]) => (
|
||||
settings.some((setting) => (
|
||||
setting.id === 'stored-chat'
|
||||
&& setting.title === 'Quarterly sponsor outreach'
|
||||
))
|
||||
))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows a chat title to be renamed from the sidebar', () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
93
src/components/AiWorkspaceChrome.tsx
Normal file
93
src/components/AiWorkspaceChrome.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Archive, ArrowSquareIn, ArrowSquareOut, GearSix, WarningCircle, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { getVaultAiGuidanceSummary, vaultAiGuidanceNeedsRestore, type VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import type { AiConversation } from './aiWorkspaceConversations'
|
||||
import type { AiWorkspaceMode } from './aiWorkspaceSizing'
|
||||
|
||||
export function GuidanceWarning({
|
||||
locale,
|
||||
onRestore,
|
||||
status,
|
||||
}: {
|
||||
locale: AppLocale
|
||||
onRestore?: () => void
|
||||
status?: VaultAiGuidanceStatus
|
||||
}) {
|
||||
if (!status || !vaultAiGuidanceNeedsRestore(status)) return null
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 border-y border-border bg-muted/50 px-3 py-2 text-[12px] text-muted-foreground">
|
||||
<WarningCircle size={15} className="shrink-0 text-amber-600" />
|
||||
<span className="min-w-0 flex-1">
|
||||
{translate(locale, 'ai.workspace.guidanceWarning', { summary: getVaultAiGuidanceSummary(status) })}
|
||||
</span>
|
||||
{status.canRestore && onRestore && (
|
||||
<Button type="button" variant="outline" size="xs" onClick={onRestore}>
|
||||
{translate(locale, 'ai.workspace.restoreGuidance')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceHeader({
|
||||
conversation,
|
||||
archiveDisabled,
|
||||
locale,
|
||||
mode,
|
||||
onArchive,
|
||||
onClose,
|
||||
onDock,
|
||||
onOpenAiSettings,
|
||||
onPopOut,
|
||||
}: {
|
||||
conversation: AiConversation
|
||||
archiveDisabled: boolean
|
||||
locale: AppLocale
|
||||
mode: AiWorkspaceMode
|
||||
onArchive: () => void
|
||||
onClose: () => void
|
||||
onDock?: () => void
|
||||
onOpenAiSettings?: () => void
|
||||
onPopOut?: (context?: { activeConversationId?: string }) => void
|
||||
}) {
|
||||
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragRegionRef}
|
||||
className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-3"
|
||||
data-testid="ai-workspace-chat-header"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="min-w-0 max-w-[260px]">
|
||||
<div className="truncate text-[13px] font-semibold text-foreground">{conversation.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onOpenAiSettings && (
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.settings')} title={translate(locale, 'ai.workspace.settings')} onClick={onOpenAiSettings}>
|
||||
<GearSix size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.archive')} title={translate(locale, 'ai.workspace.archive')} disabled={archiveDisabled} onClick={onArchive}>
|
||||
<Archive size={16} />
|
||||
</Button>
|
||||
{mode === 'docked' ? (
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.popOut')} title={translate(locale, 'ai.workspace.popOut')} onClick={() => onPopOut?.({ activeConversationId: conversation.id })}>
|
||||
<ArrowSquareOut size={16} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.dock')} title={translate(locale, 'ai.workspace.dock')} onClick={onDock}>
|
||||
<ArrowSquareIn size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.close')} title={translate(locale, 'ai.workspace.close')} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/components/AiWorkspaceResizeHandles.tsx
Normal file
62
src/components/AiWorkspaceResizeHandles.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import type { AiWorkspaceMode } from './aiWorkspaceSizing'
|
||||
|
||||
function startResizeDrag(
|
||||
event: ReactMouseEvent,
|
||||
cursor: string,
|
||||
onDrag: (deltaX: number, deltaY: number) => void,
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
let lastX = event.clientX
|
||||
let lastY = event.clientY
|
||||
const previousCursor = document.body.style.cursor
|
||||
const previousUserSelect = document.body.style.userSelect
|
||||
document.body.style.cursor = cursor
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - lastX
|
||||
const deltaY = moveEvent.clientY - lastY
|
||||
lastX = moveEvent.clientX
|
||||
lastY = moveEvent.clientY
|
||||
onDrag(deltaX, deltaY)
|
||||
}
|
||||
const handleMouseUp = () => {
|
||||
document.body.style.cursor = previousCursor
|
||||
document.body.style.userSelect = previousUserSelect
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
export function WorkspaceResizeHandles({
|
||||
mode,
|
||||
onResize,
|
||||
}: {
|
||||
mode: AiWorkspaceMode
|
||||
onResize: (deltaWidth: number, deltaHeight: number) => void
|
||||
}) {
|
||||
if (mode === 'window') return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 z-30 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-border"
|
||||
data-testid="ai-workspace-left-resize"
|
||||
onMouseDown={(event) => startResizeDrag(event, 'col-resize', (deltaX) => onResize(-deltaX, 0))}
|
||||
/>
|
||||
{mode === 'docked' && (
|
||||
<div
|
||||
className="absolute top-0 right-0 left-0 z-30 h-1 cursor-row-resize bg-transparent transition-colors hover:bg-border"
|
||||
data-testid="ai-workspace-top-resize"
|
||||
onMouseDown={(event) => startResizeDrag(event, 'row-resize', (_deltaX, deltaY) => onResize(0, -deltaY))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -307,6 +307,7 @@ body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadc
|
||||
}
|
||||
|
||||
@media print {
|
||||
html:has(body.tolaria-note-pdf-exporting),
|
||||
body.tolaria-note-pdf-exporting {
|
||||
background: #fff !important;
|
||||
color: #111 !important;
|
||||
@@ -321,11 +322,20 @@ body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadc
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
body.tolaria-note-pdf-exporting :is(#root, .app-shell, .app, .app__editor, .editor, .editor-scroll-area) {
|
||||
body.tolaria-note-pdf-exporting :is(.app__sidebar, .app__note-list, .breadcrumb-bar, .editor-memory-probe) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.tolaria-note-pdf-exporting .editor > .relative > :not(:has([data-note-pdf-export-root="true"])) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.tolaria-note-pdf-exporting :is(#root, .app-shell, .app, .app__editor, .editor, .editor > .relative, .editor-content-width--normal, .editor-content-width--wide, .editor-scroll-area) {
|
||||
display: block !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
body.tolaria-note-pdf-exporting [data-note-pdf-export-root="true"],
|
||||
@@ -334,8 +344,6 @@ body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadc
|
||||
}
|
||||
|
||||
body.tolaria-note-pdf-exporting [data-note-pdf-export-root="true"] {
|
||||
position: absolute !important;
|
||||
inset: 0 auto auto 0 !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock('@blocknote/core', () => ({
|
||||
createAudioBlockConfig: vi.fn(() => ({})),
|
||||
createCodeBlockSpec: vi.fn(() => ({})),
|
||||
createExtension: (factory: unknown) => () => factory,
|
||||
createStyleSpec: vi.fn(() => ({})),
|
||||
createVideoBlockConfig: vi.fn(() => ({})),
|
||||
defaultInlineContentSpecs: {},
|
||||
filterSuggestionItems: vi.fn(() => []),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -257,6 +257,14 @@
|
||||
color: var(--inline-styles-code-color);
|
||||
}
|
||||
|
||||
/* --- Inline: Markdown highlight --- */
|
||||
.editor__blocknote-container .bn-editor mark.markdown-highlight {
|
||||
background-color: var(--feedback-warning-bg);
|
||||
color: var(--colors-text);
|
||||
border-radius: 2px;
|
||||
padding: 0 0.12em;
|
||||
}
|
||||
|
||||
/* --- Inline: links --- */
|
||||
.editor__blocknote-container .bn-editor a {
|
||||
color: var(--inline-styles-link-color);
|
||||
@@ -280,15 +288,29 @@
|
||||
.editor__blocknote-container .math--inline {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
vertical-align: -0.15em;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math-block-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math-block-shell:not(.math-block-shell--editing) {
|
||||
width: fit-content;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math-block-shell--editing {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math-block-source::selection {
|
||||
background: var(--colors-selection);
|
||||
color: var(--colors-text);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math--block {
|
||||
display: block;
|
||||
min-width: max-content;
|
||||
@@ -316,16 +338,25 @@
|
||||
background: var(--surface-editor);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram__edit-button,
|
||||
.editor__blocknote-container .mermaid-diagram__expand-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
background: var(--surface-editor);
|
||||
box-shadow: 0 6px 16px rgb(15 23 42 / 0.14);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram__edit-button {
|
||||
right: 44px;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram__expand-button {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .mermaid-diagram:is(:hover, :focus-within) .mermaid-diagram__edit-button,
|
||||
.editor__blocknote-container .mermaid-diagram:is(:hover, :focus-within) .mermaid-diagram__expand-button {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -344,6 +375,7 @@
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.editor__blocknote-container .mermaid-diagram__edit-button,
|
||||
.editor__blocknote-container .mermaid-diagram__expand-button {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -418,6 +450,21 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body.tldraw-whiteboard-fullscreen-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard.tldraw-whiteboard--fullscreen {
|
||||
position: fixed;
|
||||
inset: 8px;
|
||||
z-index: 1200;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 18px 60px rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard .tl-container {
|
||||
--color-background: var(--card);
|
||||
--tl-layer-menu-click-capture: 25;
|
||||
@@ -429,6 +476,20 @@
|
||||
--tl-layer-following-indicator: 80;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard__fullscreen-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 90;
|
||||
background: var(--background);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard--fullscreen .tldraw-whiteboard__fullscreen-button {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard [data-radix-popper-content-wrapper] {
|
||||
position: absolute !important;
|
||||
}
|
||||
@@ -446,6 +507,10 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard--fullscreen .tldraw-whiteboard__resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tldraw-whiteboard__resize-handle--width {
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
@@ -51,6 +51,17 @@ describe('FeedbackDialog', () => {
|
||||
expect(screen.queryByText(/Sanitized and optional/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes the contribution dialog', () => {
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" locale="zh-CN" releaseChannel="alpha" />)
|
||||
|
||||
expect(screen.getByText('参与 Tolaria 贡献')).toBeInTheDocument()
|
||||
expect(screen.getByText('功能请求')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '打开产品看板' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '复制已清理的诊断信息' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Contribute to Tolaria')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Feature requests')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('focuses the primary CTA when opened', async () => {
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel={null} />)
|
||||
const cta = screen.getByRole('button', { name: 'Check out Refactoring' })
|
||||
|
||||
@@ -32,12 +32,14 @@ import { cn } from '../lib/utils'
|
||||
import { takeFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
|
||||
import { useBuildNumber } from '../hooks/useBuildNumber'
|
||||
import { APP_COMMAND_EVENT_NAME, APP_COMMAND_IDS } from '../hooks/appCommandDispatcher'
|
||||
import { createTranslator, type AppLocale, type TranslationKey } from '../lib/i18n'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
buildNumber?: string
|
||||
locale?: AppLocale
|
||||
releaseChannel?: string | null
|
||||
}
|
||||
|
||||
@@ -58,10 +60,10 @@ interface LinkFallback {
|
||||
}
|
||||
|
||||
interface ContributionPath {
|
||||
title: string
|
||||
description: string
|
||||
ctaLabel: string
|
||||
label: string
|
||||
titleKey: TranslationKey
|
||||
descriptionKey: TranslationKey
|
||||
ctaLabelKey: TranslationKey
|
||||
labelKey: TranslationKey
|
||||
url: string
|
||||
icon: typeof Lightbulb
|
||||
tone: ContributionTone
|
||||
@@ -69,8 +71,8 @@ interface ContributionPath {
|
||||
}
|
||||
|
||||
interface ContributionLink {
|
||||
ctaLabel: string
|
||||
label: string
|
||||
ctaLabelKey: TranslationKey
|
||||
labelKey: TranslationKey
|
||||
url: string
|
||||
}
|
||||
|
||||
@@ -97,46 +99,46 @@ const CONTRIBUTION_BUTTON_CLASSES: Record<ContributionTone, string> = {
|
||||
red: 'border-[var(--accent-red)] hover:bg-[var(--accent-red-light)] [&_svg]:text-[var(--accent-red)]',
|
||||
}
|
||||
|
||||
const SPONSOR_SUPPORT_PATH: ContributionPath = {
|
||||
title: 'Sponsor / Support',
|
||||
description: 'Luca here 👋 my full-time job is running Refactoring, a newsletter for 170K+ engineers about how to run good teams and ship software with AI. I write about workflows, interview tech leaders (e.g. DHH, Martin Fowler, and more) and run a private community of 2000+ engineers with monthly live coaching, AI club, and more.\n\nTolaria is FOSS and always will be. If you like it, the best way to support it is to subscribe to the newsletter.',
|
||||
ctaLabel: 'Check out Refactoring',
|
||||
label: 'Refactoring',
|
||||
const SPONSOR_SUPPORT_PATH = {
|
||||
titleKey: 'feedback.sponsor.title',
|
||||
descriptionKey: 'feedback.sponsor.description',
|
||||
ctaLabelKey: 'feedback.sponsor.cta',
|
||||
labelKey: 'feedback.sponsor.linkLabel',
|
||||
url: REFACTORING_HOME_URL,
|
||||
icon: Newspaper,
|
||||
tone: 'blue',
|
||||
}
|
||||
} satisfies ContributionPath
|
||||
|
||||
const CONTRIBUTION_PATHS: ContributionPath[] = [
|
||||
{
|
||||
title: 'Feature requests',
|
||||
description: 'Search on the board first, upvote existing ideas, and create new posts when genuinely new!',
|
||||
ctaLabel: 'Open Product Board',
|
||||
label: 'Product Board',
|
||||
titleKey: 'feedback.featureRequests.title',
|
||||
descriptionKey: 'feedback.featureRequests.description',
|
||||
ctaLabelKey: 'feedback.featureRequests.cta',
|
||||
labelKey: 'feedback.featureRequests.linkLabel',
|
||||
url: TOLARIA_PRODUCT_BOARD_URL,
|
||||
icon: Lightbulb,
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
title: 'Discussions',
|
||||
description: 'Use Discussions for questions, conversations, show & tell, and community context.',
|
||||
ctaLabel: 'Open Discussions',
|
||||
label: 'GitHub Discussions',
|
||||
titleKey: 'feedback.discussions.title',
|
||||
descriptionKey: 'feedback.discussions.description',
|
||||
ctaLabelKey: 'feedback.discussions.cta',
|
||||
labelKey: 'feedback.discussions.linkLabel',
|
||||
url: TOLARIA_GITHUB_DISCUSSIONS_URL,
|
||||
icon: MessagesSquare,
|
||||
tone: 'purple',
|
||||
},
|
||||
{
|
||||
title: 'Contribute code',
|
||||
description: 'Small, focused PRs are welcome. Check the board first so you build the right things!',
|
||||
ctaLabel: 'Open Pull Requests',
|
||||
label: 'GitHub Pull Requests',
|
||||
titleKey: 'feedback.contributeCode.title',
|
||||
descriptionKey: 'feedback.contributeCode.description',
|
||||
ctaLabelKey: 'feedback.contributeCode.cta',
|
||||
labelKey: 'feedback.contributeCode.linkLabel',
|
||||
url: TOLARIA_GITHUB_PULL_REQUESTS_URL,
|
||||
icon: GitPullRequest,
|
||||
tone: 'yellow',
|
||||
secondaryLink: {
|
||||
ctaLabel: 'Open Contributing Guide',
|
||||
label: 'the contributing guide',
|
||||
ctaLabelKey: 'feedback.contributingGuide.cta',
|
||||
labelKey: 'feedback.contributingGuide.linkLabel',
|
||||
url: TOLARIA_GITHUB_CONTRIBUTING_URL,
|
||||
},
|
||||
},
|
||||
@@ -204,7 +206,9 @@ function ContributionCard({
|
||||
)
|
||||
}
|
||||
|
||||
function LinkFallbackBanner({ linkFallback }: { linkFallback: LinkFallback | null }) {
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
|
||||
function LinkFallbackBanner({ linkFallback, t }: { linkFallback: LinkFallback | null; t: Translate }) {
|
||||
if (!linkFallback) return null
|
||||
|
||||
return (
|
||||
@@ -216,8 +220,8 @@ function LinkFallbackBanner({ linkFallback }: { linkFallback: LinkFallback | nul
|
||||
color: 'var(--feedback-warning-text)',
|
||||
}}
|
||||
>
|
||||
<p className="font-medium">Couldn’t open {linkFallback.label} automatically.</p>
|
||||
<p className="mt-1">Open this URL manually instead:</p>
|
||||
<p className="font-medium">{t('feedback.linkFallback.title', { label: linkFallback.label })}</p>
|
||||
<p className="mt-1">{t('feedback.linkFallback.description')}</p>
|
||||
<p className="mt-2 break-all rounded-md bg-popover px-3 py-2 font-mono text-xs text-foreground">
|
||||
{linkFallback.url}
|
||||
</p>
|
||||
@@ -225,18 +229,20 @@ function LinkFallbackBanner({ linkFallback }: { linkFallback: LinkFallback | nul
|
||||
)
|
||||
}
|
||||
|
||||
function getCopyDiagnosticsLabel(copyState: 'idle' | 'copied' | 'failed') {
|
||||
return copyState === 'copied' ? 'Diagnostics copied' : 'Copy sanitized diagnostics'
|
||||
function getCopyDiagnosticsLabel(copyState: 'idle' | 'copied' | 'failed', t: Translate) {
|
||||
return copyState === 'copied' ? t('feedback.diagnosticsCopied') : t('feedback.copyDiagnostics')
|
||||
}
|
||||
|
||||
function BugReportActions({
|
||||
copyState,
|
||||
canCopyDiagnostics,
|
||||
onCopyDiagnostics,
|
||||
t,
|
||||
}: {
|
||||
copyState: 'idle' | 'copied' | 'failed'
|
||||
canCopyDiagnostics: boolean
|
||||
onCopyDiagnostics: () => void
|
||||
t: Translate
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
@@ -247,15 +253,15 @@ function BugReportActions({
|
||||
onClick={onCopyDiagnostics}
|
||||
disabled={!canCopyDiagnostics}
|
||||
>
|
||||
{getCopyDiagnosticsLabel(copyState)}
|
||||
{getCopyDiagnosticsLabel(copyState, t)}
|
||||
{copyState === 'copied' ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
{copyState === 'copied' ? (
|
||||
<p className="text-xs font-medium text-foreground">Diagnostics copied.</p>
|
||||
<p className="text-xs font-medium text-foreground">{t('feedback.diagnosticsCopiedSentence')}</p>
|
||||
) : null}
|
||||
{copyState === 'failed' ? (
|
||||
<p className="text-xs font-medium text-[var(--feedback-warning-text)]">
|
||||
Clipboard access is unavailable right now. You can still open GitHub Issues directly.
|
||||
{t('feedback.clipboardUnavailable')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -341,23 +347,25 @@ function ContributionGrid({
|
||||
copyState,
|
||||
canCopyDiagnostics,
|
||||
onCopyDiagnostics,
|
||||
t,
|
||||
}: {
|
||||
onOpenLink: (label: string, url: string) => void
|
||||
copyState: 'idle' | 'copied' | 'failed'
|
||||
canCopyDiagnostics: boolean
|
||||
onCopyDiagnostics: () => void
|
||||
t: Translate
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<ContributionCard
|
||||
title={SPONSOR_SUPPORT_PATH.title}
|
||||
description={SPONSOR_SUPPORT_PATH.description}
|
||||
ctaLabel={SPONSOR_SUPPORT_PATH.ctaLabel}
|
||||
title={t(SPONSOR_SUPPORT_PATH.titleKey)}
|
||||
description={t(SPONSOR_SUPPORT_PATH.descriptionKey)}
|
||||
ctaLabel={t(SPONSOR_SUPPORT_PATH.ctaLabelKey)}
|
||||
icon={SPONSOR_SUPPORT_PATH.icon}
|
||||
tone={SPONSOR_SUPPORT_PATH.tone}
|
||||
autoFocus={true}
|
||||
onAction={() => onOpenLink(SPONSOR_SUPPORT_PATH.label, SPONSOR_SUPPORT_PATH.url)}
|
||||
onAction={() => onOpenLink(t(SPONSOR_SUPPORT_PATH.labelKey), SPONSOR_SUPPORT_PATH.url)}
|
||||
/>
|
||||
</div>
|
||||
{CONTRIBUTION_PATHS.map((path) => {
|
||||
@@ -365,36 +373,37 @@ function ContributionGrid({
|
||||
|
||||
return (
|
||||
<ContributionCard
|
||||
key={path.title}
|
||||
title={path.title}
|
||||
description={path.description}
|
||||
ctaLabel={path.ctaLabel}
|
||||
key={path.titleKey}
|
||||
title={t(path.titleKey)}
|
||||
description={t(path.descriptionKey)}
|
||||
ctaLabel={t(path.ctaLabelKey)}
|
||||
icon={path.icon}
|
||||
tone={path.tone}
|
||||
onAction={() => onOpenLink(path.label, path.url)}
|
||||
onAction={() => onOpenLink(t(path.labelKey), path.url)}
|
||||
secondaryAction={secondaryLink ? (
|
||||
<ContributionLinkButton
|
||||
label={secondaryLink.ctaLabel}
|
||||
label={t(secondaryLink.ctaLabelKey)}
|
||||
tone={path.tone}
|
||||
accented={false}
|
||||
onAction={() => onOpenLink(secondaryLink.label, secondaryLink.url)}
|
||||
onAction={() => onOpenLink(t(secondaryLink.labelKey), secondaryLink.url)}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<ContributionCard
|
||||
title="Report a bug"
|
||||
description="Explain how to reproduce, what you expected, vs what happened. Attach the diagnostics please!"
|
||||
ctaLabel="Open GitHub Issues"
|
||||
title={t('feedback.reportBug.title')}
|
||||
description={t('feedback.reportBug.description')}
|
||||
ctaLabel={t('feedback.reportBug.cta')}
|
||||
icon={Bug}
|
||||
tone="red"
|
||||
onAction={() => onOpenLink('GitHub Issues', TOLARIA_GITHUB_ISSUES_URL)}
|
||||
onAction={() => onOpenLink(t('feedback.reportBug.linkLabel'), TOLARIA_GITHUB_ISSUES_URL)}
|
||||
secondaryAction={(
|
||||
<BugReportActions
|
||||
copyState={copyState}
|
||||
canCopyDiagnostics={canCopyDiagnostics}
|
||||
onCopyDiagnostics={onCopyDiagnostics}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -406,8 +415,10 @@ export function FeedbackDialog({
|
||||
open,
|
||||
onClose,
|
||||
buildNumber,
|
||||
locale = 'en',
|
||||
releaseChannel,
|
||||
}: FeedbackDialogProps) {
|
||||
const t = createTranslator(locale)
|
||||
const detectedBuildNumber = useBuildNumber()
|
||||
const resolvedBuildNumber = buildNumber ?? detectedBuildNumber
|
||||
const diagnosticsBundle = useMemo(
|
||||
@@ -437,19 +448,20 @@ export function FeedbackDialog({
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Megaphone size={18} weight="duotone" />
|
||||
Contribute to Tolaria
|
||||
{t('feedback.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick the path that fits what you want to do! Any type of help is appreciated
|
||||
{t('feedback.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<LinkFallbackBanner linkFallback={linkFallback} />
|
||||
<LinkFallbackBanner linkFallback={linkFallback} t={t} />
|
||||
<ContributionGrid
|
||||
onOpenLink={handleOpenLink}
|
||||
copyState={copyState}
|
||||
canCopyDiagnostics={canCopyDiagnostics}
|
||||
onCopyDiagnostics={handleCopyDiagnostics}
|
||||
t={t}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
@@ -71,6 +72,43 @@ describe('FilterBuilder value inputs', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the value input focused while controlled filter state rerenders', () => {
|
||||
function StatefulBuilder() {
|
||||
const [group, setGroup] = useState<FilterGroup>({
|
||||
all: [{ field: 'title', op: 'contains', value: '' }],
|
||||
})
|
||||
|
||||
return (
|
||||
<FilterBuilder
|
||||
group={group}
|
||||
onChange={(nextGroup) => {
|
||||
onChange(nextGroup)
|
||||
setGroup(nextGroup)
|
||||
}}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<StatefulBuilder />)
|
||||
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
input.focus()
|
||||
expect(input).toHaveFocus()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'p' } })
|
||||
|
||||
const updatedInput = screen.getByTestId('filter-value-input')
|
||||
expect(updatedInput).toBe(input)
|
||||
expect(updatedInput).toHaveFocus()
|
||||
|
||||
fireEvent.change(updatedInput, { target: { value: 'po' } })
|
||||
|
||||
expect(screen.getByTestId('filter-value-input')).toBe(input)
|
||||
expect(screen.getByTestId('filter-value-input')).toHaveFocus()
|
||||
expect(screen.getByTestId('filter-value-input')).toHaveValue('po')
|
||||
})
|
||||
|
||||
it('toggles regex mode in the emitted filter payload', () => {
|
||||
renderBuilder()
|
||||
|
||||
|
||||
@@ -52,10 +52,6 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
function filterNodeKey(node: FilterNode): string {
|
||||
return JSON.stringify(node)
|
||||
}
|
||||
|
||||
function OperatorSelect({ value, onChange }: {
|
||||
value: FilterOp
|
||||
onChange: (v: FilterOp) => void
|
||||
@@ -246,7 +242,7 @@ function FilterGroupView({ group, fields, depth, onChange, onRemove }: {
|
||||
{children.map((child, i) =>
|
||||
isFilterGroup(child) ? (
|
||||
<FilterGroupView
|
||||
key={filterNodeKey(child)}
|
||||
key={i}
|
||||
group={child}
|
||||
fields={fields}
|
||||
depth={depth + 1}
|
||||
@@ -255,7 +251,7 @@ function FilterGroupView({ group, fields, depth, onChange, onRemove }: {
|
||||
/>
|
||||
) : (
|
||||
<FilterRow
|
||||
key={filterNodeKey(child)}
|
||||
key={i}
|
||||
condition={child}
|
||||
fields={fields}
|
||||
onUpdate={(c) => updateChild(i, c)}
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -61,6 +61,18 @@ describe('LinuxMenuButton', () => {
|
||||
expect(invoke).toHaveBeenCalledWith('trigger_menu_command', { id: 'file-new-note' })
|
||||
}, MENU_TEST_TIMEOUT_MS)
|
||||
|
||||
it('localizes the custom desktop menu when a locale is provided', async () => {
|
||||
render(<LinuxMenuButton locale="zh-CN" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: '文件' })).toBeInTheDocument()
|
||||
|
||||
await openHorizontalMenu('视图')
|
||||
expect(await screen.findByText('实际大小')).toBeInTheDocument()
|
||||
fireEvent.click(await screen.findByText('切换 AI 面板'))
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('trigger_menu_command', { id: 'view-toggle-ai-chat' })
|
||||
}, MENU_TEST_TIMEOUT_MS)
|
||||
|
||||
it('invokes direct window actions from the Window submenu', async () => {
|
||||
render(<LinuxMenuButton />)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { APP_COMMAND_MENU_SECTIONS } from '../hooks/appCommandCatalog'
|
||||
import { getAppCommandMenuSections } from '../hooks/appCommandCatalog'
|
||||
import { createTranslator, translate, type AppLocale } from '../lib/i18n'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -30,18 +31,28 @@ type MenuSection = {
|
||||
label: string
|
||||
}
|
||||
|
||||
const MENU_SECTIONS: ReadonlyArray<MenuSection> = [
|
||||
...APP_COMMAND_MENU_SECTIONS,
|
||||
{
|
||||
label: 'Window',
|
||||
items: [
|
||||
{ kind: 'action', label: 'Minimize', action: () => void getCurrentWindow().minimize().catch(() => {}) },
|
||||
{ kind: 'action', label: 'Maximize', action: () => void getCurrentWindow().toggleMaximize().catch(() => {}) },
|
||||
{ kind: 'separator' },
|
||||
{ kind: 'action', label: 'Close', action: () => void getCurrentWindow().close().catch(() => {}) },
|
||||
],
|
||||
},
|
||||
]
|
||||
function menuSections(locale: AppLocale): ReadonlyArray<MenuSection> {
|
||||
const t = createTranslator(locale)
|
||||
return [
|
||||
...getAppCommandMenuSections(t),
|
||||
{
|
||||
label: t('menu.window'),
|
||||
items: [
|
||||
{ kind: 'action', label: t('window.minimize'), action: () => void getCurrentWindow().minimize().catch(() => {}) },
|
||||
{ kind: 'action', label: t('window.maximize'), action: () => void getCurrentWindow().toggleMaximize().catch(() => {}) },
|
||||
{ kind: 'separator' },
|
||||
{ kind: 'action', label: t('window.close'), action: () => void getCurrentWindow().close().catch(() => {}) },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const MENU_SECTIONS: ReadonlyArray<MenuSection> = menuSections('en')
|
||||
|
||||
function getMenuSections(locale: AppLocale): ReadonlyArray<MenuSection> {
|
||||
if (locale === 'en') return MENU_SECTIONS
|
||||
return menuSections(locale)
|
||||
}
|
||||
|
||||
function triggerMenuCommand(menuItemId: string): void {
|
||||
void invoke('trigger_menu_command', { id: menuItemId }).catch(() => {})
|
||||
@@ -98,7 +109,7 @@ function HamburgerIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function AppMenuButton() {
|
||||
function AppMenuButton({ locale, sections }: { locale: AppLocale; sections: ReadonlyArray<MenuSection> }) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -106,7 +117,7 @@ function AppMenuButton() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Application menu"
|
||||
aria-label={translate(locale, 'menu.application')}
|
||||
className="h-full w-[38px] rounded-none text-foreground/70 hover:bg-foreground/10 hover:text-foreground"
|
||||
data-no-drag
|
||||
>
|
||||
@@ -114,7 +125,7 @@ function AppMenuButton() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={0} className="min-w-[200px]">
|
||||
{MENU_SECTIONS.map((section) => (
|
||||
{sections.map((section) => (
|
||||
<DropdownMenuSub key={section.label}>
|
||||
<DropdownMenuSubTrigger>{section.label}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[220px]">
|
||||
@@ -127,13 +138,13 @@ function AppMenuButton() {
|
||||
)
|
||||
}
|
||||
|
||||
function HorizontalMenuBar() {
|
||||
function HorizontalMenuBar({ sections }: { sections: ReadonlyArray<MenuSection> }) {
|
||||
return (
|
||||
<div
|
||||
className="hidden h-full min-[760px]:flex"
|
||||
data-testid="desktop-horizontal-menu"
|
||||
>
|
||||
{MENU_SECTIONS.map((section) => (
|
||||
{sections.map((section) => (
|
||||
<DropdownMenu key={section.label}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -155,13 +166,15 @@ function HorizontalMenuBar() {
|
||||
)
|
||||
}
|
||||
|
||||
export function LinuxMenuButton() {
|
||||
export function LinuxMenuButton({ locale = 'en' }: { locale?: AppLocale } = {}) {
|
||||
const sections = getMenuSections(locale)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-[760px]:hidden">
|
||||
<AppMenuButton />
|
||||
<AppMenuButton locale={locale} sections={sections} />
|
||||
</div>
|
||||
<HorizontalMenuBar />
|
||||
<HorizontalMenuBar sections={sections} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,4 +79,12 @@ describe('LinuxTitlebar', () => {
|
||||
expect(toggleMaximize).toHaveBeenCalledOnce()
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('localizes titlebar window control labels', () => {
|
||||
render(<LinuxTitlebar locale="it-IT" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Riduci a icona' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Ingrandisci' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Chiudi' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSProperties, MouseEvent, ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { resolveEffectiveLocale, translate, type AppLocale } from '../lib/i18n'
|
||||
import { shouldUseCustomWindowChrome } from '../utils/platform'
|
||||
import { cleanupTauriEventListener } from '../utils/tauriEventCleanup'
|
||||
import { LinuxMenuButton } from './LinuxMenuButton'
|
||||
@@ -21,6 +22,10 @@ type ResizeDirection =
|
||||
| 'SouthWest'
|
||||
| 'West'
|
||||
|
||||
type LinuxTitlebarProps = {
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
const RESIZE_HANDLES: ReadonlyArray<{
|
||||
cursor: CSSProperties['cursor']
|
||||
direction: ResizeDirection
|
||||
@@ -36,11 +41,25 @@ const RESIZE_HANDLES: ReadonlyArray<{
|
||||
{ direction: 'SouthEast', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: RESIZE_EDGE, height: RESIZE_EDGE } },
|
||||
]
|
||||
|
||||
export function LinuxTitlebar() {
|
||||
export function LinuxTitlebar({ locale: localeOverride }: LinuxTitlebarProps = {}) {
|
||||
const customChromeEnabled = shouldUseCustomWindowChrome()
|
||||
const [documentLocale, setDocumentLocale] = useState(readDocumentLocale)
|
||||
const locale = localeOverride ?? documentLocale
|
||||
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
|
||||
const maximized = useLinuxMaximizedState(customChromeEnabled)
|
||||
|
||||
useEffect(() => {
|
||||
if (localeOverride || !customChromeEnabled || typeof document === 'undefined') return
|
||||
|
||||
const syncLocale = () => setDocumentLocale(readDocumentLocale())
|
||||
syncLocale()
|
||||
|
||||
const observer = new MutationObserver(syncLocale)
|
||||
observer.observe(document.documentElement, { attributeFilter: ['lang'], attributes: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [customChromeEnabled, localeOverride])
|
||||
|
||||
if (!customChromeEnabled) return null
|
||||
|
||||
const appWindow = getCurrentWindow()
|
||||
@@ -55,14 +74,19 @@ export function LinuxTitlebar() {
|
||||
data-testid="linux-titlebar"
|
||||
>
|
||||
<div className="flex h-full items-center" data-no-drag>
|
||||
<LinuxMenuButton />
|
||||
<LinuxMenuButton locale={locale} />
|
||||
</div>
|
||||
<TitlebarWindowControls appWindow={appWindow} maximized={maximized} />
|
||||
<TitlebarWindowControls appWindow={appWindow} locale={locale} maximized={maximized} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function readDocumentLocale(): AppLocale {
|
||||
if (typeof document === 'undefined') return 'en'
|
||||
return resolveEffectiveLocale(document.documentElement.lang)
|
||||
}
|
||||
|
||||
function useLinuxMaximizedState(enabled: boolean): boolean {
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
|
||||
@@ -118,24 +142,30 @@ function ResizeHandles() {
|
||||
|
||||
function TitlebarWindowControls({
|
||||
appWindow,
|
||||
locale,
|
||||
maximized,
|
||||
}: {
|
||||
appWindow: ReturnType<typeof getCurrentWindow>
|
||||
locale: AppLocale
|
||||
maximized: boolean
|
||||
}) {
|
||||
const minimizeLabel = translate(locale, 'window.minimize')
|
||||
const resizeLabel = translate(locale, maximized ? 'window.restore' : 'window.maximize')
|
||||
const closeLabel = translate(locale, 'window.close')
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center" data-no-drag>
|
||||
<TitlebarButton ariaLabel="Minimize" onClick={() => void appWindow.minimize().catch(() => {})}>
|
||||
<TitlebarButton ariaLabel={minimizeLabel} onClick={() => void appWindow.minimize().catch(() => {})}>
|
||||
<MinimizeIcon />
|
||||
</TitlebarButton>
|
||||
<TitlebarButton
|
||||
ariaLabel={maximized ? 'Restore' : 'Maximize'}
|
||||
ariaLabel={resizeLabel}
|
||||
onClick={() => void appWindow.toggleMaximize().catch(() => {})}
|
||||
>
|
||||
{maximized ? <RestoreIcon /> : <MaximizeIcon />}
|
||||
</TitlebarButton>
|
||||
<TitlebarButton
|
||||
ariaLabel="Close"
|
||||
ariaLabel={closeLabel}
|
||||
close
|
||||
onClick={() => void appWindow.close().catch(() => {})}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_COMMAND_EVENT_NAME, APP_COMMAND_IDS } from '../hooks/appCommandDispatcher'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import { MermaidDiagram } from './MermaidDiagram'
|
||||
|
||||
@@ -59,15 +60,40 @@ describe('MermaidDiagram', () => {
|
||||
expect(screen.getByTestId('mermaid-diagram-dialog-viewport').querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('offers a raw editor action for editing Mermaid source immediately', () => {
|
||||
const commands: string[] = []
|
||||
const handleCommand = (event: Event) => {
|
||||
if (event instanceof CustomEvent && typeof event.detail === 'string') {
|
||||
commands.push(event.detail)
|
||||
}
|
||||
}
|
||||
window.addEventListener(APP_COMMAND_EVENT_NAME, handleCommand)
|
||||
|
||||
try {
|
||||
render(
|
||||
<MermaidDiagram
|
||||
diagram={'flowchart LR\nA --> B'}
|
||||
source={'```mermaid\nflowchart LR\nA --> B\n```'}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open the raw editor' }))
|
||||
|
||||
expect(commands).toEqual([APP_COMMAND_IDS.editToggleRawEditor])
|
||||
} finally {
|
||||
window.removeEventListener(APP_COMMAND_EVENT_NAME, handleCommand)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps rendered SVG pointer events inside the Mermaid block', async () => {
|
||||
const onBlockPointer = vi.fn()
|
||||
render(
|
||||
<button type="button" onClick={onBlockPointer} onMouseDown={onBlockPointer}>
|
||||
<div onClick={onBlockPointer} onMouseDown={onBlockPointer}>
|
||||
<MermaidDiagram
|
||||
diagram={'flowchart LR\nA --> B'}
|
||||
source={'```mermaid\nflowchart LR\nA --> B\n```'}
|
||||
/>
|
||||
</button>,
|
||||
</div>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowsOut as Maximize2 } from '@phosphor-icons/react'
|
||||
import { ArrowsOut as Maximize2, PencilSimpleLine } from '@phosphor-icons/react'
|
||||
import { useEffect, useId, useMemo, useState, type SyntheticEvent } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { APP_COMMAND_EVENT_NAME, APP_COMMAND_IDS } from '../hooks/appCommandDispatcher'
|
||||
import { translate } from '../lib/i18n'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { SafeSvgDiv } from './SafeMarkup'
|
||||
|
||||
type MermaidApi = typeof import('mermaid')['default']
|
||||
@@ -41,6 +44,7 @@ const MERMAID_RENDER_HOST_STYLE = [
|
||||
'height:0',
|
||||
'overflow:hidden',
|
||||
].join(';')
|
||||
const OPEN_RAW_EDITOR_LABEL = translate('en', 'editor.toolbar.rawOpen')
|
||||
|
||||
function renderIdFromReactId(reactId: string): string {
|
||||
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
@@ -127,6 +131,33 @@ function stopMermaidViewportEvent(event: SyntheticEvent): void {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function openRawEditorForMermaidSource(event: SyntheticEvent): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
trackEvent('editor_mermaid_raw_edit_requested')
|
||||
window.dispatchEvent(new CustomEvent(APP_COMMAND_EVENT_NAME, {
|
||||
detail: APP_COMMAND_IDS.editToggleRawEditor,
|
||||
}))
|
||||
}
|
||||
|
||||
function MermaidRawEditorButton() {
|
||||
return (
|
||||
<Button
|
||||
aria-label={OPEN_RAW_EDITOR_LABEL}
|
||||
className="mermaid-diagram__edit-button"
|
||||
contentEditable={false}
|
||||
onClick={openRawEditorForMermaidSource}
|
||||
onMouseDown={stopMermaidViewportEvent}
|
||||
size="icon-sm"
|
||||
title={OPEN_RAW_EDITOR_LABEL}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PencilSimpleLine aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function MermaidLightbox({ svg }: { svg: string }) {
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -186,6 +217,7 @@ export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
|
||||
if (!diagram.trim() || currentState.error) {
|
||||
return (
|
||||
<figure className="mermaid-diagram mermaid-diagram--error" data-testid="mermaid-diagram-error">
|
||||
<MermaidRawEditorButton />
|
||||
<figcaption>Mermaid diagram unavailable</figcaption>
|
||||
<MermaidSourceFallback source={source} />
|
||||
</figure>
|
||||
@@ -194,6 +226,7 @@ export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
|
||||
|
||||
return (
|
||||
<figure className="mermaid-diagram" data-testid="mermaid-diagram">
|
||||
<MermaidRawEditorButton />
|
||||
<MermaidLightbox svg={currentState.svg} />
|
||||
<MermaidSvgViewport
|
||||
ariaLabel="Mermaid diagram"
|
||||
|
||||
@@ -9,20 +9,26 @@ describe('NoteList context menu', () => {
|
||||
const onEnterNeighborhood = vi.fn()
|
||||
const onBulkArchive = vi.fn()
|
||||
const onBulkDeletePermanently = vi.fn()
|
||||
const onExportPdf = vi.fn()
|
||||
const onToggleFavorite = vi.fn()
|
||||
const onToggleOrganized = vi.fn()
|
||||
const onRevealFile = vi.fn()
|
||||
const onCopyFilePath = vi.fn()
|
||||
const canCopyGitUrl = vi.fn(() => true)
|
||||
const onCopyGitUrl = vi.fn()
|
||||
|
||||
renderNoteList({
|
||||
onOpenInNewWindow,
|
||||
onEnterNeighborhood,
|
||||
onBulkArchive,
|
||||
onBulkDeletePermanently,
|
||||
onExportPdf,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
})
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
@@ -56,6 +62,15 @@ 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])
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Archive this note'))
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
@@ -84,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
} from '../test-utils/noteListTestUtils'
|
||||
import type { ViewFile } from '../types'
|
||||
|
||||
vi.mock('../hooks/useTabManagement', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../hooks/useTabManagement')>()
|
||||
return { ...actual, prefetchNoteContent: vi.fn() }
|
||||
})
|
||||
|
||||
function makeBookTypeEntries(
|
||||
displayProps: string[] = [],
|
||||
entryOverrides: Parameters<typeof makeEntry>[0] = {},
|
||||
@@ -32,6 +37,7 @@ function makeBookTypeEntries(
|
||||
}
|
||||
|
||||
const noop = () => undefined
|
||||
const NOTE_LIST_SEARCH_SETTLE_TIMEOUT_MS = 3_000
|
||||
const MAC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 Safari/605.1.15'
|
||||
const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
@@ -103,10 +109,10 @@ async function searchNoteList(query: string) {
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('note-list-search-loading')).toBeInTheDocument()
|
||||
})
|
||||
}, { timeout: NOTE_LIST_SEARCH_SETTLE_TIMEOUT_MS })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('note-list-search-loading')).not.toBeInTheDocument()
|
||||
})
|
||||
}, { timeout: NOTE_LIST_SEARCH_SETTLE_TIMEOUT_MS })
|
||||
}
|
||||
|
||||
interface NoteListSearchMockResult {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -74,6 +74,22 @@ describe('PulseView', () => {
|
||||
expect(screen.getByText('Remove old notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reloads commits when the refresh key changes after sync', async () => {
|
||||
mockInvokeFn
|
||||
.mockResolvedValueOnce([mockCommits[1]])
|
||||
.mockResolvedValueOnce([{ ...mockCommits[0], hash: 'fresh', message: 'Fresh remote commit' }])
|
||||
|
||||
const { rerender } = render(<PulseView vaultPath="/test/vault" refreshKey={0} />)
|
||||
|
||||
expect(await screen.findByText('Remove old notes')).toBeInTheDocument()
|
||||
|
||||
rerender(<PulseView vaultPath="/test/vault" refreshKey={1} />)
|
||||
|
||||
expect(await screen.findByText('Fresh remote commit')).toBeInTheDocument()
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockInvokeFn).toHaveBeenLastCalledWith('get_vault_pulse', { vaultPath: '/test/vault', limit: 20, skip: 0 })
|
||||
})
|
||||
|
||||
it('shows summary badges for added/modified/deleted', async () => {
|
||||
mockInvokeFn.mockResolvedValue(mockCommits)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
|
||||
interface PulseViewProps {
|
||||
vaultPath: string
|
||||
onOpenNote?: (relativePath: string, commitHash?: string) => void
|
||||
refreshKey?: number
|
||||
sidebarCollapsed?: boolean
|
||||
onExpandSidebar?: () => void
|
||||
repositories?: GitRepositoryOption[]
|
||||
@@ -413,6 +414,7 @@ const PAGE_SIZE = 20
|
||||
export const PulseView = memo(function PulseView({
|
||||
vaultPath,
|
||||
onOpenNote,
|
||||
refreshKey = 0,
|
||||
sidebarCollapsed,
|
||||
onExpandSidebar,
|
||||
repositories,
|
||||
@@ -464,7 +466,7 @@ export const PulseView = memo(function PulseView({
|
||||
}
|
||||
}, [vaultPath, skip, loadingMore, hasMore])
|
||||
|
||||
useEffect(() => { loadInitial() }, [loadInitial])
|
||||
useEffect(() => { loadInitial() }, [loadInitial, refreshKey])
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
|
||||
import { ImageLightbox } from './ImageLightbox'
|
||||
import { ActionTooltip } from './ui/action-tooltip'
|
||||
import { Button } from './ui/button'
|
||||
import { subscribeRichEditorExternalChange } from './editorExternalChangeEvents'
|
||||
import {
|
||||
activatePlainTextPasteTarget,
|
||||
registerPlainTextPasteTarget,
|
||||
@@ -66,6 +67,16 @@ import {
|
||||
queueTitleHeadingCursorRepair,
|
||||
useEditorPasteHandler,
|
||||
} from './titleHeadingInteractions'
|
||||
import {
|
||||
CODE_BLOCK_SELECTOR,
|
||||
codeBlockText,
|
||||
eventTargetElement,
|
||||
richEditorClipboardPayload,
|
||||
selectedCodeBlockText,
|
||||
selectedEditorDomHtml,
|
||||
selectedEditorPlainText,
|
||||
selectedEditorRange,
|
||||
} from './editorRichCopy'
|
||||
|
||||
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
|
||||
| --- | --- | --- |
|
||||
@@ -383,100 +394,8 @@ function emojiSuggestionRank(entry: EmojiEntry, query: string): number {
|
||||
return 4
|
||||
}
|
||||
|
||||
const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]'
|
||||
const CLIPBOARD_INLINE_FORMAT_SELECTOR = 'a, b, code, em, i, s, span, strong, u'
|
||||
const CODE_BLOCK_COPY_RESET_MS = 1200
|
||||
|
||||
function nodeElement(node: Node | null): HTMLElement | null {
|
||||
if (!node) return null
|
||||
if (node instanceof HTMLElement) return node
|
||||
return node.parentElement
|
||||
}
|
||||
|
||||
function hasSingleActiveRange(selection: Selection | null): selection is Selection {
|
||||
return Boolean(selection && selection.rangeCount === 1 && !selection.isCollapsed)
|
||||
}
|
||||
|
||||
function closestCodeBlockInContainer(options: {
|
||||
range: Range
|
||||
container: HTMLElement
|
||||
}): HTMLElement | null {
|
||||
const { range, container } = options
|
||||
const codeBlock = nodeElement(range.commonAncestorContainer)
|
||||
?.closest<HTMLElement>(CODE_BLOCK_SELECTOR)
|
||||
|
||||
return codeBlock && container.contains(codeBlock) ? codeBlock : null
|
||||
}
|
||||
|
||||
function nodeBelongsToElement(node: Node, element: HTMLElement): boolean {
|
||||
const elementNode = nodeElement(node)
|
||||
return Boolean(elementNode && element.contains(elementNode))
|
||||
}
|
||||
|
||||
function rangeBelongsToElement(range: Range, element: HTMLElement): boolean {
|
||||
return nodeBelongsToElement(range.startContainer, element)
|
||||
&& nodeBelongsToElement(range.endContainer, element)
|
||||
}
|
||||
|
||||
function selectedCodeBlockRange(options: {
|
||||
selection: Selection | null
|
||||
container: HTMLElement
|
||||
}): Range | null {
|
||||
const { selection, container } = options
|
||||
if (!hasSingleActiveRange(selection)) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const codeBlock = closestCodeBlockInContainer({ range, container })
|
||||
if (!codeBlock || !rangeBelongsToElement(range, codeBlock)) return null
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
function selectedCodeBlockText(options: {
|
||||
selection: Selection | null
|
||||
container: HTMLElement
|
||||
}): string | null {
|
||||
const range = selectedCodeBlockRange(options)
|
||||
if (!range) return null
|
||||
|
||||
return range.cloneContents().textContent || options.selection?.toString() || ''
|
||||
}
|
||||
|
||||
function selectedEditorRange(selection: Selection | null, container: HTMLElement): Range | null {
|
||||
if (!hasSingleActiveRange(selection)) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
return rangeBelongsToElement(range, container) ? range : null
|
||||
}
|
||||
|
||||
function selectedEditorPlainText(selection: Selection, range: Range): string | null {
|
||||
const text = selection.toString() || range.cloneContents().textContent || ''
|
||||
if (text.length === 0) return null
|
||||
|
||||
return text.replace(/\r?\n$/, '')
|
||||
}
|
||||
|
||||
function selectedEditorHtml(range: Range): string {
|
||||
const wrapper = document.createElement('div')
|
||||
const selectedContent = range.cloneContents()
|
||||
const commonElement = nodeElement(range.commonAncestorContainer)
|
||||
|
||||
if (commonElement?.matches(CLIPBOARD_INLINE_FORMAT_SELECTOR)) {
|
||||
const inlineWrapper = commonElement.cloneNode(false)
|
||||
inlineWrapper.appendChild(selectedContent)
|
||||
wrapper.appendChild(inlineWrapper)
|
||||
return wrapper.innerHTML
|
||||
}
|
||||
|
||||
wrapper.appendChild(selectedContent)
|
||||
return wrapper.innerHTML
|
||||
}
|
||||
|
||||
function codeBlockText(codeBlock: HTMLElement): string {
|
||||
const codeElement = codeBlock.querySelector<HTMLElement>('pre code')
|
||||
return codeElement?.textContent ?? ''
|
||||
}
|
||||
|
||||
type CodeBlockCopyTarget = {
|
||||
codeBlock: HTMLElement
|
||||
left: number
|
||||
@@ -604,11 +523,6 @@ function CodeBlockCopyButton({ copyTarget, locale }: { copyTarget: CodeBlockCopy
|
||||
)
|
||||
}
|
||||
|
||||
function eventTargetElement(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Node)) return null
|
||||
return nodeElement(target)
|
||||
}
|
||||
|
||||
type EditorClientPoint = Pick<MouseEvent, 'clientX' | 'clientY'>
|
||||
type TiptapSelectionRange = { from: number; to: number }
|
||||
type TiptapSelectionBridge = {
|
||||
@@ -1012,7 +926,10 @@ function handleCodeBlockCopy(event: React.ClipboardEvent<HTMLDivElement>): boole
|
||||
return true
|
||||
}
|
||||
|
||||
function handleSelectedEditorCopy(event: React.ClipboardEvent<HTMLDivElement>) {
|
||||
function handleSelectedEditorCopy(
|
||||
event: React.ClipboardEvent<HTMLDivElement>,
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
) {
|
||||
const selection = window.getSelection()
|
||||
const range = selectedEditorRange(selection, event.currentTarget)
|
||||
if (!selection || !range) return
|
||||
@@ -1022,18 +939,27 @@ function handleSelectedEditorCopy(event: React.ClipboardEvent<HTMLDivElement>) {
|
||||
|
||||
event.clipboardData.setData('text/plain', plainText)
|
||||
|
||||
const markup = selectedEditorHtml(range)
|
||||
if (markup.length > 0) {
|
||||
event.clipboardData.setData('text/html', markup)
|
||||
const richPayload = richEditorClipboardPayload(editor)
|
||||
if (richPayload) {
|
||||
event.clipboardData.setData('blocknote/html', richPayload.blocknoteHtml)
|
||||
event.clipboardData.setData('text/html', richPayload.html)
|
||||
} else {
|
||||
const markup = selectedEditorDomHtml(range)
|
||||
if (markup.length > 0) {
|
||||
event.clipboardData.setData('text/html', markup)
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function handleEditorCopy(event: React.ClipboardEvent<HTMLDivElement>) {
|
||||
function handleEditorCopy(
|
||||
event: React.ClipboardEvent<HTMLDivElement>,
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
) {
|
||||
if (handleCodeBlockCopy(event)) return
|
||||
|
||||
handleSelectedEditorCopy(event)
|
||||
handleSelectedEditorCopy(event, editor)
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
@@ -1185,6 +1111,7 @@ function useSuggestionMenuItems(options: {
|
||||
}
|
||||
|
||||
type EditorInteractionControllersProps = ReturnType<typeof useSuggestionMenuItems> & {
|
||||
locale: AppLocale
|
||||
runEditorAction: (action: SuggestionAction) => void
|
||||
vaultPath?: string
|
||||
}
|
||||
@@ -1194,6 +1121,7 @@ function EditorInteractionControllers({
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
getWikilinkItems,
|
||||
locale,
|
||||
runEditorAction,
|
||||
vaultPath,
|
||||
}: EditorInteractionControllersProps) {
|
||||
@@ -1202,7 +1130,7 @@ function EditorInteractionControllers({
|
||||
<SideMenuController sideMenu={TolariaSideMenu} />
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={(props) => (
|
||||
<TolariaFormattingToolbar {...props} vaultPath={vaultPath} />
|
||||
<TolariaFormattingToolbar {...props} locale={locale} vaultPath={vaultPath} />
|
||||
)}
|
||||
floatingUIOptions={{
|
||||
elementProps: {
|
||||
@@ -1345,6 +1273,10 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
_wikilinkEntriesRef.current = entries
|
||||
}, [entries])
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeRichEditorExternalChange(editor, handleEditorChange)
|
||||
}, [editor, handleEditorChange])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
@@ -1381,6 +1313,9 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
activatePlainTextPaste()
|
||||
handleWhitespaceMouseSelection(event)
|
||||
}, [activatePlainTextPaste, handleWhitespaceMouseSelection])
|
||||
const handleCopyCapture = useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
handleEditorCopy(event, editor)
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
@@ -1411,7 +1346,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
aria-label="Rich text editor"
|
||||
className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`}
|
||||
style={cssVars as React.CSSProperties}
|
||||
onCopyCapture={handleEditorCopy}
|
||||
onCopyCapture={handleCopyCapture}
|
||||
onFocusCapture={handleFocusCapture}
|
||||
onMouseLeave={clearCopyTarget}
|
||||
onMouseDownCapture={handleMouseDownCapture}
|
||||
@@ -1439,6 +1374,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
>
|
||||
<EditorInteractionControllers
|
||||
{...suggestionMenuItems}
|
||||
locale={locale}
|
||||
runEditorAction={runEditorAction}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Editor } from 'tldraw'
|
||||
import { TldrawWhiteboard } from './TldrawWhiteboard'
|
||||
import { TooltipProvider } from './ui/tooltip'
|
||||
|
||||
interface MockTldrawProps {
|
||||
assetUrls: MockAssetUrls
|
||||
@@ -157,6 +159,27 @@ function expectBundledTldrawAssetUrls(assetUrls: MockAssetUrls) {
|
||||
expectNoCdnUrls(assetUrls.translations)
|
||||
}
|
||||
|
||||
function whiteboardProps(
|
||||
overrides: Partial<ComponentProps<typeof TldrawWhiteboard>> = {},
|
||||
): ComponentProps<typeof TldrawWhiteboard> {
|
||||
return {
|
||||
boardId: 'board-1',
|
||||
height: '520',
|
||||
snapshot: '',
|
||||
width: '',
|
||||
onSizeChange: vi.fn(),
|
||||
onSnapshotChange: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderWhiteboard(overrides: Partial<ComponentProps<typeof TldrawWhiteboard>> = {}) {
|
||||
return render(
|
||||
<TldrawWhiteboard {...whiteboardProps(overrides)} />,
|
||||
{ wrapper: TooltipProvider },
|
||||
)
|
||||
}
|
||||
|
||||
describe('TldrawWhiteboard', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -166,16 +189,7 @@ describe('TldrawWhiteboard', () => {
|
||||
})
|
||||
|
||||
it('uses bundled tldraw assets instead of CDN URLs', () => {
|
||||
render(
|
||||
<TldrawWhiteboard
|
||||
boardId="board-1"
|
||||
height="520"
|
||||
snapshot=""
|
||||
width=""
|
||||
onSizeChange={vi.fn()}
|
||||
onSnapshotChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderWhiteboard()
|
||||
|
||||
expect(screen.getByTestId('mock-tldraw')).toHaveAttribute('data-draw-font-url')
|
||||
expect(assetImportMock.getAssetUrlsByImport).toHaveBeenCalledWith(expect.any(Function))
|
||||
@@ -186,16 +200,7 @@ describe('TldrawWhiteboard', () => {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
document.documentElement.classList.add('dark')
|
||||
|
||||
render(
|
||||
<TldrawWhiteboard
|
||||
boardId="board-1"
|
||||
height="520"
|
||||
snapshot=""
|
||||
width=""
|
||||
onSizeChange={vi.fn()}
|
||||
onSnapshotChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderWhiteboard()
|
||||
|
||||
expect(renderedTldrawProps().user?.userPreferences.get().colorScheme).toBe('dark')
|
||||
})
|
||||
@@ -203,16 +208,7 @@ describe('TldrawWhiteboard', () => {
|
||||
it('updates the tldraw color scheme when Tolaria theme changes', async () => {
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
|
||||
render(
|
||||
<TldrawWhiteboard
|
||||
boardId="board-1"
|
||||
height="520"
|
||||
snapshot=""
|
||||
width=""
|
||||
onSizeChange={vi.fn()}
|
||||
onSnapshotChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderWhiteboard()
|
||||
|
||||
expect(renderedTldrawProps().user?.userPreferences.get().colorScheme).toBe('light')
|
||||
|
||||
@@ -227,16 +223,7 @@ describe('TldrawWhiteboard', () => {
|
||||
})
|
||||
|
||||
it('installs the text measurement guard when the canvas mounts', () => {
|
||||
render(
|
||||
<TldrawWhiteboard
|
||||
boardId="board-1"
|
||||
height="520"
|
||||
snapshot=""
|
||||
width=""
|
||||
onSizeChange={vi.fn()}
|
||||
onSnapshotChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderWhiteboard()
|
||||
|
||||
const editor = mockEditor()
|
||||
const cleanup = renderedTldrawProps().onMount(editor)
|
||||
@@ -254,16 +241,7 @@ describe('TldrawWhiteboard', () => {
|
||||
})
|
||||
|
||||
it('suppresses whiteboard platform permission rejections while mounted', () => {
|
||||
render(
|
||||
<TldrawWhiteboard
|
||||
boardId="board-1"
|
||||
height="520"
|
||||
snapshot=""
|
||||
width=""
|
||||
onSizeChange={vi.fn()}
|
||||
onSnapshotChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderWhiteboard()
|
||||
|
||||
const cleanup = renderedTldrawProps().onMount(mockEditor())
|
||||
const denied = {
|
||||
@@ -280,28 +258,12 @@ describe('TldrawWhiteboard', () => {
|
||||
|
||||
it('resets the drawing store when switching to a blank board snapshot', () => {
|
||||
const boardASnapshot = { records: { shape: 'from-board-a' } }
|
||||
const { rerender } = render(
|
||||
<TldrawWhiteboard
|
||||
boardId="board-1"
|
||||
height="520"
|
||||
snapshot={JSON.stringify(boardASnapshot)}
|
||||
width=""
|
||||
onSizeChange={vi.fn()}
|
||||
onSnapshotChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const { rerender } = renderWhiteboard({ snapshot: JSON.stringify(boardASnapshot) })
|
||||
|
||||
expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), boardASnapshot)
|
||||
|
||||
rerender(
|
||||
<TldrawWhiteboard
|
||||
boardId="board-2"
|
||||
height="520"
|
||||
snapshot=""
|
||||
width=""
|
||||
onSizeChange={vi.fn()}
|
||||
onSnapshotChange={vi.fn()}
|
||||
/>
|
||||
<TldrawWhiteboard {...whiteboardProps({ boardId: 'board-2' })} />
|
||||
)
|
||||
|
||||
expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), { records: {} })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { getAssetUrlsByImport } from '@tldraw/assets/imports.vite'
|
||||
import { ArrowsIn, ArrowsOut } from '@phosphor-icons/react'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import {
|
||||
Box,
|
||||
@@ -19,11 +20,15 @@ import {
|
||||
} from 'tldraw'
|
||||
import 'tldraw/tldraw.css'
|
||||
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
|
||||
import { resolveEffectiveLocale, translate, type AppLocale } from '../lib/i18n'
|
||||
import type { ResolvedThemeMode } from '../lib/themeMode'
|
||||
import { Button } from './ui/button'
|
||||
import { ActionTooltip } from './ui/action-tooltip'
|
||||
import { installTldrawTextMeasurementGuard } from './tldrawTextMeasurementGuard'
|
||||
|
||||
const EMPTY_TLDRAW_TRANSLATION_URL = 'data:application/json;base64,e30K'
|
||||
const TOLARIA_TLDRAW_USER_ID = 'tolaria-whiteboard'
|
||||
const WHITEBOARD_FULLSCREEN_BODY_CLASS = 'tldraw-whiteboard-fullscreen-open'
|
||||
|
||||
function resolveTldrawAssetUrl(assetUrl: string | undefined): string {
|
||||
return assetUrl ?? EMPTY_TLDRAW_TRANSLATION_URL
|
||||
@@ -101,6 +106,28 @@ function ignoreTldrawUserPreferencesUpdate(preferences: TLUserPreferences) {
|
||||
void preferences
|
||||
}
|
||||
|
||||
function readDocumentLocale(): AppLocale {
|
||||
if (typeof document === 'undefined') return 'en'
|
||||
return resolveEffectiveLocale(document.documentElement.lang)
|
||||
}
|
||||
|
||||
function useDocumentLocale(): AppLocale {
|
||||
const [locale, setLocale] = useState(readDocumentLocale)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const syncLocale = () => setLocale(readDocumentLocale())
|
||||
const observer = new MutationObserver(syncLocale)
|
||||
observer.observe(document.documentElement, { attributeFilter: ['lang'], attributes: true })
|
||||
syncLocale()
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return locale
|
||||
}
|
||||
|
||||
function rejectionName(error: unknown): string {
|
||||
if (error instanceof Error) return error.name
|
||||
if (typeof error !== 'object' || error === null || !('name' in error)) return ''
|
||||
@@ -425,6 +452,40 @@ function TolariaTldrawDialogs() {
|
||||
))
|
||||
}
|
||||
|
||||
function useFullscreenWhiteboard() {
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setFullscreen(false)
|
||||
}
|
||||
document.body.classList.add(WHITEBOARD_FULLSCREEN_BODY_CLASS)
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove(WHITEBOARD_FULLSCREEN_BODY_CLASS)
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [fullscreen])
|
||||
|
||||
useEffect(() => {
|
||||
const animationFrameId = window.requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
return () => { window.cancelAnimationFrame(animationFrameId) }
|
||||
}, [fullscreen])
|
||||
|
||||
const toggleFullscreen = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setFullscreen((current) => !current)
|
||||
}, [])
|
||||
|
||||
return { fullscreen, toggleFullscreen }
|
||||
}
|
||||
|
||||
export function TldrawWhiteboard({
|
||||
boardId,
|
||||
height,
|
||||
@@ -441,6 +502,12 @@ export function TldrawWhiteboard({
|
||||
const persistedSize = useMemo(() => normalizeSize({ height, width }), [height, width])
|
||||
const [resizingSize, setResizingSize] = useState<PixelSize | null>(null)
|
||||
const visibleSize = resizingSize ?? persistedSize
|
||||
const { fullscreen, toggleFullscreen } = useFullscreenWhiteboard()
|
||||
const locale = useDocumentLocale()
|
||||
const fullscreenLabel = translate(
|
||||
locale,
|
||||
fullscreen ? 'editor.whiteboard.exitFullscreen' : 'editor.whiteboard.enterFullscreen',
|
||||
)
|
||||
const themeMode = useDocumentThemeMode()
|
||||
const userPreferences = useMemo(() => tldrawUserPreferences(themeMode), [themeMode])
|
||||
const tldrawUser = useTldrawUser({
|
||||
@@ -540,7 +607,7 @@ export function TldrawWhiteboard({
|
||||
return (
|
||||
<div
|
||||
ref={boardRef}
|
||||
className="tldraw-whiteboard"
|
||||
className={fullscreen ? 'tldraw-whiteboard tldraw-whiteboard--fullscreen' : 'tldraw-whiteboard'}
|
||||
contentEditable={false}
|
||||
data-board-id={boardId}
|
||||
style={cssSize(visibleSize)}
|
||||
@@ -553,6 +620,21 @@ export function TldrawWhiteboard({
|
||||
store={store}
|
||||
user={tldrawUser}
|
||||
/>
|
||||
<ActionTooltip copy={{ label: fullscreenLabel }} side="left">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={fullscreenLabel}
|
||||
aria-pressed={fullscreen}
|
||||
className="tldraw-whiteboard__fullscreen-button"
|
||||
data-testid="tldraw-whiteboard-fullscreen-toggle"
|
||||
title={fullscreenLabel}
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{fullscreen ? <ArrowsIn aria-hidden="true" /> : <ArrowsOut aria-hidden="true" />}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Resize whiteboard width"
|
||||
|
||||
@@ -130,6 +130,12 @@ function updateEditorText(text: string) {
|
||||
fireEvent.input(editor)
|
||||
}
|
||||
|
||||
async function waitForCompositionFlush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function clickFirstSuggestion() {
|
||||
const rows = screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
@@ -336,10 +342,14 @@ describe('WikilinkChatInput', () => {
|
||||
firstEditor.appendChild(document.createTextNode('안'))
|
||||
fireEvent.input(firstEditor)
|
||||
fireEvent.compositionEnd(firstEditor)
|
||||
await waitForCompositionFlush()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith('안')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-input').textContent).toBe('안')
|
||||
})
|
||||
|
||||
const secondEditor = screen.getByTestId('agent-input') as HTMLDivElement
|
||||
secondEditor.focus()
|
||||
@@ -347,6 +357,7 @@ describe('WikilinkChatInput', () => {
|
||||
secondEditor.appendChild(document.createTextNode('녕'))
|
||||
fireEvent.input(secondEditor)
|
||||
fireEvent.compositionEnd(secondEditor)
|
||||
await waitForCompositionFlush()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith('안녕')
|
||||
|
||||
419
src/components/aiWorkspaceConversations.ts
Normal file
419
src/components/aiWorkspaceConversations.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import { type AiAgentId } from '../lib/aiAgents'
|
||||
import { agentTargets, type AiTarget } from '../lib/aiTargets'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import type { AiWorkspaceConversationSetting } from '../types'
|
||||
import {
|
||||
generateAiConversationTitleForTarget,
|
||||
type GenerateAiConversationTitleRequest,
|
||||
} from '../utils/aiConversationTitle'
|
||||
import type { AiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups'
|
||||
|
||||
export interface AiConversation {
|
||||
archived: boolean
|
||||
hasActivity: boolean
|
||||
id: string
|
||||
targetId: string
|
||||
title: string
|
||||
usesDefaultTitle: boolean
|
||||
usesDefaultTarget: boolean
|
||||
}
|
||||
|
||||
type ConversationId = AiConversation['id']
|
||||
type ConversationTitle = AiConversation['title']
|
||||
type SetConversations = Dispatch<SetStateAction<AiConversation[]>>
|
||||
type TargetId = AiConversation['targetId']
|
||||
|
||||
interface UseConversationsOptions {
|
||||
fallbackTarget: AiTarget
|
||||
initialActiveConversationId?: ConversationId
|
||||
locale: AppLocale
|
||||
onSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void
|
||||
settings?: AiWorkspaceConversationSetting[] | null
|
||||
settingsReady: boolean
|
||||
}
|
||||
|
||||
interface CloseConversationOptions {
|
||||
activeId: ConversationId
|
||||
fallbackTarget: AiTarget
|
||||
id: ConversationId
|
||||
locale: AppLocale
|
||||
}
|
||||
|
||||
let fallbackConversationIdCounter = 0
|
||||
|
||||
function randomConversationIdPart(): string {
|
||||
const cryptoApi = globalThis.crypto
|
||||
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID().slice(0, 8)
|
||||
|
||||
if (typeof cryptoApi?.getRandomValues === 'function') {
|
||||
const values = new Uint32Array(2)
|
||||
cryptoApi.getRandomValues(values)
|
||||
return Array.from(values, (value) => value.toString(36)).join('').slice(0, 8)
|
||||
}
|
||||
|
||||
fallbackConversationIdCounter += 1
|
||||
return fallbackConversationIdCounter.toString(36).padStart(4, '0')
|
||||
}
|
||||
|
||||
function nextConversationId(): string {
|
||||
return `ai-chat-${Date.now()}-${randomConversationIdPart()}`
|
||||
}
|
||||
|
||||
export function canArchiveConversation(conversation: AiConversation): boolean {
|
||||
return conversation.archived || conversation.hasActivity
|
||||
}
|
||||
|
||||
export function flatTargets(groups: AiWorkspaceTargetGroups): AiTarget[] {
|
||||
return [...groups.localAgents, ...groups.localModels, ...groups.apiModels]
|
||||
}
|
||||
|
||||
export function firstTarget(
|
||||
groups: AiWorkspaceTargetGroups,
|
||||
defaultTarget: AiTarget | undefined,
|
||||
defaultAgent: AiAgentId,
|
||||
): AiTarget {
|
||||
const targets = flatTargets(groups)
|
||||
const selectedDefault = defaultTarget ? targets.find((target) => target.id === defaultTarget.id) : undefined
|
||||
if (selectedDefault) return selectedDefault
|
||||
|
||||
const selectedAgent = targets.find((target) => target.kind === 'agent' && target.agent === defaultAgent)
|
||||
return selectedAgent ?? targets[0] ?? defaultTarget ?? agentTargets()[0]
|
||||
}
|
||||
|
||||
export function resolveTarget(conversation: AiConversation, groups: AiWorkspaceTargetGroups, fallback: AiTarget): AiTarget {
|
||||
return flatTargets(groups).find((target) => target.id === conversation.targetId) ?? fallback
|
||||
}
|
||||
|
||||
function createConversation(locale: AppLocale, target: AiTarget, index: number): AiConversation {
|
||||
return {
|
||||
archived: false,
|
||||
hasActivity: false,
|
||||
id: nextConversationId(),
|
||||
targetId: target.id,
|
||||
title: defaultConversationTitle(locale, index),
|
||||
usesDefaultTitle: true,
|
||||
usesDefaultTarget: true,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultConversationTitle(locale: AppLocale, index: number): string {
|
||||
if (index <= 1) return translate(locale, 'ai.workspace.chatTitle', { index: '' }).trim()
|
||||
return translate(locale, 'ai.workspace.chatTitle', { index })
|
||||
}
|
||||
|
||||
function isDefaultConversationTitle(title: string): boolean {
|
||||
return /^(AI\s+)?Chat(?:\s+\d+)?$/i.test(title.trim())
|
||||
}
|
||||
|
||||
function defaultConversationTitleIndex(title: string): number {
|
||||
const match = title.trim().match(/\d+$/)
|
||||
if (!match) return 1
|
||||
const parsed = Number(match[0])
|
||||
return Number.isFinite(parsed) ? parsed : 1
|
||||
}
|
||||
|
||||
function conversationFromSetting(
|
||||
setting: AiWorkspaceConversationSetting,
|
||||
fallbackTarget: AiTarget,
|
||||
locale: AppLocale,
|
||||
): AiConversation | null {
|
||||
const id = setting.id.trim()
|
||||
const storedTitle = setting.title.trim()
|
||||
if (!id || !storedTitle) return null
|
||||
const usesDefaultTitle = isDefaultConversationTitle(storedTitle)
|
||||
const title = usesDefaultTitle
|
||||
? defaultConversationTitle(locale, defaultConversationTitleIndex(storedTitle))
|
||||
: storedTitle
|
||||
|
||||
return {
|
||||
archived: setting.archived === true,
|
||||
hasActivity: !usesDefaultTitle,
|
||||
id,
|
||||
targetId: setting.target_id?.trim() || fallbackTarget.id,
|
||||
title,
|
||||
usesDefaultTitle,
|
||||
usesDefaultTarget: !setting.target_id,
|
||||
}
|
||||
}
|
||||
|
||||
function conversationsFromSettings(
|
||||
settings: AiWorkspaceConversationSetting[] | null | undefined,
|
||||
fallbackTarget: AiTarget,
|
||||
locale: AppLocale,
|
||||
): AiConversation[] {
|
||||
const stored = (settings ?? [])
|
||||
.map((setting) => conversationFromSetting(setting, fallbackTarget, locale))
|
||||
.filter((conversation): conversation is AiConversation => conversation !== null)
|
||||
return stored.length > 0 ? stored : [createConversation(locale, fallbackTarget, 1)]
|
||||
}
|
||||
|
||||
function conversationsToSettings(conversations: AiConversation[]): AiWorkspaceConversationSetting[] {
|
||||
return conversations.map((conversation) => ({
|
||||
archived: conversation.archived,
|
||||
id: conversation.id,
|
||||
target_id: conversation.usesDefaultTarget ? null : conversation.targetId,
|
||||
title: conversation.title,
|
||||
}))
|
||||
}
|
||||
|
||||
export function activeConversationForState(
|
||||
conversations: AiConversation[],
|
||||
activeId: ConversationId,
|
||||
showArchived: boolean,
|
||||
): AiConversation | undefined {
|
||||
const selected = conversations.find((conversation) => conversation.id === activeId)
|
||||
if (selected && selected.archived === showArchived) return selected
|
||||
|
||||
return conversations.find((conversation) => conversation.archived === showArchived)
|
||||
?? conversations.find((conversation) => !conversation.archived)
|
||||
?? conversations[0]
|
||||
}
|
||||
|
||||
function appendConversationState(
|
||||
current: AiConversation[],
|
||||
locale: AppLocale,
|
||||
target: AiTarget,
|
||||
): { activeId: string; conversations: AiConversation[] } {
|
||||
const next = createConversation(locale, target, current.length + 1)
|
||||
return {
|
||||
activeId: next.id,
|
||||
conversations: [...current, next],
|
||||
}
|
||||
}
|
||||
|
||||
function forkConversationState(
|
||||
current: AiConversation[],
|
||||
locale: AppLocale,
|
||||
sourceId: ConversationId,
|
||||
): { activeId: string; conversations: AiConversation[] } | null {
|
||||
const source = current.find((conversation) => conversation.id === sourceId)
|
||||
if (!source) return null
|
||||
|
||||
const index = current.length + 1
|
||||
const next: AiConversation = {
|
||||
archived: false,
|
||||
hasActivity: true,
|
||||
id: nextConversationId(),
|
||||
targetId: source.targetId,
|
||||
title: source.usesDefaultTitle ? defaultConversationTitle(locale, index) : source.title,
|
||||
usesDefaultTitle: false,
|
||||
usesDefaultTarget: source.usesDefaultTarget,
|
||||
}
|
||||
|
||||
return {
|
||||
activeId: next.id,
|
||||
conversations: [...current, next],
|
||||
}
|
||||
}
|
||||
|
||||
function archiveConversationState(
|
||||
current: AiConversation[],
|
||||
id: ConversationId,
|
||||
): { activeId?: string; conversations: AiConversation[] } {
|
||||
const conversations = current.map((conversation) => (
|
||||
conversation.id === id ? { ...conversation, archived: true } : conversation
|
||||
))
|
||||
const fallback = conversations.find((conversation) => !conversation.archived && conversation.id !== id)
|
||||
return { activeId: fallback?.id, conversations }
|
||||
}
|
||||
|
||||
function closeConversationState(
|
||||
current: AiConversation[],
|
||||
{ activeId, fallbackTarget, id, locale }: CloseConversationOptions,
|
||||
): { activeId: string; conversations: AiConversation[] } {
|
||||
const closedConversation = current.find((conversation) => conversation.id === id)
|
||||
if (!closedConversation) return { activeId, conversations: current }
|
||||
|
||||
const conversations = closedConversation.hasActivity
|
||||
? current.map((conversation) => (
|
||||
conversation.id === id ? { ...conversation, archived: true } : conversation
|
||||
))
|
||||
: current.filter((conversation) => conversation.id !== id)
|
||||
const activeConversation = conversations.find((conversation) => conversation.id === activeId && !conversation.archived)
|
||||
if (activeConversation) return { activeId, conversations }
|
||||
|
||||
const fallbackConversation = conversations.find((conversation) => !conversation.archived)
|
||||
if (fallbackConversation) return { activeId: fallbackConversation.id, conversations }
|
||||
|
||||
const nextConversation = createConversation(locale, fallbackTarget, conversations.length + 1)
|
||||
return {
|
||||
activeId: nextConversation.id,
|
||||
conversations: [...conversations, nextConversation],
|
||||
}
|
||||
}
|
||||
|
||||
function restoreConversationState(current: AiConversation[], id: ConversationId): AiConversation[] {
|
||||
return current.map((conversation) => (
|
||||
conversation.id === id ? { ...conversation, archived: false } : conversation
|
||||
))
|
||||
}
|
||||
|
||||
function retargetConversationState(current: AiConversation[], id: ConversationId, targetId: TargetId): AiConversation[] {
|
||||
return current.map((conversation) => (
|
||||
conversation.id === id ? { ...conversation, targetId, usesDefaultTarget: false } : conversation
|
||||
))
|
||||
}
|
||||
|
||||
function reorderConversationState(current: AiConversation[], activeId: ConversationId, overId: ConversationId): AiConversation[] {
|
||||
const oldIndex = current.findIndex((conversation) => conversation.id === activeId)
|
||||
const newIndex = current.findIndex((conversation) => conversation.id === overId)
|
||||
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return current
|
||||
|
||||
return arrayMove(current, oldIndex, newIndex)
|
||||
}
|
||||
|
||||
function renameConversationState(current: AiConversation[], id: ConversationId, title: ConversationTitle): AiConversation[] {
|
||||
const nextTitle = title.trim()
|
||||
if (!nextTitle) return current
|
||||
|
||||
return current.map((conversation) => (
|
||||
conversation.id === id ? { ...conversation, title: nextTitle, usesDefaultTitle: false } : conversation
|
||||
))
|
||||
}
|
||||
|
||||
function markConversationActivityState(current: AiConversation[], id: ConversationId): AiConversation[] {
|
||||
return current.map((conversation) => (
|
||||
conversation.id === id
|
||||
? {
|
||||
...conversation,
|
||||
hasActivity: true,
|
||||
}
|
||||
: conversation
|
||||
))
|
||||
}
|
||||
|
||||
function applyGeneratedConversationTitleState(current: AiConversation[], id: ConversationId, title: ConversationTitle): AiConversation[] {
|
||||
const nextTitle = title.trim()
|
||||
if (!nextTitle) return current
|
||||
|
||||
return current.map((conversation) => (
|
||||
conversation.id === id && conversation.usesDefaultTitle
|
||||
? { ...conversation, hasActivity: true, title: nextTitle, usesDefaultTitle: false }
|
||||
: conversation
|
||||
))
|
||||
}
|
||||
|
||||
function updateDefaultConversationTargetState(current: AiConversation[], targetId: TargetId): AiConversation[] {
|
||||
return current.map((conversation) => (
|
||||
conversation.usesDefaultTarget && conversation.targetId !== targetId
|
||||
? { ...conversation, targetId }
|
||||
: conversation
|
||||
))
|
||||
}
|
||||
|
||||
function initialActiveId(conversations: AiConversation[], requestedId: ConversationId | undefined): ConversationId {
|
||||
if (conversations.some((conversation) => conversation.id === requestedId)) return requestedId ?? ''
|
||||
return conversations[0]?.id ?? ''
|
||||
}
|
||||
|
||||
function useConversationSettingsPersistence({
|
||||
conversations,
|
||||
onSettingsChange,
|
||||
settingsReady,
|
||||
}: {
|
||||
conversations: AiConversation[]
|
||||
onSettingsChange?: (conversations: AiWorkspaceConversationSetting[]) => void
|
||||
settingsReady: boolean
|
||||
}) {
|
||||
const onSettingsChangeRef = useRef(onSettingsChange)
|
||||
|
||||
useEffect(() => {
|
||||
onSettingsChangeRef.current = onSettingsChange
|
||||
}, [onSettingsChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsReady) return
|
||||
onSettingsChangeRef.current?.(conversationsToSettings(conversations))
|
||||
}, [conversations, settingsReady])
|
||||
}
|
||||
|
||||
function useTitleConversationFromAnswer(setConversations: SetConversations) {
|
||||
return useCallback((request: GenerateAiConversationTitleRequest & { id: ConversationId }) => {
|
||||
void generateAiConversationTitleForTarget(request).then((title) => {
|
||||
if (!title) return
|
||||
setConversations((current) => applyGeneratedConversationTitleState(current, request.id, title))
|
||||
})
|
||||
}, [setConversations])
|
||||
}
|
||||
|
||||
function useUpdateDefaultConversationTargets(setConversations: SetConversations) {
|
||||
return useCallback((targetId: TargetId) => {
|
||||
setConversations((current) => updateDefaultConversationTargetState(current, targetId))
|
||||
}, [setConversations])
|
||||
}
|
||||
|
||||
export function useConversations({
|
||||
fallbackTarget,
|
||||
initialActiveConversationId,
|
||||
locale,
|
||||
onSettingsChange,
|
||||
settings,
|
||||
settingsReady,
|
||||
}: UseConversationsOptions) {
|
||||
const [conversations, setConversations] = useState<AiConversation[]>(() => (
|
||||
conversationsFromSettings(settings, fallbackTarget, locale)
|
||||
))
|
||||
const [activeId, setActiveId] = useState(() => initialActiveId(conversations, initialActiveConversationId))
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
const addConversation = useCallback((target: AiTarget) => {
|
||||
const next = appendConversationState(conversations, locale, target)
|
||||
setConversations(next.conversations)
|
||||
setActiveId(next.activeId)
|
||||
}, [conversations, locale])
|
||||
|
||||
const forkConversation = useCallback((sourceId: ConversationId) => {
|
||||
const next = forkConversationState(conversations, locale, sourceId)
|
||||
if (!next) return undefined
|
||||
|
||||
setConversations(next.conversations)
|
||||
setActiveId(next.activeId)
|
||||
return next.activeId
|
||||
}, [conversations, locale])
|
||||
|
||||
const archiveConversation = useCallback((id: ConversationId) => {
|
||||
const next = archiveConversationState(conversations, id)
|
||||
setConversations(next.conversations)
|
||||
if (next.activeId) setActiveId(next.activeId)
|
||||
}, [conversations])
|
||||
|
||||
const closeConversation = useCallback((id: ConversationId) => {
|
||||
const next = closeConversationState(conversations, { activeId, fallbackTarget, id, locale })
|
||||
setConversations(next.conversations)
|
||||
setActiveId(next.activeId)
|
||||
}, [activeId, conversations, fallbackTarget, locale])
|
||||
|
||||
const restoreConversation = useCallback((id: ConversationId) => {
|
||||
setConversations((current) => restoreConversationState(current, id))
|
||||
setActiveId(id)
|
||||
setShowArchived(false)
|
||||
}, [])
|
||||
|
||||
const reorderConversation = useCallback((activeId: ConversationId, overId: ConversationId) => {
|
||||
setConversations((current) => reorderConversationState(current, activeId, overId))
|
||||
}, [])
|
||||
|
||||
const setConversationTarget = useCallback((id: ConversationId, targetId: TargetId) => {
|
||||
setConversations((current) => retargetConversationState(current, id, targetId))
|
||||
}, [])
|
||||
|
||||
const renameConversation = useCallback((id: ConversationId, title: ConversationTitle) => {
|
||||
setConversations((current) => renameConversationState(current, id, title))
|
||||
}, [])
|
||||
|
||||
const markConversationActivity = useCallback((id: ConversationId) => {
|
||||
setConversations((current) => markConversationActivityState(current, id))
|
||||
}, [])
|
||||
|
||||
const titleConversationFromAnswer = useTitleConversationFromAnswer(setConversations)
|
||||
const updateDefaultConversationTargets = useUpdateDefaultConversationTargets(setConversations)
|
||||
useConversationSettingsPersistence({ conversations, onSettingsChange, settingsReady })
|
||||
|
||||
return {
|
||||
activeId, addConversation, archiveConversation, closeConversation, conversations, forkConversation,
|
||||
markConversationActivity, renameConversation, reorderConversation, restoreConversation, setActiveId,
|
||||
setConversationTarget, setShowArchived, showArchived, titleConversationFromAnswer, updateDefaultConversationTargets,
|
||||
}
|
||||
}
|
||||
131
src/components/aiWorkspaceSizing.ts
Normal file
131
src/components/aiWorkspaceSizing.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type AiWorkspaceMode = 'docked' | 'side' | 'window'
|
||||
|
||||
export interface AiWorkspaceSizing {
|
||||
onSidebarResize: (delta: number) => void
|
||||
onWorkspaceResize: (deltaWidth: number, deltaHeight: number) => void
|
||||
sidebarWidth: number
|
||||
workspaceSize: { height: number; width: number }
|
||||
}
|
||||
|
||||
const DEFAULT_DOCKED_WORKSPACE_SIZE = { height: 540, width: 560 }
|
||||
const MIN_DOCKED_WORKSPACE_SIZE = { height: 360, width: 460 }
|
||||
const DEFAULT_SIDE_WORKSPACE_WIDTH = 320
|
||||
const MIN_SIDE_WORKSPACE_WIDTH = 320
|
||||
const SIDE_WORKSPACE_WIDTH_STORAGE_KEY = 'tolaria:ai-workspace-side-width'
|
||||
const DEFAULT_SIDEBAR_WIDTH = 168
|
||||
const MIN_SIDEBAR_WIDTH = 132
|
||||
const MAX_SIDEBAR_WIDTH = 240
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function maxDockedWorkspaceSize(): { height: number; width: number } {
|
||||
if (typeof window === 'undefined') return { height: 680, width: 880 }
|
||||
|
||||
return {
|
||||
height: Math.max(MIN_DOCKED_WORKSPACE_SIZE.height, window.innerHeight - 88),
|
||||
width: Math.max(MIN_DOCKED_WORKSPACE_SIZE.width, window.innerWidth - 32),
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredSideWorkspaceWidth(): number {
|
||||
if (typeof localStorage === 'undefined') return DEFAULT_SIDE_WORKSPACE_WIDTH
|
||||
|
||||
try {
|
||||
const parsed = Number(localStorage.getItem(SIDE_WORKSPACE_WIDTH_STORAGE_KEY))
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_SIDE_WORKSPACE_WIDTH
|
||||
return clampNumber(parsed, MIN_SIDE_WORKSPACE_WIDTH, maxDockedWorkspaceSize().width)
|
||||
} catch {
|
||||
return DEFAULT_SIDE_WORKSPACE_WIDTH
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredSideWorkspaceWidth(width: number): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(SIDE_WORKSPACE_WIDTH_STORAGE_KEY, String(width))
|
||||
} catch {
|
||||
// Ignore unavailable or restricted localStorage implementations.
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceClassName(mode: AiWorkspaceMode, expanded = false): string {
|
||||
if (mode === 'side') {
|
||||
return cn(
|
||||
'z-20 flex h-full min-h-0 overflow-hidden border-l border-sidebar-border bg-sidebar text-sidebar-foreground',
|
||||
expanded ? 'absolute inset-0 border-l-0' : 'relative shrink-0',
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'window') {
|
||||
return 'flex h-full w-full overflow-hidden bg-background text-foreground'
|
||||
}
|
||||
|
||||
return 'fixed right-4 bottom-[30px] z-40 flex overflow-hidden rounded-lg border border-border bg-background text-foreground'
|
||||
}
|
||||
|
||||
export function workspaceStyle(
|
||||
mode: AiWorkspaceMode,
|
||||
size: AiWorkspaceSizing['workspaceSize'],
|
||||
expanded = false,
|
||||
): CSSProperties | undefined {
|
||||
if (mode === 'window') return undefined
|
||||
if (mode === 'side') {
|
||||
if (expanded) return undefined
|
||||
return {
|
||||
minWidth: MIN_SIDE_WORKSPACE_WIDTH,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
height: size.height,
|
||||
maxHeight: 'calc(100vh - 62px)',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
minHeight: MIN_DOCKED_WORKSPACE_SIZE.height,
|
||||
minWidth: MIN_DOCKED_WORKSPACE_SIZE.width,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAiWorkspaceSizing(mode: AiWorkspaceMode): AiWorkspaceSizing {
|
||||
const [workspaceSize, setWorkspaceSize] = useState(() => (
|
||||
mode === 'side'
|
||||
? { height: DEFAULT_DOCKED_WORKSPACE_SIZE.height, width: readStoredSideWorkspaceWidth() }
|
||||
: DEFAULT_DOCKED_WORKSPACE_SIZE
|
||||
))
|
||||
const workspaceSizeRef = useRef(workspaceSize)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH)
|
||||
|
||||
const onWorkspaceResize = useCallback((deltaWidth: number, deltaHeight: number) => {
|
||||
if (mode === 'window') return
|
||||
const current = workspaceSizeRef.current
|
||||
const max = maxDockedWorkspaceSize()
|
||||
const minWidth = mode === 'side' ? MIN_SIDE_WORKSPACE_WIDTH : MIN_DOCKED_WORKSPACE_SIZE.width
|
||||
const next = {
|
||||
height: clampNumber(current.height + deltaHeight, MIN_DOCKED_WORKSPACE_SIZE.height, max.height),
|
||||
width: clampNumber(current.width + deltaWidth, minWidth, max.width),
|
||||
}
|
||||
workspaceSizeRef.current = next
|
||||
if (mode === 'side') writeStoredSideWorkspaceWidth(next.width)
|
||||
setWorkspaceSize(next)
|
||||
}, [mode])
|
||||
const onSidebarResize = useCallback((delta: number) => {
|
||||
setSidebarWidth((current) => clampNumber(current + delta, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
workspaceSizeRef.current = workspaceSize
|
||||
}, [workspaceSize])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'side') writeStoredSideWorkspaceWidth(workspaceSize.width)
|
||||
}, [mode, workspaceSize.width])
|
||||
|
||||
return { onSidebarResize, onWorkspaceResize, sidebarWidth, workspaceSize }
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -32,6 +32,29 @@ describe('blockNoteRenderRecovery', () => {
|
||||
expect(isRecoveredBlockNoteRenderError(error, '')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes production table row index render errors that are plain Error instances', () => {
|
||||
const error = new Error(
|
||||
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
|
||||
)
|
||||
|
||||
expect(blockNoteRenderRecoveryReason(error)).toBe('table_row_index_out_of_range')
|
||||
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 & {
|
||||
@@ -26,9 +28,12 @@ export function blockNoteRenderRecoveryReason(error: unknown): BlockNoteRenderRe
|
||||
if (!(error instanceof Error)) return null
|
||||
if (error.message === BLOCKNOTE_BLOCK_TYPE_MISMATCH_ERROR) return 'block_type_mismatch'
|
||||
if (error.message.includes(BLOCKNOTE_MISSING_ID_ERROR)) return 'block_missing_id'
|
||||
if (error instanceof RangeError && BLOCKNOTE_TABLE_ROW_INDEX_ERROR.test(error.message)) {
|
||||
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
|
||||
|
||||
40
src/components/editorExternalChangeEvents.ts
Normal file
40
src/components/editorExternalChangeEvents.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const RICH_EDITOR_EXTERNAL_CHANGE_EVENT = 'tolaria:rich-editor-external-change'
|
||||
|
||||
export type RichEditorExternalChangeSource = object
|
||||
|
||||
type RichEditorExternalChangeDetail = {
|
||||
source: RichEditorExternalChangeSource
|
||||
}
|
||||
|
||||
function isExternalChangeForSource(
|
||||
event: Event,
|
||||
source: RichEditorExternalChangeSource,
|
||||
): event is CustomEvent<RichEditorExternalChangeDetail> {
|
||||
return event instanceof CustomEvent && event.detail?.source === source
|
||||
}
|
||||
|
||||
export function dispatchRichEditorExternalChange(
|
||||
source: RichEditorExternalChangeSource,
|
||||
target: EventTarget = window,
|
||||
) {
|
||||
target.dispatchEvent(new CustomEvent<RichEditorExternalChangeDetail>(
|
||||
RICH_EDITOR_EXTERNAL_CHANGE_EVENT,
|
||||
{
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { source },
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
export function subscribeRichEditorExternalChange(
|
||||
source: RichEditorExternalChangeSource,
|
||||
onChange: () => void,
|
||||
) {
|
||||
const handleExternalChange = (event: Event) => {
|
||||
if (isExternalChangeForSource(event, source)) onChange()
|
||||
}
|
||||
|
||||
window.addEventListener(RICH_EDITOR_EXTERNAL_CHANGE_EVENT, handleExternalChange)
|
||||
return () => window.removeEventListener(RICH_EDITOR_EXTERNAL_CHANGE_EVENT, handleExternalChange)
|
||||
}
|
||||
73
src/components/editorRichCopy.test.ts
Normal file
73
src/components/editorRichCopy.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { BlockNoteEditor } from '@blocknote/core'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { richEditorClipboardPayload } from './editorRichCopy'
|
||||
|
||||
function createMountedEditor() {
|
||||
const mount = globalThis.document.createElement('div')
|
||||
globalThis.document.body.appendChild(mount)
|
||||
const editor = BlockNoteEditor.create({
|
||||
initialContent: [
|
||||
{
|
||||
type: 'table',
|
||||
content: {
|
||||
type: 'tableContent',
|
||||
rows: [
|
||||
{ cells: ['Name', 'Status'] },
|
||||
{ cells: ['Copy', 'Rich'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'bulletListItem',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Bold bullet',
|
||||
styles: { bold: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
editor.mount(mount)
|
||||
|
||||
return {
|
||||
editor,
|
||||
cleanup: () => {
|
||||
editor.unmount()
|
||||
mount.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('richEditorClipboardPayload', () => {
|
||||
it('preserves semantic table and list markup from a mounted BlockNote selection', () => {
|
||||
const { cleanup, editor } = createMountedEditor()
|
||||
|
||||
try {
|
||||
editor._tiptapEditor.commands.selectAll()
|
||||
|
||||
const payload = richEditorClipboardPayload(editor)
|
||||
|
||||
expect(payload?.html).toContain('<table>')
|
||||
expect(payload?.html).toContain('<tr>')
|
||||
expect(payload?.html).toContain('<td ')
|
||||
expect(payload?.html).toContain('<ul>')
|
||||
expect(payload?.html).toContain('<li>')
|
||||
expect(payload?.html).toContain('<strong>Bold bullet</strong>')
|
||||
expect(payload?.blocknoteHtml).toContain('data-content-type="bulletListItem"')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips empty selections so callers can fall back to DOM cloning', () => {
|
||||
const { cleanup, editor } = createMountedEditor()
|
||||
|
||||
try {
|
||||
expect(richEditorClipboardPayload(editor)).toBeNull()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
130
src/components/editorRichCopy.ts
Normal file
130
src/components/editorRichCopy.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { selectedFragmentToHTML } from '@blocknote/core'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
|
||||
export const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]'
|
||||
const CLIPBOARD_INLINE_FORMAT_SELECTOR = 'a, b, code, em, i, s, span, strong, u'
|
||||
|
||||
type RichEditor = ReturnType<typeof useCreateBlockNote>
|
||||
|
||||
export type RichEditorClipboardPayload = {
|
||||
blocknoteHtml: string
|
||||
html: string
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export function richEditorClipboardPayload(editor: RichEditor): RichEditorClipboardPayload | null {
|
||||
try {
|
||||
const selection = editor.prosemirrorState?.selection
|
||||
const view = editor.prosemirrorView
|
||||
if (!selection || selection.empty || !view) return null
|
||||
|
||||
const { clipboardHTML, externalHTML, markdown } = selectedFragmentToHTML(view, editor)
|
||||
if (clipboardHTML.length === 0 && externalHTML.length === 0) return null
|
||||
|
||||
return {
|
||||
blocknoteHtml: clipboardHTML,
|
||||
html: externalHTML,
|
||||
markdown,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nodeElement(node: Node | null): HTMLElement | null {
|
||||
if (!node) return null
|
||||
if (node instanceof HTMLElement) return node
|
||||
return node.parentElement
|
||||
}
|
||||
|
||||
export function eventTargetElement(target: EventTarget | null): HTMLElement | null {
|
||||
if (!(target instanceof Node)) return null
|
||||
return nodeElement(target)
|
||||
}
|
||||
|
||||
function hasSingleActiveRange(selection: Selection | null): selection is Selection {
|
||||
return Boolean(selection && selection.rangeCount === 1 && !selection.isCollapsed)
|
||||
}
|
||||
|
||||
function closestCodeBlockInContainer(options: {
|
||||
range: Range
|
||||
container: HTMLElement
|
||||
}): HTMLElement | null {
|
||||
const { range, container } = options
|
||||
const codeBlock = nodeElement(range.commonAncestorContainer)
|
||||
?.closest<HTMLElement>(CODE_BLOCK_SELECTOR)
|
||||
|
||||
return codeBlock && container.contains(codeBlock) ? codeBlock : null
|
||||
}
|
||||
|
||||
function nodeBelongsToElement(node: Node, element: HTMLElement): boolean {
|
||||
const elementNode = nodeElement(node)
|
||||
return Boolean(elementNode && element.contains(elementNode))
|
||||
}
|
||||
|
||||
function rangeBelongsToElement(range: Range, element: HTMLElement): boolean {
|
||||
return nodeBelongsToElement(range.startContainer, element)
|
||||
&& nodeBelongsToElement(range.endContainer, element)
|
||||
}
|
||||
|
||||
function selectedCodeBlockRange(options: {
|
||||
selection: Selection | null
|
||||
container: HTMLElement
|
||||
}): Range | null {
|
||||
const { selection, container } = options
|
||||
if (!hasSingleActiveRange(selection)) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const codeBlock = closestCodeBlockInContainer({ range, container })
|
||||
if (!codeBlock || !rangeBelongsToElement(range, codeBlock)) return null
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
export function selectedCodeBlockText(options: {
|
||||
selection: Selection | null
|
||||
container: HTMLElement
|
||||
}): string | null {
|
||||
const range = selectedCodeBlockRange(options)
|
||||
if (!range) return null
|
||||
|
||||
return range.cloneContents().textContent || options.selection?.toString() || ''
|
||||
}
|
||||
|
||||
export function selectedEditorRange(
|
||||
selection: Selection | null,
|
||||
container: HTMLElement,
|
||||
): Range | null {
|
||||
if (!hasSingleActiveRange(selection)) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
return rangeBelongsToElement(range, container) ? range : null
|
||||
}
|
||||
|
||||
export function selectedEditorPlainText(selection: Selection, range: Range): string | null {
|
||||
const text = selection.toString() || range.cloneContents().textContent || ''
|
||||
if (text.length === 0) return null
|
||||
|
||||
return text.replace(/\r?\n$/, '')
|
||||
}
|
||||
|
||||
export function selectedEditorDomHtml(range: Range): string {
|
||||
const wrapper = document.createElement('div')
|
||||
const selectedContent = range.cloneContents()
|
||||
const commonElement = nodeElement(range.commonAncestorContainer)
|
||||
|
||||
if (commonElement?.matches(CLIPBOARD_INLINE_FORMAT_SELECTOR)) {
|
||||
const inlineWrapper = commonElement.cloneNode(false)
|
||||
inlineWrapper.appendChild(selectedContent)
|
||||
wrapper.appendChild(inlineWrapper)
|
||||
return wrapper.innerHTML
|
||||
}
|
||||
|
||||
wrapper.appendChild(selectedContent)
|
||||
return wrapper.innerHTML
|
||||
}
|
||||
|
||||
export function codeBlockText(codeBlock: HTMLElement): string {
|
||||
const codeElement = codeBlock.querySelector<HTMLElement>('pre code')
|
||||
return codeElement?.textContent ?? ''
|
||||
}
|
||||
26
src/components/editorSchema.markdownHighlight.test.ts
Normal file
26
src/components/editorSchema.markdownHighlight.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { BlockNoteEditor } from '@blocknote/core'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
injectMarkdownHighlightsInBlocks,
|
||||
MARKDOWN_HIGHLIGHT_STYLE,
|
||||
restoreMarkdownHighlightsInBlocks,
|
||||
} from '../utils/markdownHighlightMarkdown'
|
||||
import { schema } from './editorSchema'
|
||||
|
||||
describe('editor schema Markdown highlight parsing', () => {
|
||||
it('round-trips ==highlight== markers as a rich-editor style', async () => {
|
||||
const editor = BlockNoteEditor.create({ schema })
|
||||
const blocks = injectMarkdownHighlightsInBlocks(
|
||||
await editor.tryParseMarkdownToBlocks('Plain ==marked== text'),
|
||||
) as Array<{ content?: Array<{ styles?: Record<string, unknown>; text?: string }> }>
|
||||
|
||||
expect(blocks[0].content).toEqual([
|
||||
{ type: 'text', text: 'Plain ', styles: {} },
|
||||
{ type: 'text', text: 'marked', styles: { [MARKDOWN_HIGHLIGHT_STYLE]: true } },
|
||||
{ type: 'text', text: ' text', styles: {} },
|
||||
])
|
||||
expect(editor.blocksToMarkdownLossy(restoreMarkdownHighlightsInBlocks(blocks))).toContain(
|
||||
'Plain ==marked== text',
|
||||
)
|
||||
})
|
||||
})
|
||||
89
src/components/editorSchema.math.test.tsx
Normal file
89
src/components/editorSchema.math.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MathBlockEditor } from './editorSchema'
|
||||
import { subscribeRichEditorExternalChange } from './editorExternalChangeEvents'
|
||||
|
||||
function renderMathBlockEditor(latex = '\\sqrt{x}') {
|
||||
const editor = {
|
||||
focus: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
}
|
||||
const block = {
|
||||
id: 'math-block',
|
||||
props: { latex },
|
||||
}
|
||||
|
||||
render(<MathBlockEditor block={block} editor={editor} />)
|
||||
|
||||
return { block, editor }
|
||||
}
|
||||
|
||||
describe('MathBlockEditor', () => {
|
||||
it('renders display math without exposing Markdown delimiters as editor content', () => {
|
||||
renderMathBlockEditor()
|
||||
|
||||
expect(document.querySelector('.math--block')).toHaveAttribute('data-latex', '\\sqrt{x}')
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('edits the math block latex prop instead of inserting Markdown source', () => {
|
||||
const { editor } = renderMathBlockEditor()
|
||||
const onExternalChange = vi.fn()
|
||||
const unsubscribe = subscribeRichEditorExternalChange(editor, onExternalChange)
|
||||
|
||||
fireEvent.doubleClick(document.querySelector('.math--block')!)
|
||||
const source = screen.getByRole('textbox')
|
||||
fireEvent.change(source, { target: { value: '\\frac{1}{2}' } })
|
||||
fireEvent.blur(source)
|
||||
|
||||
expect(editor.updateBlock).toHaveBeenCalledWith('math-block', {
|
||||
props: { latex: '\\frac{1}{2}' },
|
||||
})
|
||||
expect(editor.updateBlock).not.toHaveBeenCalledWith('math-block', {
|
||||
props: { latex: '$$\\frac{1}{2}$$' },
|
||||
})
|
||||
expect(onExternalChange).toHaveBeenCalledTimes(1)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('cancels math block editing without changing the block', () => {
|
||||
const { editor } = renderMathBlockEditor()
|
||||
|
||||
fireEvent.doubleClick(document.querySelector('.math--block')!)
|
||||
const source = screen.getByRole('textbox')
|
||||
fireEvent.change(source, { target: { value: '\\frac{1}{2}' } })
|
||||
fireEvent.keyDown(source, { key: 'Escape' })
|
||||
fireEvent.blur(source)
|
||||
|
||||
expect(editor.updateBlock).not.toHaveBeenCalled()
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses editor selection colors and a single focused border while editing', () => {
|
||||
renderMathBlockEditor()
|
||||
|
||||
fireEvent.doubleClick(document.querySelector('.math--block')!)
|
||||
const source = screen.getByRole('textbox')
|
||||
|
||||
expect(source).toHaveClass('math-block-source')
|
||||
expect(source).toHaveClass('selection:bg-[var(--colors-selection)]')
|
||||
expect(source).toHaveClass('selection:text-[var(--colors-text)]')
|
||||
expect(source).toHaveClass('focus-visible:ring-0')
|
||||
expect(source).not.toHaveClass('selection:bg-primary')
|
||||
expect(source).not.toHaveClass('selection:text-primary-foreground')
|
||||
expect(source).not.toHaveClass('focus-visible:ring-[3px]')
|
||||
})
|
||||
|
||||
it('keeps display math selection chrome scoped to the rendered formula width', () => {
|
||||
const editorThemeCss = readFileSync(`${process.cwd()}/src/components/EditorTheme.css`, 'utf8')
|
||||
|
||||
expect(editorThemeCss).toContain('.editor__blocknote-container .math-block-shell {')
|
||||
expect(editorThemeCss).toContain('max-width: 100%;')
|
||||
expect(editorThemeCss).toContain('.editor__blocknote-container .math-block-shell:not(.math-block-shell--editing) {')
|
||||
expect(editorThemeCss).toContain('width: fit-content;')
|
||||
expect(editorThemeCss).toContain('margin-inline: auto;')
|
||||
expect(editorThemeCss).toContain('.editor__blocknote-container .math-block-shell--editing {')
|
||||
expect(editorThemeCss).toContain('width: 100%;')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createCodeBlockSpec,
|
||||
BlockNoteSchema,
|
||||
createAudioBlockConfig,
|
||||
createStyleSpec,
|
||||
createVideoBlockConfig,
|
||||
defaultInlineContentSpecs,
|
||||
videoParse,
|
||||
@@ -16,12 +17,13 @@ import {
|
||||
VideoBlock,
|
||||
VideoToExternalHTML,
|
||||
} from '@blocknote/react'
|
||||
import { lazy, Suspense, type ComponentProps } from 'react'
|
||||
import { lazy, Suspense, useEffect, useRef, useState, type ComponentProps, type KeyboardEvent } from 'react'
|
||||
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown'
|
||||
import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown'
|
||||
import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown'
|
||||
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { createTolariaCodeBlockOptions } from './codeBlockOptions'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
@@ -29,6 +31,8 @@ import { MermaidDiagram } from './MermaidDiagram'
|
||||
import { SafeHtmlSpan } from './SafeMarkup'
|
||||
import { updateTldrawBlockPropsSafely } from './tldrawBlockProps'
|
||||
import { useExternalMediaPreview } from '../utils/mediaPreviewRuntime'
|
||||
import { Textarea } from './ui/textarea'
|
||||
import { dispatchRichEditorExternalChange } from './editorExternalChangeEvents'
|
||||
|
||||
const TldrawWhiteboard = lazy(() => import('./TldrawWhiteboard').then(module => ({
|
||||
default: module.TldrawWhiteboard,
|
||||
@@ -107,6 +111,111 @@ function MathRender({ latex, displayMode }: { latex: string; displayMode: boolea
|
||||
)
|
||||
}
|
||||
|
||||
type MathBlockEditorProps = {
|
||||
block: {
|
||||
id: string
|
||||
props: {
|
||||
latex: string
|
||||
}
|
||||
}
|
||||
editor: {
|
||||
domElement?: EventTarget | null
|
||||
focus?: () => void
|
||||
updateBlock: (blockId: string, update: { props: { latex: string } }) => void
|
||||
}
|
||||
}
|
||||
|
||||
function stopMathEditorEvent(event: { stopPropagation: () => void }) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function isCommandModifierPressed(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
return event.metaKey || event.ctrlKey
|
||||
}
|
||||
|
||||
function isCommitMathEditShortcut(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
|
||||
return event.key === 'Enter' && isCommandModifierPressed(event)
|
||||
}
|
||||
|
||||
export function MathBlockEditor({ block, editor }: MathBlockEditorProps) {
|
||||
const currentLatex = block.props.latex
|
||||
const editingSessionRef = useRef(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [draftLatex, setDraftLatex] = useState(currentLatex)
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return
|
||||
textareaRef.current?.focus()
|
||||
textareaRef.current?.select()
|
||||
}, [editing])
|
||||
|
||||
const startEditing = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDraftLatex(currentLatex)
|
||||
editingSessionRef.current = true
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const finishEditing = () => {
|
||||
if (!editingSessionRef.current) return
|
||||
editingSessionRef.current = false
|
||||
setEditing(false)
|
||||
if (draftLatex !== currentLatex) {
|
||||
editor.updateBlock(block.id, { props: { latex: draftLatex } })
|
||||
dispatchRichEditorExternalChange(editor, editor.domElement ?? undefined)
|
||||
}
|
||||
editor.focus?.()
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
if (!editingSessionRef.current) return
|
||||
editingSessionRef.current = false
|
||||
setDraftLatex(currentLatex)
|
||||
setEditing(false)
|
||||
editor.focus?.()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
if (isCommitMathEditShortcut(event)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
finishEditing()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={editing ? 'math-block-shell math-block-shell--editing' : 'math-block-shell'}
|
||||
onDoubleClick={editing ? stopMathEditorEvent : startEditing}
|
||||
>
|
||||
{editing ? (
|
||||
<div contentEditable={false} onMouseDown={stopMathEditorEvent}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
aria-label={`Math: ${currentLatex}`}
|
||||
className="math-block-source min-h-24 font-mono text-sm selection:bg-[var(--colors-selection)] selection:text-[var(--colors-text)] focus-visible:ring-0"
|
||||
value={draftLatex}
|
||||
onBlur={finishEditing}
|
||||
onChange={(event) => setDraftLatex(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MathRender latex={currentLatex} displayMode />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MathInline = createReactInlineContentSpec(
|
||||
{
|
||||
type: MATH_INLINE_TYPE,
|
||||
@@ -132,9 +241,7 @@ const MathBlock = createReactBlockSpec(
|
||||
},
|
||||
{
|
||||
render: (props) => (
|
||||
<div className="math-block-shell">
|
||||
<MathRender latex={props.block.props.latex} displayMode />
|
||||
</div>
|
||||
<MathBlockEditor block={props.block} editor={props.editor} />
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -289,6 +396,24 @@ const mermaidBlock = MermaidBlock()
|
||||
const tldrawBlock = TldrawBlock()
|
||||
const videoBlock = VideoBlockSpec()
|
||||
|
||||
function markdownHighlightElement(): { dom: HTMLElement; contentDOM: HTMLElement } {
|
||||
const mark = document.createElement('mark')
|
||||
mark.className = 'markdown-highlight'
|
||||
return { dom: mark, contentDOM: mark }
|
||||
}
|
||||
|
||||
const MarkdownHighlightStyle = createStyleSpec(
|
||||
{
|
||||
type: MARKDOWN_HIGHLIGHT_STYLE,
|
||||
propSchema: 'boolean',
|
||||
},
|
||||
{
|
||||
render: markdownHighlightElement,
|
||||
toExternalHTML: markdownHighlightElement,
|
||||
parse: element => element.tagName === 'MARK' ? true : undefined,
|
||||
},
|
||||
)
|
||||
|
||||
export const schema = BlockNoteSchema.create({
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
@@ -296,6 +421,9 @@ export const schema = BlockNoteSchema.create({
|
||||
mathInline: MathInline,
|
||||
},
|
||||
}).extend({
|
||||
styleSpecs: {
|
||||
[MARKDOWN_HIGHLIGHT_STYLE]: MarkdownHighlightStyle,
|
||||
},
|
||||
blockSpecs: {
|
||||
audio: audioBlock,
|
||||
mathBlock,
|
||||
|
||||
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',
|
||||
})
|
||||
@@ -309,10 +309,9 @@ describe('createMathInputExtension', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('restores rendered block math source on double click', () => {
|
||||
it('leaves rendered block math intact on double click', () => {
|
||||
const fixture = createFixture()
|
||||
const latex = '\\sqrt{x}'
|
||||
const source = `$$\n${latex}\n$$`
|
||||
const mathElement = document.createElement('span')
|
||||
mathElement.className = 'math math--block'
|
||||
mathElement.dataset.latex = latex
|
||||
@@ -323,23 +322,11 @@ describe('createMathInputExtension', () => {
|
||||
|
||||
const event = fixture.fireMathDoubleClick(mathElement)
|
||||
|
||||
expect(fixture.textNodeFor).toHaveBeenCalledWith(source)
|
||||
expect(fixture.paragraphNodeType.createChecked).toHaveBeenCalledWith({}, {
|
||||
text: source,
|
||||
type: 'text',
|
||||
})
|
||||
expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(20, 21, {
|
||||
attrs: {},
|
||||
content: {
|
||||
text: source,
|
||||
type: 'text',
|
||||
},
|
||||
type: 'paragraph',
|
||||
})
|
||||
expect(fixture.setTextSelection).toHaveBeenCalledWith({ from: 24, to: 32 })
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(trackEvent).toHaveBeenCalledWith('math_source_edit_reopened', {
|
||||
expect(fixture.transaction.replaceWith).not.toHaveBeenCalled()
|
||||
expect(fixture.setTextSelection).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(event.stopPropagation).not.toHaveBeenCalled()
|
||||
expect(trackEvent).not.toHaveBeenCalledWith('math_source_edit_reopened', {
|
||||
activation: 'pointer',
|
||||
math_mode: 'block',
|
||||
})
|
||||
@@ -370,4 +357,22 @@ describe('createMathInputExtension', () => {
|
||||
math_mode: 'inline',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves selected block math intact from the keyboard', () => {
|
||||
const fixture = createFixture()
|
||||
const selectedNode = createMathNode(MATH_BLOCK_TYPE, '\\sqrt{x}')
|
||||
fixture.view.state.selection = {
|
||||
from: 12,
|
||||
node: selectedNode,
|
||||
to: 13,
|
||||
}
|
||||
fixture.mount()
|
||||
|
||||
const event = fixture.fireKeyDown({ key: 'Enter' })
|
||||
|
||||
expect(fixture.transaction.replaceWith).not.toHaveBeenCalled()
|
||||
expect(fixture.setTextSelection).not.toHaveBeenCalled()
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(event.stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,31 +110,12 @@ function replaceCompletedInlineMath(
|
||||
return transaction.scrollIntoView()
|
||||
}
|
||||
|
||||
function recoverTransformError(error: unknown): boolean {
|
||||
if (!isRecoverableEditorTransformError(error)) return false
|
||||
|
||||
reportRecoveredEditorTransformError('transform_error', error)
|
||||
return true
|
||||
function mathSource({ latex }: { latex: string }): string {
|
||||
return `$${latex}$`
|
||||
}
|
||||
|
||||
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({ kind, latex }: { kind: MathKind; latex: string }): string {
|
||||
return kind === 'block' ? `$$\n${latex}\n$$` : `$${latex}$`
|
||||
}
|
||||
|
||||
function mathLatexSelectionRange({ from, kind, latex }: { from: number; kind: MathKind; latex: string }) {
|
||||
const sourceStart = kind === 'block' ? from + 1 + '$$\n'.length : from + 1
|
||||
function mathLatexSelectionRange({ from, latex }: { from: number; latex: string }) {
|
||||
const sourceStart = from + 1
|
||||
return { from: sourceStart, to: sourceStart + latex.length }
|
||||
}
|
||||
|
||||
@@ -250,7 +226,7 @@ function readSelectedMathLocation(view: EditorViewLike): MathNodeLocation | null
|
||||
|
||||
const kind = mathKindForNode(selection.node)
|
||||
const latex = readLatexAttr(selection.node)
|
||||
if (!kind || latex === null) return null
|
||||
if (kind !== 'inline' || latex === null) return null
|
||||
|
||||
return {
|
||||
from: selection.from,
|
||||
@@ -264,14 +240,9 @@ function readSelectedMathLocation(view: EditorViewLike): MathNodeLocation | null
|
||||
function replacementForMathSource(
|
||||
view: EditorViewLike,
|
||||
location: MathNodeLocation,
|
||||
): ReturnType<EditorViewLike['state']['schema']['text']> | MathNodeLike | null {
|
||||
): ReturnType<EditorViewLike['state']['schema']['text']> {
|
||||
const source = mathSource(location)
|
||||
const textNode = view.state.schema.text(source)
|
||||
|
||||
if (location.kind === 'inline') return textNode
|
||||
|
||||
const paragraphType = view.state.schema.nodes.paragraph
|
||||
return paragraphType?.createChecked({}, textNode) ?? null
|
||||
return view.state.schema.text(source)
|
||||
}
|
||||
|
||||
function restoreMathSource({
|
||||
@@ -292,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),
|
||||
@@ -304,27 +275,13 @@ 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,
|
||||
) {
|
||||
const target = readRenderedMathTarget(event.target)
|
||||
const view = readView()
|
||||
if (!target || !view) return
|
||||
if (!target || target.kind !== 'inline' || !view) return
|
||||
|
||||
const location = readRenderedMathLocation({ ...target, view })
|
||||
if (!location) return
|
||||
@@ -351,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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,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)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { AppLocale } from '../../lib/i18n'
|
||||
import { trackEvent } from '../../lib/telemetry'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { isMarkdownEntry } from '../../utils/typeDefinitions'
|
||||
import { NoteListContextMenuNode } from './NoteListContextMenuView'
|
||||
|
||||
export type NoteListContextMenuState = {
|
||||
@@ -22,10 +23,13 @@ interface NoteListContextMenuParams {
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
onArchivePaths?: (paths: string[]) => void
|
||||
onDeletePaths?: (paths: string[]) => void
|
||||
onExportPdf?: (entry: VaultEntry) => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
canCopyGitUrl?: (entry: VaultEntry) => boolean
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
}
|
||||
|
||||
function hasNoteListContextActions({
|
||||
@@ -34,20 +38,25 @@ function hasNoteListContextActions({
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths,
|
||||
onDeletePaths,
|
||||
onExportPdf,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
}: NoteListContextMenuParams & { entry: VaultEntry }) {
|
||||
return [
|
||||
onOpenInNewWindow,
|
||||
onEnterNeighborhood && entry.fileKind !== 'binary',
|
||||
onExportPdf && isMarkdownEntry(entry),
|
||||
onArchivePaths && !entry.archived,
|
||||
onDeletePaths,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onCopyGitUrl && canCopyGitUrl?.(entry),
|
||||
].some(Boolean)
|
||||
}
|
||||
|
||||
@@ -57,10 +66,13 @@ export function useNoteListContextMenu({
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths,
|
||||
onDeletePaths,
|
||||
onExportPdf,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
}: NoteListContextMenuParams) {
|
||||
const [ctxMenu, setCtxMenu] = useState<NoteListContextMenuState | null>(null)
|
||||
const ctxMenuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -91,10 +103,13 @@ export function useNoteListContextMenu({
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths,
|
||||
onDeletePaths,
|
||||
onExportPdf,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
})) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -103,9 +118,12 @@ export function useNoteListContextMenu({
|
||||
}, [
|
||||
onArchivePaths,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onDeletePaths,
|
||||
onEnterNeighborhood,
|
||||
onExportPdf,
|
||||
onOpenInNewWindow,
|
||||
onCopyGitUrl,
|
||||
onRevealFile,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
@@ -120,10 +138,13 @@ export function useNoteListContextMenu({
|
||||
onOpenInNewWindow={onOpenInNewWindow}
|
||||
onArchivePaths={onArchivePaths}
|
||||
onDeletePaths={onDeletePaths}
|
||||
onExportPdf={onExportPdf}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
canCopyGitUrl={canCopyGitUrl}
|
||||
onCopyGitUrl={onCopyGitUrl}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
ArrowSquareOut,
|
||||
CheckCircle,
|
||||
ClipboardText,
|
||||
FilePdf,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
MapTrifold,
|
||||
Star,
|
||||
Trash,
|
||||
@@ -15,6 +17,7 @@ import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../../hooks/appCo
|
||||
import { translate, type AppLocale } from '../../lib/i18n'
|
||||
import { trackEvent } from '../../lib/telemetry'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { isMarkdownEntry } from '../../utils/typeDefinitions'
|
||||
import type { NoteListContextMenuState } from './NoteListContextMenu'
|
||||
|
||||
interface NoteListContextMenuItem {
|
||||
@@ -36,10 +39,13 @@ interface NoteListContextMenuNodeProps {
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
onArchivePaths?: (paths: string[]) => void
|
||||
onDeletePaths?: (paths: string[]) => void
|
||||
onExportPdf?: (entry: VaultEntry) => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
canCopyGitUrl?: (entry: VaultEntry) => boolean
|
||||
onCopyGitUrl?: (entry: VaultEntry) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -50,10 +56,13 @@ type BuildContextMenuItemsParams = Pick<
|
||||
| 'onOpenInNewWindow'
|
||||
| 'onArchivePaths'
|
||||
| 'onDeletePaths'
|
||||
| 'onExportPdf'
|
||||
| 'onToggleFavorite'
|
||||
| 'onToggleOrganized'
|
||||
| 'onRevealFile'
|
||||
| 'onCopyFilePath'
|
||||
| 'canCopyGitUrl'
|
||||
| 'onCopyGitUrl'
|
||||
>
|
||||
|
||||
function openWindowItem(
|
||||
@@ -145,6 +154,36 @@ 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,
|
||||
onExportPdf: ((entry: VaultEntry) => void) | undefined,
|
||||
selectAction: SelectContextAction,
|
||||
) {
|
||||
if (!onExportPdf || !isMarkdownEntry(entry)) return []
|
||||
return [{
|
||||
icon: FilePdf,
|
||||
label: translate(locale, 'editor.toolbar.exportPdf'),
|
||||
onSelect: () => selectAction('export_pdf', () => onExportPdf(entry)),
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteExportPdf),
|
||||
}]
|
||||
}
|
||||
|
||||
function archiveItem(
|
||||
entry: VaultEntry,
|
||||
locale: AppLocale,
|
||||
@@ -187,6 +226,8 @@ 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),
|
||||
]
|
||||
@@ -216,10 +257,13 @@ export function NoteListContextMenuNode({
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths,
|
||||
onDeletePaths,
|
||||
onExportPdf,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
onClose,
|
||||
}: NoteListContextMenuNodeProps) {
|
||||
if (!ctxMenu) return null
|
||||
@@ -236,10 +280,13 @@ export function NoteListContextMenuNode({
|
||||
onOpenInNewWindow,
|
||||
onArchivePaths,
|
||||
onDeletePaths,
|
||||
onExportPdf,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
canCopyGitUrl,
|
||||
onCopyGitUrl,
|
||||
}, entry, selectAction)
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user