Compare commits

..

25 Commits

Author SHA1 Message Date
lucaronin
bf3caab862 fix: recover blocked native folder picker 2026-06-07 15:00:51 +02:00
lucaronin
2556907f36 fix: launch Pi CLI from Windows shims 2026-06-07 12:04:55 +02:00
lucaronin
8e3f8fbb77 fix: bound ai agent startup status probe 2026-06-07 05:46:41 +02:00
lucaronin
501df0e265 refactor: share rich editor input transforms 2026-06-07 03:25:09 +02:00
lucaronin
48eac1e40d fix: recover paragraph editor index errors 2026-06-07 02:49:04 +02:00
lucaronin
3d28e21a5a fix: keep palette keyboard selection stable 2026-06-06 14:49:43 +02:00
lucaronin
191066a3bf feat: copy note git URLs 2026-06-06 14:24:51 +02:00
lucaronin
3a88724d8f test: update stable signing bypass guard 2026-06-06 12:06:17 +02:00
lucaronin
65ea3f37fc ci: allow v2026-06-06 stable without windows signing 2026-06-06 11:42:57 +02:00
lucaronin
0e81f20e31 docs: add release notes for v2026-06-06 2026-06-06 11:32:51 +02:00
lucaronin
3c8edd8346 fix: let OpenAI API create notes 2026-06-06 10:42:33 +02:00
lucaronin
80ee94da73 fix: localize highlight toolbar copy 2026-06-06 10:35:21 +02:00
lucaronin
008e8f3471 fix: support rich-editor markdown highlights 2026-06-06 10:15:44 +02:00
lucaronin
6aaaca8e24 fix: sync mcp server lockfile 2026-06-06 09:31:38 +02:00
lucaronin
c5dc4a9300 fix: handle symlinked gitignored vault paths 2026-06-06 08:21:49 +02:00
lucaronin
25c7ba0926 test: cover stack-based null append recovery 2026-06-06 04:24:52 +02:00
lucaronin
2b1f4395b3 fix: recover null editor transform errors 2026-06-06 03:57:21 +02:00
lucaronin
e3ef3f6073 test: stabilize wikilink ime composition test 2026-06-06 03:50:51 +02:00
lucaronin
8d47b8d7d2 fix: correct Chinese file manager labels 2026-06-06 03:45:14 +02:00
lucaronin
8fc366c8c0 fix: scope math block selection highlight 2026-06-06 03:23:04 +02:00
lucaronin
42d7a3ac44 refactor: share mcp tool orchestration 2026-06-06 02:27:19 +02:00
lucaronin
3e349fa017 fix: render markdown highlight syntax 2026-06-06 01:34:06 +02:00
lucaronin
8138925bb0 fix: localize custom menus and feedback 2026-06-06 00:48:10 +02:00
lucaronin
cc4203ab1c fix: expose mermaid raw edit action 2026-06-05 23:32:01 +02:00
lucaronin
401087a8ef fix: recover plain table row index errors 2026-06-05 22:58:28 +02:00
110 changed files with 5650 additions and 782 deletions

View File

@@ -67,9 +67,9 @@ jobs:
version: ${{ needs.version.outputs.version }}
macos_bundles: ""
upload_macos_dmg: true
# One-time stable promotion exception while Windows certificate secrets
# One-time stable promotion exceptions while Windows certificate secrets
# are still being provisioned. Future stable tags must keep Authenticode.
require_windows_authenticode: ${{ needs.version.outputs.tag != 'v2026-06-01' }}
require_windows_authenticode: ${{ !contains(fromJson('["v2026-06-01","v2026-06-06"]'), needs.version.outputs.tag) }}
secrets: inherit
# ─────────────────────────────────────────────────────────────

View File

@@ -431,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
@@ -516,6 +516,7 @@ interface PulseCommit {
| `history.rs` | File history | `git log` — last 20 commits per file |
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
| `file_url.rs` | File URL | Builds a copyable remote URL from the primary remote, current branch, and vault-relative path without exposing remote credentials |
| `commit.rs` | Commit | Ensures a local author fallback when needed, then runs `git add -A && git commit -m "..."`; broken signing helpers trigger one unsigned retry for the same app-managed commit |
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
| `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe |
@@ -644,6 +645,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
- `createImeCompositionKeyGuardExtension()` intercepts composing `Enter` keydown events before BlockNote's list shortcuts see them, so Korean/Japanese/Chinese IMEs can commit text at the start of list items without Tolaria splitting the current bullet. It stops editor shortcut propagation only; it does not prevent the browser/IME default composition action.
- `richEditorInputTransform.ts` is the shared execution shell for rich-editor Markdown `beforeinput` transforms. It reads the live ProseMirror view, skips IME composition, resets state when a stale view is detected, dispatches transform transactions, prevents native input only after successful dispatch, and reports recoverable editor-transform errors through the same telemetry path. Arrow ligatures, inline math conversion, and `==highlight==` keep their syntax-specific matching in their feature files and are composed for the main editor by `richEditorInputTransformExtension.ts`.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
@@ -854,7 +856,7 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
- Only shows after vault onboarding has already resolved to a ready state
- Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- App-managed Claude Code runs preserve the same user-managed Anthropic/provider env behavior by forwarding selected exported variables from the app process or the user's zsh/bash startup files without persisting those secrets
- The shared `useAiAgentsStatus` hook defers that command until after the first render and skips it when AI features are disabled or the current window cannot render AI status surfaces
- The shared `useAiAgentsStatus` hook defers that command until after the first render, skips it when AI features are disabled or the current window cannot render AI status surfaces, and falls back to missing-agent statuses if the native probe does not return promptly so first-launch onboarding keeps a recovery path
- Persists dismissal locally once the user continues
### Remote Git Operations
@@ -910,7 +912,7 @@ interface Settings {
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. Direct OpenAI-compatible model streams receive the active vault root and may execute Tolaria's native create-only `create_note` tool, but they do not receive shell access or general file-write tools. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -229,7 +229,7 @@ flowchart TD
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Folder creation sends the selected folder's vault-relative path and mounted root to `create_vault_folder`, so a new folder is created under the focused parent instead of defaulting to the active vault root. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. In mounted multi-vault graphs, duplicate type names still render as one sidebar section, but the visibility picker becomes a workspace matrix and writes visibility to the specific vault's Type document, so hidden type definitions suppress only notes of that type from the same workspace.
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block` plus lazy direct `@shikijs/langs` registrations for missing common grammars. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block` plus lazy direct `@shikijs/langs` registrations for missing common grammars. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Rich-editor Markdown input transforms for arrows, inline math, and `==highlight==` share one capture-phase `beforeinput` execution path in `src/components/richEditorInputTransform.ts` and are composed by `src/components/richEditorInputTransformExtension.ts`. Navigation history (Cmd+[/]) replaces tabs.
Rich-editor copy uses BlockNote's external HTML serializer for selected note content so tables, lists, checklists, and inline formatting paste richly into other apps, with Tolaria keeping fenced-code selections as raw code text and normalized plain text on the clipboard.
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and checks the native PDF capability before choosing a platform path. On macOS, the renderer asks for a filesystem PDF destination before the Tauri `export_current_webview_pdf` command saves the current `WKWebView` print operation directly; on Windows/Linux Tauri builds and in browser mode, the same export action falls back to the native/browser print dialog. The export reuses rendered BlockNote output so frontmatter is omitted, while math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified. Markdown notes expose the same export action from Cmd+K, the native Note menu, the breadcrumb overflow menu, and each Markdown row's note-list context menu.
- **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
@@ -242,7 +242,7 @@ The main Tauri window derives its minimum width from the visible panes instead o
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, and the Rust command fans per-agent CLI checks across Tokio's blocking pool. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, falls back to missing-agent onboarding state if the status IPC does not settle promptly, and the Rust command fans per-agent CLI checks across Tokio's blocking pool with per-agent timeouts. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Desktop startup registers `tauri-plugin-deep-link` and `tauri-plugin-single-instance` before setup so `tolaria://` links can focus the existing main window and deliver URL events to the renderer. `tauri.conf.json` declares the `tolaria` scheme for bundled desktop builds; Windows and Linux also run `register_all()` as a runtime repair path, while macOS relies on bundle registration.
@@ -343,7 +343,7 @@ Large active notes are compacted into a head/tail body snapshot before they ente
### Direct Model Targets
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive shell access. OpenAI-compatible direct targets can use Tolaria's narrow native `create_note` tool when an active vault is loaded; the tool calls the same create-only, active-vault-bounded note write command as the UI and emits tool events so the renderer refreshes and opens the created note. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Env-backed provider keys are resolved from the app process first, then from exported values in the user's zsh/bash startup files on Unix so GUI-launched sessions can still use shell-managed secrets. Local endpoints can omit authentication.
@@ -354,6 +354,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
## 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
@@ -399,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
@@ -778,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 |
@@ -917,7 +922,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, the breadcrumb overflow menu, and the note-list context menu, all routed through the same editor export hook. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, the breadcrumb overflow menu, and the note-list context menu, all routed through the same editor export hook. Remote-backed notes expose "Copy git URL" from the breadcrumb overflow and note-list context menu; the renderer gates the action per note workspace via `git_remote_status`, then asks `git_file_url` to build the copied URL from the primary remote, current branch, and vault-relative path. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:

View 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.

View File

@@ -187,3 +187,4 @@ proposed → active → superseded
| [0133](0133-request-scoped-ai-stream-events.md) | Request-scoped AI stream event channels | active |
| [0134](0134-direct-shiki-language-registrations.md) | Direct Shiki language registrations for code blocks | active |
| [0135](0135-clean-active-note-refresh-after-external-edit.md) | Clean active notes refresh immediately after external edits | active |
| [0137](0137-shared-rich-editor-input-transforms.md) | Shared rich-editor input transforms | active |

View File

@@ -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
@@ -531,9 +588,12 @@ files:
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.copyNoteDeepLink: 0fff274a7c47e7c12457c0b7412e86ce
editor.toolbar.copyNoteGitUrl: 04a732b98f7585e3c3b5474688e0baac
editor.toolbar.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
editor.formatting.highlight: 0b90582f4589d84be89f5b847d4d1ed1
editor.formatting.highlightTooltip: c27173fea9ec1718030c5fa8440b2c78
editor.whiteboard.enterFullscreen: 606bf766125ecff4b33ca98122222c72
editor.whiteboard.exitFullscreen: afd0a0414d79d047f00319921e4d4944
editor.exportPdf.unavailable: 2fce9834984172b64b66767485d815e9
@@ -552,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

View File

@@ -20,10 +20,7 @@ import {
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { createNote, 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}`
@@ -47,10 +44,6 @@ let reconnectTimer = null
let shutdownStarted = false
const RECONNECT_INTERVAL_MS = 3000
function activeVaultPaths() {
return requireVaultPaths()
}
function connectUiBridge() {
if (shutdownStarted) return
@@ -116,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',
@@ -220,125 +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
}
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 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}.`)
}
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)
}
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')
@@ -346,40 +224,25 @@ 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 notePath = notePathArg(args)
const vaultPath = writableVaultPath(args)
const note = await createNote(vaultPath, notePath, createNoteContent(args))
const targetPath = uiPath({ ...args, path: note.path, vaultPath })
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
const note = await toolService.createNote(args)
return {
content: [{
type: 'text',
text: JSON.stringify({ path: note.path, absolutePath: note.absolutePath, vaultPath }, null, 2),
text: JSON.stringify(note, null, 2),
}],
}
}
@@ -387,19 +250,17 @@ async function handleCreateNote(args = {}) {
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' }] }
}

View File

@@ -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"

View File

@@ -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"
}

213
mcp-server/tool-service.js Normal file
View 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)
}

View 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`
}

View File

@@ -21,12 +21,7 @@
*/
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
createNote, 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,111 +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
}
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 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}.`)
}
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)
}
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 })
@@ -154,72 +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 = {}) {
const notePath = notePathArg(args)
const vaultPath = writableVaultPath(args)
const note = await createNote(vaultPath, notePath, createNoteContent(args))
const targetPath = uiPath({ ...args, path: note.path, vaultPath })
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
return { ok: true, path: note.path, absolutePath: note.absolutePath, vaultPath }
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],
['create_note', createNoteTool],
['search_notes', (args) => searchActiveVaults(args.query, args.limit)],
['vault_context', () => activeVaultContext()],
['list_vaults', () => listVaultsTool()],
['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],
@@ -335,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,
@@ -365,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
View File

@@ -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: {}

View 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.

View File

@@ -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);

View 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());
}
}

View File

@@ -61,6 +61,9 @@ pub struct AiModelStreamRequest {
pub model_id: String,
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: Option<String>,
#[serde(default)]
pub vault_paths: Vec<String>,
pub api_key_override: Option<String>,
#[serde(default)]
pub event_name: Option<String>,
@@ -166,7 +169,7 @@ where
session_id: format!("api-{}", uuid::Uuid::new_v4()),
});
let text = send_model_message(&request)?;
let text = send_model_message(&request, &mut emit)?;
emit(AiAgentStreamEvent::TextDelta { text });
emit(AiAgentStreamEvent::Done);
@@ -182,33 +185,39 @@ pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<Str
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
.into(),
),
vault_path: None,
vault_paths: Vec::new(),
api_key_override: normalize_optional_string(request.api_key_override),
event_name: None,
};
send_model_message(&request)
send_model_message(&request, &mut |_| {})
}
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
fn send_model_message<F>(request: &AiModelStreamRequest, emit: &mut F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
match request.provider.kind {
AiModelProviderKind::Anthropic => send_anthropic_message(request),
_ => send_openai_compatible_message(request),
_ => send_openai_compatible_message(request, emit),
}
}
fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result<String, String> {
fn send_openai_compatible_message<F>(
request: &AiModelStreamRequest,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let endpoint = format!("{}/chat/completions", normalized_base_url(request)?);
let mut messages = Vec::new();
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
messages.push(serde_json::json!({ "role": "system", "content": system_prompt }));
}
messages.push(serde_json::json!({ "role": "user", "content": request.message }));
let payload = serde_json::json!({
"model": request.model_id,
"messages": messages,
"stream": false
});
let payload = crate::ai_model_tools::openai_chat_payload(request);
let json = send_json_request(request, endpoint, payload)?;
if let Some(tool_summary) =
crate::ai_model_tools::execute_openai_tool_calls(request, &json, emit)?
{
return Ok(tool_summary);
}
extract_openai_text(&json)
}
@@ -551,6 +560,8 @@ mod tests {
model_id: "demo-model".into(),
message: "Hello".into(),
system_prompt: Some(" Be concise. ".into()),
vault_path: None,
vault_paths: Vec::new(),
api_key_override: None,
event_name: None,
}
@@ -623,6 +634,51 @@ mod tests {
assert_eq!(headers, vec![("X-Demo", "demo")]);
}
#[test]
fn request_builders_apply_auth_and_provider_headers() {
let client = reqwest::blocking::Client::new();
let mut anthropic_provider = provider(AiModelProviderKind::Anthropic);
anthropic_provider.api_key_env_var = None;
anthropic_provider.headers = Some(BTreeMap::from([
("Authorization".into(), "ignored".into()),
("X-Demo".into(), "demo".into()),
]));
let mut anthropic_request = request(anthropic_provider);
anthropic_request.api_key_override = Some(" secret ".into());
let built = apply_provider_headers(
apply_auth_headers(client.post("https://example.test"), &anthropic_request).unwrap(),
&anthropic_request,
)
.build()
.unwrap();
let headers = built.headers();
assert_eq!(
(
headers["x-api-key"].to_str().unwrap(),
headers["anthropic-version"].to_str().unwrap(),
headers["X-Demo"].to_str().unwrap(),
headers.get("authorization").is_none(),
),
("secret", "2023-06-01", "demo", true),
);
let mut openai_provider = provider(AiModelProviderKind::OpenAi);
openai_provider.api_key_env_var = None;
let mut openai_request = request(openai_provider);
openai_request.api_key_override = Some(" openai-secret ".into());
let built = apply_auth_headers(client.post("https://example.test"), &openai_request)
.unwrap()
.build()
.unwrap();
assert_eq!(
built.headers()["authorization"].to_str().unwrap(),
"Bearer openai-secret"
);
}
#[test]
fn shared_provider_catalog_supplies_runtime_base_urls() {
assert_eq!(

View File

@@ -1303,6 +1303,7 @@ mod tests {
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
use std::io::Read;
@@ -1321,7 +1322,7 @@ mod tests {
let child_stdin = child.stdin.take().unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let deadline = Instant::now() + Duration::from_secs(30);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
@@ -1347,6 +1348,7 @@ mod tests {
);
}
#[cfg(unix)]
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
#[test]
fn stdin_probe_parent_child() {
@@ -1354,25 +1356,15 @@ mod tests {
return;
}
let fake_bin = current_test_binary();
let args = vec![
"stdin_probe_mock_claude_child".to_string(),
"--ignored".to_string(),
"--nocapture".to_string(),
];
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
let mut events = vec![];
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &fake_bin,
args: &args,
fallback_args: &[],
stdin_text: None,
cwd: None,
},
&mut |event| events.push(event),
);
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
let (result, events) = run_mock_script(MockClaudeScript(concat!(
"#!/bin/sh\n",
"stdin=\"$(cat)\"\n",
"if [ -n \"$stdin\" ]; then\n",
" echo \"stdin was not closed\" >&2\n",
" exit 9\n",
"fi\n",
"printf '%s\\n' '{\"type\":\"result\",\"result\":\"stdin closed\",\"session_id\":\"stdin-ok\"}'\n",
)));
assert_eq!(result.unwrap(), "stdin-ok");
assert!(matches!(
@@ -1383,28 +1375,6 @@ mod tests {
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[ignore = "spawned by stdin_probe_parent_child"]
#[test]
fn stdin_probe_mock_claude_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
return;
}
use std::io::Read;
let mut stdin = String::new();
std::io::stdin().read_to_string(&mut stdin).unwrap();
assert!(stdin.is_empty(), "stdin was not EOF");
println!(
"{}",
serde_json::json!({
"type": "result",
"result": "stdin closed",
"session_id": "stdin-ok"
})
);
}
#[cfg(unix)]
#[test]
fn run_subprocess_skips_blank_and_non_json_lines() {

View File

@@ -1,6 +1,6 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Stdio};
const OUTPUT_PREFIX: &str = "__TOLARIA_ENV__:";
@@ -110,6 +110,7 @@ fn user_shell_bindings_from_shell(shell: &Path, names: &[EnvName<'_>]) -> Option
let output = crate::hidden_command(shell)
.arg("-lc")
.arg(shell_probe_script(shell, names))
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
@@ -191,6 +192,80 @@ fn parse_probe_line(line: ProbeLine<'_>, names: &[EnvName<'_>]) -> Option<EnvBin
mod tests {
use super::*;
#[test]
fn env_value_uses_trimmed_process_value() {
let key = "TOLARIA_TEST_SHELL_ENV_PROCESS_VALUE";
std::env::set_var(key, " process-secret ");
let value = env_value_from_process_or_user_shell(EnvName::trusted(key));
std::env::remove_var(key);
assert_eq!(value.as_deref(), Some("process-secret"));
}
#[test]
fn command_value_marks_name_as_already_available() {
let mut command = Command::new("demo");
command.env("TOLARIA_TEST_COMMAND_VALUE", "command-secret");
apply_user_shell_env_vars_if_missing(
&mut command,
&[EnvName::trusted("TOLARIA_TEST_COMMAND_VALUE")],
);
let values = command
.get_envs()
.map(|(key, value)| (key.to_string_lossy().to_string(), value.is_some()))
.collect::<Vec<_>>();
assert_eq!(values, vec![("TOLARIA_TEST_COMMAND_VALUE".into(), true)]);
}
#[test]
fn parse_probe_output_filters_invalid_unexpected_and_blank_values() {
let names = [
EnvName::trusted("GOOD_VALUE"),
EnvName::trusted("OTHER_VALUE"),
];
let bindings = parse_probe_output(
"__TOLARIA_ENV__:GOOD_VALUE=kept\n\
ignored\n\
__TOLARIA_ENV__:BAD-NAME=bad\n\
__TOLARIA_ENV__:OTHER_VALUE= \n\
__TOLARIA_ENV__:UNEXPECTED=value\n",
&names,
);
assert_eq!(
bindings,
vec![EnvBinding {
name: "GOOD_VALUE".into(),
value: "kept".into(),
}]
);
}
#[cfg(unix)]
#[test]
fn user_shell_bindings_from_shell_returns_none_for_failing_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let shell = dir.path().join("zsh");
std::fs::write(&shell, "#!/bin/sh\nexit 7\n").unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
let values = user_shell_bindings_from_shell(&shell, &[EnvName::trusted("MISSING")]);
assert_eq!(values, None);
}
#[cfg(unix)]
#[test]
fn rc_source_command_ignores_unknown_shells() {
assert_eq!(rc_source_command(Path::new("fish")), "");
}
#[cfg(unix)]
#[test]
fn user_shell_bindings_from_shell_reads_zshrc_exports_for_requested_keys() {

View File

@@ -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(

View 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(), &note).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(), &note).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(), &note).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);
}
}

View File

@@ -5,6 +5,7 @@ mod conflict;
mod connect;
mod credentials;
mod dates;
mod file_url;
mod history;
mod pulse;
mod remote;
@@ -27,6 +28,7 @@ pub use conflict::{
};
pub use connect::{disconnect_all_remotes, git_add_remote, GitAddRemoteResult};
pub use dates::{get_all_file_dates, GitDates};
pub use file_url::git_file_url;
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
pub use remote::{

View File

@@ -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> {

View File

@@ -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,

View File

@@ -18,8 +18,12 @@ pub(crate) fn build_command(
request.permission_mode,
)?;
let mut command = crate::hidden_command(binary);
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
let mut command = crate::hidden_command(&target.program);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
if let Some(first_arg) = target.first_arg {
command.arg(first_arg);
}
command
.args(build_args())
.arg(build_prompt(request))
@@ -300,10 +304,15 @@ mod tests {
}
fn assert_pi_json_mode_args(args: &[String]) {
assert_eq!(args[0], "--mode");
assert_eq!(args[1], "json");
assert!(args.contains(&"--no-session".to_string()));
assert!(args.contains(&"--extension".to_string()));
assert_eq!(
(
args.first().map(String::as_str),
args.get(1).map(String::as_str),
args.iter().any(|arg| arg == "--no-session"),
args.iter().any(|arg| arg == "--extension"),
),
(Some("--mode"), Some("json"), true, true)
);
}
#[test]
@@ -324,10 +333,20 @@ mod tests {
}
fn assert_command_identity(command: &std::process::Command, actual_args: &[&OsStr]) {
assert_eq!(command.get_program(), OsStr::new("pi"));
assert_eq!(actual_args[0], OsStr::new("--mode"));
assert_eq!(actual_args[1], OsStr::new("json"));
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
assert_eq!(
(
command.get_program(),
actual_args.first().copied(),
actual_args.get(1).copied(),
actual_args.last().copied(),
),
(
OsStr::new("pi"),
Some(OsStr::new("--mode")),
Some(OsStr::new("json")),
Some(OsStr::new("Rename the note")),
)
);
}
fn assert_command_vault_scope(
@@ -340,6 +359,50 @@ mod tests {
assert!(agent_dir.join("mcp.json").exists());
}
#[test]
fn command_avoids_windows_cmd_shim_for_prompt_args() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
let source_agent_dir = tempfile::tempdir().unwrap();
let agent_dir = tempfile::tempdir().unwrap();
let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path());
let shim = agent_dir.path().join("pi.cmd");
let launcher = agent_dir
.path()
.join("node_modules")
.join("@withpi")
.join("pi")
.join("bin")
.join("pi.exe");
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&launcher, "native pi launcher").unwrap();
std::fs::write(
&shim,
r#"@ECHO off
"%~dp0\node_modules\@withpi\pi\bin\pi.exe" %*
"#,
)
.unwrap();
let command = build_command(&shim, &request(), agent_dir.path()).unwrap();
let actual_args = command.get_args().collect::<Vec<_>>();
assert_eq!(
(
command.get_program() != shim.as_os_str(),
command.get_program(),
actual_args.first().copied(),
actual_args.last().copied(),
),
(
true,
launcher.as_os_str(),
Some(OsStr::new("--mode")),
Some(OsStr::new("Rename the note")),
),
"Pi npm .cmd shims cannot be spawned directly on Windows"
);
}
#[test]
fn command_seeds_temp_agent_dir_from_existing_pi_config() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
@@ -429,10 +492,20 @@ mod tests {
}
fn assert_base_mcp_config(json: &serde_json::Value) {
assert_eq!(json["settings"]["toolPrefix"], "none");
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
assert_eq!(
(
&json["settings"]["toolPrefix"],
&json["mcpServers"]["tolaria"]["command"],
&json["mcpServers"]["tolaria"]["lifecycle"],
&json["mcpServers"]["tolaria"]["directTools"],
),
(
&serde_json::json!("none"),
&serde_json::json!("node"),
&serde_json::json!("lazy"),
&serde_json::json!(true),
)
);
}
fn assert_tolaria_mcp_env(json: &serde_json::Value) {

View File

@@ -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() {

View File

@@ -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"]);
}
}

View File

@@ -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),
}))

View File

@@ -53,6 +53,7 @@ import { useDeleteActions } from './hooks/useDeleteActions'
import { useFolderActions } from './hooks/useFolderActions'
import { useFileActions } from './hooks/useFileActions'
import { useDeepLinks } from './hooks/useDeepLinks'
import { useNoteGitUrls } from './hooks/useNoteGitUrls'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
@@ -1379,6 +1380,12 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
vaults: vaultSwitcher.allVaults,
})
const activeEditorVaultPath = activeTab ? vaultPathForEntry(activeTab.entry, resolvedPath) : resolvedPath
const noteGitUrls = useNoteGitUrls({
currentVaultPath: resolvedPath,
locale: appLocale,
remoteStatusForRepository: gitSurfaces.remoteStatusForRepository,
setToastMessage,
})
const commandAiActions = useAppCommandAiActions(aiFeaturesEnabled, dialogs, aiAgentsStatus, vaultAiGuidanceStatus, restoreVaultAiGuidanceCommand, aiAgentPreferences)
const undoCommand = useCallback(() => {
if (runNativeTextHistoryCommand('undo')) return
@@ -1594,7 +1601,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} refreshKey={gitHistoryRefreshKey} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
) : (
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onExportPdf={handleExportNotePdfFromList} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onExportPdf={handleExportNotePdfFromList} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} canCopyGitUrl={noteGitUrls.canCopyEntryGitUrl} onCopyGitUrl={noteGitUrls.copyEntryGitUrl} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1645,6 +1652,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onRevealFile={fileActions.revealFile}
onCopyFilePath={fileActions.copyFilePath}
onCopyDeepLink={activeDeletedFile ? undefined : deepLinks.copyEntryDeepLink}
onCopyGitUrl={activeDeletedFile || !activeTabEntry || !noteGitUrls.canCopyEntryGitUrl(activeTabEntry) ? undefined : noteGitUrls.copyEntryGitUrl}
onOpenExternalFile={fileActions.openExternalFile}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
@@ -1742,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 && (

View File

@@ -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} />)

View File

@@ -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>
}

View File

@@ -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',

View File

@@ -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 && (

View File

@@ -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(() => []),

View File

@@ -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}

View File

@@ -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);
@@ -284,11 +292,20 @@
}
.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);
@@ -321,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;
}
@@ -349,6 +375,7 @@
}
@media (hover: none) {
.editor__blocknote-container .mermaid-diagram__edit-button,
.editor__blocknote-container .mermaid-diagram__expand-button {
opacity: 1;
}

View File

@@ -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' })

View File

@@ -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">Couldnt 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>

View File

@@ -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 }],

View File

@@ -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 />)

View File

@@ -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} />
</>
)
}

View File

@@ -74,7 +74,7 @@ export function LinuxTitlebar({ locale: localeOverride }: LinuxTitlebarProps = {
data-testid="linux-titlebar"
>
<div className="flex h-full items-center" data-no-drag>
<LinuxMenuButton />
<LinuxMenuButton locale={locale} />
</div>
<TitlebarWindowControls appWindow={appWindow} locale={locale} maximized={maximized} />
</div>

View File

@@ -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(() => {

View File

@@ -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"

View File

@@ -14,6 +14,8 @@ describe('NoteList context menu', () => {
const onToggleOrganized = vi.fn()
const onRevealFile = vi.fn()
const onCopyFilePath = vi.fn()
const canCopyGitUrl = vi.fn(() => true)
const onCopyGitUrl = vi.fn()
renderNoteList({
onOpenInNewWindow,
@@ -25,6 +27,8 @@ describe('NoteList context menu', () => {
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
})
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
@@ -58,6 +62,11 @@ describe('NoteList context menu', () => {
fireEvent.click(screen.getByText('Copy file path'))
expect(onCopyFilePath).toHaveBeenCalledWith(mockEntries[0].path)
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Copy git URL'))
expect(canCopyGitUrl).toHaveBeenCalledWith(mockEntries[0])
expect(onCopyGitUrl).toHaveBeenCalledWith(mockEntries[0])
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Export note as PDF'))
expect(onExportPdf).toHaveBeenCalledWith(mockEntries[0])
@@ -90,4 +99,16 @@ describe('NoteList context menu', () => {
expect(screen.getByText('Remove from Favorites')).toBeInTheDocument()
expect(screen.getByText('Mark as Unorganized')).toBeInTheDocument()
})
it('hides the git URL action for notes without a remote', () => {
renderNoteList({
canCopyGitUrl: () => false,
onCopyGitUrl: vi.fn(),
onCopyFilePath: vi.fn(),
})
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
expect(screen.queryByText('Copy git URL')).not.toBeInTheDocument()
})
})

View File

@@ -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)
})

View File

@@ -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 && (

View File

@@ -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} />)

View File

@@ -1111,6 +1111,7 @@ function useSuggestionMenuItems(options: {
}
type EditorInteractionControllersProps = ReturnType<typeof useSuggestionMenuItems> & {
locale: AppLocale
runEditorAction: (action: SuggestionAction) => void
vaultPath?: string
}
@@ -1120,6 +1121,7 @@ function EditorInteractionControllers({
getPersonMentionItems,
getSlashMenuItems,
getWikilinkItems,
locale,
runEditorAction,
vaultPath,
}: EditorInteractionControllersProps) {
@@ -1128,7 +1130,7 @@ function EditorInteractionControllers({
<SideMenuController sideMenu={TolariaSideMenu} />
<TolariaFormattingToolbarController
formattingToolbar={(props) => (
<TolariaFormattingToolbar {...props} vaultPath={vaultPath} />
<TolariaFormattingToolbar {...props} locale={locale} vaultPath={vaultPath} />
)}
floatingUIOptions={{
elementProps: {
@@ -1372,6 +1374,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
>
<EditorInteractionControllers
{...suggestionMenuItems}
locale={locale}
runEditorAction={runEditorAction}
vaultPath={vaultPath}
/>

View File

@@ -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('안녕')

View File

@@ -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',
})

View File

@@ -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')

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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

View 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',
)
})
})

View File

@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MathBlockEditor } from './editorSchema'
@@ -73,4 +74,16 @@ describe('MathBlockEditor', () => {
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%;')
})
})

View File

@@ -4,6 +4,7 @@ import {
createCodeBlockSpec,
BlockNoteSchema,
createAudioBlockConfig,
createStyleSpec,
createVideoBlockConfig,
defaultInlineContentSpecs,
videoParse,
@@ -22,6 +23,7 @@ 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'
@@ -394,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,
@@ -401,6 +421,9 @@ export const schema = BlockNoteSchema.create({
mathInline: MathInline,
},
}).extend({
styleSpecs: {
[MARKDOWN_HIGHLIGHT_STYLE]: MarkdownHighlightStyle,
},
blockSpecs: {
audio: audioBlock,
mathBlock,

View 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)
})
})

View 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',
})

View File

@@ -3,9 +3,10 @@ import type { useCreateBlockNote } from '@blocknote/react'
import { trackEvent } from '../lib/telemetry'
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, readCompletedInlineMathAtEnd } from '../utils/mathMarkdown'
import {
isRecoverableEditorTransformError,
reportRecoveredEditorTransformError,
} from './richEditorTransformErrorRecoveryExtension'
dispatchRichEditorInputTransaction,
mountRichEditorInputTransforms,
type RichEditorInputTransform,
} from './richEditorInputTransform'
const INLINE_WHITESPACE_RE = /^[^\S\r\n]$/
const NEWLINE_INPUT_TYPES = new Set(['insertParagraph', 'insertLineBreak'])
@@ -59,12 +60,6 @@ function shouldHandleInput(event: InputEvent): boolean {
return isInsertedInlineWhitespace(event) || NEWLINE_INPUT_TYPES.has(event.inputType)
}
function shouldSkipInput(event: InputEvent, view: EditorViewLike): boolean {
if (event.isComposing) return true
if (view.composing) return true
return !shouldHandleInput(event)
}
function selectionHasCodeMark(view: EditorViewLike): boolean {
const marks = view.state.storedMarks ?? view.state.selection.$from.marks()
return marks.some((mark: { type: { name: string } }) => mark.type.name === 'code')
@@ -115,25 +110,6 @@ function replaceCompletedInlineMath(
return transaction.scrollIntoView()
}
function recoverTransformError(error: unknown): boolean {
if (!isRecoverableEditorTransformError(error)) return false
reportRecoveredEditorTransformError('transform_error', error)
return true
}
function readMathInputTransaction(
view: EditorViewLike,
trailingText?: string,
): EditorViewLike['state']['tr'] | null {
try {
return replaceCompletedInlineMath(view, trailingText)
} catch (error) {
if (!recoverTransformError(error)) throw error
return null
}
}
function mathSource({ latex }: { latex: string }): string {
return `$${latex}$`
}
@@ -287,7 +263,7 @@ function restoreMathSource({
.replaceWith(location.from, location.to, replacement)
.scrollIntoView()
if (!dispatchMathInputTransaction(view, transaction)) return false
if (!dispatchRichEditorInputTransaction(view, { transaction })) return false
editor._tiptapEditor?.commands?.setTextSelection?.(
mathLatexSelectionRange(location),
@@ -299,20 +275,6 @@ function restoreMathSource({
return true
}
function handleBeforeInputEvent(event: InputEvent, readView: ReadEditorView) {
const view = readView()
if (!view || shouldSkipInput(event, view)) return
const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined
const transaction = readMathInputTransaction(view, trailingText)
if (!transaction) return
if (!dispatchMathInputTransaction(view, transaction)) return
if (trailingText !== undefined) {
event.preventDefault()
}
}
function handleRenderedMathDoubleClick(
event: MouseEvent,
{ editor, readView }: MathExtensionContext,
@@ -346,16 +308,20 @@ function handleMathKeyDown(
event.stopPropagation()
}
function dispatchMathInputTransaction(
view: EditorViewLike,
transaction: EditorViewLike['state']['tr'],
): boolean {
try {
view.dispatch(transaction)
return true
} catch (error) {
if (!recoverTransformError(error)) throw error
return false
export function createMathInputTransform(): RichEditorInputTransform {
return {
handleBeforeInput(event, { view }) {
if (!shouldHandleInput(event)) return null
const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined
const transaction = replaceCompletedInlineMath(view, trailingText)
if (!transaction) return null
return {
preventDefault: trailingText !== undefined,
transaction,
}
},
}
}
@@ -367,11 +333,11 @@ export const createMathInputExtension = createExtension(({ editor }) => {
mount: ({ dom, signal }) => {
const context = { editor, readView }
dom.addEventListener('beforeinput', ((event: InputEvent) => {
handleBeforeInputEvent(event, readView)
}) as EventListener, {
capture: true,
mountRichEditorInputTransforms({
dom,
readView,
signal,
transforms: [createMathInputTransform()],
})
dom.addEventListener('dblclick', (event) => {
handleRenderedMathDoubleClick(event, context)

View File

@@ -28,6 +28,8 @@ interface NoteListContextMenuParams {
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
canCopyGitUrl?: (entry: VaultEntry) => boolean
onCopyGitUrl?: (entry: VaultEntry) => void
}
function hasNoteListContextActions({
@@ -41,6 +43,8 @@ function hasNoteListContextActions({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
}: NoteListContextMenuParams & { entry: VaultEntry }) {
return [
onOpenInNewWindow,
@@ -52,6 +56,7 @@ function hasNoteListContextActions({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
onCopyGitUrl && canCopyGitUrl?.(entry),
].some(Boolean)
}
@@ -66,6 +71,8 @@ export function useNoteListContextMenu({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
}: NoteListContextMenuParams) {
const [ctxMenu, setCtxMenu] = useState<NoteListContextMenuState | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(null)
@@ -101,6 +108,8 @@ export function useNoteListContextMenu({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
})) return
event.preventDefault()
event.stopPropagation()
@@ -109,10 +118,12 @@ export function useNoteListContextMenu({
}, [
onArchivePaths,
onCopyFilePath,
canCopyGitUrl,
onDeletePaths,
onEnterNeighborhood,
onExportPdf,
onOpenInNewWindow,
onCopyGitUrl,
onRevealFile,
onToggleFavorite,
onToggleOrganized,
@@ -132,6 +143,8 @@ export function useNoteListContextMenu({
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
canCopyGitUrl={canCopyGitUrl}
onCopyGitUrl={onCopyGitUrl}
onClose={closeContextMenu}
/>
)

View File

@@ -6,6 +6,7 @@ import {
ClipboardText,
FilePdf,
FolderOpen,
GitBranch,
MapTrifold,
Star,
Trash,
@@ -43,6 +44,8 @@ interface NoteListContextMenuNodeProps {
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
canCopyGitUrl?: (entry: VaultEntry) => boolean
onCopyGitUrl?: (entry: VaultEntry) => void
onClose: () => void
}
@@ -58,6 +61,8 @@ type BuildContextMenuItemsParams = Pick<
| 'onToggleOrganized'
| 'onRevealFile'
| 'onCopyFilePath'
| 'canCopyGitUrl'
| 'onCopyGitUrl'
>
function openWindowItem(
@@ -149,6 +154,21 @@ function copyFilePathItem(
}]
}
function copyGitUrlItem(
entry: VaultEntry,
locale: AppLocale,
canCopyGitUrl: ((entry: VaultEntry) => boolean) | undefined,
onCopyGitUrl: ((entry: VaultEntry) => void) | undefined,
selectAction: SelectContextAction,
) {
if (!onCopyGitUrl || !canCopyGitUrl?.(entry)) return []
return [{
icon: GitBranch,
label: translate(locale, 'editor.toolbar.copyNoteGitUrl'),
onSelect: () => selectAction('copy_git_url', () => onCopyGitUrl(entry)),
}]
}
function exportPdfItem(
entry: VaultEntry,
locale: AppLocale,
@@ -206,6 +226,7 @@ function buildContextMenuItems(
...neighborhoodItem(entry, props.locale, props.onEnterNeighborhood, selectAction),
...revealFileItem(entry, props.locale, props.onRevealFile, selectAction),
...copyFilePathItem(entry, props.locale, props.onCopyFilePath, selectAction),
...copyGitUrlItem(entry, props.locale, props.canCopyGitUrl, props.onCopyGitUrl, selectAction),
...exportPdfItem(entry, props.locale, props.onExportPdf, selectAction),
...archiveItem(entry, props.locale, props.onArchivePaths, selectAction),
...deleteItem(entry, props.locale, props.onDeletePaths, selectAction),
@@ -241,6 +262,8 @@ export function NoteListContextMenuNode({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
onClose,
}: NoteListContextMenuNodeProps) {
if (!ctxMenu) return null
@@ -262,6 +285,8 @@ export function NoteListContextMenuNode({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
}, entry, selectAction)
return (

View File

@@ -345,6 +345,8 @@ interface UseNoteListInteractionStateParams {
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
canCopyGitUrl?: (entry: VaultEntry) => boolean
onCopyGitUrl?: (entry: VaultEntry) => void
onAutoTriggerDiff?: () => void
onDiscardFile?: (relativePath: string) => Promise<void>
onCreateNote: (type?: string, options?: ImmediateCreateOptions) => void
@@ -374,6 +376,8 @@ function useNoteListInteractionState({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
onAutoTriggerDiff,
onDiscardFile,
onCreateNote,
@@ -393,6 +397,8 @@ function useNoteListInteractionState({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
})
const {
collapsedGroups,
@@ -555,6 +561,8 @@ export interface NoteListProps {
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
canCopyGitUrl?: (entry: VaultEntry) => boolean
onCopyGitUrl?: (entry: VaultEntry) => void
onDiscardFile?: (relativePath: string) => Promise<void>
onAutoTriggerDiff?: () => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
@@ -684,6 +692,8 @@ export function useNoteListModel({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
onDiscardFile,
onAutoTriggerDiff,
onOpenDeletedNote,
@@ -743,6 +753,8 @@ export function useNoteListModel({
onToggleOrganized,
onRevealFile,
onCopyFilePath,
canCopyGitUrl,
onCopyGitUrl,
onAutoTriggerDiff,
onDiscardFile,
onCreateNote,

View File

@@ -0,0 +1,184 @@
import { createExtension } from '@blocknote/core'
import type { useCreateBlockNote } from '@blocknote/react'
import {
isRecoverableEditorTransformError,
reportRecoveredEditorTransformError,
} from './richEditorTransformErrorRecoveryExtension'
export type RichEditorInputView = NonNullable<ReturnType<typeof useCreateBlockNote>['prosemirrorView']>
export type RichEditorInputTransaction = Parameters<RichEditorInputView['dispatch']>[0]
export interface RichEditorInputTransformContext {
view: RichEditorInputView
}
export interface RichEditorInputTransformResult {
ignoreDispatchError?: boolean
onDispatchError?: (error: unknown) => void
preventDefault?: boolean
transaction: RichEditorInputTransaction
}
export interface RichEditorInputTransform {
handleBeforeInput: (
event: InputEvent,
context: RichEditorInputTransformContext
) => RichEditorInputTransformResult | null
reset?: () => void
}
interface RichEditorInputTransformExtensionOptions {
createTransforms: () => RichEditorInputTransform[]
key: string
}
interface MountRichEditorInputTransformsOptions {
dom: HTMLElement
readView: () => RichEditorInputView | undefined
signal: AbortSignal
transforms: RichEditorInputTransform[]
}
const RECOVERED_INPUT_TRANSFORM_ERROR = Symbol('recoveredInputTransformError')
type TransformReadResult = RichEditorInputTransformResult | null | typeof RECOVERED_INPUT_TRANSFORM_ERROR
function resetInputTransforms(transforms: RichEditorInputTransform[]): void {
transforms.forEach((transform) => transform.reset?.())
}
function isLiveEditorView(view: RichEditorInputView): boolean {
if (view.isDestroyed) return false
if (view.dom?.isConnected === false) return false
return true
}
function isComposingInput(event: InputEvent, view: RichEditorInputView): boolean {
return event.isComposing || Boolean(view.composing)
}
export function recoverRichEditorInputTransformError(error: unknown): boolean {
if (!isRecoverableEditorTransformError(error)) return false
reportRecoveredEditorTransformError('transform_error', error)
return true
}
function readTransformResult(
transform: RichEditorInputTransform,
event: InputEvent,
context: RichEditorInputTransformContext,
): TransformReadResult {
try {
return transform.handleBeforeInput(event, context)
} catch (error) {
if (!recoverRichEditorInputTransformError(error)) throw error
return RECOVERED_INPUT_TRANSFORM_ERROR
}
}
function readReadyInputView({
readView,
transforms,
}: Omit<MountRichEditorInputTransformsOptions, 'dom' | 'signal'>): RichEditorInputView | null {
const view = readView()
if (!view) return null
if (isLiveEditorView(view)) return view
resetInputTransforms(transforms)
return null
}
export function dispatchRichEditorInputTransaction(
view: RichEditorInputView,
result: RichEditorInputTransformResult,
): boolean {
try {
view.dispatch(result.transaction)
return true
} catch (error) {
result.onDispatchError?.(error)
if (recoverRichEditorInputTransformError(error)) return false
if (result.ignoreDispatchError) return false
throw error
}
}
function completeInputTransform(
event: InputEvent,
view: RichEditorInputView,
result: TransformReadResult,
): boolean {
if (result === RECOVERED_INPUT_TRANSFORM_ERROR) return true
if (!result) return false
if (!dispatchRichEditorInputTransaction(view, result)) return true
if (result.preventDefault) event.preventDefault()
return true
}
function runInputTransform(
transform: RichEditorInputTransform,
event: InputEvent,
view: RichEditorInputView,
): boolean {
return completeInputTransform(
event,
view,
readTransformResult(transform, event, { view }),
)
}
function runInputTransforms(
event: InputEvent,
view: RichEditorInputView,
transforms: RichEditorInputTransform[],
): void {
transforms.some((transform) => runInputTransform(transform, event, view))
}
function handleRichEditorBeforeInput(
event: InputEvent,
{ readView, transforms }: Omit<MountRichEditorInputTransformsOptions, 'dom' | 'signal'>,
): void {
const view = readReadyInputView({ readView, transforms })
if (!view) return
if (isComposingInput(event, view)) return
runInputTransforms(event, view, transforms)
}
export function mountRichEditorInputTransforms({
dom,
readView,
signal,
transforms,
}: MountRichEditorInputTransformsOptions): void {
dom.addEventListener('beforeinput', ((event: InputEvent) => {
handleRichEditorBeforeInput(event, { readView, transforms })
}) as EventListener, {
capture: true,
signal,
})
}
export function createRichEditorInputTransformExtension({
createTransforms,
key,
}: RichEditorInputTransformExtensionOptions) {
return createExtension(({ editor }) => {
const readView = () => editor._tiptapEditor?.view ?? editor.prosemirrorView
const transforms = createTransforms()
return {
key,
mount: ({ dom, signal }) => {
mountRichEditorInputTransforms({
dom,
readView,
signal,
transforms,
})
},
} as const
})
}

View File

@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from 'vitest'
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
import { createRichEditorMarkdownInputTransformExtension } from './richEditorInputTransformExtension'
function createTransaction() {
const transaction = {
addMark: vi.fn(() => transaction),
delete: vi.fn(() => transaction),
insertText: vi.fn(() => transaction),
replaceWith: vi.fn(() => transaction),
scrollIntoView: vi.fn(() => transaction),
}
return transaction
}
function createFixture() {
let beforeInputListener: EventListener | null = null
let beforeText = ''
let parentStart = 0
let arrowPrefix = ''
const transaction = createTransaction()
const mathNode = { nodeSize: 1, type: 'mathInline' }
const highlightMark = { type: { name: MARKDOWN_HIGHLIGHT_STYLE } }
const highlightMarkType = { create: vi.fn(() => highlightMark) }
const view = {
composing: false,
dispatch: vi.fn(),
dom: { isConnected: true },
state: {
doc: {
nodesBetween: vi.fn(),
textBetween: vi.fn(() => arrowPrefix),
},
schema: {
marks: {
[MARKDOWN_HIGHLIGHT_STYLE]: highlightMarkType,
},
nodes: {
mathInline: {
createChecked: vi.fn(() => mathNode),
},
},
},
selection: {
from: 0,
to: 0,
$from: {
depth: 0,
marks: vi.fn(() => []),
node: vi.fn(() => ({ type: { name: 'paragraph', spec: {} } })),
parent: {
isTextblock: true,
textBetween: vi.fn(() => beforeText),
},
parentOffset: 0,
},
},
storedMarks: null,
tr: transaction,
},
}
const dom = {
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === 'beforeinput') beforeInputListener = listener
}),
}
const editor = {
_tiptapEditor: { view },
prosemirrorView: view,
}
const extension = createRichEditorMarkdownInputTransformExtension()({ editor: editor as never })
function setCursorText(nextBeforeText: string, nextParentStart = 0) {
beforeText = nextBeforeText
parentStart = nextParentStart
view.state.selection.from = parentStart + beforeText.length
view.state.selection.to = parentStart + beforeText.length
view.state.selection.$from.parentOffset = beforeText.length
}
return {
dom,
fireBeforeInput(event: Partial<InputEvent>) {
if (!beforeInputListener) throw new Error('Combined input transform did not mount beforeinput')
const inputEvent = {
data: null,
inputType: 'insertText',
isComposing: false,
preventDefault: vi.fn(),
...event,
}
beforeInputListener(inputEvent as InputEvent)
return inputEvent
},
highlightMark,
highlightMarkType,
mathNode,
mount() {
const controller = new AbortController()
extension.mount?.({
dom: dom as never,
root: document,
signal: controller.signal,
})
return controller
},
setArrowPrefix(nextArrowPrefix: string) {
arrowPrefix = nextArrowPrefix
},
setCursorText,
transaction,
view,
}
}
describe('createRichEditorMarkdownInputTransformExtension', () => {
it('mounts one beforeinput listener for all markdown input transforms', () => {
const fixture = createFixture()
fixture.mount()
expect(fixture.dom.addEventListener).toHaveBeenCalledTimes(1)
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
'beforeinput',
expect.any(Function),
expect.objectContaining({
capture: true,
signal: expect.any(AbortSignal),
}),
)
})
it('routes arrow, inline math, and highlight syntax through the shared listener', () => {
const fixture = createFixture()
fixture.mount()
fixture.setArrowPrefix('-')
fixture.setCursorText('-', 0)
const arrowEvent = fixture.fireBeforeInput({ data: '>' })
expect(fixture.transaction.insertText).toHaveBeenCalledWith('→', 0, 1)
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
expect(arrowEvent.preventDefault).toHaveBeenCalledTimes(1)
vi.clearAllMocks()
fixture.setCursorText('Inline $x^2$', 0)
const mathEvent = fixture.fireBeforeInput({ data: ' ' })
expect(fixture.view.state.schema.nodes.mathInline.createChecked).toHaveBeenCalledWith({ latex: 'x^2' })
expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(7, 12, fixture.mathNode)
expect(fixture.transaction.insertText).toHaveBeenCalledWith(' ', 8)
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
expect(mathEvent.preventDefault).toHaveBeenCalledTimes(1)
vi.clearAllMocks()
fixture.setCursorText('Plain ==marked=', 20)
const highlightEvent = fixture.fireBeforeInput({ data: '=' })
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(1, 34, 35)
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(2, 26, 28)
expect(fixture.highlightMarkType.create).toHaveBeenCalledWith()
expect(fixture.transaction.addMark).toHaveBeenCalledWith(26, 32, fixture.highlightMark)
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
expect(highlightEvent.preventDefault).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,13 @@
import { createArrowLigatureInputTransform } from './arrowLigaturesExtension'
import { createMarkdownHighlightInputTransform } from './markdownHighlightInputExtension'
import { createMathInputTransform } from './mathInputExtension'
import { createRichEditorInputTransformExtension } from './richEditorInputTransform'
export const createRichEditorMarkdownInputTransformExtension = createRichEditorInputTransformExtension({
createTransforms: () => [
createArrowLigatureInputTransform(),
createMarkdownHighlightInputTransform(),
createMathInputTransform(),
],
key: 'richEditorMarkdownInputTransform',
})

View File

@@ -16,6 +16,10 @@ function transformError(message = 'Invalid transform') {
return error
}
function nullFragmentAppendError(message = "null is not an object (evaluating 'o.fillBefore(e).append')") {
return new TypeError(message)
}
function createView(error?: Error) {
const currentDoc = {
eq: vi.fn((candidate: unknown) => candidate === currentDoc),
@@ -89,10 +93,24 @@ describe('isRecoverableEditorTransformError', () => {
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new Error(
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new RangeError(
'Index 1 out of range for <paragraph("A")>',
'Index 1 out of range for <paragraph("/")>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new Error(
'Index 1 out of range for <paragraph("/")>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new Error(
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
))).toBe(true)
expect(isRecoverableEditorTransformError(nullFragmentAppendError())).toBe(true)
const stackOnlyAppendError = nullFragmentAppendError("Cannot read properties of null (reading 'append')")
stackOnlyAppendError.stack =
"TypeError: Cannot read properties of null (reading 'append')\n at o.fillBefore(e).append (App-CrXlNLOq.js:1:1)"
expect(isRecoverableEditorTransformError(stackOnlyAppendError)).toBe(true)
expect(isRecoverableEditorTransformError(new TypeError(
"Cannot read properties of null (reading 'append')",
))).toBe(false)
expect(isRecoverableEditorTransformError(new Error('unrelated'))).toBe(false)
})
@@ -196,6 +214,20 @@ describe('installRichEditorTransformErrorRecovery', () => {
)
})
it('recovers production table row index transactions reported as plain errors', () => {
expectDocumentRepairRecovery(
new Error('Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>'),
'table_position_out_of_range',
)
})
it('recovers production paragraph index transactions from stale slash input', () => {
expectDocumentRepairRecovery(
new RangeError('Index 1 out of range for <paragraph("/")>'),
'paragraph_position_out_of_range',
)
})
it('recovers invalid insertion-depth transactions after note switching and saves', () => {
expectDocumentRepairRecovery(
new RangeError('Inserted content deeper than insertion position'),
@@ -203,6 +235,13 @@ describe('installRichEditorTransformErrorRecovery', () => {
)
})
it('repairs null fragment append failures from invalid document model fills', () => {
expectDocumentRepairRecovery(
nullFragmentAppendError(),
'null_fragment_append',
)
})
it('recovers stale block-reference transactions from toolbar actions', () => {
const { currentDoc, view } = createView(new Error(
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',

View File

@@ -48,11 +48,18 @@ type RecoveryReason =
| 'invalid_block_join'
| 'invalid_insertion_depth'
| 'mismatched_transaction'
| 'null_fragment_append'
| 'paragraph_position_out_of_range'
| 'stale_block_reference'
| 'stale_transaction'
| 'table_position_out_of_range'
| 'transform_error'
interface RecoveryReasonMatcher {
matches: (error: unknown) => boolean
reason: RecoveryReason
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
@@ -88,13 +95,24 @@ function isInvalidInsertionDepthError(error: unknown): boolean {
}
function isTablePositionOutOfRangeError(error: unknown): boolean {
return error instanceof RangeError && /^Index \d+ out of range for <tableRow\(/.test(error.message)
return error instanceof Error && /^Index \d+ out of range for <tableRow\(/.test(error.message)
}
function isParagraphPositionOutOfRangeError(error: unknown): boolean {
return error instanceof Error && /^Index \d+ out of range for <paragraph\(/.test(error.message)
}
function isInvalidBlockJoinError(error: unknown): boolean {
return isTransformError(error) && /^Cannot join (blockGroup|tableCell) onto blockContainer$/.test(error.message)
}
function isNullFragmentAppendError(error: unknown): boolean {
if (!(error instanceof TypeError)) return false
const details = `${error.message}\n${error.stack ?? ''}`
return details.includes('fillBefore') && details.includes('.append')
}
export function isStaleBlockReferenceError(error: unknown): boolean {
return error instanceof Error && /^Block with ID .+ not found$/.test(error.message)
}
@@ -106,6 +124,7 @@ function isTransformError(error: unknown): error is Error {
function isRecoverableRangeError(error: unknown): boolean {
return isInvalidContentTransactionError(error)
|| isInvalidInsertionDepthError(error)
|| isParagraphPositionOutOfRangeError(error)
|| isTablePositionOutOfRangeError(error)
}
@@ -113,9 +132,20 @@ const RECOVERABLE_EDITOR_ERROR_PREDICATES = [
isTransformError,
isMismatchedTransactionError,
isRecoverableRangeError,
isNullFragmentAppendError,
isStaleBlockReferenceError,
]
const RECOVERY_REASON_MATCHERS: RecoveryReasonMatcher[] = [
{ matches: isMismatchedTransactionError, reason: 'mismatched_transaction' },
{ matches: isStaleBlockReferenceError, reason: 'stale_block_reference' },
{ matches: isInvalidBlockJoinError, reason: 'invalid_block_join' },
{ matches: isInvalidInsertionDepthError, reason: 'invalid_insertion_depth' },
{ matches: isNullFragmentAppendError, reason: 'null_fragment_append' },
{ matches: isParagraphPositionOutOfRangeError, reason: 'paragraph_position_out_of_range' },
{ matches: isTablePositionOutOfRangeError, reason: 'table_position_out_of_range' },
]
export function isRecoverableEditorTransformError(error: unknown): boolean {
return RECOVERABLE_EDITOR_ERROR_PREDICATES.some((predicate) => predicate(error))
}
@@ -126,16 +156,13 @@ function recoveryReason(
view: RichEditorDispatchView,
): RecoveryReason {
if (transactionDocIsStale(transaction, view)) return 'stale_transaction'
if (isMismatchedTransactionError(error)) return 'mismatched_transaction'
if (isStaleBlockReferenceError(error)) return 'stale_block_reference'
if (isInvalidBlockJoinError(error)) return 'invalid_block_join'
if (isInvalidInsertionDepthError(error)) return 'invalid_insertion_depth'
if (isTablePositionOutOfRangeError(error)) return 'table_position_out_of_range'
return 'transform_error'
return RECOVERY_REASON_MATCHERS.find(({ matches }) => matches(error))?.reason ?? 'transform_error'
}
function shouldRepairEditorDocument(error: unknown): boolean {
return isRecoverableRangeError(error) || isInvalidBlockJoinError(error)
return isRecoverableRangeError(error)
|| isInvalidBlockJoinError(error)
|| isNullFragmentAppendError(error)
}
export const reportRecoveredEditorTransformError = (reason: RecoveryReason, error: unknown): void => {

View File

@@ -105,6 +105,7 @@ vi.mock('@phosphor-icons/react', () => ({
ArrowSquareOut: MockIcon,
CaretDown: MockIcon,
Code: MockIcon,
Highlighter: MockIcon,
TextB: MockIcon,
TextItalic: MockIcon,
TextStrikethrough: MockIcon,
@@ -155,6 +156,7 @@ function createMockEditor(blockType = 'image', props: Record<string, unknown> =
italic: { type: 'italic', propSchema: 'boolean' },
strike: { type: 'strike', propSchema: 'boolean' },
code: { type: 'code', propSchema: 'boolean' },
highlight: { type: 'highlight', propSchema: 'boolean' },
},
},
prosemirrorState: { selection: { from: 1, to: 5 } },
@@ -187,11 +189,13 @@ describe('tolariaEditorFormatting behavior', () => {
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
fireEvent.click(screen.getByRole('button', { name: /inline code/i }))
fireEvent.click(screen.getByRole('button', { name: /highlight/i }))
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
expect(editor.focus).toHaveBeenCalled()
expect(editor.toggleStyles).toHaveBeenCalledWith({ bold: true })
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
expect(editor.toggleStyles).toHaveBeenCalledWith({ highlight: true })
expect(editor.transact).toHaveBeenCalledTimes(1)
expect(editor.updateBlock).toHaveBeenCalledWith(
'file-block',

View File

@@ -48,15 +48,18 @@ import {
ArrowSquareOut as ExternalLink,
CaretDown as ChevronDown,
Code as Code2,
Highlighter,
TextB as Bold,
TextItalic as Italic,
TextStrikethrough as Strikethrough,
type Icon as PhosphorIcon,
} from '@phosphor-icons/react'
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
import {
filterTolariaFormattingToolbarItems,
getTolariaBlockTypeSelectItems,
} from './tolariaEditorFormattingConfig'
import { translate, type AppLocale } from '../lib/i18n'
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
import { openEditorAttachmentOrUrl } from './editorAttachmentActions'
import {
@@ -64,7 +67,12 @@ import {
reportRecoveredEditorTransformError,
} from './richEditorTransformErrorRecoveryExtension'
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
type TolariaBasicTextStyle =
| 'bold'
| 'italic'
| 'strike'
| 'code'
| typeof MARKDOWN_HIGHLIGHT_STYLE
const FORMATTER_CLOSE_GRACE_MS = 160
const FORMATTER_VIEWPORT_PADDING_PX = 8
@@ -184,7 +192,7 @@ const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
secondaryTooltip: '`code`',
},
} satisfies Record<
TolariaBasicTextStyle,
Exclude<TolariaBasicTextStyle, typeof MARKDOWN_HIGHLIGHT_STYLE>,
{ label: string; mainTooltip: string; secondaryTooltip: string }
>
@@ -193,6 +201,7 @@ const TOLARIA_BASIC_TEXT_STYLE_ICONS = {
italic: Italic,
strike: Strikethrough,
code: Code2,
[MARKDOWN_HIGHLIGHT_STYLE]: Highlighter,
} satisfies Record<TolariaBasicTextStyle, PhosphorIcon>
type TolariaSelectedBlock = ReturnType<
@@ -484,8 +493,10 @@ function updateSelectedBlocksToType(
function TolariaBasicTextStyleButton({
basicTextStyle,
locale = 'en',
}: {
basicTextStyle: TolariaBasicTextStyle
locale?: AppLocale
}) {
const Components = useComponentsContext()!
const editor = useBlockNoteEditor<
@@ -506,11 +517,7 @@ function TolariaBasicTextStyleButton({
if (buttonState === undefined) return null
const Icon = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_ICONS, basicTextStyle) as PhosphorIcon
const copy = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS, basicTextStyle) as {
label: string
mainTooltip: string
secondaryTooltip: string
}
const copy = basicTextStyleCopy(basicTextStyle, locale)
return (
<Components.FormattingToolbar.Button
@@ -526,6 +533,25 @@ function TolariaBasicTextStyleButton({
)
}
function basicTextStyleCopy(
basicTextStyle: TolariaBasicTextStyle,
locale: AppLocale,
) {
if (basicTextStyle === MARKDOWN_HIGHLIGHT_STYLE) {
return {
label: translate(locale, 'editor.formatting.highlight'),
mainTooltip: translate(locale, 'editor.formatting.highlightTooltip'),
secondaryTooltip: '==highlight==',
}
}
return Reflect.get(TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS, basicTextStyle) as {
label: string
mainTooltip: string
secondaryTooltip: string
}
}
function TolariaBlockTypeSelect() {
const editor = useBlockNoteEditor<
BlockSchema,
@@ -649,7 +675,7 @@ function replaceToolbarControls(items: ReactElement[], vaultPath?: string) {
})
}
function insertInlineCodeButton(items: ReactElement[]) {
function insertExtraTextStyleButtons(items: ReactElement[], locale: AppLocale) {
const strikeButtonIndex = items.findIndex(
(item) => String(item.key) === 'strikeStyleButton',
)
@@ -658,23 +684,35 @@ function insertInlineCodeButton(items: ReactElement[]) {
return [
...items.slice(0, strikeButtonIndex + 1),
<TolariaBasicTextStyleButton basicTextStyle="code" key="codeStyleButton" />,
<TolariaBasicTextStyleButton
basicTextStyle={MARKDOWN_HIGHLIGHT_STYLE}
key="highlightStyleButton"
locale={locale}
/>,
...items.slice(strikeButtonIndex + 1),
]
}
function getTolariaFormattingToolbarItems(vaultPath?: string) {
return insertInlineCodeButton(
function getTolariaFormattingToolbarItems(vaultPath: string | undefined, locale: AppLocale) {
return insertExtraTextStyleButtons(
replaceToolbarControls(
filterTolariaFormattingToolbarItems(
getFormattingToolbarItems(),
),
vaultPath,
),
locale,
)
}
export function TolariaFormattingToolbar({ vaultPath }: { vaultPath?: string } = {}) {
return <FormattingToolbar>{getTolariaFormattingToolbarItems(vaultPath)}</FormattingToolbar>
export function TolariaFormattingToolbar({
locale = 'en',
vaultPath,
}: {
locale?: AppLocale
vaultPath?: string
} = {}) {
return <FormattingToolbar>{getTolariaFormattingToolbarItems(vaultPath, locale)}</FormattingToolbar>
}
export function TolariaFormattingToolbarController(props: {

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { APP_COMMAND_IDS, APP_COMMAND_MENU_SECTIONS } from './appCommandCatalog'
import { createTranslator } from '../lib/i18n'
import { APP_COMMAND_IDS, APP_COMMAND_MENU_SECTIONS, getAppCommandMenuSections } from './appCommandCatalog'
describe('appCommandCatalog', () => {
it('keeps the AI panel toggle in the View menu', () => {
@@ -13,4 +14,20 @@ describe('appCommandCatalog', () => {
}),
]))
})
it('localizes custom desktop menu labels', () => {
const viewMenu = getAppCommandMenuSections(createTranslator('zh-CN')).find(section => section.label === '视图')
expect(viewMenu?.items).toEqual(expect.arrayContaining([
expect.objectContaining({
commandId: APP_COMMAND_IDS.viewToggleAiChat,
label: '切换 AI 面板',
menuItemId: APP_COMMAND_IDS.viewToggleAiChat,
}),
expect.objectContaining({
commandId: APP_COMMAND_IDS.viewZoomReset,
label: '实际大小',
}),
]))
})
})

View File

@@ -164,6 +164,68 @@ const APP_COMMAND_MANIFEST_STATE_GROUPS = appCommandManifest.menuStateGroups as
AppCommandMenuStateGroupReference[]
>
const MENU_LABEL_KEYS = {
File: 'menu.file',
Edit: 'menu.edit',
View: 'menu.view',
Go: 'menu.go',
Note: 'menu.note',
Vault: 'menu.vault',
Window: 'menu.window',
'New Note': 'command.note.newNote',
'New Type': 'command.note.newType',
'Quick Open': 'menu.file.quickOpen',
'Quick Open (Cmd+O)': 'menu.file.quickOpenCmdO',
'Quick Open (Ctrl+O)': 'menu.file.quickOpenCtrlO',
Save: 'menu.file.save',
Undo: 'command.note.undo',
Redo: 'command.note.redo',
'Paste without Formatting': 'menu.edit.pasteWithoutFormatting',
'Find in Note': 'command.note.findInNote',
'Replace in Note': 'command.note.replaceInNote',
'Find in Vault': 'menu.edit.findInVault',
'Toggle Note List Search': 'menu.edit.toggleNoteListSearch',
'Toggle Diff Mode': 'command.view.toggleDiff',
'Editor Only': 'command.view.editorOnly',
'Editor + Notes': 'command.view.editorNoteList',
'All Panels': 'menu.view.allPanels',
'Toggle Properties Panel': 'command.view.toggleProperties',
'Toggle AI Panel': 'command.view.toggleAiPanel',
'Zoom In': 'menu.view.zoomIn',
'Zoom Out': 'menu.view.zoomOut',
'Actual Size': 'menu.view.actualSize',
'Command Palette': 'menu.view.commandPalette',
'All Notes': 'menu.go.allNotes',
Archived: 'menu.go.archived',
Changes: 'menu.go.changes',
Inbox: 'menu.go.inbox',
'Go Back': 'command.navigation.goBack',
'Go Forward': 'command.navigation.goForward',
'Toggle Organized': 'menu.note.toggleOrganized',
'Archive Note': 'command.note.archiveNote',
'Delete Note': 'command.note.deleteNote',
'Restore Deleted Note': 'command.note.restoreDeleted',
'Open in New Window': 'command.note.openNewWindow',
'Export Note as PDF': 'command.note.exportPdf',
'Toggle Raw Editor': 'command.view.toggleRaw',
'Toggle Table of Contents': 'menu.note.toggleTableOfContents',
'Toggle Backlinks': 'command.view.toggleBacklinks',
'Open Vault…': 'command.settings.openVault',
'Remove Vault from List': 'command.settings.removeVault',
'Restore Getting Started': 'command.settings.restoreGettingStarted',
'Add Remote…': 'menu.vault.addRemote',
'Commit & Push': 'command.git.commitPush',
'Pull from Remote': 'command.git.pull',
'Resolve Conflicts': 'command.git.resolveConflicts',
'View Pending Changes': 'command.git.viewChanges',
'Reload Vault': 'command.settings.reloadVault',
'Repair Vault': 'command.settings.repairVault',
'Set Up External AI Tools…': 'command.settings.setupExternalAi',
} as const
export type AppCommandMenuLabelKey = (typeof MENU_LABEL_KEYS)[keyof typeof MENU_LABEL_KEYS]
export type AppCommandMenuTranslator = (key: AppCommandMenuLabelKey) => string
export const APP_COMMAND_IDS = Object.fromEntries(
Object.entries(APP_COMMAND_MANIFEST_COMMANDS).map(([key, command]) => [key, command.id]),
) as { readonly [K in AppCommandKey]: string }
@@ -204,6 +266,11 @@ function resolvePlatformLabel(label: PlatformLabel): string {
return label.default
}
function localizeMenuLabel(label: string, t?: AppCommandMenuTranslator): string {
const key = Reflect.get(MENU_LABEL_KEYS, label) as AppCommandMenuLabelKey | undefined
return key && t ? t(key) : label
}
function formatAcceleratorDisplay(accelerator: string): string {
const commandPrefix = isMac() ? '⌘' : 'Ctrl+'
const commandShiftPrefix = isMac() ? '⌘⇧' : 'Ctrl+Shift+'
@@ -227,15 +294,16 @@ function menuShortcutForCommand(
return undefined
}
function toMenuItem(item: AppCommandMenuManifestItem): AppCommandMenuItem {
function toMenuItem(item: AppCommandMenuManifestItem, t?: AppCommandMenuTranslator): AppCommandMenuItem {
if (item.kind === 'separator') return { kind: 'separator' }
if (item.kind === 'menu-event') {
const label = resolvePlatformLabel(item.label)
return {
kind: 'command',
commandId: item.id,
menuItemId: item.id,
label: resolvePlatformLabel(item.label),
label: localizeMenuLabel(label, t),
shortcut: typeof item.accelerator === 'string'
? formatAcceleratorDisplay(item.accelerator)
: undefined,
@@ -244,11 +312,12 @@ function toMenuItem(item: AppCommandMenuManifestItem): AppCommandMenuItem {
}
const command = Reflect.get(APP_COMMAND_MANIFEST_COMMANDS, item.command) as AppCommandManifestDefinition
const label = resolvePlatformLabel(item.label)
return {
kind: 'command',
commandId: command.id,
menuItemId: item.id ?? command.id,
label: resolvePlatformLabel(item.label),
label: localizeMenuLabel(label, t),
shortcut: menuShortcutForCommand(item, command),
enabled: item.enabled,
}
@@ -258,12 +327,16 @@ function menuCommandIds(items: AppCommandMenuItem[]): string[] {
return items.flatMap(item => item.kind === 'command' ? [item.commandId] : [])
}
export const APP_COMMAND_MENU_SECTIONS = APP_COMMAND_MANIFEST_MENUS.map(section => ({
label: section.label,
items: section.items.map(toMenuItem),
}))
export function getAppCommandMenuSections(t?: AppCommandMenuTranslator) {
return APP_COMMAND_MANIFEST_MENUS.map(section => ({
label: localizeMenuLabel(section.label, t),
items: section.items.map(item => toMenuItem(item, t)),
}))
}
const APP_COMMAND_APP_MENU_ITEMS = APP_COMMAND_MANIFEST_APP_MENU.map(toMenuItem)
export const APP_COMMAND_MENU_SECTIONS = getAppCommandMenuSections()
const APP_COMMAND_APP_MENU_ITEMS = APP_COMMAND_MANIFEST_APP_MENU.map(item => toMenuItem(item))
export const APP_COMMAND_MENU_STATE_GROUPS = Object.fromEntries(
Object.entries(APP_COMMAND_MANIFEST_STATE_GROUPS).map(([name, references]) => [

View File

@@ -2,6 +2,7 @@ import type { useCreateBlockNote } from '@blocknote/react'
import { preProcessWikilinks, injectWikilinks } from '../utils/wikilinks'
import { preProcessMathMarkdown, injectMathInBlocks } from '../utils/mathMarkdown'
import { injectDurableEditorMarkdownBlocks, preProcessDurableEditorMarkdown } from '../utils/editorDurableMarkdown'
import { injectMarkdownHighlightsInBlocks } from '../utils/markdownHighlightMarkdown'
import { resolveImageUrls } from '../utils/vaultImages'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import { inferCodeBlockLanguages } from '../utils/codeBlockLanguage'
@@ -146,7 +147,8 @@ function preProcessEditorMarkdown(
function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
const withWikilinks = injectWikilinks(blocks)
const withMath = injectMathInBlocks(withWikilinks)
return injectDurableEditorMarkdownBlocks(withMath) as EditorBlocks
const withHighlights = injectMarkdownHighlightsInBlocks(withMath)
return injectDurableEditorMarkdownBlocks(withHighlights) as EditorBlocks
}
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {
@@ -184,7 +186,7 @@ export async function resolveBlocksForTarget(
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
return cacheResolvedEditorState(cache, targetPath, {
blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks,
blocks: repairMalformedEditorBlocks(injectEditorMarkdownBlocks(fastPathBlocks)) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}, vaultPath)

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useAiAgentsStatus } from './useAiAgentsStatus'
import { AI_AGENTS_STATUS_PROBE_TIMEOUT_MS, useAiAgentsStatus } from './useAiAgentsStatus'
import { AI_AGENT_DEFINITIONS, type AiAgentsStatus } from '../lib/aiAgents'
vi.mock('@tauri-apps/api/core', () => ({
@@ -141,5 +141,23 @@ describe('useAiAgentsStatus', () => {
expect(mockInvoke).not.toHaveBeenCalled()
})
it('falls back to missing when the status probe never resolves', async () => {
mockInvoke.mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useAiAgentsStatus({ enabled: true }))
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(mockInvoke).toHaveBeenCalledWith('get_ai_agents_status')
expectStatuses(result.current, 'checking')
await act(async () => {
await vi.advanceTimersByTimeAsync(AI_AGENTS_STATUS_PROBE_TIMEOUT_MS + 1)
})
expectStatuses(result.current, 'missing')
})
})
})

View File

@@ -11,6 +11,8 @@ import {
type RawAiAgentsStatus = Partial<Record<AiAgentId, { installed?: boolean | null; version?: string | null }>>
export const AI_AGENTS_STATUS_PROBE_TIMEOUT_MS = 5000
interface UseAiAgentsStatusOptions {
/**
* When false, the hook stays in its initial state and never calls the
@@ -65,17 +67,32 @@ export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsS
}
let cancelled = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
const clearProbeTimeout = () => {
if (timeoutId === null) return
clearTimeout(timeoutId)
timeoutId = null
}
const fire = () => {
if (cancelled) return
timeoutId = setTimeout(() => {
if (!cancelled) {
setStatuses(createMissingAiAgentsStatus())
}
}, AI_AGENTS_STATUS_PROBE_TIMEOUT_MS)
tauriCall<RawAiAgentsStatus>('get_ai_agents_status')
.then((result) => {
clearProbeTimeout()
if (!cancelled) {
setStatuses(normalizeAiAgentsStatus(result))
}
})
.catch(() => {
clearProbeTimeout()
if (!cancelled) {
setStatuses(createMissingAiAgentsStatus())
}
@@ -89,6 +106,7 @@ export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsS
return () => {
cancelled = true
clearProbeTimeout()
cancelIdle(handle)
}
}, [enabled])

View File

@@ -0,0 +1,108 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mockInvoke } from '../mock-tauri'
import type { GitRemoteStatus, VaultEntry } from '../types'
import { writeClipboardText } from '../utils/clipboardText'
import { useNoteGitUrls } from './useNoteGitUrls'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
mockInvoke: vi.fn(),
}))
vi.mock('../utils/clipboardText', () => ({
writeClipboardText: vi.fn(),
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: vi.fn(),
}))
function remoteStatus(hasRemote: boolean): GitRemoteStatus {
return {
branch: 'main',
ahead: 0,
behind: 0,
hasRemote,
}
}
function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/notes/share.md',
filename: 'share.md',
title: 'Share',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
describe('useNoteGitUrls', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(writeClipboardText).mockResolvedValue(undefined)
})
it('copies the backend git URL for remote-backed notes', async () => {
const setToastMessage = vi.fn()
const note = entry()
vi.mocked(mockInvoke).mockResolvedValue('https://github.com/team/vault/blob/main/notes/share.md')
const { result } = renderHook(() => useNoteGitUrls({
currentVaultPath: '/vault',
remoteStatusForRepository: () => remoteStatus(true),
setToastMessage,
}))
expect(result.current.canCopyEntryGitUrl(note)).toBe(true)
act(() => result.current.copyEntryGitUrl(note))
await waitFor(() => {
expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/team/vault/blob/main/notes/share.md')
})
expect(mockInvoke).toHaveBeenCalledWith('git_file_url', { vaultPath: '/vault', path: note.path })
expect(setToastMessage).toHaveBeenCalledWith('Git URL copied')
})
it('does not request a URL for local-only notes', () => {
const note = entry()
const { result } = renderHook(() => useNoteGitUrls({
currentVaultPath: '/vault',
remoteStatusForRepository: () => remoteStatus(false),
setToastMessage: vi.fn(),
}))
expect(result.current.canCopyEntryGitUrl(note)).toBe(false)
act(() => result.current.copyEntryGitUrl(note))
expect(mockInvoke).not.toHaveBeenCalled()
expect(writeClipboardText).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,70 @@
import { useCallback, useMemo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { translate, type AppLocale } from '../lib/i18n'
import { trackEvent } from '../lib/telemetry'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { GitRemoteStatus, VaultEntry } from '../types'
import { writeClipboardText } from '../utils/clipboardText'
import { vaultPathForEntry } from '../utils/workspaces'
interface UseNoteGitUrlsConfig {
currentVaultPath: string
locale?: AppLocale
remoteStatusForRepository: (path: string) => GitRemoteStatus | null
setToastMessage: (message: string) => void
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function errorDetail(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function hasRemote(remoteStatus: GitRemoteStatus | null): boolean {
return remoteStatus?.hasRemote === true
}
async function loadNoteGitUrl(vaultPath: string, path: string): Promise<string | null> {
return tauriCall<string | null>('git_file_url', { vaultPath, path })
}
export function useNoteGitUrls({
currentVaultPath,
locale = 'en',
remoteStatusForRepository,
setToastMessage,
}: UseNoteGitUrlsConfig) {
const canCopyEntryGitUrl = useCallback((entry: VaultEntry) => (
hasRemote(remoteStatusForRepository(vaultPathForEntry(entry, currentVaultPath)))
), [currentVaultPath, remoteStatusForRepository])
const copyEntryGitUrl = useCallback((entry: VaultEntry) => {
const vaultPath = vaultPathForEntry(entry, currentVaultPath)
if (!hasRemote(remoteStatusForRepository(vaultPath))) return
void loadNoteGitUrl(vaultPath, entry.path)
.then((url) => {
if (!url) {
setToastMessage(translate(locale, 'noteGitUrls.error.unavailable'))
trackEvent('note_git_url_copied', { outcome: 'failed', reason: 'unavailable' })
return
}
return writeClipboardText(url).then(() => {
setToastMessage(translate(locale, 'noteGitUrls.copied'))
trackEvent('note_git_url_copied', { outcome: 'success' })
})
})
.catch((error) => {
setToastMessage(translate(locale, 'noteGitUrls.error.copyFailed', { detail: errorDetail(error) }))
trackEvent('note_git_url_copied', { outcome: 'failed', reason: 'copy_failed' })
})
}, [currentVaultPath, locale, remoteStatusForRepository, setToastMessage])
return useMemo(() => ({
canCopyEntryGitUrl,
copyEntryGitUrl,
}), [canCopyEntryGitUrl, copyEntryGitUrl])
}

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
import type { AiModelDefinition, AiModelProvider, AiTarget } from './aiTargets'
const {
buildAgentSystemPromptMock,
@@ -8,6 +9,7 @@ const {
hydrateNoteReferencesMock,
nextMessageIdMock,
streamAiAgentMock,
streamAiModelMock,
trackEventMock,
trimHistoryMock,
} = vi.hoisted(() => ({
@@ -17,6 +19,7 @@ const {
hydrateNoteReferencesMock: vi.fn(async (references: unknown) => references),
nextMessageIdMock: vi.fn(),
streamAiAgentMock: vi.fn(async () => {}),
streamAiModelMock: vi.fn(async () => {}),
trackEventMock: vi.fn(),
trimHistoryMock: vi.fn((history: unknown) => history),
}))
@@ -40,6 +43,10 @@ vi.mock('../utils/streamAiAgent', () => ({
streamAiAgent: streamAiAgentMock,
}))
vi.mock('../utils/streamAiModel', () => ({
streamAiModel: streamAiModelMock,
}))
vi.mock('../utils/ai-reference-content', () => ({
hydrateNoteReferences: hydrateNoteReferencesMock,
}))
@@ -109,6 +116,36 @@ const expectedChatHistory = [
{ role: 'user', content: 'Previous question', id: 'msg-1' },
{ role: 'assistant', content: 'Previous answer', id: 'msg-1-resp' },
]
const apiModelProvider: AiModelProvider = {
id: 'openai',
name: 'OpenAI',
kind: 'open_ai',
base_url: 'https://api.openai.com/v1',
api_key_storage: 'local_file',
api_key_env_var: null,
models: [],
}
const apiModel: AiModelDefinition = {
id: 'gpt-5-nano',
display_name: 'GPT-5 nano',
context_window: null,
max_output_tokens: null,
capabilities: {
streaming: false,
tools: false,
vision: false,
json_mode: false,
reasoning: false,
},
}
const apiTarget: AiTarget = {
kind: 'api_model',
provider: apiModelProvider,
model: apiModel,
id: 'model:openai/gpt-5-nano',
label: 'OpenAI · GPT-5 nano',
shortLabel: 'GPT-5 nano',
}
function expectStreamingRuntimeState(session: RuntimeFixture): void {
expect(session.runtime.abortRef.current).toEqual({ aborted: false })
@@ -153,6 +190,24 @@ function expectStreamingRequest(runtime: RuntimeFixture['runtime']): void {
})
}
function expectApiModelStreamingRequest(runtime: RuntimeFixture['runtime']): void {
expect(createStreamCallbacksMock).toHaveBeenCalledWith(expect.objectContaining({
messageId: 'msg-stream',
vaultPath: '/vault',
setMessages: runtime.setMessages,
setStatus: runtime.setStatus,
}))
expect(streamAiModelMock).toHaveBeenCalledWith({
provider: apiModelProvider,
model: apiModel,
message: expect.stringContaining('formatted:Latest question'),
systemPrompt: 'SYSTEM',
vaultPath: '/vault',
vaultPaths: ['/vault', '/team-vault'],
callbacks: { stream: 'callbacks' },
})
}
describe('aiAgentSession', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -286,6 +341,35 @@ describe('aiAgentSession', () => {
})
})
it('passes vault roots to api model streams for native note tools', async () => {
nextMessageIdMock.mockReturnValue('msg-stream')
const session = createRuntime([
completedHistory,
streamingHistory,
])
await sendAgentMessage({
runtime: session.runtime,
context: {
agent: 'codex',
target: apiTarget,
ready: true,
vaultPath: '/vault',
vaultPaths: ['/vault', '/team-vault'],
permissionMode: 'safe',
},
prompt: {
text: ' Latest question ',
references: [{ path: '/vault/ref.md', title: 'Ref' }],
},
})
expectStreamingRuntimeState(session)
expectFormattedHistoryUsed()
expectApiModelStreamingRequest(session.runtime)
expect(streamAiAgentMock).not.toHaveBeenCalled()
})
it('clears the conversation and resets runtime refs', () => {
const { runtime } = createRuntime([
{ id: 'msg-1', userMessage: 'Question', actions: [] },

View File

@@ -82,6 +82,8 @@ async function streamWithSelectedTarget(
model: context.target.model,
message: formattedMessage,
systemPrompt,
vaultPath: context.vaultPath,
vaultPaths: context.vaultPaths,
callbacks,
})
return

View File

@@ -65,4 +65,15 @@ describe('i18n', () => {
expect(translate('zh-CN', 'status.conflict.count', { count: 2, plural: 's' })).toBe('2 个冲突')
expect(translate('zh-TW', 'status.conflict.count', { count: 2, plural: 's' })).toBe('2 個衝突')
})
it('uses platform-neutral Chinese labels for revealing files and folders', () => {
const revealKeys = ['sidebar.action.revealFolderMenu', 'editor.toolbar.revealFile'] as const
for (const key of revealKeys) {
expect(translate('zh-CN', key)).toBe('在文件管理器中显示')
expect(translate('zh-CN', key)).not.toContain('访达')
expect(translate('zh-TW', key)).toBe('在檔案管理器中顯示')
expect(translate('zh-TW', key)).not.toContain('訪達')
}
})
})

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Паказаць/схаваць файлы, якія ігнаруюцца Git",
"command.contribute": "Далучыцца да праекта",
"command.checkUpdates": "Праверыць абнаўленні",
"menu.application": "Меню праграмы",
"menu.file": "Файл",
"menu.edit": "Рэдагаваць",
"menu.view": "Прагляд",
"menu.go": "Перайсці",
"menu.note": "Нататка",
"menu.vault": "Сховішча",
"menu.window": "Акно",
"menu.file.quickOpen": "Хуткае адкрыццё",
"menu.file.quickOpenCmdO": "Хуткае адкрыццё (Cmd+O)",
"menu.file.quickOpenCtrlO": "Хуткае адкрыццё (Ctrl+O)",
"menu.file.save": "Захаваць",
"menu.edit.pasteWithoutFormatting": "Уставіць без фарматавання",
"menu.edit.findInVault": "Знайсці ў Vault",
"menu.edit.toggleNoteListSearch": "Уключыць/выключыць пошук у спісе нататак",
"menu.view.allPanels": "Усе панэлі",
"menu.view.zoomIn": "Павелічэнне",
"menu.view.zoomOut": "Паменшыць",
"menu.view.actualSize": "Фактычны памер",
"menu.view.commandPalette": "Палітра каманд",
"menu.go.allNotes": "Усе нататкі",
"menu.go.archived": "Архівавана",
"menu.go.changes": "Змены",
"menu.go.inbox": "Уваходныя",
"menu.note.toggleOrganized": "Пераключыць «Упарадкавана»",
"menu.note.toggleTableOfContents": "Пераключыць змест",
"menu.vault.addRemote": "Дадаць аддаленае…",
"feedback.title": "Зрабіце ўнёсак у Tolaria",
"feedback.description": "Выберыце шлях, які адпавядае таму, што вы хочаце зрабіць! Мы будзем удзячныя за любую дапамогу",
"feedback.sponsor.title": "Спонсар / Падтрымка",
"feedback.sponsor.description": "Гэта Лука 👋. Мая праца на поўны працоўны дзень — кіраванне Refactoring, інфармацыйным бюлетэнем для больш чым 170 000 інжынераў пра тое, як кіраваць добрымі камандамі і выпускаць праграмнае забеспячэнне з дапамогай ШІ. Я пішу пра працоўныя працэсы, бяру інтэрв'ю ў тэхналагічных лідэраў (напрыклад, DHH, Марціна Фаўлера і іншых) і кірую прыватнай супольнасцю з больш чым 2000 інжынераў, праводзячы штомесячны жывы коўчынг, клуб ШІ і многае іншае.\n\nTolaria — гэта FOSS, і так будзе заўсёды. Калі вам гэта падабаецца, лепшы спосаб падтрымаць — падпісацца на рассылку.",
"feedback.sponsor.cta": "Паглядзіце Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Запыты на функцыі",
"feedback.featureRequests.description": "Спачатку выканайце пошук на дошцы, прагаласуйце за існуючыя ідэі і стварайце новыя публікацыі, калі яны сапраўды новыя!",
"feedback.featureRequests.cta": "Адкрыць дошку прадукту",
"feedback.featureRequests.linkLabel": "Дошка прадукту",
"feedback.discussions.title": "Дыскусіі",
"feedback.discussions.description": "Выкарыстоўвайце «Обсуждения» для пытанняў, размоваў, паказу і расповеду, а таксама кантэксту супольнасці.",
"feedback.discussions.cta": "Адкрытыя абмеркаванні",
"feedback.discussions.linkLabel": "Обсуждения GitHub",
"feedback.contributeCode.title": "Унесці код",
"feedback.contributeCode.description": "Вітаюцца невялікія, мэтанакіраваныя PR. Спачатку праверце дошку, каб ствараць правільныя рэчы!",
"feedback.contributeCode.cta": "Адкрытыя запыты на ўключэнне змяненняў",
"feedback.contributeCode.linkLabel": "Запыты на ўцягванне GitHub",
"feedback.contributingGuide.cta": "Адкрыць дапаможнік для ўдзельнікаў",
"feedback.contributingGuide.linkLabel": "дапаможнік па ўкладах",
"feedback.reportBug.title": "Паведаміць пра памылку",
"feedback.reportBug.description": "Растлумачце, як прайграць, што вы чакалі і што адбылося. Калі ласка, дадайце дыягностыку!",
"feedback.reportBug.cta": "Адкрыць праблемы GitHub",
"feedback.reportBug.linkLabel": "Праблемы GitHub",
"feedback.linkFallback.title": "Немагчыма адкрыць {label} аўтаматычна.",
"feedback.linkFallback.description": "Адкрыйце гэты URL уручную замест гэтага:",
"feedback.copyDiagnostics": "Капіраваць ачышчаную дыягностыку",
"feedback.diagnosticsCopied": "Дыягностыка скапіявана",
"feedback.diagnosticsCopiedSentence": "Дыягностыка скапіявана.",
"feedback.clipboardUnavailable": "Доступ да буфера абмену зараз недаступны. Вы ўсё яшчэ можаце адкрываць праблемы GitHub непасрэдна.",
"command.group.navigation": "Навігацыя",
"command.group.note": "Нататка",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Паказаць у Finder",
"editor.toolbar.copyFilePath": "Капіяваць шлях",
"editor.toolbar.copyNoteDeepLink": "Капіяваць дэплінк нататкі",
"editor.toolbar.copyNoteGitUrl": "Капіраваць URL git",
"editor.toolbar.exportPdf": "Экспартаваць нататку ў фармаце PDF",
"editor.toolbar.moreActions": "Больш дзеянняў",
"editor.toolbar.openProperties": "Адкрыць уласцівасці",
"editor.formatting.highlight": "Вылучэнне",
"editor.formatting.highlightTooltip": "Вылучэнне (захоўваецца ў Markdown)",
"editor.whiteboard.enterFullscreen": "Разгарнуць дошку",
"editor.whiteboard.exitFullscreen": "Выйсці з поўнаэкраннай дошкі",
"editor.exportPdf.unavailable": "Адкрыйце нататку Markdown перад экспартам у PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Сховішча глыбокіх спасылак недаступнае на гэтай прыладзе.",
"deepLinks.error.unknownVault": "Глыбокі спасылка накіравана на невядомы сховішча.",
"deepLinks.error.unsafePath": "Шлях глыбокай спасылкі не можа выходзіць за межы сховішча.",
"noteGitUrls.copied": "URL Git скапіяваны",
"noteGitUrls.error.copyFailed": "Не атрымалася скапіяваць URL git: {detail}",
"noteGitUrls.error.unavailable": "URL Git недаступны для гэтай нататкі.",
"editor.slash.math": "Матэматыка",
"tableOfContents.title": "Змест",
"tableOfContents.close": "Закрыць змест",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Pakazać/schavać fajly, jakija ihnarujucca Git",
"command.contribute": "Dalučycca da prajekta",
"command.checkUpdates": "Pravieryć abnaŭlienni",
"menu.application": "Mieniu prahramy",
"menu.file": "Fajl",
"menu.edit": "Redahavannie",
"menu.view": "Vyhliad",
"menu.go": "Pierajsci",
"menu.note": "Natatka",
"menu.vault": "Schovišča",
"menu.window": "Akno",
"menu.file.quickOpen": "Chutka adkryć",
"menu.file.quickOpenCmdO": "Chutka adkryć (Cmd+O)",
"menu.file.quickOpenCtrlO": "Chutka adkryć (Ctrl+O)",
"menu.file.save": "Zachavać",
"menu.edit.pasteWithoutFormatting": "Ustavić biez farmatavannia",
"menu.edit.findInVault": "Znajsci ŭ schoviščy",
"menu.edit.toggleNoteListSearch": "Pakazać/schavać pošuk u spisie natatak",
"menu.view.allPanels": "Usie paneli",
"menu.view.zoomIn": "Pavialičyć maštab",
"menu.view.zoomOut": "Pamienšyć maštab",
"menu.view.actualSize": "Sapraŭdny pamier",
"menu.view.commandPalette": "Palitra kamand",
"menu.go.allNotes": "Usie natatki",
"menu.go.archived": "Archivavanyja",
"menu.go.changes": "Zmieny",
"menu.go.inbox": "Uvachodnyja",
"menu.note.toggleOrganized": "Pierakliučyć uparadkavannie",
"menu.note.toggleTableOfContents": "Pakazać/schavać źmiest",
"menu.vault.addRemote": "Dadać addalieny siervier…",
"feedback.title": "Dalučycca da Tolaria",
"feedback.description": "Vybierycie šliach, jaki adpaviadaje tamu, što vy chočacie zrabić! Liubaja dapamoha vartaia",
"feedback.sponsor.title": "Sponsorstva / Padtrymka",
"feedback.sponsor.description": "Luca tut 👋 maja asnoŭnaja praca — viesci Refactoring, rasylku dlia 170K+ inžynieraŭ pra toje, jak kiravać mocnymi kamandami i pastavljać prahramy z ŠI. Ja pišu pra pracovnyja pracesy, hutaryu z technaličnymi liderami (napryklad DHH, Martin Fowler i inšymi) i viadu pryvatnuju supoĺnaść z 2000+ inžynieraŭ z štomiesiačnym live-coaching, AI club i inšym.\n\nTolaria — heta FOSS i zaŭsiody budzie takim. Kali vam padabajecca prajekt, lepšy sposab padtrymać jaho — padpisaćca na rasylku.",
"feedback.sponsor.cta": "Prahliedzieć Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Zapyty na funkcyi",
"feedback.featureRequests.description": "Spačatku pašukajcie na došcy, prahalasujcie za isnuiučyja idei i stvarajcie novyja zapisy toĺki kali idea sapraŭdy novaja!",
"feedback.featureRequests.cta": "Adkryć praduktavuju došku",
"feedback.featureRequests.linkLabel": "Praduktavaja doška",
"feedback.discussions.title": "Dyskussii",
"feedback.discussions.description": "Vykarystoŭvajcie Discussions dlia pytanniaŭ, razmoŭ, show & tell i kantêkstu supoĺnaści.",
"feedback.discussions.cta": "Adkryć dyskussii",
"feedback.discussions.linkLabel": "GitHub Discussions",
"feedback.contributeCode.title": "Dalučycca kodam",
"feedback.contributeCode.description": "Malenkija, dakladnyja PR vitajucca. Spačatku pravierycie došku, kab budavać praviĺnyja rečy!",
"feedback.contributeCode.cta": "Adkryć Pull Requests",
"feedback.contributeCode.linkLabel": "GitHub Pull Requests",
"feedback.contributingGuide.cta": "Adkryć vodič pa ŭnieskach",
"feedback.contributingGuide.linkLabel": "vodič pa ŭnieskach",
"feedback.reportBug.title": "Paviedamić pra pamylku",
"feedback.reportBug.description": "Apishicie, jak paŭtaryć, što vy čakali i što adbylosia. Kali laska, daduć dyjahnostyku!",
"feedback.reportBug.cta": "Adkryć GitHub Issues",
"feedback.reportBug.linkLabel": "GitHub Issues",
"feedback.linkFallback.title": "Nie ŭdalosia aŭtamatyčna adkryć {label}.",
"feedback.linkFallback.description": "Adkryjcie hety URL ručna:",
"feedback.copyDiagnostics": "Skapijavać ačyščanuju dyjahnostyku",
"feedback.diagnosticsCopied": "Dyjahnostyka skapijavanaja",
"feedback.diagnosticsCopiedSentence": "Dyjahnostyka skapijavanaja.",
"feedback.clipboardUnavailable": "Bufer abmienu ciapier niedastupny. Vy ŭsio roŭna možacie adkryć GitHub Issues napramuju.",
"command.group.navigation": "Navihacyja",
"command.group.note": "Natatka",
"command.group.git": "Git",
@@ -531,9 +588,12 @@
"editor.toolbar.revealFile": "Pakazać u Finder",
"editor.toolbar.copyFilePath": "Kapijavać šliach",
"editor.toolbar.copyNoteDeepLink": "Kapijavać deep-link natatki",
"editor.toolbar.copyNoteGitUrl": "Kapijavać Git URL",
"editor.toolbar.exportPdf": "Ekspartavać natatku ŭ farmacie PDF",
"editor.toolbar.moreActions": "Boĺš dziejanniaŭ",
"editor.toolbar.openProperties": "Adkryć ulascivasci",
"editor.formatting.highlight": "Vylučennie",
"editor.formatting.highlightTooltip": "Vylučennie (zachoŭvajecca ŭ Markdown)",
"editor.whiteboard.enterFullscreen": "Pašyryć došku",
"editor.whiteboard.exitFullscreen": "Vyjsci z poŭnaekrannaj doški",
"editor.exportPdf.unavailable": "Adkryjcie natatku Markdown pierad ekspartam u PDF.",
@@ -552,6 +612,9 @@
"deepLinks.error.unavailableVault": "Schovišča z deep-link niedastupnaje na hetaj pryladzie.",
"deepLinks.error.unknownVault": "Deep-link viadzie da nieviadomaha schovišča.",
"deepLinks.error.unsafePath": "Šliach deep-link nie moža vychodzić za miežy schovišča.",
"noteGitUrls.copied": "Git URL skapijavany",
"noteGitUrls.error.copyFailed": "Nie ŭdalosia skapijavać Git URL: {detail}",
"noteGitUrls.error.unavailable": "Git URL niedastupny dla hetaj natatki.",
"tableOfContents.title": "Zmiest",
"tableOfContents.close": "Zakryć zmiest",
"tableOfContents.empty": "Niama zahaloŭkaŭ",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Sichtbarkeit von Gitignored-Dateien umschalten",
"command.contribute": "Mitwirken",
"command.checkUpdates": "Nach Updates suchen",
"menu.application": "Anwendungsmenü",
"menu.file": "Datei",
"menu.edit": "Bearbeiten",
"menu.view": "Anzeigen",
"menu.go": "Los",
"menu.note": "Notiz",
"menu.vault": "Vault",
"menu.window": "Fenster",
"menu.file.quickOpen": "Schnell öffnen",
"menu.file.quickOpenCmdO": "Schnell öffnen (Cmd+O)",
"menu.file.quickOpenCtrlO": "Schnell öffnen (Strg+O)",
"menu.file.save": "Speichern",
"menu.edit.pasteWithoutFormatting": "Ohne Formatierung einfügen",
"menu.edit.findInVault": "Im Vault suchen",
"menu.edit.toggleNoteListSearch": "Suche in der Notizenliste umschalten",
"menu.view.allPanels": "Alle Panels",
"menu.view.zoomIn": "Vergrößern",
"menu.view.zoomOut": "Verkleinern",
"menu.view.actualSize": "Tatsächliche Größe",
"menu.view.commandPalette": "Befehlspalette",
"menu.go.allNotes": "Alle Notizen",
"menu.go.archived": "Archiviert",
"menu.go.changes": "Änderungen",
"menu.go.inbox": "Posteingang",
"menu.note.toggleOrganized": "„Organisiert“ umschalten",
"menu.note.toggleTableOfContents": "Inhaltsverzeichnis ein-/ausblenden",
"menu.vault.addRemote": "Remote hinzufügen …",
"feedback.title": "Zu Tolaria beitragen",
"feedback.description": "Wähle den Weg, der zu dem passt, was du tun möchtest! Jede Art von Hilfe wird geschätzt",
"feedback.sponsor.title": "Sponsor / Unterstützung",
"feedback.sponsor.description": "Ich bin Luca 👋. Mein Vollzeitjob ist die Leitung von Refactoring, einem Newsletter für über 170.000 Ingenieure, in dem es darum geht, wie man gute Teams führt und Software mit KI bereitstellt. Ich schreibe über Workflows, interviewe Tech-Leader (z. B. DHH, Martin Fowler und andere) und leite eine private Community mit über 2.000 Ingenieuren, in der es monatliches Live-Coaching, einen KI-Club und vieles mehr gibt.\n\nTolaria ist FOSS und wird es immer sein. Wenn es dir gefällt, unterstützt du es am besten, indem du den Newsletter abonnierst.",
"feedback.sponsor.cta": "Schau dir Refactoring an",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Funktionsanfragen",
"feedback.featureRequests.description": "Suche zuerst im Board, stimme für bestehende Ideen ab und erstelle neue Beiträge, wenn sie wirklich neu sind!",
"feedback.featureRequests.cta": "Product Board öffnen",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Diskussionen",
"feedback.discussions.description": "Nutze Diskussionen für Fragen, Gespräche, Show & Tell und Community-Kontext.",
"feedback.discussions.cta": "Offene Diskussionen",
"feedback.discussions.linkLabel": "GitHub-Diskussionen",
"feedback.contributeCode.title": "Code beisteuern",
"feedback.contributeCode.description": "Kleine, fokussierte PRs sind willkommen. Schau dir zuerst das Board an, damit du die richtigen Dinge baust!",
"feedback.contributeCode.cta": "Offene Pull Requests",
"feedback.contributeCode.linkLabel": "GitHub-Pull-Requests",
"feedback.contributingGuide.cta": "Leitfaden für Mitwirkende öffnen",
"feedback.contributingGuide.linkLabel": "der Leitfaden für Mitwirkende",
"feedback.reportBug.title": "Einen Fehler melden",
"feedback.reportBug.description": "Erkläre, wie man ihn reproduziert, was du erwartet hast und was passiert ist. Bitte füge die Diagnose bei!",
"feedback.reportBug.cta": "GitHub-Issues öffnen",
"feedback.reportBug.linkLabel": "GitHub-Issues",
"feedback.linkFallback.title": "{label} konnte nicht automatisch geöffnet werden.",
"feedback.linkFallback.description": "Öffnen Sie stattdessen diese URL manuell:",
"feedback.copyDiagnostics": "Bereinigte Diagnose kopieren",
"feedback.diagnosticsCopied": "Diagnose kopiert",
"feedback.diagnosticsCopiedSentence": "Diagnose kopiert.",
"feedback.clipboardUnavailable": "Der Zugriff auf die Zwischenablage ist derzeit nicht möglich. Du kannst GitHub-Issues weiterhin direkt öffnen.",
"command.group.navigation": "Navigation",
"command.group.note": "Notiz",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Im Finder anzeigen",
"editor.toolbar.copyFilePath": "Dateipfad kopieren",
"editor.toolbar.copyNoteDeepLink": "Deeplink der Notiz kopieren",
"editor.toolbar.copyNoteGitUrl": "Git-URL kopieren",
"editor.toolbar.exportPdf": "Notiz als PDF exportieren",
"editor.toolbar.moreActions": "Weitere Notizaktionen",
"editor.toolbar.openProperties": "Eigenschaften-Panel öffnen",
"editor.formatting.highlight": "Hervorheben",
"editor.formatting.highlightTooltip": "Hervorhebung (bleibt in Markdown erhalten)",
"editor.whiteboard.enterFullscreen": "Whiteboard erweitern",
"editor.whiteboard.exitFullscreen": "Vollbild-Whiteboard verlassen",
"editor.exportPdf.unavailable": "Öffne eine Markdown-Notiz, bevor du sie als PDF exportierst.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Der Deep-Link-Tresor ist auf diesem Gerät nicht verfügbar.",
"deepLinks.error.unknownVault": "Der Deep Link verweist auf einen unbekannten Vault.",
"deepLinks.error.unsafePath": "Der Deep-Link-Pfad darf den Vault nicht verlassen.",
"noteGitUrls.copied": "Git-URL kopiert",
"noteGitUrls.error.copyFailed": "Git-URL konnte nicht kopiert werden: {detail}",
"noteGitUrls.error.unavailable": "Für diese Notiz ist keine Git-URL verfügbar.",
"editor.slash.math": "Mathematik",
"tableOfContents.title": "Inhaltsverzeichnis",
"tableOfContents.close": "Inhaltsverzeichnis schließen",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Toggle Gitignored Files Visibility",
"command.contribute": "Contribute",
"command.checkUpdates": "Check for Updates",
"menu.application": "Application menu",
"menu.file": "File",
"menu.edit": "Edit",
"menu.view": "View",
"menu.go": "Go",
"menu.note": "Note",
"menu.vault": "Vault",
"menu.window": "Window",
"menu.file.quickOpen": "Quick Open",
"menu.file.quickOpenCmdO": "Quick Open (Cmd+O)",
"menu.file.quickOpenCtrlO": "Quick Open (Ctrl+O)",
"menu.file.save": "Save",
"menu.edit.pasteWithoutFormatting": "Paste without Formatting",
"menu.edit.findInVault": "Find in Vault",
"menu.edit.toggleNoteListSearch": "Toggle Note List Search",
"menu.view.allPanels": "All Panels",
"menu.view.zoomIn": "Zoom In",
"menu.view.zoomOut": "Zoom Out",
"menu.view.actualSize": "Actual Size",
"menu.view.commandPalette": "Command Palette",
"menu.go.allNotes": "All Notes",
"menu.go.archived": "Archived",
"menu.go.changes": "Changes",
"menu.go.inbox": "Inbox",
"menu.note.toggleOrganized": "Toggle Organized",
"menu.note.toggleTableOfContents": "Toggle Table of Contents",
"menu.vault.addRemote": "Add Remote…",
"feedback.title": "Contribute to Tolaria",
"feedback.description": "Pick the path that fits what you want to do! Any type of help is appreciated",
"feedback.sponsor.title": "Sponsor / Support",
"feedback.sponsor.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.",
"feedback.sponsor.cta": "Check out Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Feature requests",
"feedback.featureRequests.description": "Search on the board first, upvote existing ideas, and create new posts when genuinely new!",
"feedback.featureRequests.cta": "Open Product Board",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Discussions",
"feedback.discussions.description": "Use Discussions for questions, conversations, show & tell, and community context.",
"feedback.discussions.cta": "Open Discussions",
"feedback.discussions.linkLabel": "GitHub Discussions",
"feedback.contributeCode.title": "Contribute code",
"feedback.contributeCode.description": "Small, focused PRs are welcome. Check the board first so you build the right things!",
"feedback.contributeCode.cta": "Open Pull Requests",
"feedback.contributeCode.linkLabel": "GitHub Pull Requests",
"feedback.contributingGuide.cta": "Open Contributing Guide",
"feedback.contributingGuide.linkLabel": "the contributing guide",
"feedback.reportBug.title": "Report a bug",
"feedback.reportBug.description": "Explain how to reproduce, what you expected, vs what happened. Attach the diagnostics please!",
"feedback.reportBug.cta": "Open GitHub Issues",
"feedback.reportBug.linkLabel": "GitHub Issues",
"feedback.linkFallback.title": "Couldnt open {label} automatically.",
"feedback.linkFallback.description": "Open this URL manually instead:",
"feedback.copyDiagnostics": "Copy sanitized diagnostics",
"feedback.diagnosticsCopied": "Diagnostics copied",
"feedback.diagnosticsCopiedSentence": "Diagnostics copied.",
"feedback.clipboardUnavailable": "Clipboard access is unavailable right now. You can still open GitHub Issues directly.",
"command.group.navigation": "Navigation",
"command.group.note": "Note",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Reveal in Finder",
"editor.toolbar.copyFilePath": "Copy file path",
"editor.toolbar.copyNoteDeepLink": "Copy note deeplink",
"editor.toolbar.copyNoteGitUrl": "Copy git URL",
"editor.toolbar.exportPdf": "Export note as PDF",
"editor.toolbar.moreActions": "More note actions",
"editor.toolbar.openProperties": "Open the properties panel",
"editor.formatting.highlight": "Highlight",
"editor.formatting.highlightTooltip": "Highlight (persists in markdown)",
"editor.whiteboard.enterFullscreen": "Expand whiteboard",
"editor.whiteboard.exitFullscreen": "Exit fullscreen whiteboard",
"editor.exportPdf.unavailable": "Open a Markdown note before exporting a PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Deep link vault is not available on this device.",
"deepLinks.error.unknownVault": "Deep link targets an unknown vault.",
"deepLinks.error.unsafePath": "Deep link path cannot leave the vault.",
"noteGitUrls.copied": "Git URL copied",
"noteGitUrls.error.copyFailed": "Failed to copy git URL: {detail}",
"noteGitUrls.error.unavailable": "Git URL is not available for this note.",
"editor.slash.math": "Math",
"tableOfContents.title": "Table of Contents",
"tableOfContents.close": "Close table of contents",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Alternar la visibilidad de los archivos Gitignored",
"command.contribute": "Contribuir",
"command.checkUpdates": "Buscar actualizaciones",
"menu.application": "Menú de la aplicación",
"menu.file": "Archivo",
"menu.edit": "Editar",
"menu.view": "Ver",
"menu.go": "Ir",
"menu.note": "Nota",
"menu.vault": "Vault",
"menu.window": "Ventana",
"menu.file.quickOpen": "Abrir rápido",
"menu.file.quickOpenCmdO": "Abrir rápido (Cmd+O)",
"menu.file.quickOpenCtrlO": "Abrir rápido (Ctrl+O)",
"menu.file.save": "Guardar",
"menu.edit.pasteWithoutFormatting": "Pegar sin formato",
"menu.edit.findInVault": "Buscar en Vault",
"menu.edit.toggleNoteListSearch": "Activar/desactivar la búsqueda en la lista de notas",
"menu.view.allPanels": "Todos los paneles",
"menu.view.zoomIn": "Acercar",
"menu.view.zoomOut": "Alejar",
"menu.view.actualSize": "Tamaño real",
"menu.view.commandPalette": "Paleta de comandos",
"menu.go.allNotes": "Todas las notas",
"menu.go.archived": "Archivado",
"menu.go.changes": "Cambios",
"menu.go.inbox": "Bandeja de entrada",
"menu.note.toggleOrganized": "Alternar Organizado",
"menu.note.toggleTableOfContents": "Alternar Índice",
"menu.vault.addRemote": "Agregar remoto…",
"feedback.title": "Contribuir a Tolaria",
"feedback.description": "¡Elige el camino que se adapte a lo que quieres hacer! Cualquier tipo de ayuda es bienvenida",
"feedback.sponsor.title": "Patrocinio / Apoyo",
"feedback.sponsor.description": "Soy Luca 👋. Mi trabajo de tiempo completo es dirigir Refactoring, un boletín para más de 170 000 ingenieros sobre cómo dirigir buenos equipos y lanzar software con IA. Escribo sobre flujos de trabajo, entrevisto a líderes tecnológicos (por ejemplo, DHH, Martin Fowler y más) y dirijo una comunidad privada de más de 2000 ingenieros con coaching mensual en vivo, club de IA y más.\n\nTolaria es FOSS y siempre lo será. Si te gusta, la mejor manera de apoyarla es suscribirte al boletín.",
"feedback.sponsor.cta": "Echa un vistazo a Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Solicitudes de funciones",
"feedback.featureRequests.description": "Primero busca en el tablero, vota a favor de las ideas existentes y crea nuevas publicaciones cuando sean realmente nuevas.",
"feedback.featureRequests.cta": "Abrir el Tablero de productos",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Discusiones",
"feedback.discussions.description": "Utiliza Discussions para preguntas, conversaciones, presentaciones y contexto de la comunidad.",
"feedback.discussions.cta": "Abrir Discussions",
"feedback.discussions.linkLabel": "Discusiones de GitHub",
"feedback.contributeCode.title": "Contribuir con código",
"feedback.contributeCode.description": "Las PR pequeñas y específicas son bienvenidas. ¡Revisa primero el tablero para que desarrolles lo correcto!",
"feedback.contributeCode.cta": "Solicitudes de extracción abiertas",
"feedback.contributeCode.linkLabel": "Solicitudes de extracción de GitHub",
"feedback.contributingGuide.cta": "Abrir la Guía para contribuir",
"feedback.contributingGuide.linkLabel": "la guía de contribución",
"feedback.reportBug.title": "Informar un error",
"feedback.reportBug.description": "Explica cómo reproducirlo, qué esperabas y qué sucedió. ¡Adjunta el diagnóstico, por favor!",
"feedback.reportBug.cta": "Abrir incidencias de GitHub",
"feedback.reportBug.linkLabel": "Incidencias de GitHub",
"feedback.linkFallback.title": "No se pudo abrir {label} automáticamente.",
"feedback.linkFallback.description": "En su lugar, abra esta URL manualmente:",
"feedback.copyDiagnostics": "Copiar diagnósticos depurados",
"feedback.diagnosticsCopied": "Diagnósticos copiados",
"feedback.diagnosticsCopiedSentence": "Diagnósticos copiados.",
"feedback.clipboardUnavailable": "El acceso al portapapeles no está disponible en este momento. Aún puedes abrir los problemas de GitHub directamente.",
"command.group.navigation": "Navegación",
"command.group.note": "Nota",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Mostrar en el Finder",
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
"editor.toolbar.copyNoteDeepLink": "Copiar el enlace profundo de la nota",
"editor.toolbar.copyNoteGitUrl": "Copiar URL de Git",
"editor.toolbar.exportPdf": "Exportar nota como PDF",
"editor.toolbar.moreActions": "Más acciones de nota",
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
"editor.formatting.highlight": "Resaltar",
"editor.formatting.highlightTooltip": "Resaltado (persiste en Markdown)",
"editor.whiteboard.enterFullscreen": "Expandir pizarra",
"editor.whiteboard.exitFullscreen": "Salir de la pizarra en pantalla completa",
"editor.exportPdf.unavailable": "Abre una nota de Markdown antes de exportar un PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "El repositorio del enlace profundo no está disponible en este dispositivo.",
"deepLinks.error.unknownVault": "El enlace profundo apunta a una bóveda desconocida.",
"deepLinks.error.unsafePath": "La ruta del enlace profundo no puede salir de la bóveda.",
"noteGitUrls.copied": "URL de Git copiada",
"noteGitUrls.error.copyFailed": "Error al copiar la URL de Git: {detail}",
"noteGitUrls.error.unavailable": "La URL de Git no está disponible para esta nota.",
"editor.slash.math": "Matemáticas",
"tableOfContents.title": "Índice",
"tableOfContents.close": "Cerrar índice",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Activar/desactivar la visibilidad de los archivos Gitignored",
"command.contribute": "Contribuir",
"command.checkUpdates": "Buscar actualizaciones",
"menu.application": "Menú de la aplicación",
"menu.file": "Archivo",
"menu.edit": "Editar",
"menu.view": "Ver",
"menu.go": "Ir",
"menu.note": "Nota",
"menu.vault": "Repositorio",
"menu.window": "Ventana",
"menu.file.quickOpen": "Abrir rápido",
"menu.file.quickOpenCmdO": "Abrir rápido (Cmd+O)",
"menu.file.quickOpenCtrlO": "Abrir rápidamente (Ctrl+O)",
"menu.file.save": "Guardar",
"menu.edit.pasteWithoutFormatting": "Pegar sin formato",
"menu.edit.findInVault": "Buscar en el repositorio",
"menu.edit.toggleNoteListSearch": "Activar/desactivar la búsqueda en la lista de notas",
"menu.view.allPanels": "Todos los paneles",
"menu.view.zoomIn": "Acercar",
"menu.view.zoomOut": "Alejar",
"menu.view.actualSize": "Tamaño real",
"menu.view.commandPalette": "Paleta de comandos",
"menu.go.allNotes": "Todas las notas",
"menu.go.archived": "Archivado",
"menu.go.changes": "Cambios",
"menu.go.inbox": "Bandeja de entrada",
"menu.note.toggleOrganized": "Alternar Organizado",
"menu.note.toggleTableOfContents": "Activar/desactivar índice",
"menu.vault.addRemote": "Añadir remoto…",
"feedback.title": "Contribuir a Tolaria",
"feedback.description": "¡Elige la ruta que mejor se adapte a lo que quieres hacer! Cualquier tipo de ayuda es bienvenida",
"feedback.sponsor.title": "Patrocinio / Apoyo",
"feedback.sponsor.description": "Soy Luca 👋. Mi trabajo a tiempo completo es dirigir Refactoring, un boletín para más de 170 000 ingenieros sobre cómo dirigir buenos equipos y lanzar software con IA. Escribo sobre flujos de trabajo, entrevisto a líderes tecnológicos (por ejemplo, DHH, Martin Fowler y otros) y dirijo una comunidad privada de más de 2000 ingenieros con coaching mensual en directo, club de IA y mucho más.\n\nTolaria es FOSS y siempre lo será. Si te gusta, la mejor manera de apoyarla es suscribirte al boletín.",
"feedback.sponsor.cta": "Echa un vistazo a Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Solicitudes de funciones",
"feedback.featureRequests.description": "Primero busca en el foro, vota las ideas existentes y crea nuevas publicaciones cuando sean realmente nuevas.",
"feedback.featureRequests.cta": "Abrir Product Board",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Discusiones",
"feedback.discussions.description": "Utiliza Discussions para preguntas, conversaciones, presentaciones y contexto de la comunidad.",
"feedback.discussions.cta": "Abrir Discussions",
"feedback.discussions.linkLabel": "Discusiones de GitHub",
"feedback.contributeCode.title": "Contribuir con código",
"feedback.contributeCode.description": "Se aceptan solicitudes de extracción pequeñas y específicas. ¡Revisa primero el tablero para crear lo correcto!",
"feedback.contributeCode.cta": "Solicitudes de extracción abiertas",
"feedback.contributeCode.linkLabel": "Solicitudes de extracción de GitHub",
"feedback.contributingGuide.cta": "Abrir la Guía para contribuir",
"feedback.contributingGuide.linkLabel": "la guía de contribución",
"feedback.reportBug.title": "Informar de un error",
"feedback.reportBug.description": "Explica cómo reproducirlo, qué esperabas y qué ha ocurrido. ¡Adjunta el diagnóstico, por favor!",
"feedback.reportBug.cta": "Abrir incidencias de GitHub",
"feedback.reportBug.linkLabel": "Incidencias de GitHub",
"feedback.linkFallback.title": "No se ha podido abrir {label} automáticamente.",
"feedback.linkFallback.description": "Abra esta URL manualmente en su lugar:",
"feedback.copyDiagnostics": "Copiar diagnósticos depurados",
"feedback.diagnosticsCopied": "Diagnósticos copiados",
"feedback.diagnosticsCopiedSentence": "Diagnósticos copiados.",
"feedback.clipboardUnavailable": "El acceso al portapapeles no está disponible en este momento. Aún puede abrir los problemas de GitHub directamente.",
"command.group.navigation": "Navegación",
"command.group.note": "Nota",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Mostrar en el Finder",
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
"editor.toolbar.copyNoteDeepLink": "Copiar el enlace profundo de la nota",
"editor.toolbar.copyNoteGitUrl": "Copiar URL de Git",
"editor.toolbar.exportPdf": "Exportar nota como PDF",
"editor.toolbar.moreActions": "Más acciones de nota",
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
"editor.formatting.highlight": "Resaltado",
"editor.formatting.highlightTooltip": "Resaltado (persiste en Markdown)",
"editor.whiteboard.enterFullscreen": "Expandir pizarra",
"editor.whiteboard.exitFullscreen": "Salir de la pizarra en pantalla completa",
"editor.exportPdf.unavailable": "Abre una nota de Markdown antes de exportar un PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "El almacén del enlace profundo no está disponible en este dispositivo.",
"deepLinks.error.unknownVault": "El enlace profundo apunta a un almacén desconocido.",
"deepLinks.error.unsafePath": "La ruta del enlace profundo no puede salir de la caja fuerte.",
"noteGitUrls.copied": "URL de Git copiada",
"noteGitUrls.error.copyFailed": "Error al copiar la URL de Git: {detail}",
"noteGitUrls.error.unavailable": "La URL de Git no está disponible para esta nota.",
"editor.slash.math": "Matemáticas",
"tableOfContents.title": "Índice",
"tableOfContents.close": "Cerrar el índice",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Activer/désactiver la visibilité des fichiers Gitignored",
"command.contribute": "Contribuer",
"command.checkUpdates": "Rechercher les mises à jour",
"menu.application": "Menu de l'application",
"menu.file": "Fichier",
"menu.edit": "Modifier",
"menu.view": "Afficher",
"menu.go": "Aller",
"menu.note": "Note",
"menu.vault": "Coffre",
"menu.window": "Fenêtre",
"menu.file.quickOpen": "Ouverture rapide",
"menu.file.quickOpenCmdO": "Ouverture rapide (Cmd+O)",
"menu.file.quickOpenCtrlO": "Ouverture rapide (Ctrl+O)",
"menu.file.save": "Enregistrer",
"menu.edit.pasteWithoutFormatting": "Coller sans formatage",
"menu.edit.findInVault": "Rechercher dans le coffre-fort",
"menu.edit.toggleNoteListSearch": "Activer/désactiver la recherche dans la liste de notes",
"menu.view.allPanels": "Tous les panneaux",
"menu.view.zoomIn": "Zoom avant",
"menu.view.zoomOut": "Zoom arrière",
"menu.view.actualSize": "Taille réelle",
"menu.view.commandPalette": "Palette de commandes",
"menu.go.allNotes": "Toutes les notes",
"menu.go.archived": "Archivé",
"menu.go.changes": "Modifications",
"menu.go.inbox": "Boîte de réception",
"menu.note.toggleOrganized": "Activer/désactiver Organisé",
"menu.note.toggleTableOfContents": "Activer/désactiver la table des matières",
"menu.vault.addRemote": "Ajouter un dépôt distant…",
"feedback.title": "Contribuer à Tolaria",
"feedback.description": "Choisissez le chemin qui correspond à ce que vous voulez faire ! Toute aide est la bienvenue",
"feedback.sponsor.title": "Sponsor / Soutien",
"feedback.sponsor.description": "C'est Luca 👋 Mon travail à temps plein est de gérer Refactoring, une newsletter destinée à plus de 170 000 ingénieurs sur la façon de gérer de bonnes équipes et de livrer des logiciels avec l'IA. J'écris sur les workflows, j'interviewe des leaders du secteur tech (par exemple, DHH, Martin Fowler et bien d'autres) et je gère une communauté privée de plus de 2 000 ingénieurs avec un coaching mensuel en direct, un club IA et bien plus encore.\n\nTolaria est FOSS et le restera. Si vous l'aimez, la meilleure façon de la soutenir est de vous abonner à la newsletter.",
"feedback.sponsor.cta": "Découvrez Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Demandes de fonctionnalités",
"feedback.featureRequests.description": "Recherchez d'abord sur le forum, votez pour les idées existantes et créez de nouveaux messages lorsqu'ils sont vraiment nouveaux !",
"feedback.featureRequests.cta": "Ouvrir le Product Board",
"feedback.featureRequests.linkLabel": "Tableau produit",
"feedback.discussions.title": "Discussions",
"feedback.discussions.description": "Utilisez Discussions pour les questions, les conversations, les présentations et le contexte de la communauté.",
"feedback.discussions.cta": "Ouvrir Discussions",
"feedback.discussions.linkLabel": "Discussions GitHub",
"feedback.contributeCode.title": "Contribuer du code",
"feedback.contributeCode.description": "Les petites PR ciblées sont les bienvenues. Consultez d'abord le tableau pour créer les bonnes choses !",
"feedback.contributeCode.cta": "Pull requests ouvertes",
"feedback.contributeCode.linkLabel": "Demandes de tirage GitHub",
"feedback.contributingGuide.cta": "Ouvrir le guide de contribution",
"feedback.contributingGuide.linkLabel": "le guide de contribution",
"feedback.reportBug.title": "Signaler un bug",
"feedback.reportBug.description": "Expliquez comment reproduire le problème, ce à quoi vous vous attendiez et ce qui s'est passé. Veuillez joindre les diagnostics !",
"feedback.reportBug.cta": "Ouvrir les tickets GitHub",
"feedback.reportBug.linkLabel": "Tickets GitHub",
"feedback.linkFallback.title": "Impossible d'ouvrir {label} automatiquement.",
"feedback.linkFallback.description": "Ouvrez plutôt cette URL manuellement :",
"feedback.copyDiagnostics": "Copier les diagnostics nettoyés",
"feedback.diagnosticsCopied": "Diagnostics copiés",
"feedback.diagnosticsCopiedSentence": "Diagnostics copiés.",
"feedback.clipboardUnavailable": "L'accès au presse-papiers n'est pas disponible pour le moment. Vous pouvez toujours ouvrir les tickets GitHub directement.",
"command.group.navigation": "Navigation",
"command.group.note": "Note",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Afficher dans le Finder",
"editor.toolbar.copyFilePath": "Copier le chemin du fichier",
"editor.toolbar.copyNoteDeepLink": "Copier le deeplink de la note",
"editor.toolbar.copyNoteGitUrl": "Copier l'URL Git",
"editor.toolbar.exportPdf": "Exporter la note au format PDF",
"editor.toolbar.moreActions": "Autres actions de note",
"editor.toolbar.openProperties": "Ouvrir le panneau des propriétés",
"editor.formatting.highlight": "Surligner",
"editor.formatting.highlightTooltip": "Surlignage (persiste dans le Markdown)",
"editor.whiteboard.enterFullscreen": "Agrandir le tableau blanc",
"editor.whiteboard.exitFullscreen": "Quitter le tableau blanc en plein écran",
"editor.exportPdf.unavailable": "Ouvrez une note Markdown avant d'exporter un PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Le coffre-fort du lien profond n'est pas disponible sur cet appareil.",
"deepLinks.error.unknownVault": "Le lien profond cible un coffre inconnu.",
"deepLinks.error.unsafePath": "Le chemin du lien profond ne peut pas sortir du coffre.",
"noteGitUrls.copied": "URL Git copiée",
"noteGitUrls.error.copyFailed": "Échec de la copie de l'URL Git : {detail}",
"noteGitUrls.error.unavailable": "L'URL Git n'est pas disponible pour cette note.",
"editor.slash.math": "Mathématiques",
"tableOfContents.title": "Table des matières",
"tableOfContents.close": "Fermer la table des matières",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Alihkan Visibilitas File yang Diabaikan Git",
"command.contribute": "Berkontribusi",
"command.checkUpdates": "Periksa Pembaruan",
"menu.application": "Menu aplikasi",
"menu.file": "File",
"menu.edit": "Edit",
"menu.view": "Lihat",
"menu.go": "Mulai",
"menu.note": "Catatan",
"menu.vault": "Vault",
"menu.window": "Jendela",
"menu.file.quickOpen": "Buka Cepat",
"menu.file.quickOpenCmdO": "Buka Cepat (Cmd+O)",
"menu.file.quickOpenCtrlO": "Buka Cepat (Ctrl+O)",
"menu.file.save": "Simpan",
"menu.edit.pasteWithoutFormatting": "Tempel tanpa Format",
"menu.edit.findInVault": "Cari di Vault",
"menu.edit.toggleNoteListSearch": "Alihkan Pencarian Daftar Catatan",
"menu.view.allPanels": "Semua Panel",
"menu.view.zoomIn": "Perbesar",
"menu.view.zoomOut": "Perkecil",
"menu.view.actualSize": "Ukuran Sebenarnya",
"menu.view.commandPalette": "Palet Perintah",
"menu.go.allNotes": "Semua Catatan",
"menu.go.archived": "Diarsipkan",
"menu.go.changes": "Perubahan",
"menu.go.inbox": "Kotak Masuk",
"menu.note.toggleOrganized": "Alihkan ke Terorganisir",
"menu.note.toggleTableOfContents": "Alihkan Daftar Isi",
"menu.vault.addRemote": "Tambahkan Remote…",
"feedback.title": "Berkontribusi ke Tolaria",
"feedback.description": "Pilih jalur yang sesuai dengan apa yang ingin Anda lakukan! Segala bentuk bantuan akan sangat dihargai",
"feedback.sponsor.title": "Sponsor / Dukungan",
"feedback.sponsor.description": "Ini Luca 👋 Pekerjaan penuh waktu saya adalah menjalankan Refactoring, buletin untuk 170 ribu lebih insinyur tentang cara mengelola tim yang baik dan merilis perangkat lunak dengan AI. Saya menulis tentang alur kerja, mewawancarai para pemimpin teknologi (misalnya DHH, Martin Fowler, dan lainnya), dan menjalankan komunitas pribadi yang terdiri dari 2000+ insinyur dengan sesi coaching live bulanan, klub AI, dan lainnya.\n\nTolaria adalah FOSS dan akan selalu begitu. Jika Anda menyukainya, cara terbaik untuk mendukungnya adalah dengan berlangganan buletin.",
"feedback.sponsor.cta": "Lihat Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Permintaan fitur",
"feedback.featureRequests.description": "Cari di papan terlebih dahulu, upvote ide yang sudah ada, dan buat postingan baru jika benar-benar baru!",
"feedback.featureRequests.cta": "Buka Papan Produk",
"feedback.featureRequests.linkLabel": "Papan Produk",
"feedback.discussions.title": "Diskusi",
"feedback.discussions.description": "Gunakan Diskusi untuk pertanyaan, percakapan, show & tell, dan konteks komunitas.",
"feedback.discussions.cta": "Buka Diskusi",
"feedback.discussions.linkLabel": "Diskusi GitHub",
"feedback.contributeCode.title": "Kontribusikan kode",
"feedback.contributeCode.description": "PR kecil dan fokus dipersilakan. Periksa papan terlebih dahulu agar Anda membangun hal yang tepat!",
"feedback.contributeCode.cta": "Buka Permintaan Penarikan",
"feedback.contributeCode.linkLabel": "Permintaan Penarikan GitHub",
"feedback.contributingGuide.cta": "Buka Panduan Kontribusi",
"feedback.contributingGuide.linkLabel": "panduan kontribusi",
"feedback.reportBug.title": "Laporkan bug",
"feedback.reportBug.description": "Jelaskan cara mereproduksi, apa yang Anda harapkan, vs apa yang terjadi. Harap lampirkan diagnostiknya!",
"feedback.reportBug.cta": "Buka Isu GitHub",
"feedback.reportBug.linkLabel": "Isu GitHub",
"feedback.linkFallback.title": "Tidak dapat membuka {label} secara otomatis.",
"feedback.linkFallback.description": "Buka URL ini secara manual sebagai gantinya:",
"feedback.copyDiagnostics": "Salin diagnostik yang sudah dibersihkan",
"feedback.diagnosticsCopied": "Diagnostik disalin",
"feedback.diagnosticsCopiedSentence": "Diagnostik disalin.",
"feedback.clipboardUnavailable": "Akses clipboard saat ini tidak tersedia. Anda masih dapat membuka Isu GitHub secara langsung.",
"command.group.navigation": "Navigasi",
"command.group.note": "Catatan",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Tampilkan di Finder",
"editor.toolbar.copyFilePath": "Salin jalur file",
"editor.toolbar.copyNoteDeepLink": "Salin deeplink catatan",
"editor.toolbar.copyNoteGitUrl": "Salin URL git",
"editor.toolbar.exportPdf": "Ekspor catatan sebagai PDF",
"editor.toolbar.moreActions": "Tindakan catatan lainnya",
"editor.toolbar.openProperties": "Buka panel properti",
"editor.formatting.highlight": "Sorot",
"editor.formatting.highlightTooltip": "Sorot (bertahan dalam markdown)",
"editor.whiteboard.enterFullscreen": "Perluas papan tulis",
"editor.whiteboard.exitFullscreen": "Keluar dari papan tulis layar penuh",
"editor.exportPdf.unavailable": "Buka catatan Markdown sebelum mengekspor PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Vault tautan dalam tidak tersedia di perangkat ini.",
"deepLinks.error.unknownVault": "Tautan dalam mengarah ke vault yang tidak dikenal.",
"deepLinks.error.unsafePath": "Jalur tautan dalam tidak boleh keluar dari vault.",
"noteGitUrls.copied": "URL Git disalin",
"noteGitUrls.error.copyFailed": "Gagal menyalin URL git: {detail}",
"noteGitUrls.error.unavailable": "URL Git tidak tersedia untuk catatan ini.",
"editor.slash.math": "Matematika",
"tableOfContents.title": "Daftar Isi",
"tableOfContents.close": "Tutup daftar isi",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Attiva/disattiva la visibilità dei file Gitignored",
"command.contribute": "Contribuisci",
"command.checkUpdates": "Verifica disponibilità aggiornamenti",
"menu.application": "Menu dell'applicazione",
"menu.file": "File",
"menu.edit": "Modifica",
"menu.view": "Visualizza",
"menu.go": "Vai",
"menu.note": "Nota",
"menu.vault": "Vault",
"menu.window": "Finestra",
"menu.file.quickOpen": "Apertura rapida",
"menu.file.quickOpenCmdO": "Apertura rapida (Cmd+O)",
"menu.file.quickOpenCtrlO": "Apertura rapida (Ctrl+O)",
"menu.file.save": "Salva",
"menu.edit.pasteWithoutFormatting": "Incolla senza formattazione",
"menu.edit.findInVault": "Trova in Vault",
"menu.edit.toggleNoteListSearch": "Attiva/disattiva ricerca nell'elenco note",
"menu.view.allPanels": "Tutti i pannelli",
"menu.view.zoomIn": "Ingrandisci",
"menu.view.zoomOut": "Riduci",
"menu.view.actualSize": "Dimensioni effettive",
"menu.view.commandPalette": "Tavolozza dei comandi",
"menu.go.allNotes": "Tutte le note",
"menu.go.archived": "Archiviato",
"menu.go.changes": "Modifiche",
"menu.go.inbox": "Posta in arrivo",
"menu.note.toggleOrganized": "Attiva/disattiva Organizzato",
"menu.note.toggleTableOfContents": "Attiva/disattiva indice",
"menu.vault.addRemote": "Aggiungi remoto…",
"feedback.title": "Contribuisci a Tolaria",
"feedback.description": "Scegli il percorso che si adatta a ciò che vuoi fare! Qualsiasi tipo di aiuto è apprezzato",
"feedback.sponsor.title": "Sponsor / Supporto",
"feedback.sponsor.description": "Sono Luca 👋 Il mio lavoro a tempo pieno è gestire Refactoring, una newsletter per oltre 170.000 ingegneri su come gestire buoni team e rilasciare software con l'AI. Scrivo di flussi di lavoro, intervisto leader del settore tecnologico (ad esempio DHH, Martin Fowler e altri) e gestisco una community privata di oltre 2000 ingegneri con coaching mensile dal vivo, AI club e altro ancora.\n\nTolaria è FOSS e lo sarà sempre. Se ti piace, il modo migliore per sostenerla è iscriverti alla newsletter.",
"feedback.sponsor.cta": "Dai un'occhiata a Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Richieste di funzionalità",
"feedback.featureRequests.description": "Cerca prima sulla bacheca, vota le idee esistenti e crea nuovi post quando sono davvero nuovi!",
"feedback.featureRequests.cta": "Apri Product Board",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Discussioni",
"feedback.discussions.description": "Usa Discussions per domande, conversazioni, show & tell e contesto della community.",
"feedback.discussions.cta": "Apri Discussions",
"feedback.discussions.linkLabel": "Discussioni GitHub",
"feedback.contributeCode.title": "Contribuisci con il codice",
"feedback.contributeCode.description": "Sono benvenute piccole PR mirate. Controlla prima la bacheca in modo da creare le cose giuste!",
"feedback.contributeCode.cta": "Pull request aperte",
"feedback.contributeCode.linkLabel": "Richieste di pull di GitHub",
"feedback.contributingGuide.cta": "Apri la Guida per i contributori",
"feedback.contributingGuide.linkLabel": "la guida per i contributori",
"feedback.reportBug.title": "Segnala un bug",
"feedback.reportBug.description": "Spiega come riprodurre il problema, cosa ti aspettavi e cosa è successo. Allega la diagnostica, per favore!",
"feedback.reportBug.cta": "Apri le issue di GitHub",
"feedback.reportBug.linkLabel": "Ticket GitHub",
"feedback.linkFallback.title": "Impossibile aprire {label} automaticamente.",
"feedback.linkFallback.description": "Apri invece questo URL manualmente:",
"feedback.copyDiagnostics": "Copia la diagnostica sanificata",
"feedback.diagnosticsCopied": "Diagnostica copiata",
"feedback.diagnosticsCopiedSentence": "Diagnostica copiata.",
"feedback.clipboardUnavailable": "L'accesso agli appunti non è disponibile. Puoi comunque aprire direttamente le issue di GitHub.",
"command.group.navigation": "Navigazione",
"command.group.note": "Nota",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Mostra nel Finder",
"editor.toolbar.copyFilePath": "Copia il percorso del file",
"editor.toolbar.copyNoteDeepLink": "Copia deeplink della nota",
"editor.toolbar.copyNoteGitUrl": "Copia URL Git",
"editor.toolbar.exportPdf": "Esporta nota in PDF",
"editor.toolbar.moreActions": "Altre azioni della nota",
"editor.toolbar.openProperties": "Apri il pannello delle proprietà",
"editor.formatting.highlight": "Evidenzia",
"editor.formatting.highlightTooltip": "Evidenziazione (persiste in markdown)",
"editor.whiteboard.enterFullscreen": "Espandi la lavagna",
"editor.whiteboard.exitFullscreen": "Esci dalla lavagna a schermo intero",
"editor.exportPdf.unavailable": "Apri una nota Markdown prima di esportare un PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Il vault del deep link non è disponibile su questo dispositivo.",
"deepLinks.error.unknownVault": "Il deep link punta a un vault sconosciuto.",
"deepLinks.error.unsafePath": "Il percorso del deep link non può uscire dal vault.",
"noteGitUrls.copied": "URL Git copiato",
"noteGitUrls.error.copyFailed": "Impossibile copiare l'URL Git: {detail}",
"noteGitUrls.error.unavailable": "L'URL Git non è disponibile per questa nota.",
"editor.slash.math": "Matematica",
"tableOfContents.title": "Indice",
"tableOfContents.close": "Chiudi l'indice",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Gitignoredファイルの表示/非表示を切り替える",
"command.contribute": "投稿する",
"command.checkUpdates": "更新の確認",
"menu.application": "アプリケーションメニュー",
"menu.file": "ファイル",
"menu.edit": "編集",
"menu.view": "表示",
"menu.go": "移動",
"menu.note": "ノート",
"menu.vault": "Vault",
"menu.window": "ウィンドウ",
"menu.file.quickOpen": "クイックオープン",
"menu.file.quickOpenCmdO": "クイックオープン (Cmd+O)",
"menu.file.quickOpenCtrlO": "クイックオープン (Ctrl+O)",
"menu.file.save": "保存",
"menu.edit.pasteWithoutFormatting": "書式設定なしで貼り付け",
"menu.edit.findInVault": "Vaultで検索",
"menu.edit.toggleNoteListSearch": "ノートリスト検索の切り替え",
"menu.view.allPanels": "すべてのパネル",
"menu.view.zoomIn": "拡大",
"menu.view.zoomOut": "縮小",
"menu.view.actualSize": "実寸",
"menu.view.commandPalette": "コマンドパレット",
"menu.go.allNotes": "すべてのノート",
"menu.go.archived": "アーカイブ済み",
"menu.go.changes": "変更",
"menu.go.inbox": "受信トレイ",
"menu.note.toggleOrganized": "整理済みに切り替える",
"menu.note.toggleTableOfContents": "目次の切り替え",
"menu.vault.addRemote": "リモートを追加…",
"feedback.title": "Tolariaに貢献する",
"feedback.description": "あなたがしたいことに合った道を選んでください!どのような支援でも大歓迎です。",
"feedback.sponsor.title": "スポンサー/サポート",
"feedback.sponsor.description": "Lucaです👋。フルタイムの仕事は、17万人以上のエンジニア向けに、優れたチームの運営方法やAIを活用したソフトウェアのリリース方法についてのニュースレター「Refactoring」を運営することです。ワークフローについて書いたり、技術リーダーDHH、Martin Fowlerなどにインタビューしたり、2000人以上のエンジニアからなるプライベートコミュニティを運営して、毎月のライブコーチングやAIクラブなどを提供しています。\n\nTolariaはFOSSであり、これからもずっとFOSSです。気に入ってくださった場合、ニュースレターに登録することが最善のサポート方法です。",
"feedback.sponsor.cta": "Refactoringをチェック",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "機能リクエスト",
"feedback.featureRequests.description": "まずはボードで検索し、既存のアイデアに賛成票を投じてください。本当に新しいアイデアの場合は、新しい投稿を作成してください!",
"feedback.featureRequests.cta": "製品ボードを開く",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "ディスカッション",
"feedback.discussions.description": "ディスカッションは、質問、会話、ショー&テル、コミュニティのコンテキストに使用してください。",
"feedback.discussions.cta": "ディスカッションを開く",
"feedback.discussions.linkLabel": "GitHub Discussions",
"feedback.contributeCode.title": "コードを投稿する",
"feedback.contributeCode.description": "小規模で焦点を絞ったPRを歓迎します。まずはボードを確認して、適切なものを作成してください",
"feedback.contributeCode.cta": "オープンなプルリクエスト",
"feedback.contributeCode.linkLabel": "GitHub プルリクエスト",
"feedback.contributingGuide.cta": "貢献ガイドを開く",
"feedback.contributingGuide.linkLabel": "貢献ガイド",
"feedback.reportBug.title": "バグを報告する",
"feedback.reportBug.description": "再現方法、期待していたこと、実際に起きたことを説明してください。診断情報を添付してください!",
"feedback.reportBug.cta": "GitHubのイシューを開く",
"feedback.reportBug.linkLabel": "GitHubのイシュー",
"feedback.linkFallback.title": "{label} を自動的に開くことができませんでした。",
"feedback.linkFallback.description": "代わりに、このURLを手動で開いてください。",
"feedback.copyDiagnostics": "サニタイズされた診断情報をコピー",
"feedback.diagnosticsCopied": "診断情報をコピーしました",
"feedback.diagnosticsCopiedSentence": "診断情報をコピーしました。",
"feedback.clipboardUnavailable": "現在、クリップボードへのアクセスは利用できません。GitHubのイシューは引き続き直接開くことができます。",
"command.group.navigation": "ナビゲーション",
"command.group.note": "メモ",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Finderで表示",
"editor.toolbar.copyFilePath": "ファイルパスをコピー",
"editor.toolbar.copyNoteDeepLink": "ノートのディープリンクをコピー",
"editor.toolbar.copyNoteGitUrl": "Git URLをコピー",
"editor.toolbar.exportPdf": "ートをPDFとしてエクスポート",
"editor.toolbar.moreActions": "その他のノート操作",
"editor.toolbar.openProperties": "プロパティパネルを開く",
"editor.formatting.highlight": "ハイライト",
"editor.formatting.highlightTooltip": "ハイライトMarkdownで保持",
"editor.whiteboard.enterFullscreen": "ホワイトボードを展開",
"editor.whiteboard.exitFullscreen": "フルスクリーンホワイトボードを終了",
"editor.exportPdf.unavailable": "PDFをエクスポートする前に、Markdownートを開いてください。",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "このデバイスではディープリンクのボールトは利用できません。",
"deepLinks.error.unknownVault": "ディープリンクのターゲットは不明なボールトです。",
"deepLinks.error.unsafePath": "ディープリンクのパスは、ボールトの外に出ることはできません。",
"noteGitUrls.copied": "Git URLをコピーしました",
"noteGitUrls.error.copyFailed": "Git URLのコピーに失敗しました{detail}",
"noteGitUrls.error.unavailable": "このートではGit URLは利用できません。",
"editor.slash.math": "数学",
"tableOfContents.title": "目次",
"tableOfContents.close": "目次を閉じる",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Gitignored 파일 표시/숨기기",
"command.contribute": "기여하기",
"command.checkUpdates": "업데이트 확인",
"menu.application": "애플리케이션 메뉴",
"menu.file": "파일",
"menu.edit": "편집",
"menu.view": "보기",
"menu.go": "이동",
"menu.note": "노트",
"menu.vault": "Vault",
"menu.window": "창",
"menu.file.quickOpen": "빠른 열기",
"menu.file.quickOpenCmdO": "빠른 열기(Cmd+O)",
"menu.file.quickOpenCtrlO": "빠른 열기(Ctrl+O)",
"menu.file.save": "저장",
"menu.edit.pasteWithoutFormatting": "서식 없이 붙여넣기",
"menu.edit.findInVault": "Vault에서 찾기",
"menu.edit.toggleNoteListSearch": "노트 목록 검색 전환",
"menu.view.allPanels": "모든 패널",
"menu.view.zoomIn": "확대",
"menu.view.zoomOut": "축소",
"menu.view.actualSize": "실제 크기",
"menu.view.commandPalette": "명령 팔레트",
"menu.go.allNotes": "모든 노트",
"menu.go.archived": "보관됨",
"menu.go.changes": "변경 사항",
"menu.go.inbox": "받은 편지함",
"menu.note.toggleOrganized": "정리됨 전환",
"menu.note.toggleTableOfContents": "목차 토글",
"menu.vault.addRemote": "원격 추가…",
"feedback.title": "Tolaria에 기여하기",
"feedback.description": "원하는 일에 맞는 경로를 선택하세요! 어떤 유형의 도움도 감사드립니다",
"feedback.sponsor.title": "스폰서 / 후원",
"feedback.sponsor.description": "Luca입니다 👋 제 정규직 업무는 17만 명 이상의 엔지니어를 대상으로 좋은 팀을 운영하고 AI로 소프트웨어를 출시하는 방법에 대한 뉴스레터인 Refactoring을 운영하는 것입니다. 워크플로에 대해 글을 쓰고, DHH, Martin Fowler 등 기술 리더들을 인터뷰하며, 월간 라이브 코칭, AI 클럽 등을 통해 2,000명 이상의 엔지니어로 구성된 비공개 커뮤니티를 운영하고 있습니다.\n\nTolaria는 FOSS이며 앞으로도 항상 그럴 것입니다. 마음에 드신다면, 뉴스레터를 구독하는 것이 가장 좋은 지원 방법입니다.",
"feedback.sponsor.cta": "Refactoring 확인하기",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "기능 요청",
"feedback.featureRequests.description": "먼저 게시판에서 검색하고, 기존 아이디어에 찬성 투표한 다음, 정말로 새로운 아이디어일 때 새 게시물을 작성하세요!",
"feedback.featureRequests.cta": "제품 보드 열기",
"feedback.featureRequests.linkLabel": "제품 보드",
"feedback.discussions.title": "토론",
"feedback.discussions.description": "질문, 대화, 쇼앤텔, 커뮤니티 맥락에 대해 논의에 참여하세요.",
"feedback.discussions.cta": "Discussions 열기",
"feedback.discussions.linkLabel": "GitHub 토론",
"feedback.contributeCode.title": "코드 기여",
"feedback.contributeCode.description": "작고 집중된 PR을 환영합니다. 먼저 보드를 확인하여 올바른 것을 구축하세요!",
"feedback.contributeCode.cta": "진행 중인 풀 요청",
"feedback.contributeCode.linkLabel": "GitHub Pull Request",
"feedback.contributingGuide.cta": "기여 가이드 열기",
"feedback.contributingGuide.linkLabel": "기여 가이드",
"feedback.reportBug.title": "버그 신고",
"feedback.reportBug.description": "재현 방법을 설명하고, 예상했던 것과 실제 발생한 것을 비교해주세요. 진단 정보를 첨부해주세요!",
"feedback.reportBug.cta": "GitHub 이슈 열기",
"feedback.reportBug.linkLabel": "GitHub 이슈",
"feedback.linkFallback.title": "{label}을(를) 자동으로 열 수 없습니다.",
"feedback.linkFallback.description": "대신 이 URL을 수동으로 여세요:",
"feedback.copyDiagnostics": "정리된 진단 정보 복사",
"feedback.diagnosticsCopied": "진단 정보 복사됨",
"feedback.diagnosticsCopiedSentence": "진단 정보가 복사되었습니다.",
"feedback.clipboardUnavailable": "현재 클립보드에 액세스할 수 없습니다. 여전히 GitHub 이슈를 직접 열 수 있습니다.",
"command.group.navigation": "탐색",
"command.group.note": "노트",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Finder에서 표시",
"editor.toolbar.copyFilePath": "파일 경로 복사",
"editor.toolbar.copyNoteDeepLink": "노트 딥링크 복사",
"editor.toolbar.copyNoteGitUrl": "Git URL 복사",
"editor.toolbar.exportPdf": "노트를 PDF로 내보내기",
"editor.toolbar.moreActions": "추가 노트 작업",
"editor.toolbar.openProperties": "속성 패널 열기",
"editor.formatting.highlight": "강조",
"editor.formatting.highlightTooltip": "강조 표시(마크다운에 유지)",
"editor.whiteboard.enterFullscreen": "화이트보드 펼치기",
"editor.whiteboard.exitFullscreen": "전체 화면 화이트보드에서 나가기",
"editor.exportPdf.unavailable": "PDF로 내보내기 전에 Markdown 노트를 여세요.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "이 장치에서는 딥 링크 보관을 사용할 수 없습니다.",
"deepLinks.error.unknownVault": "딥 링크가 알 수 없는 볼트를 대상으로 합니다.",
"deepLinks.error.unsafePath": "딥 링크 경로는 볼트를 벗어날 수 없습니다.",
"noteGitUrls.copied": "Git URL 복사됨",
"noteGitUrls.error.copyFailed": "Git URL 복사에 실패했습니다: {detail}",
"noteGitUrls.error.unavailable": "이 노트에 대한 Git URL을 사용할 수 없습니다.",
"editor.slash.math": "수학",
"tableOfContents.title": "목차",
"tableOfContents.close": "목차 닫기",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Przełącz widoczność plików ignorowanych przez Git",
"command.contribute": "Współtwórz",
"command.checkUpdates": "Sprawdź aktualizacje",
"menu.application": "Menu aplikacji",
"menu.file": "Plik",
"menu.edit": "Edytuj",
"menu.view": "Widok",
"menu.go": "Przejdź",
"menu.note": "Notatka",
"menu.vault": "Magazyn",
"menu.window": "Okno",
"menu.file.quickOpen": "Szybkie otwieranie",
"menu.file.quickOpenCmdO": "Szybkie otwieranie (Cmd+O)",
"menu.file.quickOpenCtrlO": "Szybkie otwieranie (Ctrl+O)",
"menu.file.save": "Zapisz",
"menu.edit.pasteWithoutFormatting": "Wklej bez formatowania",
"menu.edit.findInVault": "Znajdź w skarbcu",
"menu.edit.toggleNoteListSearch": "Przełącz wyszukiwanie na liście notatek",
"menu.view.allPanels": "Wszystkie panele",
"menu.view.zoomIn": "Powiększ",
"menu.view.zoomOut": "Pomniejsz",
"menu.view.actualSize": "Rozmiar rzeczywisty",
"menu.view.commandPalette": "Paleta poleceń",
"menu.go.allNotes": "Wszystkie notatki",
"menu.go.archived": "Zarchiwizowane",
"menu.go.changes": "Zmiany",
"menu.go.inbox": "Skrzynka odbiorcza",
"menu.note.toggleOrganized": "Przełącz na zorganizowane",
"menu.note.toggleTableOfContents": "Przełączanie spisu treści",
"menu.vault.addRemote": "Dodaj zdalne…",
"feedback.title": "Wspieraj Tolarię",
"feedback.description": "Wybierz ścieżkę, która pasuje do tego, co chcesz robić! Docenimy każdą pomoc",
"feedback.sponsor.title": "Sponsor / Wsparcie",
"feedback.sponsor.description": "Tu Luca 👋 Moja praca na pełny etat to prowadzenie Refactoring, newslettera dla ponad 170 000 inżynierów na temat tego, jak zarządzać dobrymi zespołami i dostarczać oprogramowanie z wykorzystaniem sztucznej inteligencji. Piszę o przepływach pracy, przeprowadzam wywiady z liderami technologii (np. DHH, Martinem Fowlerem i innymi) i prowadzę prywatną społeczność ponad 2000 inżynierów, w której organizuję comiesięczny coaching na żywo, klub AI i wiele innych aktywności.\n\nTolaria jest FOSS i zawsze taka pozostanie. Jeśli Ci się podoba, najlepszym sposobem na wsparcie jest subskrypcja newslettera.",
"feedback.sponsor.cta": "Sprawdź Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Propozycje funkcji",
"feedback.featureRequests.description": "Najpierw przeszukaj tablicę, zagłosuj na istniejące pomysły i utwórz nowe posty, gdy są naprawdę nowe!",
"feedback.featureRequests.cta": "Otwórz tablicę produktu",
"feedback.featureRequests.linkLabel": "Tablica produktów",
"feedback.discussions.title": "Dyskusje",
"feedback.discussions.description": "Używaj Dyskusji do zadawania pytań, prowadzenia rozmów, prezentowania i opowiadania oraz do poznawania kontekstu społeczności.",
"feedback.discussions.cta": "Otwórz dyskusje",
"feedback.discussions.linkLabel": "Dyskusje na GitHubie",
"feedback.contributeCode.title": "Prześlij kod",
"feedback.contributeCode.description": "Mile widziane są małe, ukierunkowane PR-y. Najpierw sprawdź tablicę, żeby tworzyć właściwe rzeczy!",
"feedback.contributeCode.cta": "Otwarte pull requesty",
"feedback.contributeCode.linkLabel": "Pull requesty na GitHubie",
"feedback.contributingGuide.cta": "Otwórz Przewodnik dla współtwórców",
"feedback.contributingGuide.linkLabel": "przewodnik dla współtwórców",
"feedback.reportBug.title": "Zgłoś błąd",
"feedback.reportBug.description": "Wyjaśnij, jak odtworzyć problem, czego się spodziewałeś(-aś) i co się stało. Proszę dołączyć diagnostykę!",
"feedback.reportBug.cta": "Otwórz zgłoszenia na GitHubie",
"feedback.reportBug.linkLabel": "Zgłoszenia na GitHubie",
"feedback.linkFallback.title": "Nie można było automatycznie otworzyć {label}.",
"feedback.linkFallback.description": "Zamiast tego otwórz ten adres URL ręcznie:",
"feedback.copyDiagnostics": "Skopiuj oczyszczoną diagnostykę",
"feedback.diagnosticsCopied": "Skopiowano diagnostykę",
"feedback.diagnosticsCopiedSentence": "Skopiowano diagnostykę.",
"feedback.clipboardUnavailable": "Dostęp do schowka jest obecnie niedostępny. Nadal możesz bezpośrednio otwierać zgłoszenia na GitHubie.",
"command.group.navigation": "Nawigacja",
"command.group.note": "Notatka",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Pokaż w Finderze",
"editor.toolbar.copyFilePath": "Kopiuj ścieżkę pliku",
"editor.toolbar.copyNoteDeepLink": "Skopiuj deeplink do notatki",
"editor.toolbar.copyNoteGitUrl": "Skopiuj adres URL Git",
"editor.toolbar.exportPdf": "Eksportuj notatkę jako PDF",
"editor.toolbar.moreActions": "Więcej działań notatki",
"editor.toolbar.openProperties": "Otwórz panel właściwości",
"editor.formatting.highlight": "Wyróżnienie",
"editor.formatting.highlightTooltip": "Wyróżnienie (zachowuje się w Markdownie)",
"editor.whiteboard.enterFullscreen": "Rozwiń tablicę",
"editor.whiteboard.exitFullscreen": "Wyjdź z tablicy pełnoekranowej",
"editor.exportPdf.unavailable": "Otwórz notatkę Markdown przed wyeksportowaniem pliku PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Magazyn deep linków nie jest dostępny na tym urządzeniu.",
"deepLinks.error.unknownVault": "Głęboki link wskazuje na nieznany skarbiec.",
"deepLinks.error.unsafePath": "Ścieżka deep linku nie może wychodzić poza skarbiec.",
"noteGitUrls.copied": "Skopiowano adres URL Git",
"noteGitUrls.error.copyFailed": "Nie udało się skopiować adresu URL Git: {detail}",
"noteGitUrls.error.unavailable": "Adres URL Git nie jest dostępny dla tej notatki.",
"editor.slash.math": "Matematyka",
"tableOfContents.title": "Spis treści",
"tableOfContents.close": "Zamknij spis treści",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Alternar visibilidade dos arquivos Gitignored",
"command.contribute": "Contribuir",
"command.checkUpdates": "Verificar atualizações",
"menu.application": "Menu do aplicativo",
"menu.file": "Arquivo",
"menu.edit": "Editar",
"menu.view": "Visualizar",
"menu.go": "Ir",
"menu.note": "Nota",
"menu.vault": "Cofre",
"menu.window": "Janela",
"menu.file.quickOpen": "Abertura rápida",
"menu.file.quickOpenCmdO": "Abertura Rápida (Cmd+O)",
"menu.file.quickOpenCtrlO": "Abertura Rápida (Ctrl+O)",
"menu.file.save": "Salvar",
"menu.edit.pasteWithoutFormatting": "Colar sem formatação",
"menu.edit.findInVault": "Encontrar no Vault",
"menu.edit.toggleNoteListSearch": "Alternar pesquisa na lista de notas",
"menu.view.allPanels": "Todos os painéis",
"menu.view.zoomIn": "Ampliar",
"menu.view.zoomOut": "Diminuir zoom",
"menu.view.actualSize": "Tamanho real",
"menu.view.commandPalette": "Paleta de comandos",
"menu.go.allNotes": "Todas as notas",
"menu.go.archived": "Arquivado",
"menu.go.changes": "Alterações",
"menu.go.inbox": "Caixa de entrada",
"menu.note.toggleOrganized": "Alternar para Organizado",
"menu.note.toggleTableOfContents": "Alternar Índice",
"menu.vault.addRemote": "Adicionar remoto…",
"feedback.title": "Contribua para a Tolaria",
"feedback.description": "Escolha o caminho que se encaixa no que você quer fazer! Qualquer tipo de ajuda é bem-vinda",
"feedback.sponsor.title": "Patrocinador / Apoio",
"feedback.sponsor.description": "Aqui é o Luca 👋. Meu trabalho em tempo integral é administrar o Refactoring, um boletim informativo para mais de 170 mil engenheiros sobre como gerenciar boas equipes e entregar software com IA. Escrevo sobre fluxos de trabalho, entrevisto líderes de tecnologia (por exemplo, DHH, Martin Fowler e outros) e administro uma comunidade privada de mais de 2.000 engenheiros com coaching mensal ao vivo, clube de IA e muito mais.\n\nTolaria é FOSS e sempre será. Se você gostar, a melhor maneira de apoiar é assinar a newsletter.",
"feedback.sponsor.cta": "Confira o Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Solicitações de recursos",
"feedback.featureRequests.description": "Pesquise no fórum primeiro, vote nas ideias existentes e crie novos posts quando forem genuinamente novos!",
"feedback.featureRequests.cta": "Abrir Product Board",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Discussões",
"feedback.discussions.description": "Use Discussions para perguntas, conversas, show & tell e contexto da comunidade.",
"feedback.discussions.cta": "Abrir Discussions",
"feedback.discussions.linkLabel": "Discussões do GitHub",
"feedback.contributeCode.title": "Contribuir com código",
"feedback.contributeCode.description": "PRs pequenas e focadas são bem-vindas. Verifique o quadro primeiro para criar as coisas certas!",
"feedback.contributeCode.cta": "Pull Requests abertas",
"feedback.contributeCode.linkLabel": "Pull requests do GitHub",
"feedback.contributingGuide.cta": "Abrir Guia de Contribuição",
"feedback.contributingGuide.linkLabel": "o guia de contribuição",
"feedback.reportBug.title": "Relatar um bug",
"feedback.reportBug.description": "Explique como reproduzir, o que você esperava e o que aconteceu. Anexe o diagnóstico, por favor!",
"feedback.reportBug.cta": "Abrir issues do GitHub",
"feedback.reportBug.linkLabel": "Problemas do GitHub",
"feedback.linkFallback.title": "Não foi possível abrir {label} automaticamente.",
"feedback.linkFallback.description": "Em vez disso, abra este URL manualmente:",
"feedback.copyDiagnostics": "Copiar diagnósticos limpos",
"feedback.diagnosticsCopied": "Diagnósticos copiados",
"feedback.diagnosticsCopiedSentence": "Diagnósticos copiados.",
"feedback.clipboardUnavailable": "O acesso à área de transferência não está disponível no momento. Você ainda pode abrir os problemas do GitHub diretamente.",
"command.group.navigation": "Navegação",
"command.group.note": "Observação",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Exibir no Finder",
"editor.toolbar.copyFilePath": "Copiar caminho do arquivo",
"editor.toolbar.copyNoteDeepLink": "Copiar deeplink da nota",
"editor.toolbar.copyNoteGitUrl": "Copiar URL do Git",
"editor.toolbar.exportPdf": "Exportar nota como PDF",
"editor.toolbar.moreActions": "Mais ações da nota",
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
"editor.formatting.highlight": "Destaque",
"editor.formatting.highlightTooltip": "Destaque (persiste no markdown)",
"editor.whiteboard.enterFullscreen": "Expandir quadro branco",
"editor.whiteboard.exitFullscreen": "Sair do quadro branco em tela cheia",
"editor.exportPdf.unavailable": "Abra uma nota Markdown antes de exportar um PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "O cofre do deep link não está disponível neste dispositivo.",
"deepLinks.error.unknownVault": "O deep link aponta para um cofre desconhecido.",
"deepLinks.error.unsafePath": "O caminho do deep link não pode sair do cofre.",
"noteGitUrls.copied": "URL do Git copiada",
"noteGitUrls.error.copyFailed": "Falha ao copiar o URL do Git: {detail}",
"noteGitUrls.error.unavailable": "O URL do Git não está disponível para esta nota.",
"editor.slash.math": "Matemática",
"tableOfContents.title": "Índice",
"tableOfContents.close": "Fechar índice",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Alternar a visibilidade dos ficheiros Gitignored",
"command.contribute": "Contribuir",
"command.checkUpdates": "Verificar atualizações",
"menu.application": "Menu da aplicação",
"menu.file": "Ficheiro",
"menu.edit": "Editar",
"menu.view": "Ver",
"menu.go": "Ir",
"menu.note": "Nota",
"menu.vault": "Cofre",
"menu.window": "Janela",
"menu.file.quickOpen": "Abertura rápida",
"menu.file.quickOpenCmdO": "Abertura rápida (Cmd+O)",
"menu.file.quickOpenCtrlO": "Abertura rápida (Ctrl+O)",
"menu.file.save": "Guardar",
"menu.edit.pasteWithoutFormatting": "Colar sem formatação",
"menu.edit.findInVault": "Procurar no Vault",
"menu.edit.toggleNoteListSearch": "Ativar/desativar a pesquisa na lista de notas",
"menu.view.allPanels": "Todos os painéis",
"menu.view.zoomIn": "Ampliar",
"menu.view.zoomOut": "Reduzir",
"menu.view.actualSize": "Tamanho real",
"menu.view.commandPalette": "Paleta de comandos",
"menu.go.allNotes": "Todas as notas",
"menu.go.archived": "Arquivado",
"menu.go.changes": "Alterações",
"menu.go.inbox": "Caixa de entrada",
"menu.note.toggleOrganized": "Alternar para Organizado",
"menu.note.toggleTableOfContents": "Alternar Índice",
"menu.vault.addRemote": "Adicionar Remoto…",
"feedback.title": "Contribuir para a Tolaria",
"feedback.description": "Escolha o caminho que se adequa ao que pretende fazer! Qualquer tipo de ajuda é bem-vinda",
"feedback.sponsor.title": "Patrocinador / Apoio",
"feedback.sponsor.description": "Sou o Luca 👋 O meu trabalho a tempo inteiro é gerir a Refactoring, uma newsletter para mais de 170 mil engenheiros sobre como gerir boas equipas e lançar software com IA. Escrevo sobre fluxos de trabalho, entrevisto líderes de tecnologia (por exemplo, DHH, Martin Fowler e outros) e gerio uma comunidade privada de mais de 2000 engenheiros com coaching mensal em direto, clube de IA e muito mais.\n\nA Tolaria é FOSS e sempre será. Se gostar, a melhor forma de apoiar é subscrever a newsletter.",
"feedback.sponsor.cta": "Veja a Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Pedidos de funcionalidades",
"feedback.featureRequests.description": "Pesquise primeiro no fórum, vote em ideias existentes e crie novas publicações quando forem genuinamente novas!",
"feedback.featureRequests.cta": "Abrir Product Board",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Discussões",
"feedback.discussions.description": "Use as Discussões para perguntas, conversas, apresentações e contexto da comunidade.",
"feedback.discussions.cta": "Abrir Discussões",
"feedback.discussions.linkLabel": "Discussões do GitHub",
"feedback.contributeCode.title": "Contribuir com código",
"feedback.contributeCode.description": "PR pequenos e focados são bem-vindos. Verifique primeiro o quadro para criar as coisas certas!",
"feedback.contributeCode.cta": "Pull Requests abertos",
"feedback.contributeCode.linkLabel": "Pull Requests do GitHub",
"feedback.contributingGuide.cta": "Abrir Guia de Contribuição",
"feedback.contributingGuide.linkLabel": "o guia de contribuição",
"feedback.reportBug.title": "Comunicar um erro",
"feedback.reportBug.description": "Explique como reproduzir, o que esperava e o que aconteceu. Anexe o diagnóstico, por favor!",
"feedback.reportBug.cta": "Abrir problemas do GitHub",
"feedback.reportBug.linkLabel": "Problemas do GitHub",
"feedback.linkFallback.title": "Não foi possível abrir {label} automaticamente.",
"feedback.linkFallback.description": "Em vez disso, abra este URL manualmente:",
"feedback.copyDiagnostics": "Copiar diagnósticos limpos",
"feedback.diagnosticsCopied": "Diagnósticos copiados",
"feedback.diagnosticsCopiedSentence": "Diagnósticos copiados.",
"feedback.clipboardUnavailable": "O acesso à área de transferência não está disponível neste momento. Ainda pode abrir os problemas do GitHub diretamente.",
"command.group.navigation": "Navegação",
"command.group.note": "Nota",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Mostrar no Finder",
"editor.toolbar.copyFilePath": "Copiar caminho do ficheiro",
"editor.toolbar.copyNoteDeepLink": "Copiar deeplink da nota",
"editor.toolbar.copyNoteGitUrl": "Copiar URL do Git",
"editor.toolbar.exportPdf": "Exportar nota como PDF",
"editor.toolbar.moreActions": "Mais ações da nota",
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
"editor.formatting.highlight": "Destaque",
"editor.formatting.highlightTooltip": "Destaque (persiste em markdown)",
"editor.whiteboard.enterFullscreen": "Expandir quadro branco",
"editor.whiteboard.exitFullscreen": "Sair do quadro branco em ecrã inteiro",
"editor.exportPdf.unavailable": "Abra uma nota Markdown antes de exportar um PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "O cofre da ligação profunda não está disponível neste dispositivo.",
"deepLinks.error.unknownVault": "O deep link aponta para um cofre desconhecido.",
"deepLinks.error.unsafePath": "O caminho do deep link não pode sair do cofre.",
"noteGitUrls.copied": "URL do Git copiado",
"noteGitUrls.error.copyFailed": "Falha ao copiar o URL do Git: {detail}",
"noteGitUrls.error.unavailable": "O URL do Git não está disponível para esta nota.",
"editor.slash.math": "Matemática",
"tableOfContents.title": "Índice",
"tableOfContents.close": "Fechar índice",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Переключить отображение файлов Gitignored",
"command.contribute": "Внести свой вклад",
"command.checkUpdates": "Проверить наличие обновлений",
"menu.application": "Меню приложения",
"menu.file": "Файл",
"menu.edit": "Редактировать",
"menu.view": "Просмотр",
"menu.go": "Перейти",
"menu.note": "Заметка",
"menu.vault": "Хранилище",
"menu.window": "Окно",
"menu.file.quickOpen": "Быстрое открытие",
"menu.file.quickOpenCmdO": "Быстрое открытие (Cmd+O)",
"menu.file.quickOpenCtrlO": "Быстрое открытие (Ctrl+O)",
"menu.file.save": "Сохранить",
"menu.edit.pasteWithoutFormatting": "Вставить без форматирования",
"menu.edit.findInVault": "Найти в Vault",
"menu.edit.toggleNoteListSearch": "Переключить поиск по списку заметок",
"menu.view.allPanels": "Все панели",
"menu.view.zoomIn": "Увеличить",
"menu.view.zoomOut": "Уменьшить",
"menu.view.actualSize": "Фактический размер",
"menu.view.commandPalette": "Панель команд",
"menu.go.allNotes": "Все заметки",
"menu.go.archived": "Архивировано",
"menu.go.changes": "Изменения",
"menu.go.inbox": "Входящие",
"menu.note.toggleOrganized": "Переключить «Упорядоченное»",
"menu.note.toggleTableOfContents": "Переключить оглавление",
"menu.vault.addRemote": "Добавить удаленный…",
"feedback.title": "Внести вклад в Tolaria",
"feedback.description": "Выберите путь, который соответствует тому, что вы хотите сделать! Будем признательны за любую помощь",
"feedback.sponsor.title": "Спонсорство / Поддержка",
"feedback.sponsor.description": "Привет, я Лука 👋. Моя основная работа — вести Refactoring, рассылку для более чем 170 000 инженеров о том, как управлять хорошими командами и выпускать программное обеспечение с помощью ИИ. Я пишу о рабочих процессах, беру интервью у лидеров в сфере технологий (например, у DHH, Мартина Фаулера и других) и веду закрытое сообщество, в котором более 2000 инженеров. В нём я провожу ежемесячные коучинг-сессии в прямом эфире, веду клуб по искусственному интеллекту и многое другое.\n\nTolaria — это FOSS, и она всегда будет такой. Если вам нравится приложение, лучший способ поддержать его — подписаться на рассылку.",
"feedback.sponsor.cta": "Ознакомьтесь с Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Запросы функций",
"feedback.featureRequests.description": "Сначала выполните поиск на доске, проголосуйте за существующие идеи и создайте новые сообщения, если они действительно новые!",
"feedback.featureRequests.cta": "Открыть доску продукта",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Обсуждения",
"feedback.discussions.description": "Используйте «Обсуждения» для вопросов, бесед, демонстраций и рассказов, а также для контекста сообщества.",
"feedback.discussions.cta": "Открытые обсуждения",
"feedback.discussions.linkLabel": "Обсуждения GitHub",
"feedback.contributeCode.title": "Предоставьте код",
"feedback.contributeCode.description": "Приветствуются небольшие, целенаправленные PR. Сначала проверьте доску, чтобы создавать нужные вещи!",
"feedback.contributeCode.cta": "Открытые пулреквесты",
"feedback.contributeCode.linkLabel": "Запросы на включение внесенных изменений GitHub",
"feedback.contributingGuide.cta": "Открыть руководство для участников",
"feedback.contributingGuide.linkLabel": "руководство по участию",
"feedback.reportBug.title": "Сообщить об ошибке",
"feedback.reportBug.description": "Объясните, как воспроизвести ошибку, и сравните, что вы ожидали, и что произошло. Пожалуйста, приложите диагностику!",
"feedback.reportBug.cta": "Открыть проблемы GitHub",
"feedback.reportBug.linkLabel": "Задачи GitHub",
"feedback.linkFallback.title": "Не удалось открыть {label} автоматически.",
"feedback.linkFallback.description": "Вместо этого откройте этот URL вручную:",
"feedback.copyDiagnostics": "Копировать очищенную диагностику",
"feedback.diagnosticsCopied": "Диагностика скопирована",
"feedback.diagnosticsCopiedSentence": "Диагностика скопирована.",
"feedback.clipboardUnavailable": "Доступ к буферу обмена в данный момент недоступен. Вы по-прежнему можете открывать проблемы GitHub напрямую.",
"command.group.navigation": "Навигация",
"command.group.note": "Примечание",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Показать в Finder",
"editor.toolbar.copyFilePath": "Копировать путь к файлу",
"editor.toolbar.copyNoteDeepLink": "Копировать диплинк заметки",
"editor.toolbar.copyNoteGitUrl": "Копировать URL Git",
"editor.toolbar.exportPdf": "Экспортировать заметку в формате PDF",
"editor.toolbar.moreActions": "Другие действия с заметкой",
"editor.toolbar.openProperties": "Открыть панель свойств",
"editor.formatting.highlight": "Выделение",
"editor.formatting.highlightTooltip": "Выделение (сохраняется в Markdown)",
"editor.whiteboard.enterFullscreen": "Развернуть доску",
"editor.whiteboard.exitFullscreen": "Выйти из полноэкранной доски",
"editor.exportPdf.unavailable": "Откройте заметку Markdown перед экспортом в PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Хранилище для глубокой ссылки недоступно на этом устройстве.",
"deepLinks.error.unknownVault": "Глубинная ссылка указывает на неизвестное хранилище.",
"deepLinks.error.unsafePath": "Путь глубокой ссылки не может выходить за пределы хранилища.",
"noteGitUrls.copied": "URL Git скопирован",
"noteGitUrls.error.copyFailed": "Не удалось скопировать URL-адрес Git: {detail}",
"noteGitUrls.error.unavailable": "URL-адрес Git недоступен для этой заметки.",
"editor.slash.math": "Математика",
"tableOfContents.title": "Содержание",
"tableOfContents.close": "Закрыть оглавление",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "Bật/tắt hiển thị Git Ignored",
"command.contribute": "Đóng góp",
"command.checkUpdates": "Kiểm tra cập nhật",
"menu.application": "Menu ứng dụng",
"menu.file": "Tệp",
"menu.edit": "Chỉnh sửa",
"menu.view": "Xem",
"menu.go": "Đi",
"menu.note": "Ghi chú",
"menu.vault": "Kho lưu trữ",
"menu.window": "Cửa sổ",
"menu.file.quickOpen": "Mở nhanh",
"menu.file.quickOpenCmdO": "Mở nhanh (Cmd+O)",
"menu.file.quickOpenCtrlO": "Mở nhanh (Ctrl+O)",
"menu.file.save": "Lưu",
"menu.edit.pasteWithoutFormatting": "Dán mà không định dạng",
"menu.edit.findInVault": "Tìm trong Vault",
"menu.edit.toggleNoteListSearch": "Bật/tắt Tìm kiếm danh sách ghi chú",
"menu.view.allPanels": "Tất cả các bảng",
"menu.view.zoomIn": "Phóng to",
"menu.view.zoomOut": "Thu nhỏ",
"menu.view.actualSize": "Kích thước thực tế",
"menu.view.commandPalette": "Bảng điều khiển lệnh",
"menu.go.allNotes": "Tất cả ghi chú",
"menu.go.archived": "Đã lưu trữ",
"menu.go.changes": "Thay đổi",
"menu.go.inbox": "Hộp thư đến",
"menu.note.toggleOrganized": "Bật/tắt chế độ Đã sắp xếp",
"menu.note.toggleTableOfContents": "Bật/tắt mục lục",
"menu.vault.addRemote": "Thêm từ xa…",
"feedback.title": "Đóng góp cho Tolaria",
"feedback.description": "Chọn con đường phù hợp với những gì bạn muốn làm! Mọi sự giúp đỡ đều được đánh giá cao",
"feedback.sponsor.title": "Tài trợ / Hỗ trợ",
"feedback.sponsor.description": "Luca đây 👋 công việc toàn thời gian của tôi là điều hành Refactoring, một bản tin dành cho hơn 170.000 kỹ sư về cách điều hành các nhóm tốt và phát hành phần mềm với AI. Tôi viết về quy trình làm việc, phỏng vấn các nhà lãnh đạo công nghệ (ví dụ: DHH, Martin Fowler, v.v.) và điều hành một cộng đồng riêng gồm hơn 2000 kỹ sư với hoạt động huấn luyện trực tiếp hằng tháng, câu lạc bộ AI, v.v.\n\nTolaria là FOSS và sẽ luôn như vậy. Nếu bạn thích ứng dụng, cách tốt nhất để hỗ trợ là đăng ký nhận bản tin.",
"feedback.sponsor.cta": "Khám phá Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "Yêu cầu tính năng",
"feedback.featureRequests.description": "Trước tiên, hãy tìm kiếm trên bảng, bình chọn cho các ý tưởng hiện có và tạo bài đăng mới khi ý tưởng thực sự mới!",
"feedback.featureRequests.cta": "Mở Product Board",
"feedback.featureRequests.linkLabel": "Product Board",
"feedback.discussions.title": "Thảo luận",
"feedback.discussions.description": "Sử dụng Discussions để đặt câu hỏi, trò chuyện, chia sẻ và tìm hiểu bối cảnh cộng đồng.",
"feedback.discussions.cta": "Mở Thảo luận",
"feedback.discussions.linkLabel": "Thảo luận trên GitHub",
"feedback.contributeCode.title": "Đóng góp mã",
"feedback.contributeCode.description": "Chúng tôi hoan nghênh các PR nhỏ, tập trung. Trước tiên, hãy kiểm tra bảng để bạn xây dựng đúng thứ!",
"feedback.contributeCode.cta": "Mở Pull Request",
"feedback.contributeCode.linkLabel": "Yêu cầu kéo GitHub",
"feedback.contributingGuide.cta": "Mở Hướng dẫn đóng góp",
"feedback.contributingGuide.linkLabel": "hướng dẫn đóng góp",
"feedback.reportBug.title": "Báo lỗi",
"feedback.reportBug.description": "Giải thích cách tái tạo, những gì bạn mong đợi so với những gì đã xảy ra. Vui lòng đính kèm thông tin chẩn đoán!",
"feedback.reportBug.cta": "Mở các vấn đề trên GitHub",
"feedback.reportBug.linkLabel": "Vấn đề GitHub",
"feedback.linkFallback.title": "Không thể mở {label} tự động.",
"feedback.linkFallback.description": "Thay vào đó, hãy mở URL này theo cách thủ công:",
"feedback.copyDiagnostics": "Sao chép thông tin chẩn đoán đã được làm sạch",
"feedback.diagnosticsCopied": "Đã sao chép thông tin chẩn đoán",
"feedback.diagnosticsCopiedSentence": "Đã sao chép thông tin chẩn đoán.",
"feedback.clipboardUnavailable": "Hiện không thể truy cập vào bộ nhớ tạm. Bạn vẫn có thể mở trực tiếp các Vấn đề trên GitHub.",
"command.group.navigation": "Điều hướng",
"command.group.note": "Ghi chú",
"command.group.git": "Git",
@@ -529,9 +586,12 @@
"editor.toolbar.revealFile": "Hiển thị trong Finder",
"editor.toolbar.copyFilePath": "Sao chép đường dẫn tệp",
"editor.toolbar.copyNoteDeepLink": "Sao chép liên kết sâu của ghi chú",
"editor.toolbar.copyNoteGitUrl": "Sao chép URL git",
"editor.toolbar.exportPdf": "Xuất ghi chú dưới dạng PDF",
"editor.toolbar.moreActions": "Thêm hành động ghi chú",
"editor.toolbar.openProperties": "Mở bảng thuộc tính",
"editor.formatting.highlight": "Đánh dấu",
"editor.formatting.highlightTooltip": "Đánh dấu (duy trì trong markdown)",
"editor.whiteboard.enterFullscreen": "Mở rộng bảng trắng",
"editor.whiteboard.exitFullscreen": "Thoát chế độ bảng trắng toàn màn hình",
"editor.exportPdf.unavailable": "Mở ghi chú Markdown trước khi xuất PDF.",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "Kho liên kết sâu không khả dụng trên thiết bị này.",
"deepLinks.error.unknownVault": "Liên kết sâu trỏ đến một vault không xác định.",
"deepLinks.error.unsafePath": "Đường dẫn liên kết sâu không thể nằm ngoài vault.",
"noteGitUrls.copied": "Đã sao chép URL Git",
"noteGitUrls.error.copyFailed": "Không thể sao chép URL git: {detail}",
"noteGitUrls.error.unavailable": "URL Git không khả dụng cho ghi chú này.",
"editor.slash.math": "Toán học",
"tableOfContents.title": "Mục lục",
"tableOfContents.close": "Đóng mục lục",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "切换 Gitignored 文件的可见性",
"command.contribute": "参与贡献",
"command.checkUpdates": "检查更新",
"menu.application": "应用菜单",
"menu.file": "文件",
"menu.edit": "编辑",
"menu.view": "视图",
"menu.go": "前往",
"menu.note": "笔记",
"menu.vault": "仓库",
"menu.window": "窗口",
"menu.file.quickOpen": "快速打开",
"menu.file.quickOpenCmdO": "快速打开Cmd+O",
"menu.file.quickOpenCtrlO": "快速打开Ctrl+O",
"menu.file.save": "保存",
"menu.edit.pasteWithoutFormatting": "粘贴为纯文本",
"menu.edit.findInVault": "在仓库中查找",
"menu.edit.toggleNoteListSearch": "切换笔记列表搜索",
"menu.view.allPanels": "全部面板",
"menu.view.zoomIn": "放大",
"menu.view.zoomOut": "缩小",
"menu.view.actualSize": "实际大小",
"menu.view.commandPalette": "命令面板",
"menu.go.allNotes": "全部笔记",
"menu.go.archived": "已归档",
"menu.go.changes": "更改",
"menu.go.inbox": "收件箱",
"menu.note.toggleOrganized": "切换整理状态",
"menu.note.toggleTableOfContents": "切换目录",
"menu.vault.addRemote": "添加远程地址…",
"feedback.title": "参与 Tolaria 贡献",
"feedback.description": "选择最符合你想做的事情的路径!任何形式的帮助都非常感谢",
"feedback.sponsor.title": "赞助 / 支持",
"feedback.sponsor.description": "我是 Luca 👋 我的全职工作是运营 Refactoring这是一份面向 17 万+ 工程师的通讯,内容关于如何管理优秀团队并用 AI 交付软件。我会撰写工作流文章,采访技术领导者(例如 DHH、Martin Fowler 等),并运营一个 2000+ 工程师的私人社区提供每月直播辅导、AI 俱乐部等。\n\nTolaria 是 FOSS而且会一直如此。如果你喜欢它最好的支持方式就是订阅这份通讯。",
"feedback.sponsor.cta": "查看 Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "功能请求",
"feedback.featureRequests.description": "请先在看板上搜索,为已有想法投票,只有真正全新的想法再创建新帖子!",
"feedback.featureRequests.cta": "打开产品看板",
"feedback.featureRequests.linkLabel": "产品看板",
"feedback.discussions.title": "讨论",
"feedback.discussions.description": "使用 Discussions 提问、交流、展示作品,以及补充社区上下文。",
"feedback.discussions.cta": "打开讨论",
"feedback.discussions.linkLabel": "GitHub Discussions",
"feedback.contributeCode.title": "贡献代码",
"feedback.contributeCode.description": "欢迎小而聚焦的 PR。请先查看看板确保你构建的是正确的东西",
"feedback.contributeCode.cta": "打开 Pull Requests",
"feedback.contributeCode.linkLabel": "GitHub Pull Requests",
"feedback.contributingGuide.cta": "打开贡献指南",
"feedback.contributingGuide.linkLabel": "贡献指南",
"feedback.reportBug.title": "报告 Bug",
"feedback.reportBug.description": "说明如何复现、你期望发生什么以及实际发生了什么。请附上诊断信息!",
"feedback.reportBug.cta": "打开 GitHub Issues",
"feedback.reportBug.linkLabel": "GitHub Issues",
"feedback.linkFallback.title": "无法自动打开 {label}。",
"feedback.linkFallback.description": "请改为手动打开这个 URL",
"feedback.copyDiagnostics": "复制已清理的诊断信息",
"feedback.diagnosticsCopied": "诊断信息已复制",
"feedback.diagnosticsCopiedSentence": "诊断信息已复制。",
"feedback.clipboardUnavailable": "当前无法访问剪贴板。你仍然可以直接打开 GitHub Issues。",
"command.group.navigation": "导航",
"command.group.note": "笔记",
"command.group.git": "Git",
@@ -422,7 +479,7 @@
"sidebar.action.renameFolder": "重命名文件夹",
"sidebar.action.deleteFolder": "删除文件夹",
"sidebar.action.createNodeInFolderMenu": "在此文件夹中创建新节点",
"sidebar.action.revealFolderMenu": "在访达中显示",
"sidebar.action.revealFolderMenu": "在文件管理器中显示",
"sidebar.action.copyFolderPathMenu": "复制文件夹路径",
"sidebar.action.renameFolderMenu": "重命名文件夹...",
"sidebar.action.deleteFolderMenu": "删除文件夹...",
@@ -526,12 +583,15 @@
"editor.toolbar.restoreArchived": "恢复这条归档笔记",
"editor.toolbar.archive": "归档这条笔记",
"editor.toolbar.delete": "删除这条笔记",
"editor.toolbar.revealFile": "在访达中显示",
"editor.toolbar.revealFile": "在文件管理器中显示",
"editor.toolbar.copyFilePath": "复制文件路径",
"editor.toolbar.copyNoteDeepLink": "复制笔记深层链接",
"editor.toolbar.copyNoteGitUrl": "复制 Git URL",
"editor.toolbar.exportPdf": "将笔记导出为 PDF",
"editor.toolbar.moreActions": "更多笔记操作",
"editor.toolbar.openProperties": "打开属性面板",
"editor.formatting.highlight": "高亮",
"editor.formatting.highlightTooltip": "高亮(在 Markdown 中保留)",
"editor.whiteboard.enterFullscreen": "展开白板",
"editor.whiteboard.exitFullscreen": "退出全屏白板",
"editor.exportPdf.unavailable": "在导出 PDF 之前,请先打开 Markdown 笔记。",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "此设备上没有深层链接保管库。",
"deepLinks.error.unknownVault": "深层链接指向未知的保管库。",
"deepLinks.error.unsafePath": "深层链接路径不能离开保管库。",
"noteGitUrls.copied": "Git URL 已复制",
"noteGitUrls.error.copyFailed": "复制 Git URL 失败:{detail}",
"noteGitUrls.error.unavailable": "此笔记没有 Git URL。",
"editor.slash.math": "数学",
"tableOfContents.title": "目录",
"tableOfContents.close": "关闭目录",

View File

@@ -15,6 +15,63 @@
"command.toggleGitignoredFilesVisibility": "切換 Gitignored 檔案的顯示狀態",
"command.contribute": "參與貢獻",
"command.checkUpdates": "檢查更新",
"menu.application": "應用程式選單",
"menu.file": "檔案",
"menu.edit": "編輯",
"menu.view": "檢視",
"menu.go": "前往",
"menu.note": "筆記",
"menu.vault": "Vault",
"menu.window": "視窗",
"menu.file.quickOpen": "快速開啟",
"menu.file.quickOpenCmdO": "快速開啟 (Cmd+O)",
"menu.file.quickOpenCtrlO": "快速開啟 (Ctrl+O)",
"menu.file.save": "儲存",
"menu.edit.pasteWithoutFormatting": "貼上但不保留格式",
"menu.edit.findInVault": "在 Vault 中尋找",
"menu.edit.toggleNoteListSearch": "切換筆記清單搜尋",
"menu.view.allPanels": "所有面板",
"menu.view.zoomIn": "放大",
"menu.view.zoomOut": "縮小",
"menu.view.actualSize": "實際大小",
"menu.view.commandPalette": "命令調色盤",
"menu.go.allNotes": "所有筆記",
"menu.go.archived": "已封存",
"menu.go.changes": "變更",
"menu.go.inbox": "收件匣",
"menu.note.toggleOrganized": "切換「已整理」",
"menu.note.toggleTableOfContents": "切換目錄",
"menu.vault.addRemote": "新增遠端…",
"feedback.title": "為 Tolaria 做出貢獻",
"feedback.description": "選擇適合您想做的事情的路徑!我們感謝任何形式的協助",
"feedback.sponsor.title": "贊助/支援",
"feedback.sponsor.description": "我是 Luca 👋 我的全職工作是經營 Refactoring這是一份電子報提供給超過 17 萬名工程師,內容涵蓋如何管理優秀的團隊,以及如何使用 AI 交付軟體。我撰寫有關工作流程的文章,採訪技術領袖(例如 DHH、Martin Fowler 等),並經營一個由 2000 多名工程師組成的私人社群提供每月直播指導、AI 俱樂部等活動。\n\nTolaria 是 FOSS而且永遠都是。如果你喜歡最好的支持方式就是訂閱電子報。",
"feedback.sponsor.cta": "查看 Refactoring",
"feedback.sponsor.linkLabel": "Refactoring",
"feedback.featureRequests.title": "功能要求",
"feedback.featureRequests.description": "先在看板上搜尋,為現有的想法點讚,如果真的是新的想法,再建立新貼文!",
"feedback.featureRequests.cta": "開啟產品看板",
"feedback.featureRequests.linkLabel": "產品看板",
"feedback.discussions.title": "討論",
"feedback.discussions.description": "使用「討論」來提出問題、進行對話、分享展示,以及了解社群情況。",
"feedback.discussions.cta": "開啟討論",
"feedback.discussions.linkLabel": "GitHub 討論",
"feedback.contributeCode.title": "貢獻程式碼",
"feedback.contributeCode.description": "歡迎提交小型、重點明確的 PR。請先查看看板以便建立正確的內容",
"feedback.contributeCode.cta": "開啟提取請求",
"feedback.contributeCode.linkLabel": "GitHub 提取請求",
"feedback.contributingGuide.cta": "開啟貢獻指南",
"feedback.contributingGuide.linkLabel": "貢獻指南",
"feedback.reportBug.title": "回報錯誤",
"feedback.reportBug.description": "請說明如何重現問題,以及您的預期與實際發生的情況。請附上診斷資訊!",
"feedback.reportBug.cta": "開啟 GitHub 問題",
"feedback.reportBug.linkLabel": "GitHub 問題",
"feedback.linkFallback.title": "無法自動開啟 {label}。",
"feedback.linkFallback.description": "請改為手動開啟此 URL",
"feedback.copyDiagnostics": "複製經過清理的診斷資訊",
"feedback.diagnosticsCopied": "已複製診斷資訊",
"feedback.diagnosticsCopiedSentence": "已複製診斷資訊。",
"feedback.clipboardUnavailable": "目前無法存取剪貼簿。您仍然可以直接開啟 GitHub 問題。",
"command.group.navigation": "導航",
"command.group.note": "筆記",
"command.group.git": "Git",
@@ -422,7 +479,7 @@
"sidebar.action.renameFolder": "重新命名資料夾",
"sidebar.action.deleteFolder": "刪除資料夾",
"sidebar.action.createNodeInFolderMenu": "在此資料夾中建立新節點",
"sidebar.action.revealFolderMenu": "在訪達中顯示",
"sidebar.action.revealFolderMenu": "在檔案管理器中顯示",
"sidebar.action.copyFolderPathMenu": "複製資料夾路徑",
"sidebar.action.renameFolderMenu": "重新命名資料夾...",
"sidebar.action.deleteFolderMenu": "刪除資料夾...",
@@ -526,12 +583,15 @@
"editor.toolbar.restoreArchived": "恢復這條歸檔筆記",
"editor.toolbar.archive": "歸檔這條筆記",
"editor.toolbar.delete": "刪除這條筆記",
"editor.toolbar.revealFile": "在訪達中顯示",
"editor.toolbar.revealFile": "在檔案管理器中顯示",
"editor.toolbar.copyFilePath": "複製檔案路徑",
"editor.toolbar.copyNoteDeepLink": "複製筆記深層連結",
"editor.toolbar.copyNoteGitUrl": "複製 Git URL",
"editor.toolbar.exportPdf": "將筆記匯出為 PDF",
"editor.toolbar.moreActions": "更多筆記操作",
"editor.toolbar.openProperties": "開啟屬性面板",
"editor.formatting.highlight": "醒目提示",
"editor.formatting.highlightTooltip": "醒目提示(在 Markdown 中保留)",
"editor.whiteboard.enterFullscreen": "展開白板",
"editor.whiteboard.exitFullscreen": "退出全螢幕白板",
"editor.exportPdf.unavailable": "在匯出 PDF 之前,請先開啟 Markdown 筆記。",
@@ -550,6 +610,9 @@
"deepLinks.error.unavailableVault": "此裝置無法使用深層連結保管庫。",
"deepLinks.error.unknownVault": "深層連結指向未知的保管庫。",
"deepLinks.error.unsafePath": "深層連結路徑不能離開保管庫。",
"noteGitUrls.copied": "Git URL 已複製",
"noteGitUrls.error.copyFailed": "無法複製 Git URL{detail}",
"noteGitUrls.error.unavailable": "此筆記無法使用 Git URL。",
"editor.slash.math": "數學",
"tableOfContents.title": "目錄",
"tableOfContents.close": "關閉目錄",

View File

@@ -460,6 +460,12 @@ export const mockHandlers: Record<string, (args: any) => any> = {
const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
return { branch: 'main', ahead: 0, behind: 0, hasRemote: getMockRemoteState(vaultPath) }
},
git_file_url: (args?: { vaultPath?: string; vault_path?: string; path?: string }): string | null => {
const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
if (!getMockRemoteState(vaultPath)) return null
const path = args?.path?.replace(/^.*?\/Laputa\//, '') ?? 'note.md'
return `https://github.com/lucaong/laputa-vault/blob/main/${encodeURI(path)}`
},
git_add_remote: (args?: {
request?: { vaultPath?: string; vault_path?: string; remoteUrl?: string }
vaultPath?: string

Some files were not shown because too many files have changed in this diff Show More