Compare commits
11 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d96d6efd57 | ||
|
|
67623d848b | ||
|
|
45d72297b3 | ||
|
|
4f86b97e8d | ||
|
|
b6dd6189a2 | ||
|
|
ba07bafc97 | ||
|
|
6d86770abf | ||
|
|
e23ba0ae27 | ||
|
|
7a97f24a0e | ||
|
|
20f34ab9d1 | ||
|
|
19b31cc96a |
@@ -49,7 +49,13 @@
|
||||
"laputa-qa-reference.md",
|
||||
"attachments/laputa-reference.png"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rtl-mixed-direction",
|
||||
"reason": "Arabic and mixed English/Arabic paragraphs keep rich editor and raw editor BiDi QA anchored to the fixture.",
|
||||
"files": [
|
||||
"rtl-mixed-direction-qa.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: Note
|
||||
topics:
|
||||
- "[[topic-writing]]"
|
||||
---
|
||||
|
||||
# RTL Mixed Direction QA
|
||||
|
||||
مرحبا بالعالم. هذه فقرة عربية لاختبار اتجاه النص من اليمين إلى اليسار داخل محرر تولاريا.
|
||||
|
||||
English text should keep reading left to right when it appears next to Arabic content.
|
||||
|
||||
English then مرحبا بالعالم keeps both scripts readable on one line.
|
||||
|
||||
مرحبا بالعالم then English keeps the Arabic run anchored correctly while preserving the English words.
|
||||
|
||||
Use this note when checking rich editor and raw Markdown editor behavior for automatic LTR/RTL direction.
|
||||
@@ -367,7 +367,7 @@ Command-facing vault content is filtered through `vault::filter_gitignored_entri
|
||||
|
||||
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
|
||||
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
|
||||
@@ -246,8 +246,8 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
|
||||
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Codex, OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
|
||||
|
||||
@@ -298,15 +298,15 @@ The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the act
|
||||
|
||||
```json
|
||||
{
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "content" },
|
||||
"linkedNotes": [{ "path", "title", "content" }],
|
||||
"openTabs": [{ "title", "snippet" }],
|
||||
"vaultMetadata": { "noteTypes", "stats", "filter" },
|
||||
"references": [{ "title", "path", "type" }]
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "body", "wordCount", "bodyTruncated?" },
|
||||
"openTabs": [{ "path", "title", "type", "frontmatter" }],
|
||||
"noteList": [{ "path", "title", "type" }],
|
||||
"vault": { "types", "totalNotes" },
|
||||
"referencedNotes": [{ "title", "path", "type" }]
|
||||
}
|
||||
```
|
||||
|
||||
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
|
||||
Large active notes are compacted into a head/tail body snapshot before they enter the CLI prompt. The snapshot records `bodyTruncated` metadata and instructs agents to call `get_note(path)` before content-sensitive edits or summaries, keeping lower-context OpenCode providers from failing on oversized active-note context while preserving access to the full note through MCP.
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -350,7 +350,7 @@ Tolaria can register itself as an MCP server in:
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria` and `/usr/lib/tolaria`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -490,6 +490,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
|
||||
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
|
||||
|
||||
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
|
||||
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
@@ -556,6 +558,11 @@ sequenceDiagram
|
||||
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
alt Runtime vault path disappears
|
||||
VL->>T: invoke('check_vault_exists')
|
||||
VL-->>A: unavailable vault path + cleared stale state
|
||||
A-->>U: WelcomeScreen (vault missing)
|
||||
end
|
||||
A->>T: useMcpStatus — check explicit MCP setup state
|
||||
A->>T: sync_mcp_bridge_vault(selected path)
|
||||
VL-->>A: entries ready
|
||||
|
||||
@@ -449,7 +449,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Work with external MCP setup
|
||||
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
|
||||
@@ -39,13 +39,21 @@ fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
crate::hidden_command(codex_path_lookup_command())
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn codex_path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
@@ -102,13 +110,27 @@ fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".codex/bin/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".npm/bin/codex.cmd"),
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex"),
|
||||
home.join(".bun/bin/codex.exe"),
|
||||
home.join(".linuxbrew/bin/codex"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
@@ -208,6 +230,7 @@ fn build_codex_command(
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(vault_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
@@ -218,6 +241,7 @@ fn build_codex_args(
|
||||
last_message_path: Option<&Path>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
let node_path = crate::mcp::find_node()?;
|
||||
|
||||
let mut args = vec![
|
||||
"--sandbox".into(),
|
||||
@@ -229,14 +253,11 @@ fn build_codex_args(
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
codex_config_string("mcp_servers.tolaria.command", &node_path.to_string_lossy()),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
codex_mcp_env_config(&request.vault_path),
|
||||
];
|
||||
|
||||
if let Some(path) = last_message_path {
|
||||
@@ -247,6 +268,30 @@ fn build_codex_args(
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn codex_config_string(key: &str, value: &str) -> String {
|
||||
format!(r#"{key}="{}""#, toml_escape(value))
|
||||
}
|
||||
|
||||
fn codex_config_string_list(key: &str, values: &[&str]) -> String {
|
||||
let values = values
|
||||
.iter()
|
||||
.map(|value| format!(r#""{}""#, toml_escape(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{key}=[{values}]")
|
||||
}
|
||||
|
||||
fn codex_mcp_env_config(vault_path: &str) -> String {
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",WS_UI_PORT="9711"}}"#,
|
||||
toml_escape(vault_path)
|
||||
)
|
||||
}
|
||||
|
||||
fn toml_escape(value: &str) -> String {
|
||||
value.replace('\\', r#"\\"#).replace('"', r#"\""#)
|
||||
}
|
||||
|
||||
fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str {
|
||||
match permission_mode {
|
||||
crate::ai_agents::AiAgentPermissionMode::Safe => "read-only",
|
||||
@@ -543,6 +588,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_resolved_mcp_node_and_ui_bridge_env() {
|
||||
let args = build_codex_args(
|
||||
&AgentStreamRequest {
|
||||
message: "Read [[Test note]]".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let command_override = args
|
||||
.iter()
|
||||
.find(|arg| arg.starts_with("mcp_servers.tolaria.command="))
|
||||
.expect("Codex should receive a transient Tolaria MCP command");
|
||||
|
||||
assert!(
|
||||
!command_override.ends_with(r#""node""#),
|
||||
"Codex MCP command should use Tolaria's resolved Node path, got {command_override}"
|
||||
);
|
||||
assert!(
|
||||
command_override.contains('/'),
|
||||
"Codex MCP command should be an absolute Node path, got {command_override}"
|
||||
);
|
||||
assert!(args.iter().any(|arg| arg.contains(r#"WS_UI_PORT="9711""#)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
@@ -664,6 +738,88 @@ exit 2
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open() {
|
||||
use std::io::Read;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.arg("codex_stdin_probe_parent_child")
|
||||
.arg("--ignored")
|
||||
.arg("--nocapture")
|
||||
.env("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let child_stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
drop(child_stdin);
|
||||
panic!("Codex stdin probe child timed out");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
drop(child_stdin);
|
||||
let mut stdout_text = String::new();
|
||||
let mut stderr_text = String::new();
|
||||
stdout.read_to_string(&mut stdout_text).unwrap();
|
||||
stderr.read_to_string(&mut stderr_text).unwrap();
|
||||
|
||||
assert!(
|
||||
status.success(),
|
||||
"Codex stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "spawned by run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open"]
|
||||
#[test]
|
||||
fn codex_stdin_probe_parent_child() {
|
||||
if std::env::var_os("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
"codex",
|
||||
r#"stdin="$(cat)"
|
||||
if [ -n "$stdin" ]; then
|
||||
echo "stdin was not closed" >&2
|
||||
exit 9
|
||||
fi
|
||||
printf '%s\n' '{"type":"thread.started","thread_id":"stdin-ok"}'
|
||||
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"stdin closed"}}'
|
||||
"#,
|
||||
);
|
||||
let mut events = Vec::new();
|
||||
let result = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
codex_request(vault.path(), AiAgentPermissionMode::Safe),
|
||||
|event| events.push(event),
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap(), "stdin-ok");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "stdin closed"
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
@@ -705,6 +861,34 @@ exit 2
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from("C:/Users/alex");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
home.join(".npm/bin/codex.cmd"),
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -284,6 +284,10 @@ fn linux_package_mcp_server_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
root.join("Tolaria").join("mcp-server"),
|
||||
root.join("Tolaria").join("resources").join("mcp-server"),
|
||||
root.join("lib").join("tolaria").join("mcp-server"),
|
||||
root.join("lib")
|
||||
.join("tolaria")
|
||||
.join("resources")
|
||||
.join("mcp-server"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -759,12 +763,26 @@ mod tests {
|
||||
PathBuf::from("/usr/local/Tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/local/Tolaria/resources/mcp-server"),
|
||||
PathBuf::from("/usr/local/lib/tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/local/lib/tolaria/resources/mcp-server"),
|
||||
PathBuf::from("/usr/lib/tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/lib/tolaria/resources/mcp-server"),
|
||||
];
|
||||
|
||||
assert!(expected.iter().all(|path| candidates.contains(path)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_candidates_include_linux_appimage_resource_root() {
|
||||
let dev_path = Path::new("/repo/mcp-server");
|
||||
let exe_path = Path::new("/tmp/.mount_tolaria/usr/bin/tolaria");
|
||||
let appdir = Path::new("/tmp/.mount_tolaria");
|
||||
let candidates = mcp_server_dir_candidates(dev_path, exe_path, Some(appdir));
|
||||
|
||||
assert!(candidates.contains(&PathBuf::from(
|
||||
"/tmp/.mount_tolaria/usr/lib/tolaria/resources/mcp-server"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_new_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -108,6 +108,7 @@ import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { isActiveVaultUnavailableError } from './utils/vaultErrors'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { filenameStemToTitle } from './utils/noteTitle'
|
||||
import {
|
||||
@@ -375,6 +376,7 @@ function App() {
|
||||
}, [resolvedPath, setToastMessage])
|
||||
|
||||
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
|
||||
const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
|
||||
const {
|
||||
markInternalWrite: markRecentVaultWrite,
|
||||
filterExternalPaths: filterExternalVaultPaths,
|
||||
@@ -578,6 +580,9 @@ function App() {
|
||||
markRecentVaultWrite(path)
|
||||
vault.loadModifiedFiles()
|
||||
}, [markRecentVaultWrite, vault])
|
||||
const handleMissingActiveVault = useCallback(() => {
|
||||
if (!noteWindowParams && resolvedPath) vault.markVaultUnavailable(resolvedPath)
|
||||
}, [noteWindowParams, resolvedPath, vault])
|
||||
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
@@ -596,6 +601,7 @@ function App() {
|
||||
unsavedPaths: vault.unsavedPaths,
|
||||
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
|
||||
onNewNotePersisted: handleCreatedVaultEntryPersisted,
|
||||
onMissingActiveVault: handleMissingActiveVault,
|
||||
onTypeStateChanged: async () => { await vault.reloadVault() },
|
||||
replaceEntry: vault.replaceEntry,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
@@ -1169,7 +1175,15 @@ function App() {
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
try {
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
} catch (err) {
|
||||
if (isActiveVaultUnavailableError(err)) {
|
||||
vault.markVaultUnavailable(resolvedPath)
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
await vault.reloadViews()
|
||||
await vault.reloadVault()
|
||||
vault.reloadFolders()
|
||||
@@ -1636,8 +1650,17 @@ function App() {
|
||||
}
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = shouldResumeFreshStartOnboarding
|
||||
if (!noteWindowParams && (runtimeMissingVaultPath || onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = runtimeMissingVaultPath
|
||||
? {
|
||||
...onboarding,
|
||||
state: {
|
||||
status: 'vault-missing' as const,
|
||||
vaultPath: runtimeMissingVaultPath,
|
||||
defaultPath: vaultSwitcher.defaultPath || runtimeMissingVaultPath,
|
||||
},
|
||||
}
|
||||
: shouldResumeFreshStartOnboarding
|
||||
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
|
||||
: onboarding
|
||||
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
|
||||
|
||||
@@ -197,6 +197,18 @@ describe('AiPanel', () => {
|
||||
expect(screen.getByTestId('ai-panel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps long AI agent drafts inside a scrollable composer while keeping send visible', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
editor.textContent = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}`).join('\n')
|
||||
fireEvent.input(editor)
|
||||
|
||||
expect(editor).toHaveClass('max-h-[160px]', 'overflow-y-auto', 'overscroll-contain')
|
||||
expect(editor).toHaveStyle({ maxHeight: '160px', overflowY: 'auto' })
|
||||
expect(screen.getByTestId('agent-send')).toBeVisible()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
|
||||
55
src/components/AiPanelChrome.performance.test.tsx
Normal file
55
src/components/AiPanelChrome.performance.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AiPanelMessageHistory } from './AiPanelChrome'
|
||||
|
||||
const aiMessageRender = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./AiMessage', () => ({
|
||||
AiMessage: (props: { id?: string; response: string }) => {
|
||||
aiMessageRender(props.id)
|
||||
return <div data-testid="ai-message">{props.response}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
function HistoryRerenderHarness() {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [messages] = useState([{
|
||||
userMessage: 'Explain the note',
|
||||
actions: [],
|
||||
response: 'Here is a long answer with [[Test Note]].',
|
||||
id: 'msg-stable',
|
||||
}])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setDraft('typing')}>type</button>
|
||||
<span data-testid="draft">{draft}</span>
|
||||
<AiPanelMessageHistory
|
||||
agentLabel="Claude Code"
|
||||
agentReadiness="ready"
|
||||
messages={messages}
|
||||
isActive={false}
|
||||
onOpenNote={noop}
|
||||
onNavigateWikilink={noop}
|
||||
hasContext
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AiPanelChrome performance', () => {
|
||||
it('keeps stable message history from re-rendering while composer state changes', () => {
|
||||
render(<HistoryRerenderHarness />)
|
||||
expect(screen.getByTestId('ai-message')).toHaveTextContent('Here is a long answer')
|
||||
expect(aiMessageRender).toHaveBeenCalledTimes(1)
|
||||
aiMessageRender.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'type' }))
|
||||
|
||||
expect(screen.getByTestId('draft')).toHaveTextContent('typing')
|
||||
expect(aiMessageRender).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -164,7 +164,7 @@ function AiPanelEmptyState({
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelHeader({
|
||||
export const AiPanelHeader = memo(function AiPanelHeader({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
locale = 'en',
|
||||
@@ -221,7 +221,7 @@ export function AiPanelHeader({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function AiPermissionModeToggle({
|
||||
value,
|
||||
@@ -277,7 +277,11 @@ function AiPermissionModeToggle({
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: AiPanelContextBarProps) {
|
||||
export const AiPanelContextBar = memo(function AiPanelContextBar({
|
||||
activeEntry,
|
||||
linkedCount,
|
||||
locale = 'en',
|
||||
}: AiPanelContextBarProps) {
|
||||
const t = createTranslator(locale)
|
||||
|
||||
return (
|
||||
@@ -293,9 +297,9 @@ export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: A
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function AiPanelMessageHistory({
|
||||
export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
locale = 'en',
|
||||
@@ -332,7 +336,7 @@ export function AiPanelMessageHistory({
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function AiPanelComposer({
|
||||
entries,
|
||||
@@ -365,7 +369,7 @@ export function AiPanelComposer({
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
@@ -375,6 +379,8 @@ export function AiPanelComposer({
|
||||
disabled={composerDisabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
editorClassName="max-h-[160px] overflow-y-auto overscroll-contain"
|
||||
editorStyle={{ maxHeight: 160, overflowY: 'auto', overscrollBehavior: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Let each rich-text block resolve its own base direction for mixed LTR/RTL notes. */
|
||||
.editor__blocknote-container .bn-inline-content {
|
||||
unicode-bidi: plaintext;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Override BlockNote's default line-height on block-outer so our theme controls it */
|
||||
.editor__blocknote-container .bn-block-outer {
|
||||
line-height: var(--editor-line-height) !important;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -57,6 +58,7 @@ interface InlineWikilinkInputProps {
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
dataTestId?: string
|
||||
editorClassName?: string
|
||||
editorStyle?: CSSProperties
|
||||
suggestionListVariant?: 'floating' | 'palette'
|
||||
suggestionEmptyLabel?: string
|
||||
paletteHeader?: ReactNode
|
||||
@@ -168,6 +170,7 @@ export function InlineWikilinkInput({
|
||||
inputRef,
|
||||
dataTestId = 'agent-input',
|
||||
editorClassName,
|
||||
editorStyle,
|
||||
suggestionListVariant = 'floating',
|
||||
suggestionEmptyLabel = 'No matching notes',
|
||||
paletteHeader,
|
||||
@@ -472,6 +475,7 @@ export function InlineWikilinkInput({
|
||||
inputRef={setCombinedRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
editorStyle={editorStyle}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onInput={handleInput}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment, createElement } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
@@ -158,6 +159,7 @@ export function InlineWikilinkEditorField({
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
editorStyle,
|
||||
onCompositionEnd,
|
||||
onCompositionStart,
|
||||
onInput,
|
||||
@@ -175,6 +177,7 @@ export function InlineWikilinkEditorField({
|
||||
inputRef: React.Ref<HTMLDivElement>
|
||||
dataTestId: string
|
||||
editorClassName?: string
|
||||
editorStyle?: CSSProperties
|
||||
onCompositionEnd: () => void
|
||||
onCompositionStart: () => void
|
||||
onInput: () => void
|
||||
@@ -222,7 +225,7 @@ export function InlineWikilinkEditorField({
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
onMouseUp={onSelectionChange}
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
style={{ ...editorStyle, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
segment.kind === 'text'
|
||||
|
||||
@@ -28,6 +28,12 @@ describe('ResizeHandle', () => {
|
||||
expect(handle.style.cursor || handle.className).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stacks above z-indexed panel surfaces', () => {
|
||||
const { container } = render(<ResizeHandle onResize={vi.fn()} />)
|
||||
const handle = container.firstChild as HTMLElement
|
||||
expect(handle.className).toContain('z-30')
|
||||
})
|
||||
|
||||
it('calls onResize with delta during drag', () => {
|
||||
const onResize = vi.fn()
|
||||
const { container } = render(<ResizeHandle onResize={onResize} />)
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-10 -ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
|
||||
className="relative z-30 -ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -437,6 +437,26 @@ describe('WikilinkChatInput', () => {
|
||||
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets native modified delete shortcuts reach the browser editor pipeline', () => {
|
||||
const onDraftChange = vi.fn()
|
||||
render(<Controlled onDraftChange={onDraftChange} />)
|
||||
updateEditorText('delete the previous words')
|
||||
onDraftChange.mockClear()
|
||||
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
const optionBackspace = createEvent.keyDown(editor, { key: 'Backspace', altKey: true })
|
||||
fireEvent(editor, optionBackspace)
|
||||
const commandBackspace = createEvent.keyDown(editor, { key: 'Backspace', metaKey: true })
|
||||
fireEvent(editor, commandBackspace)
|
||||
const controlDelete = createEvent.keyDown(editor, { key: 'Delete', ctrlKey: true })
|
||||
fireEvent(editor, controlDelete)
|
||||
|
||||
expect(optionBackspace.defaultPrevented).toBe(false)
|
||||
expect(commandBackspace.defaultPrevented).toBe(false)
|
||||
expect(controlDelete.defaultPrevented).toBe(false)
|
||||
expect(onDraftChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits serialized wikilink text and resolved references', () => {
|
||||
const onSend = vi.fn()
|
||||
render(<Controlled onSend={onSend} />)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { InlineWikilinkInput } from './InlineWikilinkInput'
|
||||
@@ -11,6 +12,8 @@ interface WikilinkChatInputProps {
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
editorClassName?: string
|
||||
editorStyle?: CSSProperties
|
||||
}
|
||||
|
||||
export function WikilinkChatInput({
|
||||
@@ -22,6 +25,8 @@ export function WikilinkChatInput({
|
||||
disabled,
|
||||
placeholder,
|
||||
inputRef,
|
||||
editorClassName,
|
||||
editorStyle,
|
||||
}: WikilinkChatInputProps) {
|
||||
return (
|
||||
<InlineWikilinkInput
|
||||
@@ -33,6 +38,8 @@ export function WikilinkChatInput({
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
editorClassName={editorClassName}
|
||||
editorStyle={editorStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ function handleDeleteKeys({
|
||||
onDeleteContent,
|
||||
}: HandleDeleteKeysArgs): boolean {
|
||||
if (isInlineWikilinkCompositionEvent(event, isComposing)) return false
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return false
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { markNoteOpenTrace } from '../utils/noteOpenPerformance'
|
||||
import { errorMessage, isActiveVaultUnavailableError } from '../utils/vaultErrors'
|
||||
import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode'
|
||||
|
||||
type NotePath = VaultEntry['path']
|
||||
@@ -273,14 +274,8 @@ export async function loadContentForOpen(options: {
|
||||
return loadNoteContent(entry, forceReload || hasResolvedCachedContent(cachedEntry))
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function isNoActiveVaultSelectedError(error: unknown): boolean {
|
||||
return /no active vault selected/i.test(errorMessage(error))
|
||||
return isActiveVaultUnavailableError(error)
|
||||
}
|
||||
|
||||
export function isUnreadableNoteContentError(error: unknown): boolean {
|
||||
|
||||
@@ -42,6 +42,17 @@ describe('useCodeMirror', () => {
|
||||
expect(result.current.current?.state.facet(EditorView.cspNonce)).toBe(RUNTIME_STYLE_NONCE)
|
||||
})
|
||||
|
||||
it('enables per-line auto text direction for mixed LTR and RTL content', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useCodeMirror(ref, 'English\nمرحبا بالعالم', noopCallbacks),
|
||||
)
|
||||
const view = result.current.current!
|
||||
|
||||
expect(view.state.facet(EditorView.perLineTextDirection)).toBe(true)
|
||||
expect([...container.querySelectorAll('.cm-line')].map(line => line.getAttribute('dir'))).toEqual(['auto', 'auto'])
|
||||
})
|
||||
|
||||
it('calls requestMeasure when laputa-zoom-change event fires', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view'
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
lineNumbers,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
import { EditorState, Prec } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
|
||||
@@ -18,6 +27,10 @@ const RAW_EDITOR_COLORS = {
|
||||
gutterBorder: 'var(--border-subtle)',
|
||||
gutterText: 'var(--text-muted)',
|
||||
}
|
||||
|
||||
const AUTO_TEXT_DIRECTION_LINE = Decoration.line({
|
||||
attributes: { dir: 'auto' },
|
||||
})
|
||||
interface MarkdownFence {
|
||||
character: '`' | '~'
|
||||
length: number
|
||||
@@ -105,10 +118,49 @@ function buildBaseTheme() {
|
||||
backgroundColor: RAW_EDITOR_COLORS.activeLineBackground,
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-line': { padding: '0' },
|
||||
'.cm-line': {
|
||||
padding: '0',
|
||||
unicodeBidi: 'plaintext',
|
||||
textAlign: 'start',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function buildAutoTextDirectionDecorations(view: EditorView): DecorationSet {
|
||||
const ranges = []
|
||||
|
||||
for (const visibleRange of view.visibleRanges) {
|
||||
for (let pos = visibleRange.from; pos <= visibleRange.to;) {
|
||||
const line = view.state.doc.lineAt(pos)
|
||||
ranges.push(AUTO_TEXT_DIRECTION_LINE.range(line.from))
|
||||
pos = line.to + 1
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(ranges, true)
|
||||
}
|
||||
|
||||
function buildAutoTextDirectionExtension() {
|
||||
return [
|
||||
EditorView.perLineTextDirection.of(true),
|
||||
ViewPlugin.fromClass(class {
|
||||
decorations: DecorationSet
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildAutoTextDirectionDecorations(view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildAutoTextDirectionDecorations(update.view)
|
||||
}
|
||||
}
|
||||
}, {
|
||||
decorations: plugin => plugin.decorations,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) {
|
||||
return Prec.highest(keymap.of([{
|
||||
key: 'Mod-s',
|
||||
@@ -188,6 +240,7 @@ export function useCodeMirror(
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
EditorView.lineWrapping,
|
||||
buildAutoTextDirectionExtension(),
|
||||
history(),
|
||||
buildArrowLigaturesExtension(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
|
||||
@@ -310,6 +310,7 @@ type SwapHarnessProps = {
|
||||
tabs: ReturnType<typeof makeTab>[]
|
||||
activeTabPath: string | null
|
||||
rawMode?: boolean
|
||||
vaultPath?: string
|
||||
}
|
||||
|
||||
async function createSwapHarness(options: {
|
||||
@@ -861,6 +862,34 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
flushQueuedFrames(frameCallbacks)
|
||||
})
|
||||
|
||||
it('saves BlockNote image asset URLs as vault-relative attachment links', async () => {
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
const onContentChange = vi.fn()
|
||||
const { docRef, mockEditor, result } = await createSwapHarness({
|
||||
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false, vaultPath: '/vault' },
|
||||
onContentChange,
|
||||
})
|
||||
|
||||
docRef.current = [{
|
||||
type: 'image',
|
||||
props: { url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png' },
|
||||
children: [],
|
||||
}]
|
||||
mockEditor.blocksToMarkdownLossy.mockReturnValue(
|
||||
'\n',
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
result.current.flushPendingEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'a.md',
|
||||
'---\ntitle: Note A\n---\n\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('serializes rich inline math nodes back to Markdown on editor changes', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface NoteActionsConfig {
|
||||
onNewNotePersisted?: (path: string) => void
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** Called when note loading proves the active vault path is no longer usable. */
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
|
||||
onFrontmatterContentChanged?: (path: string, content: string) => void
|
||||
/** Called after a frontmatter mutation is fully persisted, including follow-up renames. */
|
||||
@@ -194,15 +196,19 @@ async function updateFrontmatterAndMaybeRename({
|
||||
}
|
||||
|
||||
function buildTabManagementOptions(
|
||||
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'reloadVault' | 'setToastMessage' | 'unsavedPaths'>,
|
||||
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'onMissingActiveVault' | 'reloadVault' | 'setToastMessage' | 'unsavedPaths'>,
|
||||
) {
|
||||
const options: {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
hasUnsavedChanges: (path: string) => boolean
|
||||
onMissingActiveVault: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath: (entry: VaultEntry) => void
|
||||
onUnreadableNoteContent: (entry: VaultEntry) => void
|
||||
} = {
|
||||
hasUnsavedChanges: (path) => config.unsavedPaths?.has(path) ?? false,
|
||||
onMissingActiveVault: (entry, error) => {
|
||||
void config.onMissingActiveVault?.(entry, error)
|
||||
},
|
||||
onMissingNotePath: (entry) => {
|
||||
const label = entry.title.trim() || entry.filename
|
||||
config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)
|
||||
|
||||
@@ -258,6 +258,23 @@ describe('useTabManagement (single-note model)', () => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reports an unavailable active vault instead of opening a blank stale tab', async () => {
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('Active vault is not available'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const onMissingActiveVault = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ onMissingActiveVault }))
|
||||
await selectNote(result, { path: '/vault/note/orphaned.md', title: 'Orphaned Note' })
|
||||
|
||||
expect(result.current.tabs).toEqual([])
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
expect(onMissingActiveVault).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/vault/note/orphaned.md', title: 'Orphaned Note' }),
|
||||
expect.any(Error),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses the note-window vault path when Tauri reloads the selected note', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockResolvedValue('# Window content')
|
||||
|
||||
@@ -51,6 +51,7 @@ export type { Tab }
|
||||
interface TabManagementOptions {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
hasUnsavedChanges?: (path: string) => boolean
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}
|
||||
@@ -64,6 +65,7 @@ interface NavigateToEntryOptions {
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
hasUnsavedChanges?: (path: string) => boolean
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}
|
||||
@@ -252,6 +254,7 @@ function handleRecoverableEntryLoadFailure(options: {
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
error: unknown
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}) {
|
||||
@@ -263,6 +266,7 @@ function handleRecoverableEntryLoadFailure(options: {
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
error,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
} = options
|
||||
@@ -279,6 +283,16 @@ function handleRecoverableEntryLoadFailure(options: {
|
||||
})
|
||||
failNoteOpenTrace(entry.path, kind)
|
||||
|
||||
if (kind === 'missing-active-vault') {
|
||||
runEntryFailureCallback({
|
||||
callback: onMissingActiveVault,
|
||||
entry,
|
||||
error,
|
||||
warning: 'Failed to handle missing active vault:',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === 'missing-path') {
|
||||
runEntryFailureCallback({
|
||||
callback: onMissingNotePath,
|
||||
@@ -308,6 +322,7 @@ function handleEntryLoadFailure(options: {
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
error: unknown
|
||||
onMissingActiveVault?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}) {
|
||||
@@ -320,6 +335,7 @@ function handleEntryLoadFailure(options: {
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
error,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
} = options
|
||||
@@ -337,6 +353,7 @@ function handleEntryLoadFailure(options: {
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
error,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
})
|
||||
@@ -370,6 +387,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
} = options
|
||||
@@ -409,6 +427,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
error: err,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
})
|
||||
@@ -443,6 +462,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
const beforeNavigateSeqRef = useRef(0)
|
||||
const beforeNavigate = options.beforeNavigate
|
||||
const hasUnsavedChanges = options.hasUnsavedChanges
|
||||
const onMissingActiveVault = options.onMissingActiveVault
|
||||
const onMissingNotePath = options.onMissingNotePath
|
||||
const onUnreadableNoteContent = options.onUnreadableNoteContent
|
||||
|
||||
@@ -484,13 +504,14 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
}))
|
||||
if (!navigated) {
|
||||
resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path)
|
||||
}
|
||||
}, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingNotePath, onUnreadableNoteContent])
|
||||
}, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent])
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => {
|
||||
requestedActiveTabPathRef.current = path
|
||||
@@ -523,13 +544,14 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
onMissingActiveVault,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
}))
|
||||
if (!navigated) {
|
||||
resetRequestedPathIfStillPending(requestedActiveTabPathRef, activeTabPathRef, entry.path)
|
||||
}
|
||||
}, [executeNavigationWithBoundary, onMissingNotePath, onUnreadableNoteContent])
|
||||
}, [executeNavigationWithBoundary, onMissingActiveVault, onMissingNotePath, onUnreadableNoteContent])
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
tabsRef.current = []
|
||||
|
||||
60
src/hooks/useUnavailableVaultState.ts
Normal file
60
src/hooks/useUnavailableVaultState.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { clearPrefetchCache } from './useTabManagement'
|
||||
import { resetVaultState, type VaultStateResetOptions } from './vaultStateReset'
|
||||
|
||||
interface UnavailableVaultStateOptions extends VaultStateResetOptions {
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
export function useUnavailableVaultState(options: UnavailableVaultStateOptions) {
|
||||
const [unavailableVaultPath, setUnavailableVaultPath] = useState<string | null>(null)
|
||||
const {
|
||||
clearNewPaths,
|
||||
clearUnsaved,
|
||||
isCurrentVaultPath,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
vaultPath,
|
||||
} = options
|
||||
|
||||
const markVaultUnavailable = useCallback((path: string) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
clearPrefetchCache()
|
||||
resetVaultState({
|
||||
clearNewPaths,
|
||||
clearUnsaved,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
})
|
||||
setUnavailableVaultPath(path)
|
||||
}, [
|
||||
clearNewPaths,
|
||||
clearUnsaved,
|
||||
isCurrentVaultPath,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
])
|
||||
|
||||
const markVaultAvailable = useCallback((path: string) => {
|
||||
if (isCurrentVaultPath(path)) setUnavailableVaultPath(null)
|
||||
}, [isCurrentVaultPath])
|
||||
|
||||
return {
|
||||
markVaultAvailable,
|
||||
markVaultUnavailable,
|
||||
unavailableVaultPath: unavailableVaultPath === vaultPath ? unavailableVaultPath : null,
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,30 @@ describe('useVaultLoader', () => {
|
||||
expect(issuedCommands).not.toContain('list_vault')
|
||||
})
|
||||
|
||||
it('marks the vault unavailable when the initial load finds a missing active vault', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (isVaultLoadCommand(cmd)) return Promise.reject(new Error('No such file or directory'))
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
|
||||
if (cmd === 'list_vault_folders') return Promise.reject(new Error('Active vault is not available'))
|
||||
if (cmd === 'list_views') return Promise.reject(new Error('Active vault is not available'))
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.unavailableVaultPath).toBe('/vault')
|
||||
})
|
||||
expect(result.current.entries).toEqual([])
|
||||
expect(result.current.folders).toEqual([])
|
||||
expect(result.current.views).toEqual([])
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ignores stale reload_vault results after the vault path changes', async () => {
|
||||
await enableTauriMode()
|
||||
const firstLoad = createDeferred<VaultEntry[]>()
|
||||
@@ -919,6 +943,46 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.entries).toEqual(mockEntries)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears stale entries and marks the vault unavailable when the active vault disappears', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const initialViews = [{
|
||||
filename: 'work.yml',
|
||||
definition: {
|
||||
name: 'Work',
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sort: null,
|
||||
filters: { all: [] },
|
||||
},
|
||||
}]
|
||||
backendInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'reload_vault') return Promise.reject(new Error('No such file or directory'))
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles)
|
||||
if (cmd === 'list_vault_folders') return Promise.resolve([{ name: 'note', path: '/vault/note', children: [] }])
|
||||
if (cmd === 'list_views') return Promise.resolve(initialViews)
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = await renderVaultLoader()
|
||||
await waitFor(() => expect(result.current.views).toHaveLength(1))
|
||||
|
||||
let entries: VaultEntry[] = []
|
||||
await act(async () => {
|
||||
entries = await result.current.reloadVault()
|
||||
})
|
||||
|
||||
expect(entries).toEqual([])
|
||||
expect(result.current.entries).toEqual([])
|
||||
expect(result.current.folders).toEqual([])
|
||||
expect(result.current.views).toEqual([])
|
||||
expect(result.current.modifiedFiles).toEqual([])
|
||||
expect(result.current.unavailableVaultPath).toBe('/vault')
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reloadViews', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../lib/gitignoredVisibilityEvents'
|
||||
import { clearPrefetchCache } from './useTabManagement'
|
||||
import {
|
||||
checkVaultPathAvailability,
|
||||
commitWithPush,
|
||||
hasVaultPath,
|
||||
loadVaultChrome,
|
||||
@@ -17,55 +18,91 @@ import {
|
||||
tauriCall,
|
||||
} from './vaultLoaderCommands'
|
||||
import { normalizeVaultEntry } from '../utils/vaultMetadataNormalization'
|
||||
import { useUnavailableVaultState } from './useUnavailableVaultState'
|
||||
import { resetVaultState } from './vaultStateReset'
|
||||
|
||||
function resetVaultState(options: {
|
||||
clearNewPaths: () => void
|
||||
clearUnsaved: () => void
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setModifiedFiles: (files: ModifiedFile[]) => void
|
||||
setModifiedFilesError: (message: string | null) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
options.setEntries([])
|
||||
options.setFolders([])
|
||||
options.setViews([])
|
||||
options.setModifiedFiles([])
|
||||
options.setModifiedFilesError(null)
|
||||
options.setIsLoading(false)
|
||||
options.clearNewPaths()
|
||||
options.clearUnsaved()
|
||||
}
|
||||
|
||||
async function loadInitialVaultState(options: {
|
||||
interface InitialVaultLoadStateOptions {
|
||||
handleVaultAvailable: (path: string) => void
|
||||
path: string
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
const { path, isCurrentVaultPath, setEntries, setFolders, setIsLoading, setViews } = options
|
||||
const chromeLoad = loadVaultChrome({ vaultPath: path }).then(({ folders, views }) => {
|
||||
if (!isCurrentVaultPath(path)) return
|
||||
setFolders(folders)
|
||||
setViews(views)
|
||||
}
|
||||
|
||||
interface InitialVaultChromeOptions extends Pick<
|
||||
InitialVaultLoadStateOptions,
|
||||
'handleVaultUnavailable' | 'isCurrentVaultPath' | 'path' | 'setFolders' | 'setViews'
|
||||
> {
|
||||
shouldApplyChrome: () => boolean
|
||||
}
|
||||
|
||||
async function loadInitialVaultChromeState(options: InitialVaultChromeOptions): Promise<boolean> {
|
||||
const { handleVaultUnavailable, isCurrentVaultPath, path, setFolders, setViews, shouldApplyChrome } = options
|
||||
try {
|
||||
const { folders, views } = await loadVaultChrome({ vaultPath: path })
|
||||
if (shouldApplyChrome()) {
|
||||
setFolders(folders)
|
||||
setViews(views)
|
||||
}
|
||||
} catch (err) {
|
||||
const unavailable = await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
|
||||
if (unavailable) return true
|
||||
console.warn('Vault chrome load failed:', err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadInitialVaultEntriesState(options: Pick<
|
||||
InitialVaultLoadStateOptions,
|
||||
'handleVaultAvailable' | 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'path' | 'setEntries'
|
||||
>): Promise<boolean> {
|
||||
const { handleVaultAvailable, handleVaultUnavailable, isCurrentVaultPath, path, setEntries } = options
|
||||
|
||||
try {
|
||||
const { entries } = await loadVaultData({ vaultPath: path })
|
||||
if (isCurrentVaultPath(path)) {
|
||||
handleVaultAvailable(path)
|
||||
setEntries(entries)
|
||||
}
|
||||
} catch (err) {
|
||||
const unavailable = await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
|
||||
if (unavailable) return true
|
||||
console.warn('Vault scan failed:', err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadInitialVaultState(options: InitialVaultLoadStateOptions) {
|
||||
const { path, isCurrentVaultPath, setIsLoading } = options
|
||||
let vaultUnavailable = false
|
||||
const chromeLoad = loadInitialVaultChromeState({
|
||||
...options,
|
||||
shouldApplyChrome: () => !vaultUnavailable && isCurrentVaultPath(path),
|
||||
})
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { entries } = await loadVaultData({ vaultPath: path })
|
||||
if (isCurrentVaultPath(path)) setEntries(entries)
|
||||
} catch (err) {
|
||||
console.warn('Vault scan failed:', err)
|
||||
} finally {
|
||||
if (isCurrentVaultPath(path)) setIsLoading(false)
|
||||
}
|
||||
|
||||
vaultUnavailable = await loadInitialVaultEntriesState(options)
|
||||
if (isCurrentVaultPath(path)) setIsLoading(false)
|
||||
await chromeLoad
|
||||
}
|
||||
|
||||
async function handleUnavailableVaultPath(options: {
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
path: string
|
||||
}): Promise<boolean> {
|
||||
const { handleVaultUnavailable, isCurrentVaultPath, path } = options
|
||||
if (!isCurrentVaultPath(path)) return true
|
||||
|
||||
const available = await checkVaultPathAvailability({ vaultPath: path })
|
||||
if (available !== false) return false
|
||||
if (isCurrentVaultPath(path)) handleVaultUnavailable(path)
|
||||
return true
|
||||
}
|
||||
|
||||
function useCurrentVaultPathGuard(vaultPath: string) {
|
||||
const currentPathRef = useRef(vaultPath)
|
||||
|
||||
@@ -164,19 +201,9 @@ export function resolveNoteStatus({
|
||||
return resolveGitBackedNoteStatus(modifiedFiles.find((file) => file.path === path))
|
||||
}
|
||||
|
||||
function useInitialVaultLoad({
|
||||
vaultPath,
|
||||
tracker,
|
||||
unsaved,
|
||||
isCurrentVaultPath,
|
||||
resetReloading,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
}: {
|
||||
interface InitialVaultLoadOptions {
|
||||
handleVaultAvailable: (path: string) => void
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
vaultPath: string
|
||||
tracker: ReturnType<typeof useNewNoteTracker>
|
||||
unsaved: ReturnType<typeof useUnsavedTracker>
|
||||
@@ -188,7 +215,25 @@ function useInitialVaultLoad({
|
||||
setModifiedFiles: (files: ModifiedFile[]) => void
|
||||
setModifiedFilesError: (message: string | null) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
}
|
||||
|
||||
function useInitialVaultLoad(options: InitialVaultLoadOptions) {
|
||||
const {
|
||||
handleVaultAvailable,
|
||||
handleVaultUnavailable,
|
||||
vaultPath,
|
||||
tracker,
|
||||
unsaved,
|
||||
isCurrentVaultPath,
|
||||
resetReloading,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
setViews,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
const path = vaultPath
|
||||
clearPrefetchCache()
|
||||
@@ -208,7 +253,9 @@ function useInitialVaultLoad({
|
||||
|
||||
let cancelled = false
|
||||
void loadInitialVaultState({
|
||||
handleVaultAvailable,
|
||||
path,
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath: (candidate) => !cancelled && isCurrentVaultPath(candidate),
|
||||
setEntries,
|
||||
setFolders,
|
||||
@@ -217,6 +264,8 @@ function useInitialVaultLoad({
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [
|
||||
handleVaultAvailable,
|
||||
handleVaultUnavailable,
|
||||
vaultPath,
|
||||
tracker.clear,
|
||||
unsaved.clearAll,
|
||||
@@ -349,42 +398,71 @@ function useGitLoaders(vaultPath: string) {
|
||||
return { loadGitHistory, loadDiffAtCommit, loadDiff, commitAndPush }
|
||||
}
|
||||
|
||||
function useVaultReloads({
|
||||
vaultPath,
|
||||
isCurrentVaultPath,
|
||||
loadModifiedFiles,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setViews,
|
||||
}: {
|
||||
interface VaultReloadOptions {
|
||||
handleVaultAvailable: (path: string) => void
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
vaultPath: string
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
loadModifiedFiles: () => Promise<void>
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}) {
|
||||
const [activeReloads, setActiveReloads] = useState(0)
|
||||
const isReloading = activeReloads > 0
|
||||
const beginReload = useCallback(() => setActiveReloads((count) => count + 1), [])
|
||||
const finishReload = useCallback(() => setActiveReloads((count) => Math.max(0, count - 1)), [])
|
||||
const resetReloading = useCallback(() => setActiveReloads(0), [])
|
||||
}
|
||||
|
||||
const reloadFolders = useCallback(async () => {
|
||||
const path = vaultPath
|
||||
if (!hasVaultPath({ vaultPath: path })) return [] as FolderNode[]
|
||||
try {
|
||||
const folders = await loadVaultFolders({ vaultPath: path })
|
||||
if (!isCurrentVaultPath(path)) return [] as FolderNode[]
|
||||
const nextFolders = folders ?? []
|
||||
setFolders(nextFolders)
|
||||
return nextFolders
|
||||
} catch {
|
||||
return [] as FolderNode[]
|
||||
}
|
||||
}, [vaultPath, isCurrentVaultPath, setFolders])
|
||||
interface EntryReloadOptions extends VaultReloadOptions {
|
||||
beginReload: () => void
|
||||
finishReload: () => void
|
||||
}
|
||||
|
||||
const reloadVault = useCallback(async () => {
|
||||
interface CollectionReloadOptions<T> {
|
||||
handleVaultUnavailable: (path: string) => void
|
||||
isCurrentVaultPath: (path: string) => boolean
|
||||
loadCollection: (options: { vaultPath: string }) => Promise<T[]>
|
||||
path: string
|
||||
setCollection: (items: T[]) => void
|
||||
}
|
||||
|
||||
async function reloadVaultCollection<T>(options: CollectionReloadOptions<T>): Promise<T[]> {
|
||||
const { handleVaultUnavailable, isCurrentVaultPath, loadCollection, path, setCollection } = options
|
||||
if (!hasVaultPath({ vaultPath: path })) return []
|
||||
try {
|
||||
const items = await loadCollection({ vaultPath: path })
|
||||
if (!isCurrentVaultPath(path)) return []
|
||||
const nextItems = items ?? []
|
||||
setCollection(nextItems)
|
||||
return nextItems
|
||||
} catch {
|
||||
await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function useFolderReload({
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
setFolders,
|
||||
vaultPath,
|
||||
}: Pick<VaultReloadOptions, 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'setFolders' | 'vaultPath'>) {
|
||||
return useCallback(() => reloadVaultCollection({
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
loadCollection: loadVaultFolders,
|
||||
path: vaultPath,
|
||||
setCollection: setFolders,
|
||||
}), [handleVaultUnavailable, vaultPath, isCurrentVaultPath, setFolders])
|
||||
}
|
||||
|
||||
function useEntryReload({
|
||||
beginReload,
|
||||
finishReload,
|
||||
handleVaultAvailable,
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
loadModifiedFiles,
|
||||
setEntries,
|
||||
vaultPath,
|
||||
}: EntryReloadOptions) {
|
||||
return useCallback(async () => {
|
||||
const path = vaultPath
|
||||
if (!hasVaultPath({ vaultPath: path })) return [] as VaultEntry[]
|
||||
clearPrefetchCache()
|
||||
@@ -392,29 +470,44 @@ function useVaultReloads({
|
||||
try {
|
||||
const entries = await reloadVaultEntries({ vaultPath: path })
|
||||
if (!isCurrentVaultPath(path)) return [] as VaultEntry[]
|
||||
handleVaultAvailable(path)
|
||||
setEntries(entries)
|
||||
void loadModifiedFiles()
|
||||
return entries
|
||||
} catch (err) {
|
||||
if (await handleUnavailableVaultPath({ handleVaultUnavailable, isCurrentVaultPath, path })) return [] as VaultEntry[]
|
||||
console.warn('Vault reload failed:', err)
|
||||
return [] as VaultEntry[]
|
||||
} finally {
|
||||
finishReload()
|
||||
}
|
||||
}, [vaultPath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries])
|
||||
}, [handleVaultAvailable, handleVaultUnavailable, vaultPath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries])
|
||||
}
|
||||
|
||||
const reloadViews = useCallback(async () => {
|
||||
const path = vaultPath
|
||||
if (!hasVaultPath({ vaultPath: path })) return []
|
||||
try {
|
||||
const nextViews = await loadVaultViews({ vaultPath: path })
|
||||
if (!isCurrentVaultPath(path)) return []
|
||||
const resolvedViews = nextViews ?? []
|
||||
setViews(resolvedViews)
|
||||
return resolvedViews
|
||||
} catch { /* views are optional */ }
|
||||
return []
|
||||
}, [vaultPath, isCurrentVaultPath, setViews])
|
||||
function useViewReload({
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
setViews,
|
||||
vaultPath,
|
||||
}: Pick<VaultReloadOptions, 'handleVaultUnavailable' | 'isCurrentVaultPath' | 'setViews' | 'vaultPath'>) {
|
||||
return useCallback(() => reloadVaultCollection({
|
||||
handleVaultUnavailable,
|
||||
isCurrentVaultPath,
|
||||
loadCollection: loadVaultViews,
|
||||
path: vaultPath,
|
||||
setCollection: setViews,
|
||||
}), [handleVaultUnavailable, vaultPath, isCurrentVaultPath, setViews])
|
||||
}
|
||||
|
||||
function useVaultReloads(options: VaultReloadOptions) {
|
||||
const [activeReloads, setActiveReloads] = useState(0)
|
||||
const isReloading = activeReloads > 0
|
||||
const beginReload = useCallback(() => setActiveReloads((count) => count + 1), [])
|
||||
const finishReload = useCallback(() => setActiveReloads((count) => Math.max(0, count - 1)), [])
|
||||
const resetReloading = useCallback(() => setActiveReloads(0), [])
|
||||
const reloadFolders = useFolderReload(options)
|
||||
const reloadVault = useEntryReload({ ...options, beginReload, finishReload })
|
||||
const reloadViews = useViewReload(options)
|
||||
|
||||
return { isReloading, reloadFolders, reloadVault, reloadViews, resetReloading }
|
||||
}
|
||||
@@ -445,7 +538,7 @@ function useGitignoredVisibilityReloads(
|
||||
}, [reloadFolders, reloadVault, reloadViews])
|
||||
}
|
||||
|
||||
export function useVaultLoader(vaultPath: string) {
|
||||
function useVaultState(vaultPath: string) {
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [folders, setFolders] = useState<FolderNode[]>([])
|
||||
const [isLoading, setIsLoading] = useState(() => hasVaultPath({ vaultPath }))
|
||||
@@ -454,16 +547,67 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const pendingSave = usePendingSaveTracker()
|
||||
const unsaved = useUnsavedTracker()
|
||||
const isCurrentVaultPath = useCurrentVaultPathGuard(vaultPath)
|
||||
const modified = useModifiedFilesLoader(vaultPath, isCurrentVaultPath)
|
||||
|
||||
return {
|
||||
entries,
|
||||
folders,
|
||||
isCurrentVaultPath,
|
||||
isLoading,
|
||||
modified,
|
||||
pendingSave,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setViews,
|
||||
tracker,
|
||||
unsaved,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
function useVaultUnavailable(vaultPath: string, state: ReturnType<typeof useVaultState>) {
|
||||
const {
|
||||
isCurrentVaultPath,
|
||||
modified,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setViews,
|
||||
tracker,
|
||||
unsaved,
|
||||
} = state
|
||||
|
||||
return useUnavailableVaultState({
|
||||
clearNewPaths: tracker.clear,
|
||||
clearUnsaved: unsaved.clearAll,
|
||||
isCurrentVaultPath,
|
||||
setEntries,
|
||||
setFolders,
|
||||
setIsLoading,
|
||||
setModifiedFiles: modified.setModifiedFiles,
|
||||
setModifiedFilesError: modified.setModifiedFilesError,
|
||||
setViews,
|
||||
vaultPath,
|
||||
})
|
||||
}
|
||||
|
||||
export function useVaultLoader(vaultPath: string) {
|
||||
const state = useVaultState(vaultPath)
|
||||
const { entries, folders, isCurrentVaultPath, isLoading, modified, pendingSave, setEntries, setFolders, setIsLoading, setViews, tracker, unsaved, views } = state
|
||||
const {
|
||||
modifiedFiles,
|
||||
modifiedFilesError,
|
||||
setModifiedFiles,
|
||||
setModifiedFilesError,
|
||||
loadModifiedFiles,
|
||||
} = useModifiedFilesLoader(vaultPath, isCurrentVaultPath)
|
||||
} = modified
|
||||
const entryMutations = useEntryMutations(setEntries, tracker.trackNew)
|
||||
const gitLoaders = useGitLoaders(vaultPath)
|
||||
const unavailableVault = useVaultUnavailable(vaultPath, state)
|
||||
const vaultReloads = useVaultReloads({
|
||||
handleVaultAvailable: unavailableVault.markVaultAvailable,
|
||||
handleVaultUnavailable: unavailableVault.markVaultUnavailable,
|
||||
vaultPath,
|
||||
isCurrentVaultPath,
|
||||
loadModifiedFiles,
|
||||
@@ -474,6 +618,8 @@ export function useVaultLoader(vaultPath: string) {
|
||||
useGitignoredVisibilityReloads(vaultReloads)
|
||||
|
||||
useInitialVaultLoad({
|
||||
handleVaultAvailable: unavailableVault.markVaultAvailable,
|
||||
handleVaultUnavailable: unavailableVault.markVaultUnavailable,
|
||||
vaultPath,
|
||||
tracker,
|
||||
unsaved,
|
||||
@@ -498,6 +644,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
|
||||
return {
|
||||
entries, folders, isLoading, isReloading: vaultReloads.isReloading, views, modifiedFiles, modifiedFilesError,
|
||||
unavailableVaultPath: unavailableVault.unavailableVaultPath,
|
||||
...entryMutations,
|
||||
loadModifiedFiles,
|
||||
...gitLoaders,
|
||||
@@ -505,6 +652,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
reloadVault: vaultReloads.reloadVault,
|
||||
reloadFolders: vaultReloads.reloadFolders,
|
||||
reloadViews: vaultReloads.reloadViews,
|
||||
markVaultUnavailable: unavailableVault.markVaultUnavailable,
|
||||
addPendingSave: pendingSave.addPendingSave,
|
||||
removePendingSave: pendingSave.removePendingSave,
|
||||
unsavedPaths: unsaved.unsavedPaths,
|
||||
|
||||
@@ -34,6 +34,19 @@ export function tauriCall<T>({ command, tauriArgs, mockArgs }: TauriCallOptions)
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
||||
}
|
||||
|
||||
export async function checkVaultPathAvailability({ vaultPath }: VaultPathOptions): Promise<boolean | null> {
|
||||
if (!hasVaultPath({ vaultPath })) return false
|
||||
|
||||
try {
|
||||
return await tauriCall<boolean>({
|
||||
command: 'check_vault_exists',
|
||||
tauriArgs: { path: vaultPath },
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function loadVaultEntriesWithCommand({ vaultPath, command }: VaultPathOptions & { command: string }): Promise<VaultEntry[]> {
|
||||
return tauriCall<unknown>({ command, tauriArgs: { path: vaultPath } })
|
||||
.then((entries) => normalizeVaultEntries(entries, vaultPath))
|
||||
|
||||
23
src/hooks/vaultStateReset.ts
Normal file
23
src/hooks/vaultStateReset.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { FolderNode, ModifiedFile, VaultEntry, ViewFile } from '../types'
|
||||
|
||||
export interface VaultStateResetOptions {
|
||||
clearNewPaths: () => void
|
||||
clearUnsaved: () => void
|
||||
setEntries: (entries: VaultEntry[]) => void
|
||||
setFolders: (folders: FolderNode[]) => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setModifiedFiles: (files: ModifiedFile[]) => void
|
||||
setModifiedFilesError: (message: string | null) => void
|
||||
setViews: (views: ViewFile[]) => void
|
||||
}
|
||||
|
||||
export function resetVaultState(options: VaultStateResetOptions) {
|
||||
options.setEntries([])
|
||||
options.setFolders([])
|
||||
options.setViews([])
|
||||
options.setModifiedFiles([])
|
||||
options.setModifiedFilesError(null)
|
||||
options.setIsLoading(false)
|
||||
options.clearNewPaths()
|
||||
options.clearUnsaved()
|
||||
}
|
||||
@@ -219,6 +219,37 @@ describe('aiAgentStreamCallbacks', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('gives OpenCode an actionable empty-response message', () => {
|
||||
const messages = createMessageStore([
|
||||
{
|
||||
id: 'msg-1',
|
||||
userMessage: 'Summarize the current note',
|
||||
actions: [],
|
||||
isStreaming: true,
|
||||
},
|
||||
])
|
||||
const status = createStatusStore('thinking')
|
||||
|
||||
const callbacks = createStreamCallbacks({
|
||||
agent: 'opencode',
|
||||
messageId: 'msg-1',
|
||||
vaultPath: '/vault',
|
||||
setMessages: messages.setMessages,
|
||||
setStatus: status.setStatus,
|
||||
abortRef: { current: { aborted: false } },
|
||||
responseAccRef: { current: '' },
|
||||
toolInputMapRef: { current: new Map() },
|
||||
fileCallbacksRef: { current: undefined },
|
||||
})
|
||||
|
||||
callbacks.onDone()
|
||||
|
||||
expect(status.getStatus()).toBe('done')
|
||||
expect(messages.getMessages()[0].response).toContain('OpenCode returned no assistant text')
|
||||
expect(messages.getMessages()[0].response).toContain('provider/model context limit')
|
||||
expect(messages.getMessages()[0].response).not.toContain('finished without returning a reply')
|
||||
})
|
||||
|
||||
it('ignores stream events after the request has been aborted', () => {
|
||||
const messages = createMessageStore([
|
||||
{
|
||||
|
||||
@@ -26,9 +26,17 @@ export interface StreamMutationContext {
|
||||
}
|
||||
|
||||
function finalResponseText(response: string, agent: AiAgentId): string {
|
||||
return response.trim()
|
||||
? response
|
||||
: `${getAiAgentDefinition(agent).label} finished without returning a reply.`
|
||||
if (response.trim()) return response
|
||||
|
||||
if (agent === 'opencode') {
|
||||
return [
|
||||
'OpenCode returned no assistant text.',
|
||||
'Check the selected provider/model context limit or retry the request.',
|
||||
'For large active notes, Tolaria sends a compact note snapshot and OpenCode can read the full file with get_note(path).',
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
return `${getAiAgentDefinition(agent).label} finished without returning a reply.`
|
||||
}
|
||||
|
||||
export function createStreamCallbacks(context: StreamMutationContext) {
|
||||
|
||||
@@ -312,6 +312,34 @@ describe('buildContextSnapshot', () => {
|
||||
expect(json.activeNote.body).toBe('Fresh editor content')
|
||||
})
|
||||
|
||||
it('compacts large active note bodies and points agents at the full note tool', () => {
|
||||
const largeBody = [
|
||||
'Opening section '.repeat(900),
|
||||
'Middle section that should not be fully embedded '.repeat(900),
|
||||
'Closing section '.repeat(900),
|
||||
].join('\n')
|
||||
const result = buildContextSnapshot({
|
||||
activeEntry: makeEntry({
|
||||
path: '/vault/large-note.md',
|
||||
title: 'Large Note',
|
||||
wordCount: 2700,
|
||||
}),
|
||||
entries,
|
||||
activeNoteContent: largeBody,
|
||||
})
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
|
||||
expect(json.activeNote.body.length).toBeLessThan(largeBody.length)
|
||||
expect(json.activeNote.body).toContain('Opening section')
|
||||
expect(json.activeNote.body).toContain('Closing section')
|
||||
expect(json.activeNote.body).toContain('get_note("/vault/large-note.md")')
|
||||
expect(json.activeNote.bodyTruncated).toEqual({
|
||||
shownChars: expect.any(Number),
|
||||
totalChars: largeBody.trim().length,
|
||||
strategy: 'head-tail',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty body when no activeNoteContent', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, entries })
|
||||
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
|
||||
|
||||
@@ -81,92 +81,177 @@ export interface ContextSnapshotParams {
|
||||
references?: NoteReference[]
|
||||
}
|
||||
|
||||
const MAX_ACTIVE_NOTE_BODY_CHARS = 24_000
|
||||
const ACTIVE_NOTE_BODY_HEAD_CHARS = 16_000
|
||||
const ACTIVE_NOTE_BODY_TAIL_CHARS = 4_000
|
||||
const MAX_NOTE_LIST_ITEMS = 100
|
||||
|
||||
interface ActiveNoteBody {
|
||||
body: string
|
||||
bodyTruncated?: {
|
||||
shownChars: number
|
||||
totalChars: number
|
||||
strategy: 'head-tail'
|
||||
}
|
||||
}
|
||||
|
||||
function isPresentValue(value: unknown): boolean {
|
||||
if (value === null) return false
|
||||
if (value === undefined) return false
|
||||
if (value === '') return false
|
||||
return true
|
||||
}
|
||||
|
||||
function assignIfPresent(target: Record<string, unknown>, key: string, value: unknown): void {
|
||||
if (isPresentValue(value)) target[key] = value
|
||||
}
|
||||
|
||||
function assignIfNonEmpty(target: Record<string, unknown>, key: string, values: unknown[]): void {
|
||||
if (values.length > 0) {
|
||||
target[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
function propertyString(value: unknown): string | undefined {
|
||||
if (!isPresentValue(value)) return undefined
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
}
|
||||
|
||||
function entryFrontmatter(e: VaultEntry): Record<string, unknown> {
|
||||
const fm: Record<string, unknown> = {}
|
||||
if (e.isA) fm.type = e.isA
|
||||
if (e.status) fm.status = e.status
|
||||
// Owner and cadence are now stored in properties, not first-class fields
|
||||
const owner = e.properties?.Owner ?? e.properties?.owner
|
||||
const cadence = e.properties?.Cadence ?? e.properties?.cadence
|
||||
if (owner) fm.owner = typeof owner === 'string' ? owner : String(owner)
|
||||
if (cadence) fm.cadence = typeof cadence === 'string' ? cadence : String(cadence)
|
||||
if (e.belongsTo.length > 0) fm.belongsTo = e.belongsTo
|
||||
if (e.relatedTo.length > 0) fm.relatedTo = e.relatedTo
|
||||
if (Object.keys(e.relationships).length > 0) fm.relationships = e.relationships
|
||||
assignIfPresent(fm, 'type', e.isA)
|
||||
assignIfPresent(fm, 'status', e.status)
|
||||
assignIfPresent(fm, 'owner', propertyString(e.properties?.Owner ?? e.properties?.owner))
|
||||
assignIfPresent(fm, 'cadence', propertyString(e.properties?.Cadence ?? e.properties?.cadence))
|
||||
assignIfNonEmpty(fm, 'belongsTo', e.belongsTo)
|
||||
assignIfNonEmpty(fm, 'relatedTo', e.relatedTo)
|
||||
assignIfNonEmpty(fm, 'relationships', Object.keys(e.relationships))
|
||||
if (fm.relationships) fm.relationships = e.relationships
|
||||
return fm
|
||||
}
|
||||
|
||||
const MAX_NOTE_LIST_ITEMS = 100
|
||||
function unavailableBodyInstruction(activeEntry: VaultEntry): string {
|
||||
return `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]`
|
||||
}
|
||||
|
||||
/** Build a structured context snapshot as a system prompt for Claude. */
|
||||
export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
|
||||
function truncatedBodyInstruction(path: string, omittedChars: number): string {
|
||||
return [
|
||||
'[Active note body truncated by Tolaria to keep CLI agent context within provider limits.',
|
||||
`Omitted approximately ${omittedChars} characters from the middle.`,
|
||||
`Use get_note("${path}") to read the full note before making content-sensitive edits or summaries.]`,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
const rawContent = activeNoteContent || ''
|
||||
let body = extractBody(rawContent)
|
||||
|
||||
// Defence-in-depth: when body is empty but the note has content on disk,
|
||||
// include an explicit instruction in the body field itself (more reliable
|
||||
// than a preamble instruction that Claude might skip).
|
||||
if (!body && activeEntry.wordCount > 0) {
|
||||
body = `[Content not available in editor context — use get_note("${activeEntry.path}") to read the full note (${activeEntry.wordCount} words)]`
|
||||
function compactActiveNoteBody(body: string, path: string): ActiveNoteBody {
|
||||
if (body.length <= MAX_ACTIVE_NOTE_BODY_CHARS) {
|
||||
return { body }
|
||||
}
|
||||
|
||||
const snapshot: Record<string, unknown> = {
|
||||
activeNote: {
|
||||
path: activeEntry.path,
|
||||
title: activeEntry.title,
|
||||
type: activeEntry.isA ?? 'Note',
|
||||
frontmatter: entryFrontmatter(activeEntry),
|
||||
body,
|
||||
wordCount: activeEntry.wordCount,
|
||||
const head = body.slice(0, ACTIVE_NOTE_BODY_HEAD_CHARS).trimEnd()
|
||||
const tail = body.slice(-ACTIVE_NOTE_BODY_TAIL_CHARS).trimStart()
|
||||
const omittedChars = Math.max(0, body.length - ACTIVE_NOTE_BODY_HEAD_CHARS - ACTIVE_NOTE_BODY_TAIL_CHARS)
|
||||
|
||||
return {
|
||||
body: `${head}\n\n${truncatedBodyInstruction(path, omittedChars)}\n\n${tail}`,
|
||||
bodyTruncated: {
|
||||
shownChars: ACTIVE_NOTE_BODY_HEAD_CHARS + ACTIVE_NOTE_BODY_TAIL_CHARS,
|
||||
totalChars: body.length,
|
||||
strategy: 'head-tail',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function activeNoteBody(activeEntry: VaultEntry, activeNoteContent?: string): ActiveNoteBody {
|
||||
const body = extractBody(activeNoteContent || '')
|
||||
if (!body && activeEntry.wordCount > 0) {
|
||||
return { body: unavailableBodyInstruction(activeEntry) }
|
||||
}
|
||||
return compactActiveNoteBody(body, activeEntry.path)
|
||||
}
|
||||
|
||||
function activeNoteSnapshot(activeEntry: VaultEntry, activeNoteContent?: string): Record<string, unknown> {
|
||||
const bodySnapshot = activeNoteBody(activeEntry, activeNoteContent)
|
||||
const note: Record<string, unknown> = {
|
||||
path: activeEntry.path,
|
||||
title: activeEntry.title,
|
||||
type: activeEntry.isA ?? 'Note',
|
||||
frontmatter: entryFrontmatter(activeEntry),
|
||||
body: bodySnapshot.body,
|
||||
wordCount: activeEntry.wordCount,
|
||||
}
|
||||
assignIfPresent(note, 'bodyTruncated', bodySnapshot.bodyTruncated)
|
||||
return note
|
||||
}
|
||||
|
||||
function appendOpenTabs(snapshot: Record<string, unknown>, activeEntry: VaultEntry, openTabs?: VaultEntry[]): void {
|
||||
const otherTabs = openTabs?.filter(t => t.path !== activeEntry.path)
|
||||
if (otherTabs && otherTabs.length > 0) {
|
||||
snapshot.openTabs = otherTabs.map(t => ({
|
||||
path: t.path,
|
||||
title: t.title,
|
||||
type: t.isA ?? 'Note',
|
||||
frontmatter: entryFrontmatter(t),
|
||||
}))
|
||||
}
|
||||
if (!otherTabs?.length) return
|
||||
|
||||
if (noteList && noteList.length > 0) {
|
||||
const items = noteList.slice(0, MAX_NOTE_LIST_ITEMS)
|
||||
snapshot.noteList = items
|
||||
if (noteList.length > MAX_NOTE_LIST_ITEMS) {
|
||||
snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length }
|
||||
}
|
||||
}
|
||||
snapshot.openTabs = otherTabs.map(t => ({
|
||||
path: t.path,
|
||||
title: t.title,
|
||||
type: t.isA ?? 'Note',
|
||||
frontmatter: entryFrontmatter(t),
|
||||
}))
|
||||
}
|
||||
|
||||
if (noteListFilter && (noteListFilter.type || noteListFilter.query)) {
|
||||
snapshot.noteListFilter = noteListFilter
|
||||
}
|
||||
function appendNoteList(snapshot: Record<string, unknown>, noteList?: NoteListItem[]): void {
|
||||
if (!noteList?.length) return
|
||||
|
||||
snapshot.noteList = noteList.slice(0, MAX_NOTE_LIST_ITEMS)
|
||||
if (noteList.length > MAX_NOTE_LIST_ITEMS) {
|
||||
snapshot.noteListTruncated = { shown: MAX_NOTE_LIST_ITEMS, total: noteList.length }
|
||||
}
|
||||
}
|
||||
|
||||
function hasNoteListFilter(noteListFilter?: { type: string | null; query: string }): boolean {
|
||||
return Boolean(noteListFilter?.type || noteListFilter?.query)
|
||||
}
|
||||
|
||||
function appendReferencedNotes(snapshot: Record<string, unknown>, references?: NoteReference[]): void {
|
||||
if (!references?.length) return
|
||||
|
||||
snapshot.referencedNotes = references.map(ref => ({
|
||||
path: ref.path,
|
||||
title: ref.title,
|
||||
type: ref.type ?? 'Note',
|
||||
}))
|
||||
}
|
||||
|
||||
function vaultSummary(entries: VaultEntry[]): Record<string, unknown> {
|
||||
const types = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA) types.add(e.isA)
|
||||
}
|
||||
snapshot.vault = {
|
||||
return {
|
||||
types: [...types].sort(),
|
||||
totalNotes: entries.length,
|
||||
}
|
||||
}
|
||||
|
||||
if (references && references.length > 0) {
|
||||
snapshot.referencedNotes = references.map(ref => ({
|
||||
path: ref.path,
|
||||
title: ref.title,
|
||||
type: ref.type ?? 'Note',
|
||||
}))
|
||||
function contextSnapshot(params: ContextSnapshotParams): Record<string, unknown> {
|
||||
const { activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
|
||||
const snapshot: Record<string, unknown> = {
|
||||
activeNote: activeNoteSnapshot(activeEntry, activeNoteContent),
|
||||
}
|
||||
|
||||
appendOpenTabs(snapshot, activeEntry, openTabs)
|
||||
appendNoteList(snapshot, noteList)
|
||||
if (hasNoteListFilter(noteListFilter)) snapshot.noteListFilter = noteListFilter
|
||||
snapshot.vault = vaultSummary(entries)
|
||||
appendReferencedNotes(snapshot, references)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** Build a structured context snapshot as a system prompt for Claude. */
|
||||
export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
const snapshot = contextSnapshot(params)
|
||||
|
||||
const preamble = [
|
||||
'You are an AI assistant integrated into Tolaria, a personal knowledge management app.',
|
||||
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
|
||||
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
|
||||
'If the body field is empty but wordCount is > 0, the content may be stale — use get_note to read the full note from disk.',
|
||||
'If the body field is empty or truncated, use get_note to read the full note from disk before content-sensitive edits or summaries.',
|
||||
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
|
||||
].join('\n')
|
||||
|
||||
|
||||
9
src/utils/vaultErrors.ts
Normal file
9
src/utils/vaultErrors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function isActiveVaultUnavailableError(error: unknown): boolean {
|
||||
return /no active vault selected|active vault is not available/i.test(errorMessage(error))
|
||||
}
|
||||
@@ -129,6 +129,24 @@ describe('relativePathStem', () => {
|
||||
expect(relativePathStem('/Users/luca/Vault/docs/adr/0031.md', '/Users/luca/Vault')).toBe('docs/adr/0031')
|
||||
})
|
||||
|
||||
it('normalizes Windows extended-length paths before extracting the vault-relative stem', () => {
|
||||
expect(
|
||||
relativePathStem(
|
||||
'\\\\?\\C:\\Users\\lrfno\\Documents\\tolaria-vault\\application-design-and-build.md',
|
||||
'C:\\Users\\lrfno\\Documents\\tolaria-vault',
|
||||
),
|
||||
).toBe('application-design-and-build')
|
||||
})
|
||||
|
||||
it('keeps nested Windows note paths vault-relative with slash separators', () => {
|
||||
expect(
|
||||
relativePathStem(
|
||||
'C:\\Users\\lrfno\\Documents\\Tolaria Vault\\projects\\application-design-and-build.md',
|
||||
'c:/users/lrfno/documents/tolaria vault',
|
||||
),
|
||||
).toBe('projects/application-design-and-build')
|
||||
})
|
||||
|
||||
it('falls back to filename stem when vault path does not match', () => {
|
||||
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
|
||||
})
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import { slugifyNoteStem } from './noteSlug'
|
||||
|
||||
export type AbsoluteNotePath = string
|
||||
export type NoteTitleOrTarget = string
|
||||
export type VaultPath = string
|
||||
export type WikilinkReference = string
|
||||
export type WikilinkTarget = string
|
||||
|
||||
/** Extracts the target path from a wikilink reference (strips [[ ]] and display text). */
|
||||
export function wikilinkTarget(ref: string): string {
|
||||
export function wikilinkTarget(ref: WikilinkReference): WikilinkTarget {
|
||||
const inner = ref.replace(/^\[\[|\]\]$/g, '')
|
||||
const pipeIdx = inner.indexOf('|')
|
||||
return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
|
||||
}
|
||||
|
||||
/** Extracts the display label from a wikilink reference. Falls back to humanised path stem. */
|
||||
export function wikilinkDisplay(ref: string): string {
|
||||
export function wikilinkDisplay(ref: WikilinkReference): string {
|
||||
const inner = ref.replace(/^\[\[|\]\]$/g, '')
|
||||
const pipeIdx = inner.indexOf('|')
|
||||
if (pipeIdx !== -1) return inner.slice(pipeIdx + 1)
|
||||
@@ -19,29 +25,49 @@ export function wikilinkDisplay(ref: string): string {
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function stripWindowsExtendedPathPrefix(path: AbsoluteNotePath | VaultPath): string {
|
||||
return path
|
||||
.replace(/^\\\\\?\\UNC\\/i, '//')
|
||||
.replace(/^\\\\\?\\/, '')
|
||||
}
|
||||
|
||||
function normalizeFilesystemPath(path: AbsoluteNotePath | VaultPath): string {
|
||||
return stripWindowsExtendedPathPrefix(path)
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\/+$/g, '')
|
||||
}
|
||||
|
||||
function withoutMarkdownExtension(pathStem: WikilinkTarget): WikilinkTarget {
|
||||
return pathStem.replace(/\.md$/i, '')
|
||||
}
|
||||
|
||||
/** Extract the vault-relative path stem (no leading slash, no .md extension). */
|
||||
export function relativePathStem(absolutePath: string, vaultPath: string): string {
|
||||
const prefix = vaultPath.endsWith('/') ? vaultPath : vaultPath + '/'
|
||||
if (absolutePath.startsWith(prefix)) return absolutePath.slice(prefix.length).replace(/\.md$/, '')
|
||||
export function relativePathStem(absolutePath: AbsoluteNotePath, vaultPath: VaultPath): WikilinkTarget {
|
||||
const normalizedAbsolutePath = normalizeFilesystemPath(absolutePath)
|
||||
const normalizedVaultPath = normalizeFilesystemPath(vaultPath)
|
||||
const prefix = normalizedVaultPath.endsWith('/') ? normalizedVaultPath : normalizedVaultPath + '/'
|
||||
if (normalizedAbsolutePath.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return withoutMarkdownExtension(normalizedAbsolutePath.slice(prefix.length))
|
||||
}
|
||||
// Fallback: just the filename stem
|
||||
const filename = absolutePath.split('/').pop() ?? absolutePath
|
||||
return filename.replace(/\.md$/, '')
|
||||
const filename = normalizedAbsolutePath.split('/').pop() ?? normalizedAbsolutePath
|
||||
return withoutMarkdownExtension(filename)
|
||||
}
|
||||
|
||||
/** Slugify a human-readable title into the canonical wikilink filename stem. */
|
||||
export const slugifyWikilinkTarget = slugifyNoteStem
|
||||
|
||||
/** Build the canonical wikilink target for a vault entry. */
|
||||
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: string): string {
|
||||
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: VaultPath): WikilinkTarget {
|
||||
return relativePathStem(entry.path, vaultPath)
|
||||
}
|
||||
|
||||
/** Resolve a user-facing title/path input to the canonical wikilink target. */
|
||||
export function canonicalWikilinkTargetForTitle(
|
||||
titleOrTarget: string,
|
||||
titleOrTarget: NoteTitleOrTarget,
|
||||
entries: VaultEntry[],
|
||||
vaultPath: string,
|
||||
): string {
|
||||
vaultPath: VaultPath,
|
||||
): WikilinkTarget {
|
||||
const trimmed = titleOrTarget.trim()
|
||||
const resolved = resolveEntry(entries, trimmed)
|
||||
return resolved
|
||||
@@ -52,7 +78,7 @@ export function canonicalWikilinkTargetForTitle(
|
||||
}
|
||||
|
||||
/** Wrap a target in wikilink syntax. */
|
||||
export function formatWikilinkRef(target: string): string {
|
||||
export function formatWikilinkRef(target: WikilinkTarget): WikilinkReference {
|
||||
return `[[${target}]]`
|
||||
}
|
||||
|
||||
@@ -63,7 +89,7 @@ interface ResolutionKey {
|
||||
humanizedTarget: string | null
|
||||
}
|
||||
|
||||
function buildResolutionKey(rawTarget: string): ResolutionKey {
|
||||
function buildResolutionKey(rawTarget: WikilinkTarget): ResolutionKey {
|
||||
const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
|
||||
const normalizedTarget = exactTarget.toLowerCase()
|
||||
const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget
|
||||
@@ -116,7 +142,7 @@ function findEntryByHumanizedTitle(entries: VaultEntry[], resolutionKey: Resolut
|
||||
* 4. Exact title match
|
||||
* 5. Humanized title match (kebab-case → words)
|
||||
*/
|
||||
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
|
||||
export function resolveEntry(entries: VaultEntry[], rawTarget: WikilinkTarget): VaultEntry | undefined {
|
||||
const resolutionKey = buildResolutionKey(rawTarget)
|
||||
return (
|
||||
findEntryByPathSuffix(entries, resolutionKey)
|
||||
|
||||
113
tests/smoke/missing-active-vault-recovery.spec.ts
Normal file
113
tests/smoke/missing-active-vault-recovery.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
type MockHandlers = Record<string, (args?: unknown) => unknown>
|
||||
type MockWindow = Window & {
|
||||
__markVaultMissing?: () => void
|
||||
}
|
||||
|
||||
const entry = {
|
||||
path: '/vault/note/runtime.md',
|
||||
filename: 'runtime.md',
|
||||
title: 'Runtime Vault Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 64,
|
||||
snippet: 'Loaded before the vault path disappears.',
|
||||
wordCount: 7,
|
||||
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',
|
||||
}
|
||||
|
||||
async function installMissingVaultMock(page: Page): Promise<void> {
|
||||
await page.addInitScript((noteEntry: typeof entry) => {
|
||||
localStorage.setItem('tolaria_welcome_dismissed', '1')
|
||||
localStorage.setItem('tolaria:ai-agents-onboarding-dismissed', '1')
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
const missingError = () => new Error('Active vault is not available')
|
||||
const mockWindow = window as MockWindow
|
||||
let vaultAvailable = true
|
||||
let handlers: MockHandlers | null = null
|
||||
|
||||
mockWindow.__markVaultMissing = () => {
|
||||
vaultAvailable = false
|
||||
}
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value: unknown) {
|
||||
handlers = value as MockHandlers
|
||||
handlers.load_vault_list = () => ({
|
||||
vaults: [{ label: 'Runtime Vault', path: '/vault' }],
|
||||
active_vault: '/vault',
|
||||
hidden_defaults: [],
|
||||
})
|
||||
handlers.check_vault_exists = () => vaultAvailable
|
||||
handlers.get_default_vault_path = () => '/vault'
|
||||
handlers.get_settings = () => ({
|
||||
auto_pull_interval_minutes: null,
|
||||
auto_advance_inbox_after_organize: null,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
})
|
||||
handlers.get_vault_settings = () => ({ theme: null })
|
||||
handlers.list_vault = () => vaultAvailable ? [noteEntry] : Promise.reject(new Error('No such file or directory'))
|
||||
handlers.reload_vault = () => vaultAvailable ? [noteEntry] : Promise.reject(new Error('No such file or directory'))
|
||||
handlers.list_vault_folders = () => vaultAvailable ? [] : Promise.reject(missingError())
|
||||
handlers.list_views = () => vaultAvailable ? [] : Promise.reject(missingError())
|
||||
handlers.get_modified_files = () => vaultAvailable ? [] : Promise.reject(missingError())
|
||||
handlers.get_all_content = () => ({ [noteEntry.path]: '# Runtime Vault Note\n\nBody.' })
|
||||
handlers.get_note_content = () => vaultAvailable
|
||||
? '# Runtime Vault Note\n\nBody.'
|
||||
: Promise.reject(missingError())
|
||||
handlers.is_git_repo = () => true
|
||||
handlers.sync_mcp_bridge_vault = () => null
|
||||
},
|
||||
get() {
|
||||
return handlers
|
||||
},
|
||||
})
|
||||
}, entry)
|
||||
}
|
||||
|
||||
test('missing active vault reload shows recovery state and clears stale notes @smoke', async ({ page }) => {
|
||||
await installMissingVaultMock(page)
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByText('Runtime Vault Note')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as MockWindow).__markVaultMissing?.()
|
||||
})
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Reload Vault')
|
||||
|
||||
await expect(page.getByText('Vault not found')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('welcome-open-folder')).toContainText('Choose a different folder')
|
||||
await expect(page.getByText('Runtime Vault Note')).not.toBeVisible()
|
||||
})
|
||||
64
tests/smoke/rtl-editor-direction.spec.ts
Normal file
64
tests/smoke/rtl-editor-direction.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
const RTL_TITLE = 'RTL Mixed Direction'
|
||||
const RTL_PARAGRAPH = 'مرحبا بالعالم'
|
||||
const MIXED_PARAGRAPH = 'English then مرحبا'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
fs.writeFileSync(
|
||||
path.join(tempVaultDir, 'note', 'rtl-mixed-direction.md'),
|
||||
[
|
||||
'---',
|
||||
'Is A: Note',
|
||||
'---',
|
||||
'',
|
||||
`# ${RTL_TITLE}`,
|
||||
'',
|
||||
RTL_PARAGRAPH,
|
||||
'',
|
||||
MIXED_PARAGRAPH,
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function openRawMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
test('rich and raw editors resolve text direction per line for Arabic and mixed content', async ({ page }) => {
|
||||
await openNote(page, RTL_TITLE)
|
||||
|
||||
const rtlRichBlock = page.locator('.bn-inline-content', { hasText: RTL_PARAGRAPH }).first()
|
||||
await expect(rtlRichBlock).toBeVisible({ timeout: 5_000 })
|
||||
await expect(rtlRichBlock).toHaveCSS('unicode-bidi', 'plaintext')
|
||||
await expect(rtlRichBlock).toHaveCSS('text-align', 'start')
|
||||
|
||||
await openRawMode(page)
|
||||
|
||||
const rawLines = page.locator('.cm-line')
|
||||
await expect(rawLines.filter({ hasText: RTL_PARAGRAPH })).toHaveAttribute('dir', 'auto')
|
||||
await expect(rawLines.filter({ hasText: MIXED_PARAGRAPH })).toHaveAttribute('dir', 'auto')
|
||||
await expect(rawLines.filter({ hasText: RTL_PARAGRAPH })).toHaveCSS('unicode-bidi', 'plaintext')
|
||||
await expect(rawLines.filter({ hasText: RTL_PARAGRAPH })).toHaveCSS('text-align', 'start')
|
||||
})
|
||||
Reference in New Issue
Block a user