Compare commits
4 Commits
alpha-v202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3caab862 | ||
|
|
2556907f36 | ||
|
|
8e3f8fbb77 | ||
|
|
501df0e265 |
@@ -645,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.
|
||||
@@ -855,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
58
docs/adr/0137-shared-rich-editor-input-transforms.md
Normal file
58
docs/adr/0137-shared-rich-editor-input-transforms.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0137"
|
||||
title: "Shared rich-editor input transforms"
|
||||
status: active
|
||||
date: 2026-06-07
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria has several Markdown-style conveniences in the rich BlockNote editor:
|
||||
typed arrows become ligatures, completed inline math becomes a math node, and
|
||||
completed `==highlight==` syntax becomes the durable highlight mark.
|
||||
|
||||
These features were added incrementally as separate `beforeinput` extensions.
|
||||
Each extension repeated the same lifecycle work: reading the live ProseMirror
|
||||
view, skipping IME composition, guarding stale views, dispatching transactions,
|
||||
preventing native input only after a successful transform, and recovering known
|
||||
BlockNote/ProseMirror transform failures. The syntax matchers differed, but the
|
||||
execution shell was parallel enough that each new Markdown affordance risked a
|
||||
slightly different edge-case policy.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria routes rich-editor Markdown input transforms through one shared
|
||||
`beforeinput` execution path.**
|
||||
|
||||
`src/components/richEditorInputTransform.ts` owns the common lifecycle,
|
||||
dispatch, and recoverable-error behavior. Feature files such as
|
||||
`arrowLigaturesExtension.ts`, `mathInputExtension.ts`, and
|
||||
`markdownHighlightInputExtension.ts` expose small transform objects that only
|
||||
decide whether the current input event should produce a transaction.
|
||||
|
||||
`src/components/richEditorInputTransformExtension.ts` composes the Markdown
|
||||
transform set used by the main editor and hidden editor probe.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Shared transform primitive with feature-owned matchers** (chosen): removes
|
||||
duplicate listener, dispatch, composition, and recovery code while keeping each
|
||||
syntax rule local and testable.
|
||||
- **One monolithic Markdown input extension**: reduces listener count, but mixes
|
||||
unrelated syntax rules in one file and makes each future input affordance
|
||||
harder to test independently.
|
||||
- **Keep one extension per feature**: preserves local ownership, but keeps the
|
||||
repeated edge-case shell and invites divergence as more transforms are added.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `Editor` mounts one Markdown input-transform extension rather than separate
|
||||
arrow, math, and highlight `beforeinput` listeners.
|
||||
- Feature-specific files remain responsible for their syntax matching and
|
||||
transaction construction only.
|
||||
- Recoverable transform errors use the same telemetry event and fallback policy
|
||||
across Markdown input transforms.
|
||||
- New rich-editor Markdown input affordances should plug into the shared
|
||||
transform primitive instead of adding another capture-phase `beforeinput`
|
||||
extension.
|
||||
@@ -187,3 +187,4 @@ proposed → active → superseded
|
||||
| [0133](0133-request-scoped-ai-stream-events.md) | Request-scoped AI stream event channels | active |
|
||||
| [0134](0134-direct-shiki-language-registrations.md) | Direct Shiki language registrations for code blocks | active |
|
||||
| [0135](0135-clean-active-note-refresh-after-external-edit.md) | Clean active notes refresh immediately after external edits | active |
|
||||
| [0137](0137-shared-rich-editor-input-transforms.md) | Shared rich-editor input transforms | active |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,8 +18,12 @@ pub(crate) fn build_command(
|
||||
request.permission_mode,
|
||||
)?;
|
||||
|
||||
let mut command = crate::hidden_command(binary);
|
||||
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
|
||||
let mut command = crate::hidden_command(&target.program);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
||||
if let Some(first_arg) = target.first_arg {
|
||||
command.arg(first_arg);
|
||||
}
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
@@ -300,10 +304,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_pi_json_mode_args(args: &[String]) {
|
||||
assert_eq!(args[0], "--mode");
|
||||
assert_eq!(args[1], "json");
|
||||
assert!(args.contains(&"--no-session".to_string()));
|
||||
assert!(args.contains(&"--extension".to_string()));
|
||||
assert_eq!(
|
||||
(
|
||||
args.first().map(String::as_str),
|
||||
args.get(1).map(String::as_str),
|
||||
args.iter().any(|arg| arg == "--no-session"),
|
||||
args.iter().any(|arg| arg == "--extension"),
|
||||
),
|
||||
(Some("--mode"), Some("json"), true, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -324,10 +333,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_command_identity(command: &std::process::Command, actual_args: &[&OsStr]) {
|
||||
assert_eq!(command.get_program(), OsStr::new("pi"));
|
||||
assert_eq!(actual_args[0], OsStr::new("--mode"));
|
||||
assert_eq!(actual_args[1], OsStr::new("json"));
|
||||
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
|
||||
assert_eq!(
|
||||
(
|
||||
command.get_program(),
|
||||
actual_args.first().copied(),
|
||||
actual_args.get(1).copied(),
|
||||
actual_args.last().copied(),
|
||||
),
|
||||
(
|
||||
OsStr::new("pi"),
|
||||
Some(OsStr::new("--mode")),
|
||||
Some(OsStr::new("json")),
|
||||
Some(OsStr::new("Rename the note")),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_command_vault_scope(
|
||||
@@ -340,6 +359,50 @@ mod tests {
|
||||
assert!(agent_dir.join("mcp.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_avoids_windows_cmd_shim_for_prompt_args() {
|
||||
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
|
||||
let source_agent_dir = tempfile::tempdir().unwrap();
|
||||
let agent_dir = tempfile::tempdir().unwrap();
|
||||
let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path());
|
||||
let shim = agent_dir.path().join("pi.cmd");
|
||||
let launcher = agent_dir
|
||||
.path()
|
||||
.join("node_modules")
|
||||
.join("@withpi")
|
||||
.join("pi")
|
||||
.join("bin")
|
||||
.join("pi.exe");
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&launcher, "native pi launcher").unwrap();
|
||||
std::fs::write(
|
||||
&shim,
|
||||
r#"@ECHO off
|
||||
"%~dp0\node_modules\@withpi\pi\bin\pi.exe" %*
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let command = build_command(&shim, &request(), agent_dir.path()).unwrap();
|
||||
let actual_args = command.get_args().collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
command.get_program() != shim.as_os_str(),
|
||||
command.get_program(),
|
||||
actual_args.first().copied(),
|
||||
actual_args.last().copied(),
|
||||
),
|
||||
(
|
||||
true,
|
||||
launcher.as_os_str(),
|
||||
Some(OsStr::new("--mode")),
|
||||
Some(OsStr::new("Rename the note")),
|
||||
),
|
||||
"Pi npm .cmd shims cannot be spawned directly on Windows"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_seeds_temp_agent_dir_from_existing_pi_config() {
|
||||
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
|
||||
@@ -429,10 +492,20 @@ mod tests {
|
||||
}
|
||||
|
||||
fn assert_base_mcp_config(json: &serde_json::Value) {
|
||||
assert_eq!(json["settings"]["toolPrefix"], "none");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
|
||||
assert_eq!(
|
||||
(
|
||||
&json["settings"]["toolPrefix"],
|
||||
&json["mcpServers"]["tolaria"]["command"],
|
||||
&json["mcpServers"]["tolaria"]["lifecycle"],
|
||||
&json["mcpServers"]["tolaria"]["directTools"],
|
||||
),
|
||||
(
|
||||
&serde_json::json!("none"),
|
||||
&serde_json::json!("node"),
|
||||
&serde_json::json!("lazy"),
|
||||
&serde_json::json!(true),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_tolaria_mcp_env(json: &serde_json::Value) {
|
||||
|
||||
@@ -88,12 +88,19 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
first_existing_path_for_platform(stdout, cfg!(windows))
|
||||
}
|
||||
|
||||
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
if windows && !crate::cli_agent_runtime::has_windows_cli_extension(&candidate) {
|
||||
return None;
|
||||
}
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
@@ -358,6 +365,18 @@ mod tests {
|
||||
assert_eq!(first_existing_path(&stdout), Some(pi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_windows_path_skips_extensionless_npm_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let wrapper = dir.path().join("pi");
|
||||
let shim = dir.path().join("pi.cmd");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&shim, "@ECHO off\n").unwrap();
|
||||
let stdout = format!("{}\n{}\n", wrapper.display(), shim.display());
|
||||
|
||||
assert_eq!(first_existing_path_for_platform(&stdout, true), Some(shim));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_pi_from_interactive_login_shell() {
|
||||
|
||||
@@ -31,10 +31,8 @@ import {
|
||||
} from './editorRawModeSync'
|
||||
import { useRegisterEditorContentFlushes } from './editorContentFlushRegistration'
|
||||
import { useRawModeWithFlush } from './useRawModeWithFlush'
|
||||
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
|
||||
import { createImeCompositionKeyGuardExtension } from './imeCompositionKeyGuardExtension'
|
||||
import { createMarkdownHighlightInputExtension } from './markdownHighlightInputExtension'
|
||||
import { createMathInputExtension } from './mathInputExtension'
|
||||
import { createRichEditorMarkdownInputTransformExtension } from './richEditorInputTransformExtension'
|
||||
import { createRichEditorTransformErrorRecoveryExtension } from './richEditorTransformErrorRecoveryExtension'
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
import { useEditorPdfExport } from './useEditorPdfExport'
|
||||
@@ -227,9 +225,7 @@ function useEditorSetup({
|
||||
extensions: [
|
||||
createRichEditorTransformErrorRecoveryExtension(),
|
||||
createImeCompositionKeyGuardExtension(),
|
||||
createArrowLigaturesExtension(),
|
||||
createMarkdownHighlightInputExtension(),
|
||||
createMathInputExtension(),
|
||||
createRichEditorMarkdownInputTransformExtension(),
|
||||
],
|
||||
})
|
||||
useFilenameAutolinkGuard(editor)
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createExtension } from '@blocknote/core'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
|
||||
import {
|
||||
isRecoverableEditorTransformError,
|
||||
reportRecoveredEditorTransformError,
|
||||
} from './richEditorTransformErrorRecoveryExtension'
|
||||
createRichEditorInputTransformExtension,
|
||||
type RichEditorInputTransform,
|
||||
} from './richEditorInputTransform'
|
||||
|
||||
const MARKDOWN_HIGHLIGHT_DELIMITER = '=='
|
||||
const MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH = MARKDOWN_HIGHLIGHT_DELIMITER.length
|
||||
@@ -15,7 +14,6 @@ type EditorViewLike = NonNullable<ReturnType<typeof useCreateBlockNote>['prosemi
|
||||
type MarkLike = { type: { name: string } }
|
||||
type EditorMark = Parameters<EditorViewLike['state']['tr']['addMark']>[2]
|
||||
type MarkTypeLike = { create: () => EditorMark }
|
||||
type ReadEditorView = () => EditorViewLike | undefined
|
||||
|
||||
interface MarkdownHighlightCursorText {
|
||||
beforeText: string
|
||||
@@ -116,13 +114,6 @@ export function readMarkdownHighlightInputReplacement({
|
||||
}
|
||||
}
|
||||
|
||||
function recoverTransformError(error: unknown): boolean {
|
||||
if (!isRecoverableEditorTransformError(error)) return false
|
||||
|
||||
reportRecoveredEditorTransformError('transform_error', error)
|
||||
return true
|
||||
}
|
||||
|
||||
function readHighlightMarkType(view: EditorViewLike): MarkTypeLike | null {
|
||||
const markType = Reflect.get(view.state.schema.marks, MARKDOWN_HIGHLIGHT_STYLE) as MarkTypeLike | undefined
|
||||
return markType ?? null
|
||||
@@ -151,58 +142,20 @@ function replaceCompletedMarkdownHighlight(
|
||||
.scrollIntoView()
|
||||
}
|
||||
|
||||
function readMarkdownHighlightInputTransaction(
|
||||
view: EditorViewLike,
|
||||
): EditorViewLike['state']['tr'] | null {
|
||||
try {
|
||||
return replaceCompletedMarkdownHighlight(view)
|
||||
} catch (error) {
|
||||
if (!recoverTransformError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipInputEvent(event: InputEvent, view: EditorViewLike): boolean {
|
||||
return !isInsertedFinalEquals(event) || event.isComposing || Boolean(view.composing)
|
||||
}
|
||||
|
||||
function dispatchMarkdownHighlightTransaction(
|
||||
view: EditorViewLike,
|
||||
transaction: EditorViewLike['state']['tr'],
|
||||
): boolean {
|
||||
try {
|
||||
view.dispatch(transaction)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!recoverTransformError(error)) throw error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handleMarkdownHighlightBeforeInput(event: InputEvent, readView: ReadEditorView) {
|
||||
const view = readView()
|
||||
if (!view || shouldSkipInputEvent(event, view)) return
|
||||
|
||||
const transaction = readMarkdownHighlightInputTransaction(view)
|
||||
if (!transaction) return
|
||||
|
||||
if (!dispatchMarkdownHighlightTransaction(view, transaction)) return
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
export const createMarkdownHighlightInputExtension = createExtension(({ editor }) => {
|
||||
const readView = () => editor._tiptapEditor?.view ?? editor.prosemirrorView
|
||||
|
||||
export function createMarkdownHighlightInputTransform(): RichEditorInputTransform {
|
||||
return {
|
||||
key: 'markdownHighlightInput',
|
||||
mount: ({ dom, signal }) => {
|
||||
dom.addEventListener('beforeinput', ((event: InputEvent) => {
|
||||
handleMarkdownHighlightBeforeInput(event, readView)
|
||||
}) as EventListener, {
|
||||
capture: true,
|
||||
signal,
|
||||
})
|
||||
handleBeforeInput(event, { view }) {
|
||||
if (!isInsertedFinalEquals(event)) return null
|
||||
|
||||
const transaction = replaceCompletedMarkdownHighlight(view)
|
||||
if (!transaction) return null
|
||||
|
||||
return { preventDefault: true, transaction }
|
||||
},
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
export const createMarkdownHighlightInputExtension = createRichEditorInputTransformExtension({
|
||||
createTransforms: () => [createMarkdownHighlightInputTransform()],
|
||||
key: 'markdownHighlightInput',
|
||||
})
|
||||
|
||||
@@ -3,9 +3,10 @@ import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, readCompletedInlineMathAtEnd } from '../utils/mathMarkdown'
|
||||
import {
|
||||
isRecoverableEditorTransformError,
|
||||
reportRecoveredEditorTransformError,
|
||||
} from './richEditorTransformErrorRecoveryExtension'
|
||||
dispatchRichEditorInputTransaction,
|
||||
mountRichEditorInputTransforms,
|
||||
type RichEditorInputTransform,
|
||||
} from './richEditorInputTransform'
|
||||
|
||||
const INLINE_WHITESPACE_RE = /^[^\S\r\n]$/
|
||||
const NEWLINE_INPUT_TYPES = new Set(['insertParagraph', 'insertLineBreak'])
|
||||
@@ -59,12 +60,6 @@ function shouldHandleInput(event: InputEvent): boolean {
|
||||
return isInsertedInlineWhitespace(event) || NEWLINE_INPUT_TYPES.has(event.inputType)
|
||||
}
|
||||
|
||||
function shouldSkipInput(event: InputEvent, view: EditorViewLike): boolean {
|
||||
if (event.isComposing) return true
|
||||
if (view.composing) return true
|
||||
return !shouldHandleInput(event)
|
||||
}
|
||||
|
||||
function selectionHasCodeMark(view: EditorViewLike): boolean {
|
||||
const marks = view.state.storedMarks ?? view.state.selection.$from.marks()
|
||||
return marks.some((mark: { type: { name: string } }) => mark.type.name === 'code')
|
||||
@@ -115,25 +110,6 @@ function replaceCompletedInlineMath(
|
||||
return transaction.scrollIntoView()
|
||||
}
|
||||
|
||||
function recoverTransformError(error: unknown): boolean {
|
||||
if (!isRecoverableEditorTransformError(error)) return false
|
||||
|
||||
reportRecoveredEditorTransformError('transform_error', error)
|
||||
return true
|
||||
}
|
||||
|
||||
function readMathInputTransaction(
|
||||
view: EditorViewLike,
|
||||
trailingText?: string,
|
||||
): EditorViewLike['state']['tr'] | null {
|
||||
try {
|
||||
return replaceCompletedInlineMath(view, trailingText)
|
||||
} catch (error) {
|
||||
if (!recoverTransformError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mathSource({ latex }: { latex: string }): string {
|
||||
return `$${latex}$`
|
||||
}
|
||||
@@ -287,7 +263,7 @@ function restoreMathSource({
|
||||
.replaceWith(location.from, location.to, replacement)
|
||||
.scrollIntoView()
|
||||
|
||||
if (!dispatchMathInputTransaction(view, transaction)) return false
|
||||
if (!dispatchRichEditorInputTransaction(view, { transaction })) return false
|
||||
|
||||
editor._tiptapEditor?.commands?.setTextSelection?.(
|
||||
mathLatexSelectionRange(location),
|
||||
@@ -299,20 +275,6 @@ function restoreMathSource({
|
||||
return true
|
||||
}
|
||||
|
||||
function handleBeforeInputEvent(event: InputEvent, readView: ReadEditorView) {
|
||||
const view = readView()
|
||||
if (!view || shouldSkipInput(event, view)) return
|
||||
|
||||
const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined
|
||||
const transaction = readMathInputTransaction(view, trailingText)
|
||||
if (!transaction) return
|
||||
|
||||
if (!dispatchMathInputTransaction(view, transaction)) return
|
||||
if (trailingText !== undefined) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenderedMathDoubleClick(
|
||||
event: MouseEvent,
|
||||
{ editor, readView }: MathExtensionContext,
|
||||
@@ -346,16 +308,20 @@ function handleMathKeyDown(
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function dispatchMathInputTransaction(
|
||||
view: EditorViewLike,
|
||||
transaction: EditorViewLike['state']['tr'],
|
||||
): boolean {
|
||||
try {
|
||||
view.dispatch(transaction)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!recoverTransformError(error)) throw error
|
||||
return false
|
||||
export function createMathInputTransform(): RichEditorInputTransform {
|
||||
return {
|
||||
handleBeforeInput(event, { view }) {
|
||||
if (!shouldHandleInput(event)) return null
|
||||
|
||||
const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined
|
||||
const transaction = replaceCompletedInlineMath(view, trailingText)
|
||||
if (!transaction) return null
|
||||
|
||||
return {
|
||||
preventDefault: trailingText !== undefined,
|
||||
transaction,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,11 +333,11 @@ export const createMathInputExtension = createExtension(({ editor }) => {
|
||||
mount: ({ dom, signal }) => {
|
||||
const context = { editor, readView }
|
||||
|
||||
dom.addEventListener('beforeinput', ((event: InputEvent) => {
|
||||
handleBeforeInputEvent(event, readView)
|
||||
}) as EventListener, {
|
||||
capture: true,
|
||||
mountRichEditorInputTransforms({
|
||||
dom,
|
||||
readView,
|
||||
signal,
|
||||
transforms: [createMathInputTransform()],
|
||||
})
|
||||
dom.addEventListener('dblclick', (event) => {
|
||||
handleRenderedMathDoubleClick(event, context)
|
||||
|
||||
184
src/components/richEditorInputTransform.ts
Normal file
184
src/components/richEditorInputTransform.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { createExtension } from '@blocknote/core'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import {
|
||||
isRecoverableEditorTransformError,
|
||||
reportRecoveredEditorTransformError,
|
||||
} from './richEditorTransformErrorRecoveryExtension'
|
||||
|
||||
export type RichEditorInputView = NonNullable<ReturnType<typeof useCreateBlockNote>['prosemirrorView']>
|
||||
export type RichEditorInputTransaction = Parameters<RichEditorInputView['dispatch']>[0]
|
||||
|
||||
export interface RichEditorInputTransformContext {
|
||||
view: RichEditorInputView
|
||||
}
|
||||
|
||||
export interface RichEditorInputTransformResult {
|
||||
ignoreDispatchError?: boolean
|
||||
onDispatchError?: (error: unknown) => void
|
||||
preventDefault?: boolean
|
||||
transaction: RichEditorInputTransaction
|
||||
}
|
||||
|
||||
export interface RichEditorInputTransform {
|
||||
handleBeforeInput: (
|
||||
event: InputEvent,
|
||||
context: RichEditorInputTransformContext
|
||||
) => RichEditorInputTransformResult | null
|
||||
reset?: () => void
|
||||
}
|
||||
|
||||
interface RichEditorInputTransformExtensionOptions {
|
||||
createTransforms: () => RichEditorInputTransform[]
|
||||
key: string
|
||||
}
|
||||
|
||||
interface MountRichEditorInputTransformsOptions {
|
||||
dom: HTMLElement
|
||||
readView: () => RichEditorInputView | undefined
|
||||
signal: AbortSignal
|
||||
transforms: RichEditorInputTransform[]
|
||||
}
|
||||
|
||||
const RECOVERED_INPUT_TRANSFORM_ERROR = Symbol('recoveredInputTransformError')
|
||||
type TransformReadResult = RichEditorInputTransformResult | null | typeof RECOVERED_INPUT_TRANSFORM_ERROR
|
||||
|
||||
function resetInputTransforms(transforms: RichEditorInputTransform[]): void {
|
||||
transforms.forEach((transform) => transform.reset?.())
|
||||
}
|
||||
|
||||
function isLiveEditorView(view: RichEditorInputView): boolean {
|
||||
if (view.isDestroyed) return false
|
||||
if (view.dom?.isConnected === false) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isComposingInput(event: InputEvent, view: RichEditorInputView): boolean {
|
||||
return event.isComposing || Boolean(view.composing)
|
||||
}
|
||||
|
||||
export function recoverRichEditorInputTransformError(error: unknown): boolean {
|
||||
if (!isRecoverableEditorTransformError(error)) return false
|
||||
|
||||
reportRecoveredEditorTransformError('transform_error', error)
|
||||
return true
|
||||
}
|
||||
|
||||
function readTransformResult(
|
||||
transform: RichEditorInputTransform,
|
||||
event: InputEvent,
|
||||
context: RichEditorInputTransformContext,
|
||||
): TransformReadResult {
|
||||
try {
|
||||
return transform.handleBeforeInput(event, context)
|
||||
} catch (error) {
|
||||
if (!recoverRichEditorInputTransformError(error)) throw error
|
||||
return RECOVERED_INPUT_TRANSFORM_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
function readReadyInputView({
|
||||
readView,
|
||||
transforms,
|
||||
}: Omit<MountRichEditorInputTransformsOptions, 'dom' | 'signal'>): RichEditorInputView | null {
|
||||
const view = readView()
|
||||
if (!view) return null
|
||||
if (isLiveEditorView(view)) return view
|
||||
|
||||
resetInputTransforms(transforms)
|
||||
return null
|
||||
}
|
||||
|
||||
export function dispatchRichEditorInputTransaction(
|
||||
view: RichEditorInputView,
|
||||
result: RichEditorInputTransformResult,
|
||||
): boolean {
|
||||
try {
|
||||
view.dispatch(result.transaction)
|
||||
return true
|
||||
} catch (error) {
|
||||
result.onDispatchError?.(error)
|
||||
if (recoverRichEditorInputTransformError(error)) return false
|
||||
if (result.ignoreDispatchError) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function completeInputTransform(
|
||||
event: InputEvent,
|
||||
view: RichEditorInputView,
|
||||
result: TransformReadResult,
|
||||
): boolean {
|
||||
if (result === RECOVERED_INPUT_TRANSFORM_ERROR) return true
|
||||
if (!result) return false
|
||||
if (!dispatchRichEditorInputTransaction(view, result)) return true
|
||||
if (result.preventDefault) event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function runInputTransform(
|
||||
transform: RichEditorInputTransform,
|
||||
event: InputEvent,
|
||||
view: RichEditorInputView,
|
||||
): boolean {
|
||||
return completeInputTransform(
|
||||
event,
|
||||
view,
|
||||
readTransformResult(transform, event, { view }),
|
||||
)
|
||||
}
|
||||
|
||||
function runInputTransforms(
|
||||
event: InputEvent,
|
||||
view: RichEditorInputView,
|
||||
transforms: RichEditorInputTransform[],
|
||||
): void {
|
||||
transforms.some((transform) => runInputTransform(transform, event, view))
|
||||
}
|
||||
|
||||
function handleRichEditorBeforeInput(
|
||||
event: InputEvent,
|
||||
{ readView, transforms }: Omit<MountRichEditorInputTransformsOptions, 'dom' | 'signal'>,
|
||||
): void {
|
||||
const view = readReadyInputView({ readView, transforms })
|
||||
if (!view) return
|
||||
if (isComposingInput(event, view)) return
|
||||
|
||||
runInputTransforms(event, view, transforms)
|
||||
}
|
||||
|
||||
export function mountRichEditorInputTransforms({
|
||||
dom,
|
||||
readView,
|
||||
signal,
|
||||
transforms,
|
||||
}: MountRichEditorInputTransformsOptions): void {
|
||||
dom.addEventListener('beforeinput', ((event: InputEvent) => {
|
||||
handleRichEditorBeforeInput(event, { readView, transforms })
|
||||
}) as EventListener, {
|
||||
capture: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function createRichEditorInputTransformExtension({
|
||||
createTransforms,
|
||||
key,
|
||||
}: RichEditorInputTransformExtensionOptions) {
|
||||
return createExtension(({ editor }) => {
|
||||
const readView = () => editor._tiptapEditor?.view ?? editor.prosemirrorView
|
||||
const transforms = createTransforms()
|
||||
|
||||
return {
|
||||
key,
|
||||
mount: ({ dom, signal }) => {
|
||||
mountRichEditorInputTransforms({
|
||||
dom,
|
||||
readView,
|
||||
signal,
|
||||
transforms,
|
||||
})
|
||||
},
|
||||
} as const
|
||||
})
|
||||
}
|
||||
167
src/components/richEditorInputTransformExtension.test.ts
Normal file
167
src/components/richEditorInputTransformExtension.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
|
||||
import { createRichEditorMarkdownInputTransformExtension } from './richEditorInputTransformExtension'
|
||||
|
||||
function createTransaction() {
|
||||
const transaction = {
|
||||
addMark: vi.fn(() => transaction),
|
||||
delete: vi.fn(() => transaction),
|
||||
insertText: vi.fn(() => transaction),
|
||||
replaceWith: vi.fn(() => transaction),
|
||||
scrollIntoView: vi.fn(() => transaction),
|
||||
}
|
||||
return transaction
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
let beforeInputListener: EventListener | null = null
|
||||
let beforeText = ''
|
||||
let parentStart = 0
|
||||
let arrowPrefix = ''
|
||||
const transaction = createTransaction()
|
||||
const mathNode = { nodeSize: 1, type: 'mathInline' }
|
||||
const highlightMark = { type: { name: MARKDOWN_HIGHLIGHT_STYLE } }
|
||||
const highlightMarkType = { create: vi.fn(() => highlightMark) }
|
||||
const view = {
|
||||
composing: false,
|
||||
dispatch: vi.fn(),
|
||||
dom: { isConnected: true },
|
||||
state: {
|
||||
doc: {
|
||||
nodesBetween: vi.fn(),
|
||||
textBetween: vi.fn(() => arrowPrefix),
|
||||
},
|
||||
schema: {
|
||||
marks: {
|
||||
[MARKDOWN_HIGHLIGHT_STYLE]: highlightMarkType,
|
||||
},
|
||||
nodes: {
|
||||
mathInline: {
|
||||
createChecked: vi.fn(() => mathNode),
|
||||
},
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
$from: {
|
||||
depth: 0,
|
||||
marks: vi.fn(() => []),
|
||||
node: vi.fn(() => ({ type: { name: 'paragraph', spec: {} } })),
|
||||
parent: {
|
||||
isTextblock: true,
|
||||
textBetween: vi.fn(() => beforeText),
|
||||
},
|
||||
parentOffset: 0,
|
||||
},
|
||||
},
|
||||
storedMarks: null,
|
||||
tr: transaction,
|
||||
},
|
||||
}
|
||||
const dom = {
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
if (type === 'beforeinput') beforeInputListener = listener
|
||||
}),
|
||||
}
|
||||
const editor = {
|
||||
_tiptapEditor: { view },
|
||||
prosemirrorView: view,
|
||||
}
|
||||
const extension = createRichEditorMarkdownInputTransformExtension()({ editor: editor as never })
|
||||
|
||||
function setCursorText(nextBeforeText: string, nextParentStart = 0) {
|
||||
beforeText = nextBeforeText
|
||||
parentStart = nextParentStart
|
||||
view.state.selection.from = parentStart + beforeText.length
|
||||
view.state.selection.to = parentStart + beforeText.length
|
||||
view.state.selection.$from.parentOffset = beforeText.length
|
||||
}
|
||||
|
||||
return {
|
||||
dom,
|
||||
fireBeforeInput(event: Partial<InputEvent>) {
|
||||
if (!beforeInputListener) throw new Error('Combined input transform did not mount beforeinput')
|
||||
const inputEvent = {
|
||||
data: null,
|
||||
inputType: 'insertText',
|
||||
isComposing: false,
|
||||
preventDefault: vi.fn(),
|
||||
...event,
|
||||
}
|
||||
|
||||
beforeInputListener(inputEvent as InputEvent)
|
||||
return inputEvent
|
||||
},
|
||||
highlightMark,
|
||||
highlightMarkType,
|
||||
mathNode,
|
||||
mount() {
|
||||
const controller = new AbortController()
|
||||
extension.mount?.({
|
||||
dom: dom as never,
|
||||
root: document,
|
||||
signal: controller.signal,
|
||||
})
|
||||
return controller
|
||||
},
|
||||
setArrowPrefix(nextArrowPrefix: string) {
|
||||
arrowPrefix = nextArrowPrefix
|
||||
},
|
||||
setCursorText,
|
||||
transaction,
|
||||
view,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createRichEditorMarkdownInputTransformExtension', () => {
|
||||
it('mounts one beforeinput listener for all markdown input transforms', () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
fixture.mount()
|
||||
|
||||
expect(fixture.dom.addEventListener).toHaveBeenCalledTimes(1)
|
||||
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
|
||||
'beforeinput',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
capture: true,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('routes arrow, inline math, and highlight syntax through the shared listener', () => {
|
||||
const fixture = createFixture()
|
||||
fixture.mount()
|
||||
|
||||
fixture.setArrowPrefix('-')
|
||||
fixture.setCursorText('-', 0)
|
||||
const arrowEvent = fixture.fireBeforeInput({ data: '>' })
|
||||
|
||||
expect(fixture.transaction.insertText).toHaveBeenCalledWith('→', 0, 1)
|
||||
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
|
||||
expect(arrowEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.clearAllMocks()
|
||||
fixture.setCursorText('Inline $x^2$', 0)
|
||||
const mathEvent = fixture.fireBeforeInput({ data: ' ' })
|
||||
|
||||
expect(fixture.view.state.schema.nodes.mathInline.createChecked).toHaveBeenCalledWith({ latex: 'x^2' })
|
||||
expect(fixture.transaction.replaceWith).toHaveBeenCalledWith(7, 12, fixture.mathNode)
|
||||
expect(fixture.transaction.insertText).toHaveBeenCalledWith(' ', 8)
|
||||
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
|
||||
expect(mathEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.clearAllMocks()
|
||||
fixture.setCursorText('Plain ==marked=', 20)
|
||||
const highlightEvent = fixture.fireBeforeInput({ data: '=' })
|
||||
|
||||
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(1, 34, 35)
|
||||
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(2, 26, 28)
|
||||
expect(fixture.highlightMarkType.create).toHaveBeenCalledWith()
|
||||
expect(fixture.transaction.addMark).toHaveBeenCalledWith(26, 32, fixture.highlightMark)
|
||||
expect(fixture.view.dispatch).toHaveBeenLastCalledWith(fixture.transaction)
|
||||
expect(highlightEvent.preventDefault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
13
src/components/richEditorInputTransformExtension.ts
Normal file
13
src/components/richEditorInputTransformExtension.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createArrowLigatureInputTransform } from './arrowLigaturesExtension'
|
||||
import { createMarkdownHighlightInputTransform } from './markdownHighlightInputExtension'
|
||||
import { createMathInputTransform } from './mathInputExtension'
|
||||
import { createRichEditorInputTransformExtension } from './richEditorInputTransform'
|
||||
|
||||
export const createRichEditorMarkdownInputTransformExtension = createRichEditorInputTransformExtension({
|
||||
createTransforms: () => [
|
||||
createArrowLigatureInputTransform(),
|
||||
createMarkdownHighlightInputTransform(),
|
||||
createMathInputTransform(),
|
||||
],
|
||||
key: 'richEditorMarkdownInputTransform',
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useAiAgentsStatus } from './useAiAgentsStatus'
|
||||
import { AI_AGENTS_STATUS_PROBE_TIMEOUT_MS, useAiAgentsStatus } from './useAiAgentsStatus'
|
||||
import { AI_AGENT_DEFINITIONS, type AiAgentsStatus } from '../lib/aiAgents'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
@@ -141,5 +141,23 @@ describe('useAiAgentsStatus', () => {
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to missing when the status probe never resolves', async () => {
|
||||
mockInvoke.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { result } = renderHook(() => useAiAgentsStatus({ enabled: true }))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_ai_agents_status')
|
||||
expectStatuses(result.current, 'checking')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AI_AGENTS_STATUS_PROBE_TIMEOUT_MS + 1)
|
||||
})
|
||||
|
||||
expectStatuses(result.current, 'missing')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
|
||||
type RawAiAgentsStatus = Partial<Record<AiAgentId, { installed?: boolean | null; version?: string | null }>>
|
||||
|
||||
export const AI_AGENTS_STATUS_PROBE_TIMEOUT_MS = 5000
|
||||
|
||||
interface UseAiAgentsStatusOptions {
|
||||
/**
|
||||
* When false, the hook stays in its initial state and never calls the
|
||||
@@ -65,17 +67,32 @@ export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsS
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearProbeTimeout = () => {
|
||||
if (timeoutId === null) return
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
|
||||
const fire = () => {
|
||||
if (cancelled) return
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
setStatuses(createMissingAiAgentsStatus())
|
||||
}
|
||||
}, AI_AGENTS_STATUS_PROBE_TIMEOUT_MS)
|
||||
|
||||
tauriCall<RawAiAgentsStatus>('get_ai_agents_status')
|
||||
.then((result) => {
|
||||
clearProbeTimeout()
|
||||
if (!cancelled) {
|
||||
setStatuses(normalizeAiAgentsStatus(result))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
clearProbeTimeout()
|
||||
if (!cancelled) {
|
||||
setStatuses(createMissingAiAgentsStatus())
|
||||
}
|
||||
@@ -89,6 +106,7 @@ export function useAiAgentsStatus(options?: UseAiAgentsStatusOptions): AiAgentsS
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearProbeTimeout()
|
||||
cancelIdle(handle)
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('../lib/appUpdater', () => ({
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE:
|
||||
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
|
||||
isRestartRequiredAfterUpdate: vi.fn(() => false),
|
||||
markRestartRequiredAfterUpdate: vi.fn(),
|
||||
}))
|
||||
|
||||
const openMock = vi.fn()
|
||||
@@ -21,12 +22,16 @@ import { pickFolder } from './vault-dialog'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
isRestartRequiredAfterUpdate,
|
||||
markRestartRequiredAfterUpdate,
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
|
||||
} from '../lib/appUpdater'
|
||||
|
||||
describe('pickFolder', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
openMock.mockReset()
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('returns user input from prompt in browser mode', async () => {
|
||||
@@ -70,6 +75,15 @@ describe('pickFolder', () => {
|
||||
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
|
||||
})
|
||||
|
||||
it('translates an NSOpenPanel panic into the restart-required folder picker error', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
openMock.mockRejectedValue('panic: unexpected NULL returned from +[NSOpenPanel openPanel]')
|
||||
|
||||
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
|
||||
expect(markRestartRequiredAfterUpdate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('normalizes a native single-selection array to its first folder path', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
|
||||
@@ -98,6 +112,7 @@ describe('pickFolder', () => {
|
||||
const secondRequest = pickFolder('Open vault folder')
|
||||
|
||||
await expect(secondRequest).resolves.toBeNull()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(openMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveOpen?.('/Users/test/restored-vault')
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import {
|
||||
isRestartRequiredAfterUpdate,
|
||||
markRestartRequiredAfterUpdate,
|
||||
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
|
||||
} from '../lib/appUpdater'
|
||||
|
||||
const NS_OPEN_PANEL_UNAVAILABLE_MARKER = 'unexpected NULL returned from +[NSOpenPanel openPanel]'
|
||||
|
||||
export class NativeFolderPickerBlockedError extends Error {
|
||||
constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) {
|
||||
super(message)
|
||||
@@ -23,6 +26,16 @@ export function isNativeFolderPickerBlockedError(
|
||||
return error instanceof NativeFolderPickerBlockedError
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
return ''
|
||||
}
|
||||
|
||||
function isUnavailableNativeFolderPicker(error: unknown): boolean {
|
||||
return errorMessage(error).includes(NS_OPEN_PANEL_UNAVAILABLE_MARKER)
|
||||
}
|
||||
|
||||
export function formatFolderPickerActionError(
|
||||
action: string,
|
||||
error: unknown,
|
||||
@@ -31,12 +44,7 @@ export function formatFolderPickerActionError(
|
||||
return error.message
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: ''
|
||||
const message = errorMessage(error)
|
||||
|
||||
return message ? `${action}: ${message}` : action
|
||||
}
|
||||
@@ -77,6 +85,28 @@ function normalizePickedFolderPath(selected: string | string[] | null): string |
|
||||
|
||||
let folderPickerRequestInFlight = false
|
||||
|
||||
async function pickNativeFolder(title?: string): Promise<string | null> {
|
||||
if (isRestartRequiredAfterUpdate()) {
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: title ?? 'Select folder',
|
||||
})
|
||||
return normalizePickedFolderPath(selected)
|
||||
} catch (error) {
|
||||
if (isUnavailableNativeFolderPicker(error)) {
|
||||
markRestartRequiredAfterUpdate()
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
|
||||
* Returns the selected folder path, or null if the user cancelled.
|
||||
@@ -87,17 +117,7 @@ export async function pickFolder(title?: string): Promise<string | null> {
|
||||
folderPickerRequestInFlight = true
|
||||
try {
|
||||
if (isTauri()) {
|
||||
if (isRestartRequiredAfterUpdate()) {
|
||||
throw new NativeFolderPickerBlockedError()
|
||||
}
|
||||
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: title ?? 'Select folder',
|
||||
})
|
||||
return normalizePickedFolderPath(selected)
|
||||
return await pickNativeFolder(title)
|
||||
}
|
||||
return normalizePickedFolderPath(prompt(title ?? 'Enter folder path:'))
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user