Compare commits

...

12 Commits

Author SHA1 Message Date
lucaronin
77286457bf fix: anchor settings default agent dropdown 2026-04-23 20:09:49 +02:00
lucaronin
5b8aa8da0d fix: guard pull refresh during unsaved editor edits 2026-04-23 19:34:15 +02:00
lucaronin
21b6d8984a fix: support symlinked vault notes 2026-04-23 18:36:45 +02:00
lucaronin
11fc123492 fix: guard folder picker after update install 2026-04-23 18:16:43 +02:00
lucaronin
d98fa94e14 fix: retry commits when signing helper is missing 2026-04-23 17:27:53 +02:00
lucaronin
add3c4075f fix: thread vault image path through tab swaps 2026-04-23 16:35:27 +02:00
lucaronin
fb0d550eb2 fix: make starter vault images portable 2026-04-23 16:31:36 +02:00
lucaronin
6adf7fd53b fix: capture react root errors in sentry 2026-04-23 15:34:39 +02:00
lucaronin
3b4d9599d2 fix: finish empty Claude Code runs 2026-04-23 14:15:15 +02:00
lucaronin
bb1b18686f fix: stabilize smoke dev server watch 2026-04-23 13:45:24 +02:00
lucaronin
6dbcc334bf fix: create repos despite commit signing 2026-04-23 13:26:01 +02:00
lucaronin
4944365ed7 fix: detect local Claude Code installs 2026-04-23 12:24:26 +02:00
38 changed files with 1640 additions and 308 deletions

View File

@@ -304,6 +304,8 @@ type SidebarSelection =
- Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
The folder tree is narrower than the scanner: it exposes user-created folders only, while system folders such as `attachments/`, `type/`, and `views/` remain hidden from folder navigation.
6. Sorts by `modified_at` descending
7. Skips unparseable files with a warning log
@@ -394,7 +396,7 @@ interface PulseCommit {
| `history.rs` | File history | `git log` — last 20 commits per file |
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
| `commit.rs` | Commit | `git add -A && git commit -m "..."` |
| `commit.rs` | Commit | `git add -A && git commit -m "..."`; broken signing helpers trigger one unsigned retry for the same app-managed commit |
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
| `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe |
| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual |
@@ -654,6 +656,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
### Libraries
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` / `VITE_POSTHOG_KEY` env vars.
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
### Tauri Commands

View File

@@ -176,7 +176,7 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders only; system folders such as `attachments/`, `type/`, and `views/` remain scannable but are hidden from folder navigation. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
@@ -214,6 +214,8 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
Claude Code availability intentionally does not depend only on the desktop app's inherited `PATH`. The detector checks 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, npm-global, and Homebrew paths so first-run onboarding works on fresh macOS installs.
#### Agent Event Flow
```mermaid
@@ -441,6 +443,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
- **Open an existing folder** → system file picker
- **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
When an opened folder is not yet a git repo, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
@@ -646,6 +650,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| Command | Description |
|---------|-------------|
| `init_git_repo` | Initialize a local repo, add default `.gitignore`, and create the unsigned setup commit |
| `git_commit` | Stage all + commit |
| `git_pull` | Pull from remote |
| `git_push` | Push to remote |
@@ -885,7 +890,7 @@ sequenceDiagram
**Architecture:**
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`

View File

@@ -1,8 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::BufRead;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
/// Status returned by `check_claude_cli`.
#[derive(Debug, Serialize, Clone)]
@@ -63,33 +63,98 @@ pub struct AgentStreamRequest {
// ---------------------------------------------------------------------------
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
// Try `which claude` first (works when PATH is inherited).
if let Some(binary) = find_claude_binary_on_path()? {
return Ok(binary);
}
if let Some(binary) = find_claude_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(claude_binary_candidates()) {
return Ok(binary);
}
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
}
fn find_claude_binary_on_path() -> Result<Option<PathBuf>, String> {
let output = Command::new("which")
.arg("claude")
.output()
.map_err(|e| format!("Failed to run `which claude`: {e}"))?;
Ok(path_from_successful_output(&output))
}
fn find_claude_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "claude"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
Command::new(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
// Fallback: check common install locations.
let home = dirs::home_dir().unwrap_or_default();
let candidates = [
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn claude_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| claude_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/claude"),
home.join(".claude/local/claude"),
home.join(".local/share/mise/shims/claude"),
home.join(".asdf/shims/claude"),
home.join(".npm-global/bin/claude"),
home.join(".npm/bin/claude"),
PathBuf::from("/usr/local/bin/claude"),
PathBuf::from("/opt/homebrew/bin/claude"),
];
for p in &candidates {
if p.exists() {
return Ok(p.clone());
}
}
PathBuf::from("/usr/local/bin/claude"),
]
}
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
// ---------------------------------------------------------------------------
@@ -292,17 +357,9 @@ where
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
if !status.success() && state.session_id.is_empty() {
let msg = if stderr_output.contains("not logged in")
|| stderr_output.contains("authentication")
|| stderr_output.contains("auth")
{
"Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into()
} else if stderr_output.is_empty() {
format!("claude exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
};
emit(ClaudeStreamEvent::Error { message: msg });
emit(ClaudeStreamEvent::Error {
message: format_failed_claude_exit(&stderr_output, status),
});
}
emit(ClaudeStreamEvent::Done);
@@ -310,6 +367,25 @@ where
Ok(state.session_id)
}
fn format_failed_claude_exit(stderr_output: &str, status: ExitStatus) -> String {
if is_claude_auth_error(stderr_output) {
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
}
if stderr_output.is_empty() {
format!("claude exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_claude_auth_error(stderr_output: &str) -> bool {
let lower = stderr_output.to_ascii_lowercase();
["not logged in", "authentication", "auth"]
.iter()
.any(|pattern| lower.contains(pattern))
}
/// Parse a single JSON line from the stream and emit the appropriate event.
fn dispatch_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
@@ -1003,6 +1079,26 @@ mod tests {
// --- find_claude_binary ---
#[test]
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude"),
home.join(".claude/local/claude"),
home.join(".local/share/mise/shims/claude"),
home.join(".npm-global/bin/claude"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn find_claude_binary_returns_result() {
let result = find_claude_binary();

View File

@@ -1,6 +1,11 @@
use std::path::Path;
use std::process::Command;
struct CommitFailure {
stdout: String,
stderr: String,
}
/// Commit all changes with a message.
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
@@ -17,26 +22,65 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
return Err(format!("git add failed: {}", stderr));
}
// Commit
let commit = Command::new("git")
match run_commit(vault, message, false) {
Ok(stdout) => Ok(stdout),
Err(failure) if is_commit_signing_failure(&failure.detail()) => {
run_commit(vault, message, true).map_err(|retry_failure| {
format!(
"git commit signing failed; retried without signing but git commit still failed: {}",
retry_failure.detail()
)
})
}
Err(failure) => Err(format!("git commit failed: {}", failure.detail())),
}
}
fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result<String, CommitFailure> {
let mut command = Command::new("git");
if disable_signing {
command.args(["-c", "commit.gpgsign=false"]);
}
let commit = command
.args(["commit", "-m", message])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?;
.map_err(|e| CommitFailure {
stdout: String::new(),
stderr: format!("Failed to run git commit: {}", e),
})?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
let stdout = String::from_utf8_lossy(&commit.stdout);
// git writes "nothing to commit" to stdout, not stderr
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
return Err(format!("git commit failed: {}", detail.trim()));
if commit.status.success() {
return Ok(String::from_utf8_lossy(&commit.stdout).to_string());
}
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
Err(CommitFailure {
stdout: String::from_utf8_lossy(&commit.stdout).to_string(),
stderr: String::from_utf8_lossy(&commit.stderr).to_string(),
})
}
impl CommitFailure {
fn detail(&self) -> String {
// git writes "nothing to commit" to stdout, not stderr.
let detail = if self.stderr.trim().is_empty() {
&self.stdout
} else {
&self.stderr
};
detail.trim().to_string()
}
}
fn is_commit_signing_failure(detail: &str) -> bool {
let lower = detail.to_ascii_lowercase();
lower.contains("cannot run gpg")
|| lower.contains("gpg failed to sign")
|| lower.contains("failed to sign the data")
|| lower.contains("gpg.ssh")
|| (lower.contains("failed to write commit object")
&& (lower.contains("sign") || lower.contains("gpg")))
}
#[cfg(test)]
@@ -84,4 +128,53 @@ mod tests {
"Error should mention 'nothing to commit'"
);
}
#[test]
fn test_git_commit_retries_without_signing_when_gpg_is_missing() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
Command::new("git")
.args(["config", "commit.gpgsign", "true"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
.current_dir(vault)
.output()
.unwrap();
fs::write(vault.join("signed-config.md"), "# Signed config\n").unwrap();
let result = git_commit(vp, "Commit with broken signing config");
assert!(
result.is_ok(),
"commit should retry unsigned when signing helper is missing: {result:?}"
);
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config"));
let config = Command::new("git")
.args(["config", "commit.gpgsign"])
.current_dir(vault)
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&config.stdout).trim(), "true");
}
#[test]
fn test_commit_signing_failure_detection_is_specific() {
assert!(is_commit_signing_failure(
"error: cannot run gpg: No such file or directory\nfatal: failed to write commit object"
));
assert!(!is_commit_signing_failure(
"On branch main\nnothing to commit, working tree clean"
));
}
}

View File

@@ -79,18 +79,31 @@ pub fn init_repo(path: &str) -> Result<(), String> {
ensure_gitignore(path)?;
run_git(dir, &["add", "."])?;
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
commit_initial_vault_setup(dir)?;
Ok(())
}
fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> {
run_git(
dir,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"Initial vault setup",
],
)
}
/// Run a git command in the given directory, returning an error on failure.
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
.map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))?;
if output.status.success() {
return Ok(());
@@ -98,11 +111,19 @@ fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
Err(format!(
"git {} failed: {}",
args[0],
git_command_label(args),
String::from_utf8_lossy(&output.stderr)
))
}
fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
if args.first() == Some(&"-c") {
return args.get(2).copied().unwrap_or(args[0]);
}
args[0]
}
/// Set local user.name and user.email if not already configured.
fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [
@@ -289,6 +310,39 @@ mod tests {
assert!(log_str.contains("Initial vault setup"));
}
#[test]
fn test_init_repo_creates_initial_commit_when_signing_is_misconfigured() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
Command::new("git")
.args(["init"])
.current_dir(&vault)
.output()
.unwrap();
Command::new("git")
.args(["config", "commit.gpgsign", "true"])
.current_dir(&vault)
.output()
.unwrap();
Command::new("git")
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
.current_dir(&vault)
.output()
.unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let log = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&vault)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&log.stdout).contains("Initial vault setup"));
}
#[test]
fn test_init_repo_stages_all_files() {
let dir = TempDir::new().unwrap();

View File

@@ -14,7 +14,7 @@ pub mod vault;
pub mod vault_list;
#[cfg(desktop)]
use std::path::Path;
use std::path::{Path, PathBuf};
#[cfg(desktop)]
use std::process::Child;
#[cfg(desktop)]
@@ -24,7 +24,7 @@ use std::sync::Mutex;
struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
struct ActiveAssetScopeRoot(Mutex<Option<std::path::PathBuf>>);
struct ActiveAssetScopeRoots(Mutex<Vec<PathBuf>>);
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
@@ -148,45 +148,49 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
}
#[cfg(desktop)]
pub(crate) fn sync_vault_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
) -> Result<(), String> {
use tauri::Manager;
fn vault_asset_scope_roots(vault_path: &Path) -> Result<Vec<PathBuf>, String> {
let canonical_vault_path = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"Failed to resolve asset scope for {}: {e}",
vault_path.display()
)
})?;
let mut roots = vec![canonical_vault_path.clone()];
let requested_vault_path = vault_path.to_path_buf();
if requested_vault_path != canonical_vault_path {
roots.push(requested_vault_path);
}
Ok(roots)
}
#[cfg(desktop)]
pub(crate) fn sync_vault_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
) -> Result<(), String> {
use tauri::Manager;
let next_roots = vault_asset_scope_roots(vault_path)?;
let scope = app_handle.asset_protocol_scope();
let state: tauri::State<'_, ActiveAssetScopeRoot> = app_handle.state();
let mut active_root = state
let state: tauri::State<'_, ActiveAssetScopeRoots> = app_handle.state();
let mut active_roots = state
.0
.lock()
.map_err(|_| "Failed to lock active asset scope state".to_string())?;
if active_root.as_ref() == Some(&canonical_vault_path) {
return Ok(());
for root in &next_roots {
scope
.allow_directory(root, true)
.map_err(|e| format!("Failed to allow asset access for {}: {e}", root.display()))?;
}
scope
.allow_directory(&canonical_vault_path, true)
.map_err(|e| {
format!(
"Failed to allow asset access for {}: {e}",
canonical_vault_path.display()
)
})?;
if let Some(previous_root) = active_root.as_ref() {
if previous_root != &canonical_vault_path {
for previous_root in active_roots.iter() {
if !next_roots.contains(previous_root) {
let _ = scope.forbid_directory(previous_root, true);
}
}
*active_root = Some(canonical_vault_path);
*active_roots = next_roots;
Ok(())
}
@@ -297,7 +301,7 @@ pub fn run() {
#[cfg(desktop)]
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))
.manage(ActiveAssetScopeRoot(Mutex::new(None)));
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())));
with_invoke_handler(builder)
.setup(setup_app)
@@ -313,8 +317,26 @@ pub fn run() {
mod tests {
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[cfg(all(desktop, unix))]
use super::vault_asset_scope_roots;
#[test]
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
#[cfg(all(desktop, unix))]
#[test]
fn vault_asset_scope_roots_include_requested_symlink_path() {
let dir = tempfile::tempdir().unwrap();
let canonical_vault = dir.path().join("Getting Started");
let symlinked_vault = dir.path().join("Symlinked Getting Started");
std::fs::create_dir(&canonical_vault).unwrap();
std::os::unix::fs::symlink(&canonical_vault, &symlinked_vault).unwrap();
let roots = vault_asset_scope_roots(&symlinked_vault).unwrap();
assert_eq!(roots[0], canonical_vault.canonicalize().unwrap());
assert!(roots.contains(&symlinked_vault));
}
}

View File

@@ -211,13 +211,19 @@ pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
}
}
/// Directories that are never shown in the folder tree or scanned for notes.
/// Directories hidden from user-facing vault scans.
const HIDDEN_DIRS: &[&str] = &[".git", ".laputa", ".DS_Store"];
/// System folders that hold Tolaria metadata or assets rather than user note groups.
const FOLDER_TREE_EXCLUDED_DIRS: &[&str] = &["attachments", "type", "views"];
fn is_hidden_dir(name: &str) -> bool {
name.starts_with('.') || HIDDEN_DIRS.contains(&name)
}
fn is_folder_tree_hidden_dir(name: &str) -> bool {
is_hidden_dir(name) || FOLDER_TREE_EXCLUDED_DIRS.contains(&name)
}
pub(crate) fn is_md_file(path: &Path) -> bool {
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
}
@@ -431,8 +437,7 @@ pub fn scan_vault(
Ok(entries)
}
/// Build a tree of visible folders in the vault.
/// Excludes hidden directories (starting with `.`).
/// Build a tree of user-created folders in the vault.
pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
if !vault_path.is_dir() {
return Err(format!("Not a directory: {}", vault_path.display()));
@@ -449,7 +454,7 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String>
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if is_hidden_dir(&name) {
if is_folder_tree_hidden_dir(&name) {
continue;
}
let rel_path = path

View File

@@ -33,6 +33,20 @@ fn test_scan_vault_folders_excludes_hidden() {
assert_eq!(folders[0].name, "visible");
}
#[test]
fn test_scan_vault_folders_excludes_system_asset_folders() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("attachments")).unwrap();
std::fs::create_dir_all(dir.path().join("type")).unwrap();
std::fs::create_dir_all(dir.path().join("views")).unwrap();
std::fs::create_dir_all(dir.path().join("projects")).unwrap();
let folders = scan_vault_folders(dir.path()).unwrap();
let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect();
assert_eq!(names, vec!["projects"]);
}
#[test]
fn test_scan_vault_folders_flat_vault() {
let dir = TempDir::new().unwrap();

View File

@@ -640,7 +640,7 @@ function App() {
const appSave = useAppSave({
updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage,
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: async () => { await vault.reloadViews() },
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename,
replaceEntry: vault.replaceEntry, resolvedPath,

View File

@@ -514,6 +514,27 @@ describe('raw-mode sync content guards', () => {
expect(rawLatestContentRef.current).toBe(result)
})
it('keeps raw-mode serialization portable for vault attachment images', () => {
const rawLatestContentRef = { current: null as string | null }
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce(
'# Test Project\n\n![shot](asset://localhost/%2Fvault%2Fattachments%2Fshot.png)\n',
)
const result = syncActiveTabIntoRawBuffer({
editor: mockEditor as never,
activeTabPath: mockEntry.path,
activeTabContent: mockContent,
rawLatestContentRef,
vaultPath: '/vault',
})
expect(result).toBe(
'---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\n![shot](attachments/shot.png)\n',
)
expect(rawLatestContentRef.current).toBe(result)
})
it('does not emit a content change when leaving raw mode without user edits', () => {
const onContentChange = vi.fn()
const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n'

View File

@@ -184,6 +184,7 @@ function useEditorSetup({
activeTabPath,
activeTab?.content ?? null,
onContentChange,
vaultPath,
)
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
@@ -197,7 +198,7 @@ function useEditorSetup({
}, [activeTabPath, setPendingRawExitContent, tabs])
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode,
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath,
})
useEditorFocus(editor, editorMountedRef)

View File

@@ -77,6 +77,29 @@ describe('SettingsPanel', () => {
expect(screen.queryByText(/Beta\/Stable/i)).not.toBeInTheDocument()
})
it('anchors the default agent dropdown with the popper strategy', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.pointerDown(screen.getByTestId('settings-default-ai-agent'), { button: 0, pointerType: 'mouse' })
expect(document.querySelector('[data-anchor-strategy="popper"]')).toBeInTheDocument()
})
it('keeps keyboard opening enabled for the default agent dropdown', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const trigger = screen.getByTestId('settings-default-ai-agent')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown', code: 'ArrowDown' })
expect(document.querySelector('[data-anchor-strategy="popper"]')).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Codex/i })).toBeInTheDocument()
})
it('treats a legacy beta release channel as stable', () => {
render(
<SettingsPanel

View File

@@ -661,7 +661,7 @@ function LabeledSelect({
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" data-anchor-strategy="popper">
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}

View File

@@ -2,6 +2,7 @@ import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
import { portableImageUrls } from '../utils/vaultImages'
interface Tab {
entry: VaultEntry
@@ -24,10 +25,12 @@ export function buildPendingRawExitContent(
export function serializeEditorDocumentToMarkdown(
editor: ReturnType<typeof useCreateBlockNote>,
tabContent: string,
vaultPath?: string,
): string {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const bodyMarkdown = vaultPath ? portableImageUrls(rawBodyMarkdown, vaultPath) : rawBodyMarkdown
const [frontmatter] = splitFrontmatter(tabContent)
return `${frontmatter}${bodyMarkdown}`
}
@@ -74,11 +77,12 @@ export function syncActiveTabIntoRawBuffer(options: {
activeTabPath: string | null
activeTabContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
vaultPath?: string
}) {
const { editor, activeTabPath, activeTabContent, rawLatestContentRef } = options
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, vaultPath } = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent)
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath)
rawLatestContentRef.current = syncedContent
return syncedContent
}

View File

@@ -135,6 +135,7 @@ function useHandleFlushPending({
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
setRawModeContentOverride,
vaultPath,
}: {
editor: ReturnType<typeof useCreateBlockNote>
activeTabPath: string | null
@@ -145,6 +146,7 @@ function useHandleFlushPending({
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
vaultPath?: string
}) {
return useCallback(async () => {
rawSourceContentRef.current = activeTabContent
@@ -153,6 +155,7 @@ function useHandleFlushPending({
activeTabPath,
activeTabContent,
rawLatestContentRef,
vaultPath,
})
rawInitialContentRef.current = syncedContent ?? activeTabContent
pendingRawRestoreRef.current = buildPendingRawRestore({
@@ -176,6 +179,7 @@ function useHandleFlushPending({
rawLatestContentRef,
rawSourceContentRef,
setRawModeContentOverride,
vaultPath,
])
}
@@ -246,21 +250,16 @@ function useSyncRawModeContentOverride({
rawSourceContentRef: React.MutableRefObject<string | null>
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
}) {
const syncRawModeContentOverride = (
current: PendingRawExitContent | null,
nextContent: string,
) => {
if (!current) return current
if (current.path !== activeTabPath || current.content === nextContent) return current
return { path: activeTabPath, content: nextContent }
}
useLayoutEffect(() => {
if (!activeTabPath || activeTabContent === null) return
if (rawSourceContentRef.current === null || activeTabContent === rawSourceContentRef.current) return
const nextContent = activeTabContent
setRawModeContentOverride((current) => syncRawModeContentOverride(current, nextContent))
setRawModeContentOverride((current) => {
if (!current) return current
if (current.path !== activeTabPath || current.content === nextContent) return current
return { path: activeTabPath, content: nextContent }
})
}, [activeTabContent, activeTabPath, rawSourceContentRef, setRawModeContentOverride])
}
@@ -269,6 +268,7 @@ export function useRawModeWithFlush(
activeTabPath: string | null,
activeTabContent: string | null,
onContentChange?: (path: string, content: string) => void,
vaultPath?: string,
) {
const rawLatestContentRef = useRef<string | null>(null)
const rawInitialContentRef = useRef<string | null>(null)
@@ -304,6 +304,7 @@ export function useRawModeWithFlush(
pendingRawRestoreRef,
pendingRoundTripRawRestoreRef,
setRawModeContentOverride,
vaultPath,
})
const handleBeforeRawEnd = useHandleBeforeRawEnd({
activeTabPath,

View File

@@ -0,0 +1,126 @@
import { splitFrontmatter } from '../utils/wikilinks'
type MarkdownContent = string
type FilePath = string
type Frontmatter = string
type NoteTitle = string
type PathStem = string
type HeadingTextInline = { type?: string; text?: string }
type ParsedBlock = {
type?: string
props?: {
url?: string
previewWidth?: number
}
children?: unknown[]
}
const LOCAL_FILE_URL_PREFIXES = ['asset://localhost/', 'http://asset.localhost/']
const BROKEN_IMAGE_FALLBACK_MAX_WIDTH = 32
export function extractEditorBody(rawFileContent: MarkdownContent): MarkdownContent {
const [, rawBody] = splitFrontmatter(rawFileContent)
return rawBody.trimStart()
}
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
const first = blocks?.[0] as {
type?: string
props?: { level?: number }
content?: HeadingTextInline[]
} | undefined
if (!first) return null
if (first.type !== 'heading') return null
if (first.props?.level !== 1) return null
if (!Array.isArray(first.content)) return null
return first.content
}
export function getH1TextFromBlocks(blocks: unknown[]): NoteTitle | null {
const content = extractH1Content(blocks)
if (!content) return null
let text = ''
for (const item of content) {
if (item.type === 'text') {
text += item.text || ''
}
}
const trimmed = text.trim()
return trimmed || null
}
export function replaceTitleInFrontmatter(frontmatter: Frontmatter, newTitle: NoteTitle): Frontmatter {
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
}
export function pathStem(path: FilePath): PathStem {
const filename = path.split('/').pop() ?? path
return filename.replace(/\.md$/, '')
}
export function slugifyPathStem(title: NoteTitle): PathStem {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
export function isUntitledPath(path: FilePath): boolean {
return pathStem(path).startsWith('untitled-')
}
function isParsedBlock(value: unknown): value is ParsedBlock {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasLocalFileUrl(block: ParsedBlock): boolean {
const url = block.props?.url
return typeof url === 'string'
&& LOCAL_FILE_URL_PREFIXES.some(prefix => url.startsWith(prefix))
}
function hasSyntheticPreviewWidth(block: ParsedBlock): boolean {
const previewWidth = block.props?.previewWidth
return typeof previewWidth === 'number'
&& previewWidth > 0
&& previewWidth <= BROKEN_IMAGE_FALLBACK_MAX_WIDTH
}
function shouldClearLocalImagePreviewWidth(block: ParsedBlock): boolean {
return block.type === 'image'
&& hasLocalFileUrl(block)
&& hasSyntheticPreviewWidth(block)
}
function normalizeParsedBlockChildren(block: ParsedBlock): unknown[] | undefined {
if (!Array.isArray(block.children)) return block.children
return block.children.map(normalizeParsedImageBlock)
}
function withNormalizedImageProps(
block: ParsedBlock,
shouldClearPreviewWidth: boolean,
): ParsedBlock['props'] {
if (!shouldClearPreviewWidth) return block.props
return { ...block.props, previewWidth: undefined }
}
function normalizeParsedImageBlock(block: unknown): unknown {
if (!isParsedBlock(block)) return block
const children = normalizeParsedBlockChildren(block)
const shouldClearPreviewWidth = shouldClearLocalImagePreviewWidth(block)
const props = withNormalizedImageProps(block, shouldClearPreviewWidth)
if (!shouldClearPreviewWidth && children === block.children) return block
return {
...block,
...(children === block.children ? {} : { children }),
...(props === block.props ? {} : { props }),
}
}
export function normalizeParsedImageBlocks(blocks: unknown[]): unknown[] {
return blocks.map(normalizeParsedImageBlock)
}

View File

@@ -38,6 +38,7 @@ describe('useAppSave', () => {
handleSwitchTab: vi.fn(),
setToastMessage: vi.fn(),
loadModifiedFiles: vi.fn(),
trackUnsaved: vi.fn(),
clearUnsaved: vi.fn(),
unsavedPaths: new Set<string>(),
tabs: [] as Array<{ entry: VaultEntry; content: string }>,
@@ -56,6 +57,7 @@ describe('useAppSave', () => {
deps.unsavedPaths = new Set()
deps.tabs = []
deps.activeTabPath = null
deps.trackUnsaved.mockReset()
deps.handleRenameNote.mockResolvedValue(undefined)
deps.handleRenameFilename.mockResolvedValue(undefined)
deps.initialH1AutoRenameEnabled = true
@@ -172,6 +174,20 @@ describe('useAppSave', () => {
expect(typeof result.current.handleContentChange).toBe('function')
})
it('marks the edited path as unsaved immediately on content change', () => {
const entry = makeEntry('/vault/note.md', 'Note', 'note.md')
const { result } = renderSave({
tabs: [{ entry, content: '# Note\n\nBefore' }],
activeTabPath: entry.path,
})
act(() => {
result.current.handleContentChange(entry.path, '# Note\n\nAfter')
})
expect(deps.trackUnsaved).toHaveBeenCalledWith(entry.path)
})
it('bumps modifiedAt in live entry state after saving', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-16T14:45:00Z'))

View File

@@ -414,6 +414,7 @@ interface AppSaveDeps {
setToastMessage: (msg: string | null) => void
loadModifiedFiles: () => void
reloadViews?: () => Promise<void>
trackUnsaved?: (path: string) => void
clearUnsaved: (path: string) => void
unsavedPaths: Set<string>
tabs: TabState[]
@@ -587,6 +588,7 @@ function useEditorPersistence({
setTabs,
setToastMessage,
loadModifiedFiles,
trackUnsaved,
clearUnsaved,
reloadViews,
scheduleUntitledRename,
@@ -597,6 +599,7 @@ function useEditorPersistence({
setTabs: AppSaveDeps['setTabs']
setToastMessage: AppSaveDeps['setToastMessage']
loadModifiedFiles: AppSaveDeps['loadModifiedFiles']
trackUnsaved?: AppSaveDeps['trackUnsaved']
clearUnsaved: AppSaveDeps['clearUnsaved']
reloadViews: AppSaveDeps['reloadViews']
scheduleUntitledRename: (path: string, content: string) => void
@@ -629,8 +632,10 @@ function useEditorPersistence({
})
const handleContentChange = useCallback((path: string, content: string) => {
handleContentChangeRaw(resolveCurrentPath(path), content)
}, [handleContentChangeRaw, resolveCurrentPath])
const resolvedPath = resolveCurrentPath(path)
trackUnsaved?.(resolvedPath)
handleContentChangeRaw(resolvedPath, content)
}, [handleContentChangeRaw, resolveCurrentPath, trackUnsaved])
const savePendingForPath = useCallback((path: string) => (
savePendingForPathRaw(resolveCurrentPath(path))
@@ -736,7 +741,7 @@ function useAppSaveHandlers({
export function useAppSave({
updateEntry, setTabs, handleSwitchTab, setToastMessage, loadModifiedFiles, reloadViews,
clearUnsaved, unsavedPaths, tabs, activeTabPath, handleRenameNote,
trackUnsaved, clearUnsaved, unsavedPaths, tabs, activeTabPath, handleRenameNote,
handleRenameFilename: handleRenameFilenameRaw, replaceEntry, resolvedPath,
initialH1AutoRenameEnabled,
}: AppSaveDeps) {
@@ -760,6 +765,7 @@ export function useAppSave({
setTabs,
setToastMessage,
loadModifiedFiles,
trackUnsaved,
clearUnsaved,
reloadViews,
scheduleUntitledRename,

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, useEditorTabSwap } from './useEditorTabSwap'
import { normalizeParsedImageBlocks } from './editorTabContent'
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
@@ -139,6 +140,62 @@ describe('replaceTitleInFrontmatter', () => {
})
})
describe('normalizeParsedImageBlocks', () => {
it('clears broken-image fallback widths from local asset image blocks', () => {
const blocks = normalizeParsedImageBlocks([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: 16,
},
}])
expect(blocks).toEqual([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: undefined,
},
}])
})
it('preserves explicit image widths and non-local image URLs', () => {
const blocks = normalizeParsedImageBlocks([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
expect(blocks).toEqual([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
})
})
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
function makeTab(path: string, title: string) {

View File

@@ -4,6 +4,16 @@ import type { VaultEntry } from '../types'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages'
import {
extractEditorBody,
getH1TextFromBlocks,
isUntitledPath,
normalizeParsedImageBlocks,
pathStem,
slugifyPathStem,
} from './editorTabContent'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
interface Tab {
entry: VaultEntry
@@ -32,6 +42,7 @@ interface UseEditorTabSwapOptions {
onContentChange?: (path: string, content: string) => void
/** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */
rawMode?: boolean
vaultPath?: string
}
function signalEditorTabSwapped(path: string): void {
@@ -41,63 +52,6 @@ function signalEditorTabSwapped(path: string): void {
finishNoteOpenTrace(path)
}
/** Strip the YAML frontmatter from raw file content, returning the body
* (including any H1 heading) that should appear in the editor. */
export function extractEditorBody(rawFileContent: string): string {
const [, rawBody] = splitFrontmatter(rawFileContent)
return rawBody.trimStart()
}
type HeadingTextInline = { type?: string; text?: string }
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
const first = blocks?.[0] as {
type?: string
props?: { level?: number }
content?: HeadingTextInline[]
} | undefined
if (!first) return null
if (first.type !== 'heading') return null
if (first.props?.level !== 1) return null
if (!Array.isArray(first.content)) return null
return first.content
}
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
const content = extractH1Content(blocks)
if (!content) return null
let text = ''
for (const item of content) {
if (item.type === 'text') {
text += item.text || ''
}
}
const trimmed = text.trim()
return trimmed || null
}
/** Replace the title: line in YAML frontmatter with a new title value. */
export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string): string {
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
}
function pathStem(path: string): string {
const filename = path.split('/').pop() ?? path
return filename.replace(/\.md$/, '')
}
function slugifyPathStem(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
function isUntitledPath(path: string): boolean {
return pathStem(path).startsWith('untitled-')
}
function readEditorScrollTop(): number {
const scrollEl = document.querySelector('.editor__blocknote-container')
return scrollEl?.scrollTop ?? 0
@@ -179,16 +133,21 @@ async function parseMarkdownBlocks(
}
async function resolveBlocksForTarget(
editor: ReturnType<typeof useCreateBlockNote>,
cache: Map<string, CachedTabState>,
targetPath: string,
content: string,
options: {
editor: ReturnType<typeof useCreateBlockNote>
cache: Map<string, CachedTabState>
targetPath: string
content: string
vaultPath?: string
},
): Promise<CachedTabState> {
const { editor, cache, targetPath, content, vaultPath } = options
const cached = cache.get(targetPath)
if (cached?.sourceContent === content) return cached
const body = extractEditorBody(content)
const preprocessed = preProcessWikilinks(body)
const withImages = vaultPath ? resolveImageUrls(body, vaultPath) : body
const preprocessed = preProcessWikilinks(withImages)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
@@ -196,7 +155,7 @@ async function resolveBlocksForTarget(
return nextState
}
const parsed = await parseMarkdownBlocks(editor, preprocessed)
const parsed = normalizeParsedImageBlocks(await parseMarkdownBlocks(editor, preprocessed)) as EditorBlocks
const withWikilinks = injectWikilinks(parsed)
const nextState = { blocks: withWikilinks, scrollTop: 0, sourceContent: content }
cacheEditorState(cache, targetPath, nextState)
@@ -279,12 +238,16 @@ function applyHtmlStateToEditor(
async function resolveEmptyHeadingHtml(
editor: ReturnType<typeof useCreateBlockNote>,
content: string,
vaultPath?: string,
): Promise<string | null> {
const remainder = extractBodyRemainderAfterEmptyH1({ content })
if (remainder === null) return null
if (!remainder.trim()) return '<h1></h1><p></p>'
const parsed = await parseMarkdownBlocks(editor, preProcessWikilinks(remainder))
const withImages = vaultPath ? resolveImageUrls(remainder, vaultPath) : remainder
const parsed = normalizeParsedImageBlocks(
await parseMarkdownBlocks(editor, preProcessWikilinks(withImages)),
) as EditorBlocks
const withWikilinks = injectWikilinks(parsed)
return `<h1></h1>${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}`
}
@@ -375,6 +338,7 @@ function useEditorChangeHandler(options: {
suppressChangeRef: MutableRefObject<boolean>
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPathRef: MutableRefObject<string | undefined>
}) {
const {
editor,
@@ -384,6 +348,7 @@ function useEditorChangeHandler(options: {
suppressChangeRef,
tabCacheRef,
pendingLocalContentRef,
vaultPathRef,
} = options
return useCallback(() => {
@@ -396,7 +361,10 @@ function useEditorChangeHandler(options: {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const rawBodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const bodyMarkdown = vaultPathRef.current
? portableImageUrls(rawBodyMarkdown, vaultPathRef.current)
: rawBodyMarkdown
const [frontmatter] = splitFrontmatter(tab.content)
const nextContent = `${frontmatter}${bodyMarkdown}`
pendingLocalContentRef.current = { path, content: nextContent }
@@ -406,7 +374,7 @@ function useEditorChangeHandler(options: {
sourceContent: nextContent,
})
onContentChangeRef.current?.(path, nextContent)
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef])
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef, vaultPathRef])
}
function consumeRawModeTransition(
@@ -781,6 +749,7 @@ function scheduleEmptyHeadingSwap(options: {
content: string
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
vaultPath?: string
}) {
const {
editor,
@@ -788,11 +757,12 @@ function scheduleEmptyHeadingSwap(options: {
content,
prevActivePathRef,
suppressChangeRef,
vaultPath,
} = options
if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false
void resolveEmptyHeadingHtml(editor, content)
void resolveEmptyHeadingHtml(editor, content, vaultPath)
.then((html) => {
if (prevActivePathRef.current !== targetPath || !html) return
applyHtmlStateToEditor(editor, html, suppressChangeRef)
@@ -814,6 +784,7 @@ function scheduleParsedBlockSwap(options: {
content: string
prevActivePathRef: MutableRefObject<string | null>
suppressChangeRef: MutableRefObject<boolean>
vaultPath?: string
}) {
const {
editor,
@@ -822,9 +793,10 @@ function scheduleParsedBlockSwap(options: {
content,
prevActivePathRef,
suppressChangeRef,
vaultPath,
} = options
void resolveBlocksForTarget(editor, cache, targetPath, content)
void resolveBlocksForTarget({ editor, cache, targetPath, content, vaultPath })
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
@@ -846,6 +818,7 @@ function scheduleTabSwap(options: {
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
vaultPath?: string
}) {
const {
editor,
@@ -856,6 +829,7 @@ function scheduleTabSwap(options: {
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
vaultPath,
} = options
suppressChangeRef.current = true
@@ -881,6 +855,7 @@ function scheduleTabSwap(options: {
content: activeTab.content,
prevActivePathRef,
suppressChangeRef,
vaultPath,
})) {
return
}
@@ -892,6 +867,7 @@ function scheduleTabSwap(options: {
content: activeTab.content,
prevActivePathRef,
suppressChangeRef,
vaultPath,
})
}
@@ -991,6 +967,7 @@ function runTabSwapEffect(options: {
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPath?: string
}) {
const {
tabs,
@@ -1005,6 +982,7 @@ function runTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
vaultPath,
} = options
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
@@ -1040,6 +1018,7 @@ function runTabSwapEffect(options: {
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
vaultPath,
})
}
@@ -1056,6 +1035,7 @@ function useTabSwapEffect(options: {
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPathRef: MutableRefObject<string | undefined>
}) {
const {
tabs,
@@ -1070,6 +1050,7 @@ function useTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
vaultPathRef,
} = options
useEffect(() => {
@@ -1086,6 +1067,7 @@ function useTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
vaultPath: vaultPathRef.current,
})
}, [
activeTabPath,
@@ -1100,6 +1082,7 @@ function useTabSwapEffect(options: {
tabCacheRef,
tabs,
pendingLocalContentRef,
vaultPathRef,
])
}
@@ -1114,7 +1097,7 @@ function useTabSwapEffect(options: {
*
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) {
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode, vaultPath }: UseEditorTabSwapOptions) {
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
const pendingLocalContentRef = useRef<PendingLocalContent | null>(null)
const prevActivePathRef = useRef<string | null>(null)
@@ -1125,6 +1108,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const suppressChangeRef = useRef(false)
const onContentChangeRef = useLatestRef(onContentChange)
const tabsRef = useLatestRef(tabs)
const vaultPathRef = useLatestRef(vaultPath)
const handleEditorChange = useEditorChangeHandler({
editor,
tabsRef,
@@ -1133,6 +1117,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
suppressChangeRef,
tabCacheRef,
pendingLocalContentRef,
vaultPathRef,
})
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
@@ -1149,6 +1134,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
vaultPathRef,
})
return { handleEditorChange, editorMountedRef }

View File

@@ -8,11 +8,15 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
vi.mock('../utils/vault-dialog', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/vault-dialog')>()
return {
...actual,
pickFolder: vi.fn(),
}
})
import { pickFolder } from '../utils/vault-dialog'
import { NativeFolderPickerBlockedError, pickFolder } from '../utils/vault-dialog'
import { useGettingStartedClone } from './useGettingStartedClone'
describe('useGettingStartedClone', () => {
@@ -70,4 +74,21 @@ describe('useGettingStartedClone', () => {
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith('Could not download Getting Started vault. Check your connection and try again.')
})
it('surfaces the restart-required message when folder picking is blocked after update install', async () => {
vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError())
const onSuccess = vi.fn()
const onError = vi.fn()
const { result } = renderHook(() => useGettingStartedClone({ onError, onSuccess }))
await act(async () => {
await result.current()
})
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
)
})
})

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
import {
buildGettingStartedVaultPath,
formatGettingStartedCloneError,
@@ -22,7 +22,14 @@ export function useGettingStartedClone({
onSuccess,
}: UseGettingStartedCloneOptions) {
return useCallback(async () => {
const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
let parentPath: string | null
try {
parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
} catch (err) {
onError(formatFolderPickerActionError('Could not choose a parent folder', err))
return
}
if (!parentPath) return
const targetPath = buildGettingStartedVaultPath(parentPath)

View File

@@ -30,11 +30,15 @@ vi.mock('../mock-tauri', () => ({
vi.mock('./useVaultSwitcher', () => ({}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
vi.mock('../utils/vault-dialog', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/vault-dialog')>()
return {
...actual,
pickFolder: vi.fn(),
}
})
import { pickFolder } from '../utils/vault-dialog'
import { NativeFolderPickerBlockedError, pickFolder } from '../utils/vault-dialog'
import { useOnboarding } from './useOnboarding'
function mockCommands(overrides: Record<string, MockOverride> = {}) {
@@ -314,6 +318,23 @@ describe('useOnboarding', () => {
})
})
it('shows the restart-required picker message instead of crashing the welcome flow', async () => {
mockCommands()
vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError())
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleOpenFolder()
})
expect(result.current.error).toBe(
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
)
expect(result.current.state.status).toBe('welcome')
})
it('marks the welcome screen dismissed and keeps the initial vault path', async () => {
mockCommands()

View File

@@ -1,9 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
import { buildGettingStartedVaultPath, formatGettingStartedCloneError } from '../utils/gettingStartedVault'
import { pickFolder } from '../utils/vault-dialog'
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
type OnboardingState =
| { status: 'loading' }
@@ -12,6 +12,21 @@ type OnboardingState =
| { status: 'ready'; vaultPath: string }
type CreatingAction = 'template' | 'empty' | null
type SetError = Dispatch<SetStateAction<string | null>>
type SetCreatingAction = Dispatch<SetStateAction<CreatingAction>>
interface ReadyVaultHandlers {
setState: Dispatch<SetStateAction<OnboardingState>>
setUserReadyVaultPath: Dispatch<SetStateAction<string | null>>
}
interface TemplateVaultCreationOptions {
handlers: ReadyVaultHandlers
setCreatingAction: SetCreatingAction
setError: SetError
setLastTemplatePath: Dispatch<SetStateAction<string | null>>
onTemplateVaultReady?: (vaultPath: string) => void
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
@@ -58,6 +73,107 @@ async function clearMissingActiveVault(missingPath: string): Promise<boolean> {
}
}
function markVaultReady(
handlers: ReadyVaultHandlers,
vaultPath: string,
) {
markDismissed()
handlers.setState({ status: 'ready', vaultPath })
handlers.setUserReadyVaultPath(vaultPath)
}
async function pickFolderWithOnboardingError(
title: string,
setError: SetError,
action: string,
): Promise<string | null> {
setError(null)
try {
return await pickFolder(title)
} catch (err) {
setError(formatFolderPickerActionError(action, err))
return null
}
}
function useTemplateVaultCreation(
options: TemplateVaultCreationOptions,
) {
return useCallback(async (targetPath: string) => {
options.setCreatingAction('template')
options.setError(null)
options.setLastTemplatePath(targetPath)
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
markVaultReady(options.handlers, vaultPath)
options.onTemplateVaultReady?.(vaultPath)
} catch (err) {
options.setError(formatGettingStartedCloneError(err))
} finally {
options.setCreatingAction(null)
}
}, [options])
}
function useCreateVaultHandler(
createTemplateVault: (targetPath: string) => Promise<void>,
setError: SetError,
) {
return useCallback(async () => {
const parentPath = await pickFolderWithOnboardingError(
'Choose a parent folder for the Getting Started vault',
setError,
'Could not choose a parent folder',
)
if (!parentPath) return
await createTemplateVault(buildGettingStartedVaultPath(parentPath))
}, [createTemplateVault, setError])
}
function useCreateEmptyVaultHandler(
handlers: ReadyVaultHandlers,
setCreatingAction: SetCreatingAction,
setError: SetError,
) {
return useCallback(async () => {
const path = await pickFolderWithOnboardingError(
'Choose where to create your vault',
setError,
'Could not choose where to create your vault',
)
if (!path) return
try {
setCreatingAction('empty')
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
markVaultReady(handlers, vaultPath)
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
setCreatingAction(null)
}
}, [handlers, setCreatingAction, setError])
}
function useOpenFolderHandler(
handlers: ReadyVaultHandlers,
setError: SetError,
) {
return useCallback(async () => {
const path = await pickFolderWithOnboardingError(
'Open vault folder',
setError,
'Failed to open folder',
)
if (!path) return
markVaultReady(handlers, path)
}, [handlers, setError])
}
export function useOnboarding(
initialVaultPath: string,
onTemplateVaultReady?: (vaultPath: string) => void,
@@ -68,12 +184,12 @@ export function useOnboarding(
const [error, setError] = useState<string | null>(null)
const [lastTemplatePath, setLastTemplatePath] = useState<string | null>(null)
const [userReadyVaultPath, setUserReadyVaultPath] = useState<string | null>(null)
const readyVaultHandlers = { setState, setUserReadyVaultPath }
useEffect(() => {
let cancelled = false
if (!initialVaultResolved) {
setState({ status: 'loading' })
return () => { cancelled = true }
}
@@ -108,71 +224,38 @@ export function useOnboarding(
return () => { cancelled = true }
}, [initialVaultPath, initialVaultResolved])
const createTemplateVault = useCallback(async (targetPath: string) => {
setCreatingAction('template')
setError(null)
setLastTemplatePath(targetPath)
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
markDismissed()
setState({ status: 'ready', vaultPath })
setUserReadyVaultPath(vaultPath)
onTemplateVaultReady?.(vaultPath)
} catch (err) {
setError(formatGettingStartedCloneError(err))
} finally {
setCreatingAction(null)
}
}, [onTemplateVaultReady])
const createTemplateVault = useTemplateVaultCreation({
handlers: readyVaultHandlers,
setCreatingAction,
setError,
setLastTemplatePath,
onTemplateVaultReady,
})
const handleCreateVault = useCallback(async () => {
const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
if (!parentPath) return
await createTemplateVault(buildGettingStartedVaultPath(parentPath))
}, [createTemplateVault])
const handleCreateVault = useCreateVaultHandler(createTemplateVault, setError)
const retryCreateVault = useCallback(async () => {
if (!lastTemplatePath) return
await createTemplateVault(lastTemplatePath)
}, [createTemplateVault, lastTemplatePath])
const handleCreateEmptyVault = useCallback(async () => {
try {
setError(null)
const path = await pickFolder('Choose where to create your vault')
if (!path) return
setCreatingAction('empty')
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
markDismissed()
setState({ status: 'ready', vaultPath })
setUserReadyVaultPath(vaultPath)
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
setCreatingAction(null)
}
}, [])
const handleCreateEmptyVault = useCreateEmptyVaultHandler(
readyVaultHandlers,
setCreatingAction,
setError,
)
const handleOpenFolder = useCallback(async () => {
try {
setError(null)
const path = await pickFolder('Open vault folder')
if (!path) return
markDismissed()
setState({ status: 'ready', vaultPath: path })
setUserReadyVaultPath(path)
} catch (err) {
setError(`Failed to open folder: ${err}`)
}
}, [])
const handleOpenFolder = useOpenFolderHandler(readyVaultHandlers, setError)
const handleDismiss = useCallback(() => {
markDismissed()
setState({ status: 'ready', vaultPath: initialVaultPath })
}, [initialVaultPath])
const resolvedState = initialVaultResolved ? state : { status: 'loading' as const }
return {
state,
state: resolvedState,
creating: creatingAction !== null,
creatingAction,
error,

View File

@@ -1,6 +1,10 @@
import { renderHook, act } from '@testing-library/react'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { useUpdater } from './useUpdater'
import {
clearRestartRequiredAfterUpdate,
isRestartRequiredAfterUpdate,
} from '../lib/appUpdater'
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
@@ -108,6 +112,7 @@ describe('useUpdater', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
clearRestartRequiredAfterUpdate()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
@@ -270,6 +275,7 @@ describe('useUpdater', () => {
version: '2026.4.16',
displayVersion: '2026.4.16',
})
expect(isRestartRequiredAfterUpdate()).toBe(true)
})
it('transitions to error when download fails', async () => {

View File

@@ -28,9 +28,15 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
vi.mock('../utils/vault-dialog', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/vault-dialog')>()
return {
...actual,
pickFolder: vi.fn(),
}
})
import { NativeFolderPickerBlockedError } from '../utils/vault-dialog'
type MockInvokeOverrides = {
checkVaultExists?: boolean | ((args: { path?: string }) => boolean)
@@ -294,6 +300,21 @@ describe('useVaultSwitcher', () => {
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
})
it('shows a clear toast when folder picking is blocked until restart', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError())
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.handleOpenLocalFolder()
})
expect(onToast).toHaveBeenCalledWith(
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
)
})
it('creates an empty vault and switches to it', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/New Vault')

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
import type { VaultOption } from '../components/StatusBar'
import { trackEvent } from '../lib/telemetry'
@@ -650,7 +650,14 @@ function useOpenLocalFolderAction(
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback(async () => {
const path = await pickFolder('Open vault folder')
let path: string | null
try {
path = await pickFolder('Open vault folder')
} catch (err) {
onToastRef.current(formatFolderPickerActionError('Could not open vault folder', err))
return
}
if (!path) return
const label = labelFromPath({ path })
@@ -664,10 +671,16 @@ function useCreateEmptyVaultAction(
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback(async () => {
let targetPath: string | null
try {
const targetPath = await pickFolder('Choose where to create your vault')
if (!targetPath) return
targetPath = await pickFolder('Choose where to create your vault')
} catch (err) {
onToastRef.current(formatFolderPickerActionError('Could not choose where to create your vault', err))
return
}
try {
if (!targetPath) return
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath })
const label = labelFromPath({ path: vaultPath })
addAndSwitch(vaultPath, label)

View File

@@ -152,6 +152,43 @@ describe('aiAgentStreamCallbacks', () => {
])
})
it('finishes with a readable empty state when Claude exits without assistant text', () => {
const messages = createMessageStore([
{
id: 'msg-1',
userMessage: '/exit',
actions: [],
isStreaming: true,
},
])
const status = createStatusStore('thinking')
const callbacks = createStreamCallbacks({
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()).toEqual([
{
id: 'msg-1',
userMessage: '/exit',
actions: [],
isStreaming: false,
reasoningDone: true,
response: 'Claude Code finished without returning a reply.',
},
])
})
it('ignores stream events after the request has been aborted', () => {
const messages = createMessageStore([
{

View File

@@ -20,6 +20,12 @@ export interface StreamMutationContext {
fileCallbacksRef: MutableRefObject<AgentFileCallbacks | undefined>
}
const EMPTY_CLAUDE_RESPONSE = 'Claude Code finished without returning a reply.'
function finalResponseText(response: string): string {
return response.trim() ? response : EMPTY_CLAUDE_RESPONSE
}
export function createStreamCallbacks(context: StreamMutationContext) {
const {
messageId,
@@ -95,7 +101,7 @@ export function createStreamCallbacks(context: StreamMutationContext) {
if (abortRef.current.aborted) return
setStatus('done')
const finalResponse = responseAccRef.current || undefined
const finalResponse = finalResponseText(responseAccRef.current)
updateMessage(setMessages, messageId, (message) => ({
...message,
isStreaming: false,

View File

@@ -13,6 +13,23 @@ export type AppUpdateDownloadEvent =
| { event: 'Progress'; data: { chunkLength: number } }
| { event: 'Finished' }
export const RESTART_REQUIRED_FOLDER_PICKER_MESSAGE =
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.'
let restartRequiredAfterUpdate = false
export function markRestartRequiredAfterUpdate(): void {
restartRequiredAfterUpdate = true
}
export function clearRestartRequiredAfterUpdate(): void {
restartRequiredAfterUpdate = false
}
export function isRestartRequiredAfterUpdate(): boolean {
return restartRequiredAfterUpdate
}
export async function checkForAppUpdate(
releaseChannel: string | null | undefined,
): Promise<AppUpdateMetadata | null> {
@@ -34,4 +51,5 @@ export async function downloadAndInstallAppUpdate(
expectedVersion,
onEvent: channel,
})
markRestartRequiredAfterUpdate()
}

88
src/main.test.ts Normal file
View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createElement, type ReactNode } from 'react'
type ReactRootErrorInfo = { componentStack?: string }
type ReactRootOptions = {
onCaughtError?: (error: unknown, errorInfo: ReactRootErrorInfo) => void
onUncaughtError?: (error: unknown, errorInfo: ReactRootErrorInfo) => void
onRecoverableError?: (error: unknown, errorInfo: ReactRootErrorInfo) => void
}
const mocks = vi.hoisted(() => {
const render = vi.fn()
const createRoot = vi.fn(() => ({ render }))
const sentryHandler = vi.fn()
const reactErrorHandler = vi.fn(() => sentryHandler)
const getShortcutEventInit = vi.fn(() => ({ key: 'x' }))
return {
createRoot,
getShortcutEventInit,
reactErrorHandler,
render,
sentryHandler,
}
})
vi.mock('react-dom/client', () => ({ createRoot: mocks.createRoot }))
vi.mock('@sentry/react', () => ({ reactErrorHandler: mocks.reactErrorHandler }))
vi.mock('./App.tsx', () => ({
default: () => createElement('div', { 'data-testid': 'mock-app' }),
}))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: ReactNode }) => createElement('div', null, children),
}))
vi.mock('./hooks/appCommandDispatcher', () => ({
APP_COMMAND_EVENT_NAME: 'laputa:command',
isAppCommandId: (id: string) => id === 'known-command',
isNativeMenuCommandId: (id: string) => id === 'native-command',
}))
vi.mock('./hooks/appCommandCatalog', () => ({
getShortcutEventInit: mocks.getShortcutEventInit,
}))
async function importEntrypoint() {
await import('./main')
}
function rootOptions(): ReactRootOptions {
const options = mocks.createRoot.mock.calls[0]?.[1]
if (!options) throw new Error('createRoot was not called with root options')
return options
}
describe('main entrypoint', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
document.body.innerHTML = '<div id="root"></div>'
})
it('captures React root errors through Sentry with component stack context', async () => {
await importEntrypoint()
expect(mocks.reactErrorHandler).toHaveBeenCalledOnce()
expect(mocks.createRoot).toHaveBeenCalledWith(
document.getElementById('root'),
expect.objectContaining({
onCaughtError: expect.any(Function),
onUncaughtError: expect.any(Function),
onRecoverableError: expect.any(Function),
}),
)
const error = new Error('Maximum update depth exceeded')
rootOptions().onCaughtError?.(error, { componentStack: '\n in App' })
expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '\n in App' })
})
it('normalizes missing React component stacks before handing errors to Sentry', async () => {
await importEntrypoint()
const error = new Error('recoverable render error')
rootOptions().onRecoverableError?.(error, {})
expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '' })
})
})

View File

@@ -1,4 +1,5 @@
import { StrictMode } from 'react'
import * as Sentry from '@sentry/react'
import { createRoot } from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
@@ -72,7 +73,20 @@ window.__laputaTest = {
},
}
createRoot(document.getElementById('root')!).render(
const sentryReactErrorHandler = Sentry.reactErrorHandler()
function captureReactRootError(
error: unknown,
errorInfo: { componentStack?: string },
): void {
sentryReactErrorHandler(error, { componentStack: errorInfo.componentStack ?? '' })
}
createRoot(document.getElementById('root')!, {
onCaughtError: captureReactRootError,
onUncaughtError: captureReactRootError,
onRecoverableError: captureReactRootError,
}).render(
<StrictMode>
<TooltipProvider>
<App />

View File

@@ -5,8 +5,18 @@ vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
}))
vi.mock('../lib/appUpdater', () => ({
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE:
'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.',
isRestartRequiredAfterUpdate: vi.fn(() => false),
}))
import { pickFolder } from './vault-dialog'
import { isTauri } from '../mock-tauri'
import {
isRestartRequiredAfterUpdate,
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
} from '../lib/appUpdater'
describe('pickFolder', () => {
beforeEach(() => {
@@ -37,4 +47,11 @@ describe('pickFolder', () => {
await pickFolder()
expect(window.prompt).toHaveBeenCalledWith('Enter folder path:')
})
it('blocks the native folder picker when a restart is required after update install', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(true)
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
})
})

View File

@@ -5,6 +5,41 @@
*/
import { isTauri } from '../mock-tauri'
import {
isRestartRequiredAfterUpdate,
RESTART_REQUIRED_FOLDER_PICKER_MESSAGE,
} from '../lib/appUpdater'
export class NativeFolderPickerBlockedError extends Error {
constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) {
super(message)
this.name = 'NativeFolderPickerBlockedError'
}
}
export function isNativeFolderPickerBlockedError(
error: unknown,
): error is NativeFolderPickerBlockedError {
return error instanceof NativeFolderPickerBlockedError
}
export function formatFolderPickerActionError(
action: string,
error: unknown,
): string {
if (isNativeFolderPickerBlockedError(error)) {
return error.message
}
const message =
typeof error === 'string'
? error
: error instanceof Error
? error.message
: ''
return message ? `${action}: ${message}` : action
}
/**
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
@@ -12,6 +47,10 @@ import { isTauri } from '../mock-tauri'
*/
export async function pickFolder(title?: string): Promise<string | null> {
if (isTauri()) {
if (isRestartRequiredAfterUpdate()) {
throw new NativeFolderPickerBlockedError()
}
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
directory: true,

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, vi } from 'vitest'
import { resolveImageUrls, portableImageUrls } from './vaultImages'
let tauriMode = false
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => tauriMode,
}))
function assetUrl(path: string): string {
return `http://asset.localhost/${encodeURIComponent(path)}`
}
function legacyAssetUrl(path: string): string {
return `asset://localhost/${encodeURIComponent(path)}`
}
describe('resolveImageUrls', () => {
it('is a no-op outside Tauri', () => {
tauriMode = false
const markdown = '![alt](attachments/file.png)'
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
})
it('is a no-op when vaultPath is empty', () => {
tauriMode = true
const markdown = '![alt](attachments/file.png)'
expect(resolveImageUrls(markdown, '')).toBe(markdown)
})
it('converts relative attachment paths to asset URLs', () => {
tauriMode = true
const markdown = '![screenshot](attachments/1776369786040-CleanShot_2026-04-16.png)'
expect(resolveImageUrls(markdown, '/vault')).toBe(
`![screenshot](${assetUrl('/vault/attachments/1776369786040-CleanShot_2026-04-16.png')})`,
)
})
it('leaves already-correct asset URLs unchanged', () => {
tauriMode = true
const url = assetUrl('/vault/attachments/file.png')
const markdown = `![alt](${url})`
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
})
it('rewrites legacy asset URLs from a different vault', () => {
tauriMode = true
const legacyUrl = legacyAssetUrl('/Users/luca/Workspace/tolaria-getting-started/attachments/CleanShot.png')
const markdown = `![CleanShot](${legacyUrl})`
expect(resolveImageUrls(markdown, '/Users/john/Documents/Getting Started')).toBe(
`![CleanShot](${assetUrl('/Users/john/Documents/Getting Started/attachments/CleanShot.png')})`,
)
})
it('leaves external URLs unchanged', () => {
tauriMode = true
const httpImage = '![logo](https://example.com/logo.png)'
const dataImage = '![icon](data:image/png;base64,abc123)'
expect(resolveImageUrls(httpImage, '/vault')).toBe(httpImage)
expect(resolveImageUrls(dataImage, '/vault')).toBe(dataImage)
})
it('handles multiple images in one document', () => {
tauriMode = true
const markdown = `![a](${legacyAssetUrl('/old/attachments/a.png')})\n\n![b](attachments/b.png)`
const result = resolveImageUrls(markdown, '/vault')
expect(result).toContain(`![a](${assetUrl('/vault/attachments/a.png')})`)
expect(result).toContain(`![b](${assetUrl('/vault/attachments/b.png')})`)
})
it('preserves alt text and title attributes', () => {
tauriMode = true
const markdown = '![my screenshot](attachments/file.png "starter vault")'
expect(resolveImageUrls(markdown, '/vault')).toBe(
`![my screenshot](${assetUrl('/vault/attachments/file.png')} "starter vault")`,
)
})
it('skips unknown asset URLs without an attachments segment', () => {
tauriMode = true
const url = assetUrl('/some/other/path/file.png')
const markdown = `![alt](${url})`
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
})
})
describe('portableImageUrls', () => {
it('converts vault attachment asset URLs to relative paths', () => {
const url = assetUrl('/vault/attachments/1776369786040-CleanShot.png')
const markdown = `![screenshot](${url})`
expect(portableImageUrls(markdown, '/vault')).toBe(
'![screenshot](attachments/1776369786040-CleanShot.png)',
)
})
it('converts legacy asset protocol attachment URLs to relative paths', () => {
const url = legacyAssetUrl('/vault/attachments/legacy.png')
const markdown = `![screenshot](${url})`
expect(portableImageUrls(markdown, '/vault')).toBe(
'![screenshot](attachments/legacy.png)',
)
})
it('is a no-op when vaultPath is empty', () => {
const url = assetUrl('/vault/attachments/file.png')
const markdown = `![alt](${url})`
expect(portableImageUrls(markdown, '')).toBe(markdown)
})
it('leaves asset URLs from other vaults unchanged', () => {
const url = assetUrl('/other-vault/attachments/file.png')
const markdown = `![alt](${url})`
expect(portableImageUrls(markdown, '/vault')).toBe(markdown)
})
it('leaves relative and external paths unchanged', () => {
const relativeImage = '![alt](attachments/file.png)'
const httpImage = '![logo](https://example.com/logo.png)'
expect(portableImageUrls(relativeImage, '/vault')).toBe(relativeImage)
expect(portableImageUrls(httpImage, '/vault')).toBe(httpImage)
})
it('handles multiple images', () => {
const markdown = `![a](${assetUrl('/vault/attachments/a.png')})\n\n![b](${assetUrl('/vault/attachments/b.png')})`
const result = portableImageUrls(markdown, '/vault')
expect(result).toContain('![a](attachments/a.png)')
expect(result).toContain('![b](attachments/b.png)')
})
it('preserves title attributes when converting to portable paths', () => {
const markdown = `![shot](${assetUrl('/vault/attachments/a.png')} "starter vault")`
expect(portableImageUrls(markdown, '/vault')).toBe('![shot](attachments/a.png "starter vault")')
})
})
describe('resolveImageUrls / portableImageUrls round-trip', () => {
it('keeps relative attachment markdown stable', () => {
tauriMode = true
const markdown = '![shot](attachments/file.png)'
expect(portableImageUrls(resolveImageUrls(markdown, '/vault'), '/vault')).toBe(markdown)
})
})

100
src/utils/vaultImages.ts Normal file
View File

@@ -0,0 +1,100 @@
import { convertFileSrc } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
const ASSET_URL_PREFIX = 'asset://localhost/'
const HTTP_ASSET_URL_PREFIX = 'http://asset.localhost/'
const ASSET_URL_PREFIXES = [ASSET_URL_PREFIX, HTTP_ASSET_URL_PREFIX]
const ATTACHMENTS_SEGMENT = '/attachments/'
const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/'
type Markdown = string
type VaultPath = string
type AttachmentPath = string
type AbsolutePath = string
type MarkdownImageUrl = string
// Matches markdown image syntax: ![alt](url) or ![alt](url "title").
const MD_IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)\s"]+)(\s+"[^"]*")?\)/g
function assetUrl(path: AbsolutePath): MarkdownImageUrl {
return normalizeWebviewAssetUrl(convertFileSrc(path), path)
}
function vaultAttachmentPath(vaultPath: VaultPath, attachmentPath: AttachmentPath): AbsolutePath {
return `${vaultPath}/${attachmentPath}`
}
function extractAttachmentPath(absolutePath: AbsolutePath): AttachmentPath | null {
const index = absolutePath.lastIndexOf(ATTACHMENTS_SEGMENT)
if (index === -1) return null
const filename = absolutePath.slice(index + ATTACHMENTS_SEGMENT.length)
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
}
function normalizeWebviewAssetUrl(url: MarkdownImageUrl, path: AbsolutePath): MarkdownImageUrl {
if (url.startsWith(ASSET_URL_PREFIX)) {
return `${HTTP_ASSET_URL_PREFIX}${encodeURIComponent(path)}`
}
return url
}
function assetUrlPrefix(url: MarkdownImageUrl): string | null {
return ASSET_URL_PREFIXES.find(prefix => url.startsWith(prefix)) ?? null
}
function decodeAssetPath(url: MarkdownImageUrl): AbsolutePath {
const prefix = assetUrlPrefix(url)
return prefix ? decodeURIComponent(url.slice(prefix.length)) : ''
}
function isAssetUrl(url: MarkdownImageUrl): boolean {
return assetUrlPrefix(url) !== null
}
function isCurrentVaultAsset(url: MarkdownImageUrl, vaultPath: VaultPath): boolean {
const absolutePath = decodeAssetPath(url)
return absolutePath === vaultPath || absolutePath.startsWith(`${vaultPath}/`)
}
function rewriteMarkdownImages(
markdown: Markdown,
transformUrl: (url: MarkdownImageUrl) => MarkdownImageUrl | null,
): Markdown {
return markdown.replace(MD_IMAGE_PATTERN, (match, alt, url, title = '') => {
const nextUrl = transformUrl(url)
return nextUrl ? `![${alt}](${nextUrl}${title})` : match
})
}
export function resolveImageUrls(markdown: Markdown, vaultPath: VaultPath): Markdown {
if (!isTauri() || !vaultPath) return markdown
return rewriteMarkdownImages(markdown, (url) => {
if (url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) {
return assetUrl(vaultAttachmentPath(vaultPath, url))
}
if (!isAssetUrl(url) || isCurrentVaultAsset(url, vaultPath)) {
return null
}
const attachmentPath = extractAttachmentPath(decodeAssetPath(url))
return attachmentPath ? assetUrl(vaultAttachmentPath(vaultPath, attachmentPath)) : null
})
}
export function portableImageUrls(markdown: Markdown, vaultPath: VaultPath): Markdown {
if (!vaultPath) return markdown
const attachmentsPrefix = `${vaultPath}/${RELATIVE_ATTACHMENTS_PREFIX}`
return rewriteMarkdownImages(markdown, (url) => {
if (!isAssetUrl(url)) return null
const absolutePath = decodeAssetPath(url)
if (!absolutePath.startsWith(attachmentsPrefix)) return null
return `${RELATIVE_ATTACHMENTS_PREFIX}${absolutePath.slice(attachmentsPrefix.length)}`
})
}

View File

@@ -0,0 +1,121 @@
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
function trackUnexpectedErrors(page: Page): string[] {
const errors: string[] = []
page.on('pageerror', (error) => {
errors.push(error.message)
})
page.on('console', (message) => {
if (message.type() !== 'error') return
const text = message.text()
if (text.includes('ws://localhost:9711')) return
errors.push(text)
})
return errors
}
function notePath(vaultPath: string): string {
return path.join(vaultPath, 'note', 'note-b.md')
}
function writeChecklistNote(vaultPath: string, marker: string, checked = false): void {
const checkbox = checked ? 'x' : ' '
fs.writeFileSync(notePath(vaultPath), `---
Is A: Note
Status: Active
---
# Note B
- [${checkbox}] Toggle me
- [ ] Keep me
${marker}
`, 'utf8')
}
async function openNote(page: Page, title: string) {
await page.getByTestId('note-list-container').getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function pullFromRemote(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Pull from Remote')
}
async function stubUpdatedPull(page: Page, updatedFile: string) {
await page.evaluate((filePath) => {
window.__mockHandlers!.git_pull = () => ({
status: 'updated',
message: 'Pulled 1 update from remote',
updatedFiles: [filePath],
conflictFiles: [],
})
}, updatedFile)
}
function checklistCheckbox(page: Page, index: number) {
return page.locator('.bn-block-content[data-content-type="checkListItem"] input[type="checkbox"]').nth(index)
}
test.describe('checklist pull refresh regression', () => {
test.beforeEach(({ page }, testInfo) => {
void page
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
writeChecklistNote(tempVaultDir, 'Initial checklist body.')
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('pull does not replace an unsaved checklist note in place', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
const pulledMarker = `Pulled checklist refresh ${Date.now()}`
const filePath = notePath(tempVaultDir)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await openNote(page, 'Note B')
const firstCheckbox = checklistCheckbox(page, 0)
await expect(firstCheckbox).toBeVisible({ timeout: 5_000 })
await expect(firstCheckbox).not.toBeChecked()
await firstCheckbox.click()
await expect(firstCheckbox).toBeChecked()
writeChecklistNote(tempVaultDir, pulledMarker, true)
await stubUpdatedPull(page, filePath)
await pullFromRemote(page)
await expect(page.getByText('Pulled 1 update(s) from remote')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).not.toContainText(pulledMarker)
await expect(page.locator('.bn-editor')).toContainText('Initial checklist body.')
await expect(checklistCheckbox(page, 0)).toBeChecked()
await openNote(page, 'Note C')
await openNote(page, 'Note B')
await expect(page.locator('.bn-editor')).not.toContainText(pulledMarker)
await expect(page.locator('.bn-editor')).toContainText('Initial checklist body.')
await expect(checklistCheckbox(page, 0)).toBeChecked()
expect(errors).toEqual([])
})
})

View File

@@ -88,88 +88,96 @@ function parseYamlBool(value: unknown): boolean | null {
const vitestCoverageDirectory = process.env.VITEST_COVERAGE_DIR
?? path.join(os.tmpdir(), 'tolaria-vitest-coverage', String(process.pid))
const devServerWatchIgnored = [
'**/coverage/**',
'**/test-results/**',
'**/playwright-report/**',
'**/dist/**',
'**/src-tauri/target/**',
]
function frontmatterString(frontmatter: Record<string, unknown>, ...keys: string[]): string | null {
const value = getFrontmatterValue(frontmatter, keys)
return typeof value === 'string' ? value : null
}
function frontmatterStringArray(frontmatter: Record<string, unknown>, ...keys: string[]): string[] {
const value = getFrontmatterValue(frontmatter, keys)
if (Array.isArray(value)) return value.map(String)
if (typeof value === 'string') return [value]
return []
}
function frontmatterBool(frontmatter: Record<string, unknown>, ...keys: string[]): boolean | null {
return parseYamlBool(getFrontmatterValue(frontmatter, keys))
}
function markdownTitle(content: string, frontmatter: Record<string, unknown>, fallback: string): string {
const title = frontmatterString(frontmatter, 'title')
if (title) return title
const h1Match = content.match(/^#\s+(.+)$/m)
return h1Match ? h1Match[1].trim() : fallback
}
function markdownBodyText(content: string): string {
return content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim()
}
function frontmatterWikiLinks(frontmatter: Record<string, unknown>, ...keys: string[]): string[] {
return frontmatterStringArray(frontmatter, ...keys).flatMap((value) => extractWikiLinks(value))
}
function frontmatterRelationships(frontmatter: Record<string, unknown>): Record<string, string[]> {
const relationships: Record<string, string[]> = {}
for (const [key, value] of Object.entries(frontmatter)) {
if (DEDICATED_KEYS.has(key.toLowerCase())) continue
const links = wikiLinksFromValue(value)
if (links.length > 0) relationships[key] = links
}
return relationships
}
function parseMarkdownFile(filePath: string): VaultEntry | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8')
const stats = fs.statSync(filePath)
const { data: fm, content } = matter(raw)
const { data, content } = matter(raw)
const fm = data as Record<string, unknown>
const filename = path.basename(filePath)
const basename = filename.replace(/\.md$/, '')
// Title: first H1 in body, or frontmatter title, or filename
const h1Match = content.match(/^#\s+(.+)$/m)
const title = (fm.title as string) || (h1Match ? h1Match[1].trim() : basename)
// Helper to get a frontmatter string field (case-insensitive key)
const getString = (...keys: string[]): string | null => {
const value = getFrontmatterValue(fm, keys)
return typeof value === 'string' ? value : null
}
// Helper to get a frontmatter array-of-strings field
const getArray = (...keys: string[]): string[] => {
const value = getFrontmatterValue(fm, keys)
if (Array.isArray(value)) return value.map(String)
if (typeof value === 'string') return [value]
return []
}
const aliases = getArray('aliases')
const belongsToRaw = getArray('belongs_to', 'belongs to')
const relatedToRaw = getArray('related_to', 'related to')
const belongsTo = belongsToRaw.flatMap((v) => extractWikiLinks(v))
const relatedTo = relatedToRaw.flatMap((v) => extractWikiLinks(v))
// Created at from filesystem metadata (birthtime), not frontmatter
const createdAt = stats.birthtimeMs
// Snippet: first 200 chars of body after frontmatter, stripped of markdown
const bodyText = content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim()
const title = markdownTitle(content, fm, basename)
const bodyText = markdownBodyText(content)
const snippet = bodyText.slice(0, 200)
// Generic relationships: any frontmatter key whose value contains wiki-links
const relationships: Record<string, string[]> = {}
for (const key of Object.keys(fm)) {
if (DEDICATED_KEYS.has(key.toLowerCase())) continue
const links = wikiLinksFromValue(fm[key])
if (links.length > 0) {
relationships[key] = links
}
}
// Boolean field helper — handles both real booleans and YAML 1.1 string
// variants ("Yes"/"yes"/"True"/"true") that js-yaml 4.x (YAML 1.2) leaves as strings.
const getBool = (...keys: string[]): boolean | null => {
return parseYamlBool(getFrontmatterValue(fm, keys))
}
return {
path: filePath,
filename,
title,
isA: getString('is_a', 'is a', 'type'),
aliases,
belongsTo,
relatedTo,
status: getString('status'),
archived: getBool('archived') ?? false,
trashed: getBool('trashed') ?? false,
isA: frontmatterString(fm, 'is_a', 'is a', 'type'),
aliases: frontmatterStringArray(fm, 'aliases'),
belongsTo: frontmatterWikiLinks(fm, 'belongs_to', 'belongs to'),
relatedTo: frontmatterWikiLinks(fm, 'related_to', 'related to'),
status: frontmatterString(fm, 'status'),
archived: frontmatterBool(fm, 'archived') ?? false,
trashed: frontmatterBool(fm, 'trashed') ?? false,
trashedAt: null,
modifiedAt: stats.mtimeMs,
createdAt,
createdAt: stats.birthtimeMs,
fileSize: stats.size,
snippet,
wordCount: bodyText.split(/\s+/).filter(Boolean).length,
relationships,
icon: getString('icon'),
color: getString('color'),
relationships: frontmatterRelationships(fm),
icon: frontmatterString(fm, 'icon'),
color: frontmatterString(fm, 'color'),
order: fm.order != null ? Number(fm.order) : null,
sidebarLabel: getString('sidebar label', 'sidebar_label'),
template: getString('template'),
sort: getString('sort'),
view: getString('view'),
visible: getBool('visible'),
sidebarLabel: frontmatterString(fm, 'sidebar label', 'sidebar_label'),
template: frontmatterString(fm, 'template'),
sort: frontmatterString(fm, 'sort'),
view: frontmatterString(fm, 'view'),
visible: frontmatterBool(fm, 'visible'),
outgoingLinks: [],
properties: {},
}
@@ -365,6 +373,21 @@ async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResp
return true
}
type FilenameStemValidation =
| { ok: true; stem: string }
| { ok: false; error: string }
function validateMarkdownFilenameStem(value: unknown): FilenameStemValidation {
const stem = String(value ?? '').trim().replace(/\.md$/i, '').trim()
if (!stem) return { ok: false, error: 'New filename cannot be empty' }
if (isUnsafeMarkdownFilenameStem(stem)) return { ok: false, error: 'Invalid filename' }
return { ok: true, stem }
}
function isUnsafeMarkdownFilenameStem(stem: string): boolean {
return stem === '.' || stem === '..' || stem.includes('/') || stem.includes('\\')
}
async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
if (url.pathname !== '/api/vault/rename-filename' || req.method !== 'POST') return false
try {
@@ -374,17 +397,13 @@ async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: Se
old_path: oldPath,
new_filename_stem: newFilenameStem,
} = JSON.parse(body)
const trimmed = String(newFilenameStem ?? '').trim().replace(/\.md$/i, '').trim()
if (!trimmed) {
sendJson(res, { error: 'New filename cannot be empty' }, 400)
return true
}
if (trimmed === '.' || trimmed === '..' || trimmed.includes('/') || trimmed.includes('\\')) {
sendJson(res, { error: 'Invalid filename' }, 400)
const filename = validateMarkdownFilenameStem(newFilenameStem)
if (!filename.ok) {
sendJson(res, { error: filename.error }, 400)
return true
}
const newPath = path.join(path.dirname(oldPath), `${trimmed}.md`)
const newPath = path.join(path.dirname(oldPath), `${filename.stem}.md`)
const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md')
if (newPath !== oldPath && fs.existsSync(newPath)) {
sendJson(res, { error: 'A note with that name already exists' }, 409)
@@ -505,6 +524,9 @@ export default defineConfig({
port: 5202,
strictPort: true,
allowedHosts: true,
watch: {
ignored: devServerWatchIgnored,
},
},
// Env variables starting with TAURI_ are exposed to the frontend