Compare commits
23 Commits
alpha-v202
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77286457bf | ||
|
|
5b8aa8da0d | ||
|
|
21b6d8984a | ||
|
|
11fc123492 | ||
|
|
d98fa94e14 | ||
|
|
add3c4075f | ||
|
|
fb0d550eb2 | ||
|
|
6adf7fd53b | ||
|
|
3b4d9599d2 | ||
|
|
bb1b18686f | ||
|
|
6dbcc334bf | ||
|
|
4944365ed7 | ||
|
|
c4c6bb6c57 | ||
|
|
35dd2dd47b | ||
|
|
89141bae14 | ||
|
|
2b9294f884 | ||
|
|
a756af54ec | ||
|
|
b01b156dfb | ||
|
|
2c9361d704 | ||
|
|
cea71a4fd9 | ||
|
|
8b73ef5836 | ||
|
|
a6a727a4c0 | ||
|
|
bf13eed3ab |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.9
|
||||
AVERAGE_THRESHOLD=9.79
|
||||
HOTSPOT_THRESHOLD=9.94
|
||||
AVERAGE_THRESHOLD=9.81
|
||||
|
||||
@@ -153,7 +153,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
|
||||
└── type/ ← type definition documents
|
||||
```
|
||||
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
|
||||
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
|
||||
|
||||
@@ -270,6 +270,7 @@ type SidebarSelection =
|
||||
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
|
||||
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
|
||||
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
|
||||
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
|
||||
- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection.
|
||||
|
||||
@@ -303,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
|
||||
|
||||
@@ -393,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 |
|
||||
@@ -653,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
|
||||
|
||||
@@ -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. 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.
|
||||
@@ -586,7 +590,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
|
||||
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `rename.rs` | `rename_note` / `rename_note_filename` — stage crash-safe file renames, update `title` frontmatter, recover unfinished rename transactions, and report backlink rewrite failures |
|
||||
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
|
||||
@@ -620,6 +624,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
|
||||
| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
|
||||
| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based wikilinks |
|
||||
| `create_vault_folder` | Create a folder relative to the active vault root |
|
||||
| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths |
|
||||
| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root |
|
||||
@@ -645,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 |
|
||||
@@ -737,7 +743,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
|
||||
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
|
||||
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
|
||||
| `useNoteRename` | — | Note renaming with wikilink update |
|
||||
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
|
||||
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
|
||||
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
|
||||
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
@@ -768,7 +775,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
| `[[` in editor | Open wikilink suggestion menu |
|
||||
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu.
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
|
||||
Shortcut routing is explicit:
|
||||
|
||||
@@ -883,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`
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0076"
|
||||
title: "Note retargeting separates type changes from folder moves"
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0025 made `type:` the canonical classification field, and ADR-0033 reopened subfolders as a valid way to organize files in the vault. Once Tolaria exposed both type sections and the folder tree in the sidebar, note reorganization had an unresolved ambiguity: does retargeting a note mean changing its semantic type, moving its file, or both?
|
||||
|
||||
Without an explicit model, drag-and-drop and command-palette flows would need to duplicate their own validation and persistence logic, and Tolaria could easily drift back toward the old type-folder coupling that ADR-0006 deliberately removed.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria treats note retargeting as one shared interaction model with two distinct mutation paths: types change metadata, folders change file paths.**
|
||||
|
||||
- Retargeting a note to a type updates only the note's `type:` frontmatter. The file stays where it is.
|
||||
- Retargeting a note to a folder preserves the current filename and `type:` value, and moves the file through the same crash-safe rename transaction pipeline used for backend rename commands.
|
||||
- Drag/drop targets and command-palette actions both route through the same frontend retargeting abstraction so validation, dialogs, collision handling, and success/error behavior stay consistent.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Shared retargeting model with separate type-vs-folder semantics** (chosen): preserves ADR-0025/ADR-0006's decoupling of type from path, lets folder moves reuse ADR-0075's crash-safe rename guarantees, and keeps multiple UI surfaces behaviorally aligned.
|
||||
- **Treat folders as the source of truth for note type**: simpler mental model for some vaults, but it reintroduces path-based type inference and makes type changes depend on file moves again.
|
||||
- **Implement drag/drop and command-palette retargeting as separate flows**: lower short-term coordination cost, but it duplicates mutation rules and makes consistency regressions likely.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Type sections are semantic targets only; they must never imply a filesystem move.
|
||||
- Folder targets are physical move operations; they must preserve filename/title behavior, reject collisions, and rewrite path-based wikilinks through the shared rename pipeline.
|
||||
- Future note-retargeting surfaces should reuse the shared retargeting abstraction instead of introducing another mutation path.
|
||||
- Re-evaluate this ADR if Tolaria later supports bulk retargeting, folder rules that intentionally infer type, or another organization primitive that needs different semantics.
|
||||
@@ -130,3 +130,5 @@ proposed → active → superseded
|
||||
| [0072](0072-confirmed-vault-paths-gate-startup-state.md) | Confirmed vault paths gate startup state | active |
|
||||
| [0073](0073-persistent-linkify-protocol-registry-across-editor-remounts.md) | Persistent linkify protocol registry across editor remounts | active |
|
||||
| [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | Explicit external AI tool setup and least-privilege desktop scope | active |
|
||||
| [0075](0075-crash-safe-note-rename-transactions.md) | Crash-safe note rename transactions | active |
|
||||
| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -139,6 +139,22 @@ mod tests {
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_rejects_traversal_outside_active_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let escape_path = vault_path.join("../outside.md");
|
||||
|
||||
let err = create_note_content(
|
||||
escape_path,
|
||||
"# Outside\n".to_string(),
|
||||
vault_path_arg(vault_path),
|
||||
)
|
||||
.expect_err("expected traversal create to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_folder_rejects_escape_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -39,6 +39,22 @@ fn with_requested_root_path<T>(
|
||||
with_requested_root(raw_vault_path.as_ref(), action)
|
||||
}
|
||||
|
||||
fn with_writable_note_path<T>(
|
||||
path: PathBuf,
|
||||
vault_path: Option<PathBuf>,
|
||||
action: impl FnOnce(&str) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
with_validated_path(
|
||||
path.to_string_lossy().as_ref(),
|
||||
vault_path
|
||||
.as_ref()
|
||||
.map(|value| value.to_string_lossy())
|
||||
.as_deref(),
|
||||
ValidatedPathMode::Writable,
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<String, String> {
|
||||
with_note_path(
|
||||
@@ -55,15 +71,20 @@ pub fn save_note_content(
|
||||
content: String,
|
||||
vault_path: Option<PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
with_validated_path(
|
||||
path.to_string_lossy().as_ref(),
|
||||
vault_path
|
||||
.as_ref()
|
||||
.map(|value| value.to_string_lossy())
|
||||
.as_deref(),
|
||||
ValidatedPathMode::Writable,
|
||||
|validated_path| vault::save_note_content(validated_path, &content),
|
||||
)
|
||||
with_writable_note_path(path, vault_path, |validated_path| {
|
||||
vault::save_note_content(validated_path, &content)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_note_content(
|
||||
path: PathBuf,
|
||||
content: String,
|
||||
vault_path: Option<PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
with_writable_note_path(path, vault_path, |validated_path| {
|
||||
vault::create_note_content(validated_path, &content)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::commands::expand_tilde;
|
||||
use crate::vault::{self, DetectedRename, RenameResult};
|
||||
use std::path::Path;
|
||||
|
||||
use super::boundary::with_existing_path_in_requested_vault;
|
||||
use super::boundary::{
|
||||
with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note(
|
||||
@@ -39,6 +42,42 @@ pub fn rename_note_filename(
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn move_note_to_folder(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
folder_path: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
with_existing_path_in_requested_vault(
|
||||
&vault_path,
|
||||
&old_path,
|
||||
|requested_root, validated_path| {
|
||||
let trimmed_folder_path = folder_path.trim();
|
||||
if trimmed_folder_path.is_empty() {
|
||||
return Err("Folder path cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let folder_absolute_path = Path::new(requested_root).join(trimmed_folder_path);
|
||||
with_validated_path(
|
||||
folder_absolute_path.to_string_lossy().as_ref(),
|
||||
Some(&vault_path),
|
||||
ValidatedPathMode::Existing,
|
||||
|validated_folder_path| {
|
||||
let validated_folder = Path::new(validated_folder_path);
|
||||
if !validated_folder.is_dir() {
|
||||
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
|
||||
}
|
||||
vault::move_note_to_folder(
|
||||
requested_root,
|
||||
validated_path,
|
||||
validated_folder_path,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: String,
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -196,11 +200,13 @@ macro_rules! app_invoke_handler {
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::create_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::rename_note_filename,
|
||||
commands::move_note_to_folder,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
@@ -295,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)
|
||||
@@ -311,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::fs;
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
@@ -63,3 +64,25 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
validate_save_path(file_path, path)?;
|
||||
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Create a new note file without overwriting any existing file.
|
||||
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let file_path = Path::new(path);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
}
|
||||
validate_save_path(file_path, path)?;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(file_path)
|
||||
.map_err(|e| match e.kind() {
|
||||
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
|
||||
_ => format!("Failed to create {}: {}", path, e),
|
||||
})?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|e| format!("Failed to save {}: {}", path, e))
|
||||
}
|
||||
|
||||
@@ -20,13 +20,13 @@ pub use config_seed::{
|
||||
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
|
||||
};
|
||||
pub use entry::{FolderNode, VaultEntry};
|
||||
pub use file::{get_note_content, save_note_content};
|
||||
pub use file::{create_note_content, get_note_content, save_note_content};
|
||||
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
|
||||
auto_rename_untitled, detect_renames, move_note_to_folder, rename_note, rename_note_filename,
|
||||
update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -165,3 +165,29 @@ fn test_save_note_content_deeply_nested_new_directory() {
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_creates_parent_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("new-type/briefing.md");
|
||||
let content = "---\ntitle: Briefing\ntype: Note\n---\n";
|
||||
|
||||
assert!(!path.parent().unwrap().exists());
|
||||
create_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_rejects_existing_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("briefing.md");
|
||||
fs::write(&path, "# Existing\n").unwrap();
|
||||
|
||||
let err = create_note_content(path.to_str().unwrap(), "# Replacement\n")
|
||||
.expect_err("expected create-only write to reject collisions");
|
||||
|
||||
assert!(err.contains("already exists"));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n");
|
||||
}
|
||||
|
||||
@@ -385,6 +385,57 @@ pub fn rename_note_filename(
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Move a note into a different folder while preserving its filename and content.
|
||||
pub fn move_note_to_folder(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
destination_folder_path: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
let destination_dir = Path::new(destination_folder_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
ensure_existing_note(old_file, old_path)?;
|
||||
|
||||
if !destination_dir.exists() {
|
||||
return Err(format!(
|
||||
"Folder does not exist: {}",
|
||||
destination_folder_path
|
||||
));
|
||||
}
|
||||
if !destination_dir.is_dir() {
|
||||
return Err(format!(
|
||||
"Folder is not a directory: {}",
|
||||
destination_folder_path
|
||||
));
|
||||
}
|
||||
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let new_file = destination_dir.join(&old_filename);
|
||||
|
||||
if new_file == old_file {
|
||||
return Ok(unchanged_result(old_path));
|
||||
}
|
||||
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
|
||||
fn is_untitled_filename(filename: &str) -> bool {
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
@@ -931,6 +982,72 @@ mod tests {
|
||||
assert_eq!(result.unwrap_err(), "A note with that name already exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_to_folder_preserves_filename_and_updates_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"projects/weekly-review.md",
|
||||
"---\ntitle: Weekly Review\n---\n# Weekly Review\nBody\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"areas/linked.md",
|
||||
"Reference [[projects/weekly-review]]\n",
|
||||
);
|
||||
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
vault.join("areas").to_str().unwrap(),
|
||||
)
|
||||
.expect("move should succeed");
|
||||
|
||||
assert!(result.new_path.ends_with("areas/weekly-review.md"));
|
||||
assert!(!vault.join("projects/weekly-review.md").exists());
|
||||
assert!(vault.join("areas/weekly-review.md").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(vault.join("areas/linked.md")).unwrap(),
|
||||
"Reference [[areas/weekly-review]]\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_to_folder_noop_when_destination_matches_current_parent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
|
||||
|
||||
let source = vault.join("projects/weekly-review.md");
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
vault.join("projects").to_str().unwrap(),
|
||||
)
|
||||
.expect("move should noop");
|
||||
|
||||
assert_eq!(result.new_path, source.to_string_lossy());
|
||||
assert!(source.exists());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_to_folder_rejects_existing_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
|
||||
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
vault.join("areas").to_str().unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap_err(), "A note with that name already exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
|
||||
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
|
||||
|
||||
486
src/App.tsx
486
src/App.tsx
@@ -20,6 +20,8 @@ import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
|
||||
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
||||
import { FeedbackDialog } from './components/FeedbackDialog'
|
||||
import { McpSetupDialog } from './components/McpSetupDialog'
|
||||
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
|
||||
import { NoteRetargetingProvider } from './components/note-retargeting/noteRetargetingContext'
|
||||
import { useTelemetry } from './hooks/useTelemetry'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
||||
@@ -30,7 +32,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { slugify } from './hooks/useNoteCreation'
|
||||
import { planNewTypeCreation } from './hooks/useNoteCreation'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
@@ -63,6 +65,7 @@ import { useFolderActions } from './hooks/useFolderActions'
|
||||
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import { useConflictFlow } from './hooks/useConflictFlow'
|
||||
import { useAppSave } from './hooks/useAppSave'
|
||||
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
|
||||
import { useVaultBridge } from './hooks/useVaultBridge'
|
||||
import type { CommitDiffRequest } from './hooks/useDiffMode'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
@@ -348,44 +351,6 @@ function App() {
|
||||
return true
|
||||
}, [handleSetSelection])
|
||||
|
||||
const shouldBlockNeighborhoodEscape = (
|
||||
dialogs.showCreateTypeDialog
|
||||
|| dialogs.showQuickOpen
|
||||
|| dialogs.showCommandPalette
|
||||
|| dialogs.showAIChat
|
||||
|| dialogs.showSettings
|
||||
|| dialogs.showCloneVault
|
||||
|| dialogs.showSearch
|
||||
|| dialogs.showConflictResolver
|
||||
|| dialogs.showCreateViewDialog
|
||||
|| showFeedback
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
if (isEditorEscapeTarget(activeElement)) {
|
||||
event.preventDefault()
|
||||
activeElement.blur()
|
||||
requestAnimationFrame(() => {
|
||||
focusNoteListContainer(document)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableElement(activeElement)) return
|
||||
|
||||
if (handleNeighborhoodHistoryBack()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
|
||||
|
||||
const handleSaveExplicitOrganization = useCallback((enabled: boolean) => {
|
||||
updateConfig('inbox', {
|
||||
noteListProperties: vaultConfig.inbox?.noteListProperties ?? null,
|
||||
@@ -675,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,
|
||||
@@ -937,37 +902,40 @@ function App() {
|
||||
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
|
||||
|
||||
const handleCreateType = useCallback((name: string) => {
|
||||
notes.handleCreateType(name)
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
const handleCreateType = useCallback(async (name: string) => {
|
||||
const created = await notes.handleCreateType(name)
|
||||
if (created) setToastMessage(`Type "${name}" created`)
|
||||
return created
|
||||
}, [notes, setToastMessage])
|
||||
|
||||
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
|
||||
const trimmed = nextTypeName.trim()
|
||||
if (!trimmed) return
|
||||
if (!trimmed) return false
|
||||
|
||||
const targetFilename = `${slugify(trimmed)}.md`
|
||||
const exactType = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === trimmed)
|
||||
const slugMatch = vault.entries.find((entry) => entry.isA === 'Type' && slugify(entry.title) === slugify(trimmed))
|
||||
const filenameCollision = vault.entries.find((entry) => entry.filename.toLowerCase() === targetFilename)
|
||||
const resolvedTypeName = exactType?.title ?? slugMatch?.title ?? trimmed
|
||||
|
||||
if (filenameCollision && filenameCollision.isA !== 'Type') {
|
||||
setToastMessage(`Cannot create type "${trimmed}" because ${targetFilename} already exists`)
|
||||
throw new Error(`Type filename collision for ${targetFilename}`)
|
||||
const plan = planNewTypeCreation({ entries: vault.entries, typeName: trimmed, vaultPath: resolvedPath })
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!exactType && !slugMatch) {
|
||||
await notes.createTypeEntrySilent(trimmed)
|
||||
let resolvedTypeName = plan.status === 'existing' ? plan.entry.title : trimmed
|
||||
|
||||
if (plan.status === 'create') {
|
||||
try {
|
||||
resolvedTypeName = (await notes.createTypeEntrySilent(trimmed)).title
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName)
|
||||
setToastMessage(
|
||||
resolvedTypeName === missingType
|
||||
plan.status === 'create' && resolvedTypeName === missingType
|
||||
? `Type "${resolvedTypeName}" created`
|
||||
: `Type set to "${resolvedTypeName}"`,
|
||||
)
|
||||
}, [notes, setToastMessage, vault.entries])
|
||||
return true
|
||||
}, [notes, resolvedPath, setToastMessage, vault.entries])
|
||||
|
||||
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
|
||||
const editing = dialogs.editingView
|
||||
@@ -1140,10 +1108,60 @@ function App() {
|
||||
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
|
||||
?? null
|
||||
}, [notes.activeTabPath, notes.tabs, vault.entries])
|
||||
const noteRetargetingUi = useNoteRetargetingUi({
|
||||
activeEntry: activeCommandEntry,
|
||||
activeNoteBlocked: !!activeDeletedFile,
|
||||
entries: vault.entries,
|
||||
folders: vault.folders,
|
||||
selection: effectiveSelection,
|
||||
setSelection: handleSetSelection,
|
||||
setToastMessage,
|
||||
vaultPath: resolvedPath,
|
||||
updateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
moveNoteToFolder: notes.handleMoveNoteToFolder,
|
||||
})
|
||||
|
||||
const canToggleRichEditor = !!activeCommandEntry
|
||||
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
|
||||
&& !activeDeletedFile
|
||||
const shouldBlockNeighborhoodEscape = (
|
||||
dialogs.showCreateTypeDialog
|
||||
|| dialogs.showQuickOpen
|
||||
|| dialogs.showCommandPalette
|
||||
|| dialogs.showAIChat
|
||||
|| dialogs.showSettings
|
||||
|| dialogs.showCloneVault
|
||||
|| dialogs.showSearch
|
||||
|| dialogs.showConflictResolver
|
||||
|| dialogs.showCreateViewDialog
|
||||
|| noteRetargetingUi.isDialogOpen
|
||||
|| showFeedback
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
if (isEditorEscapeTarget(activeElement)) {
|
||||
event.preventDefault()
|
||||
activeElement.blur()
|
||||
requestAnimationFrame(() => {
|
||||
focusNoteListContainer(document)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableElement(activeElement)) return
|
||||
|
||||
if (handleNeighborhoodHistoryBack()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
|
||||
|
||||
const noteListColumnsLabel = useMemo(() => {
|
||||
if (effectiveSelection.kind === 'view') {
|
||||
@@ -1155,6 +1173,45 @@ function App() {
|
||||
? 'Customize All Notes columns'
|
||||
: 'Customize Inbox columns'
|
||||
}, [effectiveSelection, vault.views])
|
||||
const activeNoteModified = useMemo(
|
||||
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
|
||||
[notes.activeTabPath, vault.modifiedFiles],
|
||||
)
|
||||
const toggleDiffCommand = useCallback(() => diffToggleRef.current(), [])
|
||||
const toggleRawEditorCommand = useMemo(
|
||||
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
|
||||
[canToggleRichEditor],
|
||||
)
|
||||
const removeActiveVaultCommand = useCallback(() => {
|
||||
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
|
||||
}, [vaultSwitcher])
|
||||
const restoreVaultAiGuidanceCommand = useCallback(() => {
|
||||
void restoreVaultAiGuidance()
|
||||
}, [restoreVaultAiGuidance])
|
||||
const changeNoteTypeCommand = useMemo(
|
||||
() => noteRetargetingUi.canChangeActiveNoteType ? noteRetargetingUi.openChangeNoteTypeDialog : undefined,
|
||||
[noteRetargetingUi.canChangeActiveNoteType, noteRetargetingUi.openChangeNoteTypeDialog],
|
||||
)
|
||||
const moveNoteToFolderCommand = useMemo(
|
||||
() => noteRetargetingUi.canMoveActiveNoteToFolder ? noteRetargetingUi.openMoveNoteToFolderDialog : undefined,
|
||||
[noteRetargetingUi.canMoveActiveNoteToFolder, noteRetargetingUi.openMoveNoteToFolderDialog],
|
||||
)
|
||||
const activeNoteHasIcon = useMemo(() => {
|
||||
const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath)
|
||||
return hasNoteIconValue(entry?.icon)
|
||||
}, [notes.activeTabPath, vault.entries])
|
||||
const toggleOrganizedCommand = explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined
|
||||
const canCustomizeNoteListColumns = useMemo(() => (
|
||||
effectiveSelection.kind === 'view'
|
||||
|| (
|
||||
effectiveSelection.kind === 'filter'
|
||||
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
|
||||
)
|
||||
), [effectiveSelection, explicitOrganizationEnabled])
|
||||
const restoreDeletedNoteCommand = useMemo(
|
||||
() => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
|
||||
[activeDeletedFile, handleDiscardFile],
|
||||
)
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
@@ -1162,7 +1219,7 @@ function App() {
|
||||
visibleNotesRef,
|
||||
multiSelectionCommandRef,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
activeNoteModified,
|
||||
selection: effectiveSelection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
@@ -1178,8 +1235,8 @@ function App() {
|
||||
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
|
||||
onSetViewMode: handleSetViewMode,
|
||||
onToggleInspector: handleToggleInspector,
|
||||
onToggleDiff: () => diffToggleRef.current(),
|
||||
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
|
||||
onToggleDiff: toggleDiffCommand,
|
||||
onToggleRawEditor: toggleRawEditorCommand,
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: handleSetSelection,
|
||||
@@ -1195,7 +1252,7 @@ function App() {
|
||||
onCreateType: dialogs.openCreateType,
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
onCheckForUpdates: handleCheckForUpdates,
|
||||
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
|
||||
onRemoveActiveVault: removeActiveVaultCommand,
|
||||
onRestoreGettingStarted: cloneGettingStartedVault,
|
||||
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
@@ -1204,7 +1261,7 @@ function App() {
|
||||
onOpenAiAgents: dialogs.openSettings,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
|
||||
onRestoreVaultAiGuidance: restoreVaultAiGuidanceCommand,
|
||||
selectedAiAgent: aiAgentPreferences.defaultAiAgent,
|
||||
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
|
||||
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
|
||||
@@ -1213,23 +1270,19 @@ function App() {
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
onRemoveNoteIcon: handleRemoveNoteIconCommand,
|
||||
activeNoteHasIcon: (() => {
|
||||
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
|
||||
return hasNoteIconValue(ae?.icon)
|
||||
})(),
|
||||
onChangeNoteType: changeNoteTypeCommand,
|
||||
onMoveNoteToFolder: moveNoteToFolderCommand,
|
||||
canMoveNoteToFolder: noteRetargetingUi.canMoveActiveNoteToFolder,
|
||||
activeNoteHasIcon,
|
||||
noteListFilter,
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
|
||||
onToggleOrganized: toggleOrganizedCommand,
|
||||
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
|
||||
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|
||||
|| (
|
||||
effectiveSelection.kind === 'filter'
|
||||
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
|
||||
),
|
||||
canCustomizeNoteListColumns,
|
||||
noteListColumnsLabel,
|
||||
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
|
||||
onRestoreDeletedNote: restoreDeletedNoteCommand,
|
||||
canRestoreDeletedNote: !!activeDeletedFile,
|
||||
})
|
||||
|
||||
@@ -1325,146 +1378,157 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
)}
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
</>
|
||||
)}
|
||||
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
|
||||
<Editor
|
||||
tabs={notes.tabs}
|
||||
activeTabPath={notes.activeTabPath}
|
||||
entries={vault.entries}
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={vault.loadDiff}
|
||||
onLoadDiffAtCommit={vault.loadDiffAtCommit}
|
||||
pendingCommitDiffRequest={pulseCommitDiffRequest}
|
||||
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
|
||||
getNoteStatus={vault.getNoteStatus}
|
||||
onCreateNote={notes.handleCreateNoteImmediate}
|
||||
inspectorCollapsed={layout.inspectorCollapsed}
|
||||
onToggleInspector={handleToggleInspector}
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
|
||||
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab?.entry ?? null}
|
||||
inspectorContent={activeTab?.content ?? null}
|
||||
gitHistory={gitHistory}
|
||||
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateMissingType={handleCreateMissingType}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
onInitializeProperties={handleInitializeProperties}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={resolvedPath}
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
|
||||
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
|
||||
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
||||
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
||||
onContentChange={handleTrackedContentChange}
|
||||
onSave={handleTrackedSave}
|
||||
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
onGoBack={handleGoBack}
|
||||
onGoForward={handleGoForward}
|
||||
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
||||
onFileCreated={vaultBridge.handleAgentFileCreated}
|
||||
onFileModified={vaultBridge.handleAgentFileModified}
|
||||
onVaultChanged={vaultBridge.handleAgentVaultChanged}
|
||||
isConflicted={conflictFlow.isConflicted}
|
||||
onKeepMine={conflictFlow.handleKeepMine}
|
||||
onKeepTheirs={conflictFlow.handleKeepTheirs}
|
||||
flushPendingRawContentRef={flushPendingRawContentRef}
|
||||
/>
|
||||
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
)}
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
</>
|
||||
)}
|
||||
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
|
||||
<Editor
|
||||
tabs={notes.tabs}
|
||||
activeTabPath={notes.activeTabPath}
|
||||
entries={vault.entries}
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={vault.loadDiff}
|
||||
onLoadDiffAtCommit={vault.loadDiffAtCommit}
|
||||
pendingCommitDiffRequest={pulseCommitDiffRequest}
|
||||
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
|
||||
getNoteStatus={vault.getNoteStatus}
|
||||
onCreateNote={notes.handleCreateNoteImmediate}
|
||||
inspectorCollapsed={layout.inspectorCollapsed}
|
||||
onToggleInspector={handleToggleInspector}
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
|
||||
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab?.entry ?? null}
|
||||
inspectorContent={activeTab?.content ?? null}
|
||||
gitHistory={gitHistory}
|
||||
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateMissingType={handleCreateMissingType}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
onInitializeProperties={handleInitializeProperties}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={resolvedPath}
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
|
||||
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
|
||||
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
||||
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
||||
onContentChange={handleTrackedContentChange}
|
||||
onSave={handleTrackedSave}
|
||||
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
onGoBack={handleGoBack}
|
||||
onGoForward={handleGoForward}
|
||||
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
||||
onFileCreated={vaultBridge.handleAgentFileCreated}
|
||||
onFileModified={vaultBridge.handleAgentFileModified}
|
||||
onVaultChanged={vaultBridge.handleAgentVaultChanged}
|
||||
isConflicted={conflictFlow.isConflicted}
|
||||
onKeepMine={conflictFlow.handleKeepMine}
|
||||
onKeepTheirs={conflictFlow.handleKeepTheirs}
|
||||
flushPendingRawContentRef={flushPendingRawContentRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
entries={vault.entries}
|
||||
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<NoteRetargetingDialogs
|
||||
dialogState={noteRetargetingUi.dialogState}
|
||||
dialogEntry={noteRetargetingUi.dialogEntry}
|
||||
typeOptions={noteRetargetingUi.typeOptions}
|
||||
folderOptions={noteRetargetingUi.folderOptions}
|
||||
onClose={noteRetargetingUi.closeDialog}
|
||||
onSelectType={noteRetargetingUi.selectType}
|
||||
onSelectFolder={noteRetargetingUi.selectFolder}
|
||||
/>
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CommitDialog
|
||||
open={commitFlow.showCommitDialog}
|
||||
modifiedCount={vault.modifiedFiles.length}
|
||||
commitMode={commitFlow.commitMode}
|
||||
suggestedMessage={suggestedCommitMessage}
|
||||
onCommit={commitFlow.handleCommitPush}
|
||||
onClose={commitFlow.closeCommitDialog}
|
||||
/>
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
fileStates={conflictResolver.fileStates}
|
||||
allResolved={conflictResolver.allResolved}
|
||||
committing={conflictResolver.committing}
|
||||
error={conflictResolver.error}
|
||||
onResolveFile={conflictResolver.resolveFile}
|
||||
onOpenInEditor={conflictResolver.openInEditor}
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title={deleteActions.confirmDelete.title}
|
||||
message={deleteActions.confirmDelete.message}
|
||||
confirmLabel={deleteActions.confirmDelete.confirmLabel}
|
||||
onConfirm={deleteActions.confirmDelete.onConfirm}
|
||||
onCancel={() => deleteActions.setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
{folderActions.confirmDeleteFolder && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title={folderActions.confirmDeleteFolder.title}
|
||||
message={folderActions.confirmDeleteFolder.message}
|
||||
confirmLabel={folderActions.confirmDeleteFolder.confirmLabel}
|
||||
onConfirm={folderActions.confirmDeleteSelectedFolder}
|
||||
onCancel={folderActions.cancelDeleteFolder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
entries={vault.entries}
|
||||
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CommitDialog
|
||||
open={commitFlow.showCommitDialog}
|
||||
modifiedCount={vault.modifiedFiles.length}
|
||||
commitMode={commitFlow.commitMode}
|
||||
suggestedMessage={suggestedCommitMessage}
|
||||
onCommit={commitFlow.handleCommitPush}
|
||||
onClose={commitFlow.closeCommitDialog}
|
||||
/>
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
fileStates={conflictResolver.fileStates}
|
||||
allResolved={conflictResolver.allResolved}
|
||||
committing={conflictResolver.committing}
|
||||
error={conflictResolver.error}
|
||||
onResolveFile={conflictResolver.resolveFile}
|
||||
onOpenInEditor={conflictResolver.openInEditor}
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title={deleteActions.confirmDelete.title}
|
||||
message={deleteActions.confirmDelete.message}
|
||||
confirmLabel={deleteActions.confirmDelete.confirmLabel}
|
||||
onConfirm={deleteActions.confirmDelete.onConfirm}
|
||||
onCancel={() => deleteActions.setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
{folderActions.confirmDeleteFolder && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title={folderActions.confirmDeleteFolder.title}
|
||||
message={folderActions.confirmDeleteFolder.message}
|
||||
confirmLabel={folderActions.confirmDeleteFolder.confirmLabel}
|
||||
onConfirm={folderActions.confirmDeleteSelectedFolder}
|
||||
onCancel={folderActions.cancelDeleteFolder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</NoteRetargetingProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,19 @@ describe('CreateTypeDialog', () => {
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('stays open when create reports a handled collision', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onCreate = vi.fn().mockResolvedValue(false)
|
||||
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={onCreate} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...'), { target: { value: 'Recipe' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Recipe'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Create New Type')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={() => {}} />)
|
||||
|
||||
@@ -6,14 +6,14 @@ import { Input } from '@/components/ui/input'
|
||||
interface CreateTypeDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (name: string) => void | Promise<void>
|
||||
onCreate: (name: string) => boolean | void | Promise<boolean | void>
|
||||
initialName?: string
|
||||
}
|
||||
|
||||
interface CreateTypeDialogFormProps {
|
||||
initialName: string
|
||||
onClose: () => void
|
||||
onCreate: (name: string) => void | Promise<void>
|
||||
onCreate: (name: string) => boolean | void | Promise<boolean | void>
|
||||
}
|
||||
|
||||
function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) {
|
||||
@@ -23,8 +23,8 @@ function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDial
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
await onCreate(trimmed)
|
||||
onClose()
|
||||
const created = await onCreate(trimmed)
|
||||
if (created !== false) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -239,7 +239,7 @@ export function DynamicPropertiesPanel({
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}) {
|
||||
const {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
|
||||
@@ -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\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\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'
|
||||
|
||||
@@ -52,7 +52,7 @@ interface EditorProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
showAIChat?: boolean
|
||||
@@ -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)
|
||||
|
||||
@@ -360,7 +361,7 @@ function EditorLayout({
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
|
||||
@@ -27,7 +27,7 @@ interface EditorRightPanelProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onToggleRawEditor?: () => void
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { FolderTree } from './FolderTree'
|
||||
import { FOLDER_ROW_SINGLE_CLICK_DELAY_MS } from './folder-tree/useFolderRowInteractions'
|
||||
import type { FolderNode, SidebarSelection } from '../types'
|
||||
|
||||
const mockFolders: FolderNode[] = [
|
||||
@@ -49,6 +51,32 @@ describe('FolderTree', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
|
||||
})
|
||||
|
||||
it('expands children when single-clicking a folder row with children', () => {
|
||||
vi.useFakeTimers()
|
||||
function FolderTreeHarness() {
|
||||
const [selection, setSelection] = useState<SidebarSelection>(defaultSelection)
|
||||
return <FolderTree folders={mockFolders} selection={selection} onSelect={setSelection} />
|
||||
}
|
||||
|
||||
render(<FolderTreeHarness />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('folder-row:projects'))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
|
||||
})
|
||||
|
||||
expect(screen.getByText('laputa')).toBeInTheDocument()
|
||||
expect(screen.getByText('portfolio')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('folder-row:projects'))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('collapses section when clicking the FOLDERS header', () => {
|
||||
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
|
||||
expect(screen.getByText('projects')).toBeInTheDocument()
|
||||
@@ -87,8 +115,45 @@ describe('FolderTree', () => {
|
||||
onCancelRenameFolder={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.doubleClick(screen.getByTestId('folder-row:areas'))
|
||||
expect(onStartRenameFolder).toHaveBeenCalledWith('areas')
|
||||
fireEvent.doubleClick(screen.getByTestId('folder-row:projects'))
|
||||
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
|
||||
})
|
||||
|
||||
it('shows inline rename and delete actions for folders', () => {
|
||||
const onDeleteFolder = vi.fn()
|
||||
const onStartRenameFolder = vi.fn()
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={onSelect}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onRenameFolder={vi.fn().mockResolvedValue(true)}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onCancelRenameFolder={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('rename-folder-btn:projects'))
|
||||
fireEvent.click(screen.getByTestId('delete-folder-btn:projects'))
|
||||
|
||||
expect(onSelect).toHaveBeenNthCalledWith(1, { kind: 'folder', path: 'projects' })
|
||||
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
|
||||
expect(onSelect).toHaveBeenNthCalledWith(2, { kind: 'folder', path: 'projects' })
|
||||
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
|
||||
})
|
||||
|
||||
it('does not reserve a disclosure slot for leaf folders', () => {
|
||||
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
|
||||
|
||||
const leafRowContainer = screen.getByTestId('folder-row:areas').parentElement
|
||||
const parentRowContainer = screen.getByTestId('folder-row:projects').parentElement
|
||||
|
||||
expect(leafRowContainer).not.toBeNull()
|
||||
expect(parentRowContainer).not.toBeNull()
|
||||
expect(within(leafRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1)
|
||||
expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the rename input when a folder is being renamed', () => {
|
||||
@@ -105,6 +170,43 @@ describe('FolderTree', () => {
|
||||
expect(screen.getByTestId('rename-folder-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps folder toggling healthy after cancelling rename', () => {
|
||||
vi.useFakeTimers()
|
||||
const onCancelRenameFolder = vi.fn()
|
||||
const { rerender } = render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={{ kind: 'folder', path: 'projects' }}
|
||||
onSelect={vi.fn()}
|
||||
onRenameFolder={vi.fn().mockResolvedValue(true)}
|
||||
renamingFolderPath="projects"
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('rename-folder-input'), { key: 'Escape' })
|
||||
expect(onCancelRenameFolder).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={{ kind: 'folder', path: 'projects' }}
|
||||
onSelect={vi.fn()}
|
||||
onRenameFolder={vi.fn().mockResolvedValue(true)}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
/>,
|
||||
)
|
||||
|
||||
const wasExpanded = screen.queryByText('laputa') !== null
|
||||
fireEvent.click(screen.getByTestId('folder-row:projects'))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('laputa') !== null).toBe(!wasExpanded)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens a context menu with a delete action on right-click', () => {
|
||||
const onDeleteFolder = vi.fn()
|
||||
render(
|
||||
|
||||
@@ -32,7 +32,7 @@ interface InspectorProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onToggleRawEditor?: () => void
|
||||
@@ -71,7 +71,7 @@ function ValidFrontmatterPanels({
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onCreateMissingType?: (typeName: string) => Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -134,7 +134,7 @@ function PrimaryInspectorPanel({
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onCreateMissingType?: (typeName: string) => Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => Promise<boolean | void>
|
||||
}) {
|
||||
if (frontmatterState === 'valid') {
|
||||
return (
|
||||
@@ -162,12 +162,10 @@ function PrimaryInspectorPanel({
|
||||
return onInitializeProperties ? <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} /> : null
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
collapsed,
|
||||
onToggle,
|
||||
function InspectorBody({
|
||||
entry,
|
||||
content,
|
||||
entries,
|
||||
content,
|
||||
gitHistory,
|
||||
vaultPath,
|
||||
onNavigate,
|
||||
@@ -179,7 +177,7 @@ export function Inspector({
|
||||
onCreateAndOpenNote,
|
||||
onInitializeProperties,
|
||||
onToggleRawEditor,
|
||||
}: InspectorProps) {
|
||||
}: Omit<InspectorProps, 'collapsed' | 'onToggle'>) {
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
@@ -198,40 +196,46 @@ export function Inspector({
|
||||
onCreateMissingType,
|
||||
})
|
||||
|
||||
if (!entry) {
|
||||
return <EmptyInspector />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrimaryInspectorPanel
|
||||
entry={entry}
|
||||
frontmatterState={frontmatterState}
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
referencedBy={referencedBy}
|
||||
onNavigate={onNavigate}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
|
||||
/>
|
||||
{backlinks.length > 0 && <Separator />}
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
<Separator />
|
||||
<NoteInfoPanel entry={entry} content={content} />
|
||||
{gitHistory.length > 0 && <Separator />}
|
||||
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Inspector({ collapsed, onToggle, ...bodyProps }: InspectorProps) {
|
||||
return (
|
||||
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
|
||||
<InspectorHeader collapsed={collapsed} onToggle={onToggle} />
|
||||
{!collapsed && (
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
|
||||
{entry ? (
|
||||
<>
|
||||
<PrimaryInspectorPanel
|
||||
entry={entry}
|
||||
frontmatterState={frontmatterState}
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
referencedBy={referencedBy}
|
||||
onNavigate={onNavigate}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
|
||||
/>
|
||||
{backlinks.length > 0 && <Separator />}
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
<Separator />
|
||||
<NoteInfoPanel entry={entry} content={content} />
|
||||
{gitHistory.length > 0 && <Separator />}
|
||||
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
|
||||
</>
|
||||
) : (
|
||||
<EmptyInspector />
|
||||
)}
|
||||
<InspectorBody {...bodyProps} />
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -118,7 +118,7 @@ function MissingTypeWarning({
|
||||
onCreateMissingType,
|
||||
}: {
|
||||
missingTypeName: string
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const canCreateMissingType = Boolean(onCreateMissingType)
|
||||
@@ -148,9 +148,7 @@ function MissingTypeWarning({
|
||||
<CreateTypeDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onCreate={async (typeName) => {
|
||||
await onCreateMissingType?.(typeName)
|
||||
}}
|
||||
onCreate={(typeName) => onCreateMissingType?.(typeName)}
|
||||
initialName={missingTypeName}
|
||||
/>
|
||||
)}
|
||||
@@ -165,7 +163,7 @@ function TypeRowValue({
|
||||
}: {
|
||||
children: ReactNode
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-start gap-1">
|
||||
@@ -191,7 +189,7 @@ function ReadOnlyType({
|
||||
customColorKey?: string | null
|
||||
onNavigate?: (target: string) => void
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
@@ -230,7 +228,7 @@ interface TypeSelectorProps {
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
}
|
||||
|
||||
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
228
src/components/folder-tree/FolderItemRow.tsx
Normal file
228
src/components/folder-tree/FolderItemRow.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
||||
import {
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
PencilSimple,
|
||||
Trash,
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FolderNode } from '../../types'
|
||||
import { useFolderRowInteractions } from './useFolderRowInteractions'
|
||||
|
||||
interface FolderItemRowProps {
|
||||
contentInset: number
|
||||
depthIndent: number
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
node: FolderNode
|
||||
onDeleteFolder?: (folderPath: string) => void
|
||||
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
|
||||
onSelect: () => void
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
onToggle: (path: string) => void
|
||||
}
|
||||
|
||||
export function FolderItemRow({
|
||||
contentInset,
|
||||
depthIndent,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
node,
|
||||
onDeleteFolder,
|
||||
onOpenMenu,
|
||||
onSelect,
|
||||
onStartRenameFolder,
|
||||
onToggle,
|
||||
}: FolderItemRowProps) {
|
||||
const hasChildren = node.children.length > 0
|
||||
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
|
||||
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
|
||||
const { handleRenameDoubleClick, handleSelectClick } = useFolderRowInteractions({
|
||||
hasChildren,
|
||||
onRenameFolder: onStartRenameFolder ? () => onStartRenameFolder(node.path) : undefined,
|
||||
onSelect,
|
||||
onToggle: () => onToggle(node.path),
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center gap-1 rounded transition-colors',
|
||||
isSelected
|
||||
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
|
||||
: 'text-foreground hover:bg-accent',
|
||||
)}
|
||||
style={{ paddingLeft: depthIndent, borderRadius: 4 }}
|
||||
onContextMenu={(event) => {
|
||||
onSelect()
|
||||
onOpenMenu(node, event)
|
||||
}}
|
||||
>
|
||||
<FolderToggleButton
|
||||
expandLabel={expandLabel}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => onToggle(node.path)}
|
||||
/>
|
||||
<FolderSelectButton
|
||||
contentInset={contentInset}
|
||||
hasActions={hasActions}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
isSelected={isSelected}
|
||||
node={node}
|
||||
onClick={handleSelectClick}
|
||||
onDoubleClick={handleRenameDoubleClick}
|
||||
/>
|
||||
{hasActions && (
|
||||
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
|
||||
{onStartRenameFolder && (
|
||||
<FolderActionButton
|
||||
ariaLabel={`Rename ${node.name}`}
|
||||
testId={`rename-folder-btn:${node.path}`}
|
||||
title="Rename folder"
|
||||
onClick={() => {
|
||||
onSelect()
|
||||
onStartRenameFolder(node.path)
|
||||
}}
|
||||
>
|
||||
<PencilSimple size={12} />
|
||||
</FolderActionButton>
|
||||
)}
|
||||
{onDeleteFolder && (
|
||||
<FolderActionButton
|
||||
ariaLabel={`Delete ${node.name}`}
|
||||
testId={`delete-folder-btn:${node.path}`}
|
||||
title="Delete folder"
|
||||
destructive
|
||||
onClick={() => {
|
||||
onSelect()
|
||||
onDeleteFolder(node.path)
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
</FolderActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderToggleButton({
|
||||
expandLabel,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
expandLabel: string
|
||||
hasChildren: boolean
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
if (!hasChildren) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="h-6 w-4 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
aria-label={expandLabel}
|
||||
>
|
||||
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderActionButton({
|
||||
ariaLabel,
|
||||
children,
|
||||
destructive = false,
|
||||
onClick,
|
||||
testId,
|
||||
title,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
children: ReactNode
|
||||
destructive?: boolean
|
||||
onClick: () => void
|
||||
testId: string
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
className={cn(
|
||||
'h-5 w-5 rounded p-0 text-muted-foreground',
|
||||
destructive ? 'hover:text-destructive' : 'hover:text-foreground',
|
||||
)}
|
||||
data-testid={testId}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderSelectButton({
|
||||
contentInset,
|
||||
hasActions,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
node,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
contentInset: number
|
||||
hasActions: boolean
|
||||
hasChildren: boolean
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
node: FolderNode
|
||||
onClick: (clickDetail: number) => void
|
||||
onDoubleClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-auto flex-1 justify-start gap-2 rounded text-left text-[13px] font-medium hover:bg-transparent',
|
||||
isSelected ? 'text-primary hover:text-primary' : 'text-foreground hover:text-foreground',
|
||||
)}
|
||||
style={{
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
paddingLeft: hasChildren ? 0 : contentInset,
|
||||
paddingRight: hasActions ? 48 : 16,
|
||||
}}
|
||||
title={node.path}
|
||||
onClick={(event) => onClick(event.detail)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
data-testid={`folder-row:${node.path}`}
|
||||
>
|
||||
{isSelected || isExpanded ? (
|
||||
<FolderOpen size={17} weight="fill" className="size-[17px] shrink-0" />
|
||||
) : (
|
||||
<Folder size={17} className="size-[17px] shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ interface FolderNameInputProps {
|
||||
ariaLabel: string
|
||||
initialValue: string
|
||||
placeholder: string
|
||||
leftInset?: number
|
||||
selectTextOnFocus?: boolean
|
||||
submitOnBlur?: boolean
|
||||
testId: string
|
||||
@@ -17,6 +18,7 @@ export function FolderNameInput({
|
||||
ariaLabel,
|
||||
initialValue,
|
||||
placeholder,
|
||||
leftInset = 16,
|
||||
selectTextOnFocus = false,
|
||||
submitOnBlur = false,
|
||||
testId,
|
||||
@@ -45,12 +47,12 @@ export function FolderNameInput({
|
||||
}, [onSubmit, value])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2" style={{ padding: '4px 8px' }}>
|
||||
<Folder size={18} className="shrink-0 text-muted-foreground" />
|
||||
<div className="flex items-center gap-2 rounded" style={{ paddingTop: 6, paddingBottom: 6, paddingRight: 16, paddingLeft: leftInset, borderRadius: 4 }}>
|
||||
<Folder size={17} className="size-[17px] shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
aria-label={ariaLabel}
|
||||
className="h-7 flex-1 rounded-sm px-2 text-[13px]"
|
||||
className="h-auto min-h-0 flex-1 rounded-sm px-2 py-[3px] text-[13px] font-medium"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={submitOnBlur ? () => { void handleSubmit() } : undefined}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { memo, useCallback, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import {
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FolderNode, SidebarSelection } from '../../types'
|
||||
import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
|
||||
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
|
||||
import { FolderNameInput } from './FolderNameInput'
|
||||
import { FolderItemRow } from './FolderItemRow'
|
||||
|
||||
interface FolderTreeRowProps {
|
||||
depth: number
|
||||
@@ -26,22 +21,25 @@ interface FolderTreeRowProps {
|
||||
}
|
||||
|
||||
function FolderRenameRow({
|
||||
indentation,
|
||||
contentInset,
|
||||
depthIndent,
|
||||
node,
|
||||
onCancelRenameFolder,
|
||||
onRenameFolder,
|
||||
}: {
|
||||
indentation: number
|
||||
contentInset: number
|
||||
depthIndent: number
|
||||
node: FolderNode
|
||||
onCancelRenameFolder: () => void
|
||||
onRenameFolder: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{ paddingLeft: indentation }}>
|
||||
<div style={{ paddingLeft: depthIndent }}>
|
||||
<FolderNameInput
|
||||
ariaLabel="Folder name"
|
||||
initialValue={node.name}
|
||||
placeholder="Folder name"
|
||||
leftInset={contentInset}
|
||||
selectTextOnFocus={true}
|
||||
testId="rename-folder-input"
|
||||
onCancel={onCancelRenameFolder}
|
||||
@@ -51,131 +49,6 @@ function FolderRenameRow({
|
||||
)
|
||||
}
|
||||
|
||||
function FolderItemRow({
|
||||
indentation,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
node,
|
||||
onOpenMenu,
|
||||
onSelect,
|
||||
onStartRenameFolder,
|
||||
onToggle,
|
||||
}: {
|
||||
indentation: number
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
node: FolderNode
|
||||
onOpenMenu: FolderTreeRowProps['onOpenMenu']
|
||||
onSelect: () => void
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
onToggle: (path: string) => void
|
||||
}) {
|
||||
const hasChildren = node.children.length > 0
|
||||
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-[5px] transition-colors',
|
||||
isSelected
|
||||
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
|
||||
: 'text-foreground hover:bg-accent',
|
||||
)}
|
||||
style={{ paddingLeft: indentation }}
|
||||
onContextMenu={(event) => {
|
||||
onSelect()
|
||||
onOpenMenu(node, event)
|
||||
}}
|
||||
>
|
||||
<FolderToggleButton
|
||||
expandLabel={expandLabel}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => onToggle(node.path)}
|
||||
/>
|
||||
<FolderSelectButton
|
||||
isExpanded={isExpanded}
|
||||
isSelected={isSelected}
|
||||
node={node}
|
||||
onSelect={onSelect}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderToggleButton({
|
||||
expandLabel,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
expandLabel: string
|
||||
hasChildren: boolean
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="h-6 w-4 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
disabled={!hasChildren}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (hasChildren) onToggle()
|
||||
}}
|
||||
aria-label={hasChildren ? expandLabel : undefined}
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />
|
||||
) : (
|
||||
<span className="block h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderSelectButton({
|
||||
isExpanded,
|
||||
isSelected,
|
||||
node,
|
||||
onSelect,
|
||||
onStartRenameFolder,
|
||||
}: {
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
node: FolderNode
|
||||
onSelect: () => void
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-7 flex-1 justify-start gap-2 rounded-[5px] px-2 py-0 text-left text-[13px]',
|
||||
isSelected ? 'font-medium text-primary hover:text-primary' : 'hover:text-foreground',
|
||||
)}
|
||||
title={node.path}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={() => {
|
||||
onSelect()
|
||||
onStartRenameFolder?.(node.path)
|
||||
}}
|
||||
data-testid={`folder-row:${node.path}`}
|
||||
>
|
||||
{isSelected || isExpanded ? (
|
||||
<FolderOpen size={18} weight="fill" className="shrink-0" />
|
||||
) : (
|
||||
<Folder size={18} className="shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderChildren({
|
||||
depth,
|
||||
expanded,
|
||||
@@ -238,31 +111,46 @@ export const FolderTreeRow = memo(function FolderTreeRow({
|
||||
const isExpanded = expanded[node.path] ?? false
|
||||
const isRenaming = renamingFolderPath === node.path
|
||||
const isSelected = selection.kind === 'folder' && selection.path === node.path
|
||||
const indentation = 8 + depth * 16
|
||||
const depthIndent = depth * 16
|
||||
const contentInset = 16
|
||||
const noteRetargeting = useNoteRetargetingContext()
|
||||
const selectFolder = useCallback(() => {
|
||||
onSelect({ kind: 'folder', path: node.path })
|
||||
}, [node.path, onSelect])
|
||||
const row = (
|
||||
<FolderItemRow
|
||||
contentInset={contentInset}
|
||||
depthIndent={depthIndent}
|
||||
isExpanded={isExpanded}
|
||||
isSelected={isSelected}
|
||||
node={node}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenMenu={onOpenMenu}
|
||||
onSelect={selectFolder}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRenaming && onRenameFolder && onCancelRenameFolder ? (
|
||||
<FolderRenameRow
|
||||
indentation={indentation}
|
||||
contentInset={contentInset}
|
||||
depthIndent={depthIndent}
|
||||
node={node}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
/>
|
||||
) : (
|
||||
<FolderItemRow
|
||||
indentation={indentation}
|
||||
isExpanded={isExpanded}
|
||||
isSelected={isSelected}
|
||||
node={node}
|
||||
onOpenMenu={onOpenMenu}
|
||||
onSelect={selectFolder}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
noteRetargeting ? (
|
||||
<NoteDropTarget
|
||||
canAcceptNotePath={(notePath) => noteRetargeting.canDropNoteOnFolder(notePath, node.path)}
|
||||
onDropNote={(notePath) => noteRetargeting.dropNoteOnFolder(notePath, node.path)}
|
||||
>
|
||||
{row}
|
||||
</NoteDropTarget>
|
||||
) : row
|
||||
)}
|
||||
<FolderChildren
|
||||
depth={depth}
|
||||
|
||||
@@ -3,6 +3,10 @@ export function expandedTreePaths(path: string): string[] {
|
||||
return segments.map((_, index) => segments.slice(0, index + 1).join('/'))
|
||||
}
|
||||
|
||||
export function ancestorTreePaths(path: string): string[] {
|
||||
return expandedTreePaths(path).slice(0, -1)
|
||||
}
|
||||
|
||||
export function mergeExpandedPaths(
|
||||
current: Record<string, boolean>,
|
||||
paths: string[],
|
||||
|
||||
56
src/components/folder-tree/useFolderRowInteractions.ts
Normal file
56
src/components/folder-tree/useFolderRowInteractions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export const FOLDER_ROW_SINGLE_CLICK_DELAY_MS = 180
|
||||
|
||||
interface UseFolderRowInteractionsInput {
|
||||
hasChildren: boolean
|
||||
onRenameFolder?: () => void
|
||||
onSelect: () => void
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
export function useFolderRowInteractions({
|
||||
hasChildren,
|
||||
onRenameFolder,
|
||||
onSelect,
|
||||
onToggle,
|
||||
}: UseFolderRowInteractionsInput) {
|
||||
const pendingToggleRef = useRef<number | null>(null)
|
||||
|
||||
const clearPendingToggle = useCallback(() => {
|
||||
if (pendingToggleRef.current === null) return
|
||||
window.clearTimeout(pendingToggleRef.current)
|
||||
pendingToggleRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => clearPendingToggle, [clearPendingToggle])
|
||||
|
||||
const handleSelectClick = useCallback((clickDetail: number) => {
|
||||
onSelect()
|
||||
if (!hasChildren) return
|
||||
|
||||
if (clickDetail === 0) {
|
||||
clearPendingToggle()
|
||||
onToggle()
|
||||
return
|
||||
}
|
||||
|
||||
if (clickDetail !== 1) return
|
||||
|
||||
clearPendingToggle()
|
||||
pendingToggleRef.current = window.setTimeout(() => {
|
||||
pendingToggleRef.current = null
|
||||
onToggle()
|
||||
}, FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
|
||||
}, [clearPendingToggle, hasChildren, onSelect, onToggle])
|
||||
|
||||
const handleRenameDoubleClick = useCallback(() => {
|
||||
clearPendingToggle()
|
||||
onRenameFolder?.()
|
||||
}, [clearPendingToggle, onRenameFolder])
|
||||
|
||||
return {
|
||||
handleRenameDoubleClick,
|
||||
handleSelectClick,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { SidebarSelection } from '../../types'
|
||||
import { expandedTreePaths, mergeExpandedPaths } from './folderTreeUtils'
|
||||
import { ancestorTreePaths, expandedTreePaths, mergeExpandedPaths } from './folderTreeUtils'
|
||||
|
||||
interface UseFolderTreeDisclosureInput {
|
||||
collapsed?: boolean
|
||||
@@ -9,21 +9,11 @@ interface UseFolderTreeDisclosureInput {
|
||||
selection: SidebarSelection
|
||||
}
|
||||
|
||||
export function useFolderTreeDisclosure({
|
||||
collapsed: externalCollapsed,
|
||||
onToggle,
|
||||
renamingFolderPath,
|
||||
selection,
|
||||
}: UseFolderTreeDisclosureInput) {
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false)
|
||||
function useExpandedFolders(selection: SidebarSelection, renamingFolderPath?: string | null) {
|
||||
const [manualExpanded, setManualExpanded] = useState<Record<string, boolean>>({})
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const baseSectionCollapsed = externalCollapsed ?? internalCollapsed
|
||||
const sectionCollapsed = !isCreating && !renamingFolderPath && baseSectionCollapsed
|
||||
const requiredExpandedPaths = useMemo(() => {
|
||||
const nextPaths: string[] = []
|
||||
if (selection.kind === 'folder') nextPaths.push(...expandedTreePaths(selection.path))
|
||||
if (selection.kind === 'folder') nextPaths.push(...ancestorTreePaths(selection.path))
|
||||
if (renamingFolderPath) nextPaths.push(...expandedTreePaths(renamingFolderPath))
|
||||
return [...new Set(nextPaths)]
|
||||
}, [renamingFolderPath, selection])
|
||||
@@ -33,6 +23,27 @@ export function useFolderTreeDisclosure({
|
||||
[manualExpanded, requiredExpandedPaths],
|
||||
)
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setManualExpanded((current) => ({ ...current, [path]: !current[path] }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
expanded,
|
||||
toggleFolder,
|
||||
}
|
||||
}
|
||||
|
||||
function useFolderSectionState(
|
||||
externalCollapsed: boolean | undefined,
|
||||
onToggle: (() => void) | undefined,
|
||||
renamingFolderPath?: string | null,
|
||||
) {
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const baseSectionCollapsed = externalCollapsed ?? internalCollapsed
|
||||
const sectionCollapsed = !isCreating && !renamingFolderPath && baseSectionCollapsed
|
||||
|
||||
const handleToggleSection = useCallback(() => {
|
||||
if (onToggle) {
|
||||
onToggle()
|
||||
@@ -50,9 +61,30 @@ export function useFolderTreeDisclosure({
|
||||
}, [baseSectionCollapsed, onToggle])
|
||||
|
||||
const closeCreateForm = useCallback(() => setIsCreating(false), [])
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setManualExpanded((current) => ({ ...current, [path]: !current[path] }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
handleToggleSection,
|
||||
isCreating,
|
||||
openCreateForm,
|
||||
sectionCollapsed,
|
||||
closeCreateForm,
|
||||
}
|
||||
}
|
||||
|
||||
export function useFolderTreeDisclosure({
|
||||
collapsed: externalCollapsed,
|
||||
onToggle,
|
||||
renamingFolderPath,
|
||||
selection,
|
||||
}: UseFolderTreeDisclosureInput) {
|
||||
const { expanded, toggleFolder } = useExpandedFolders(selection, renamingFolderPath)
|
||||
const {
|
||||
closeCreateForm,
|
||||
handleToggleSection,
|
||||
isCreating,
|
||||
openCreateForm,
|
||||
sectionCollapsed,
|
||||
} = useFolderSectionState(externalCollapsed, onToggle, renamingFolderPath)
|
||||
|
||||
return {
|
||||
closeCreateForm,
|
||||
|
||||
@@ -8,7 +8,7 @@ interface InspectorPropertyActionsConfig {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>
|
||||
}
|
||||
|
||||
function bindEntryAction<TArgs extends unknown[], TResult>(
|
||||
@@ -21,7 +21,7 @@ function bindEntryAction<TArgs extends unknown[], TResult>(
|
||||
|
||||
function bindMissingTypeAction(
|
||||
entry: VaultEntry | null,
|
||||
action: ((path: string, missingType: string, nextTypeName: string) => Promise<void>) | undefined,
|
||||
action: ((path: string, missingType: string, nextTypeName: string) => Promise<boolean | void>) | undefined,
|
||||
) {
|
||||
const missingType = entry?.isA
|
||||
if (!entry || !missingType || !action) return undefined
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import type { NoteListFilter } from '../../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
|
||||
import { NoteItem } from '../NoteItem'
|
||||
import { DraggableNoteItem } from '../note-retargeting/DraggableNoteItem'
|
||||
import { prefetchNoteContent } from '../../hooks/useTabManagement'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
|
||||
@@ -349,21 +350,39 @@ function useRenderItem({
|
||||
const contextMenuHandler = isChangesView && onDiscardFile ? noteContextMenu : undefined
|
||||
|
||||
return useCallback((entry: VaultEntry, options?: { forceSelected?: boolean }) => (
|
||||
<NoteItem
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
isSelected={options?.forceSelected || selectedNotePath === entry.path}
|
||||
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
|
||||
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
|
||||
noteStatus={resolvedGetNoteStatus(entry.path)}
|
||||
changeStatus={getChangeStatus(entry.path)}
|
||||
typeEntryMap={typeEntryMap}
|
||||
allEntries={entries}
|
||||
displayPropsOverride={displayPropsOverride}
|
||||
onClickNote={handleClickNote}
|
||||
onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent}
|
||||
onContextMenu={contextMenuHandler}
|
||||
/>
|
||||
isDeletedNoteEntry(entry) ? (
|
||||
<NoteItem
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
isSelected={options?.forceSelected || selectedNotePath === entry.path}
|
||||
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
|
||||
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
|
||||
noteStatus={resolvedGetNoteStatus(entry.path)}
|
||||
changeStatus={getChangeStatus(entry.path)}
|
||||
typeEntryMap={typeEntryMap}
|
||||
allEntries={entries}
|
||||
displayPropsOverride={displayPropsOverride}
|
||||
onClickNote={handleClickNote}
|
||||
onContextMenu={contextMenuHandler}
|
||||
/>
|
||||
) : (
|
||||
<DraggableNoteItem key={entry.path} notePath={entry.path}>
|
||||
<NoteItem
|
||||
entry={entry}
|
||||
isSelected={options?.forceSelected || selectedNotePath === entry.path}
|
||||
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
|
||||
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
|
||||
noteStatus={resolvedGetNoteStatus(entry.path)}
|
||||
changeStatus={getChangeStatus(entry.path)}
|
||||
typeEntryMap={typeEntryMap}
|
||||
allEntries={entries}
|
||||
displayPropsOverride={displayPropsOverride}
|
||||
onClickNote={handleClickNote}
|
||||
onPrefetch={prefetchNoteContent}
|
||||
onContextMenu={contextMenuHandler}
|
||||
/>
|
||||
</DraggableNoteItem>
|
||||
)
|
||||
), [
|
||||
contextMenuHandler,
|
||||
displayPropsOverride,
|
||||
@@ -581,18 +600,22 @@ export function useNoteListModel({
|
||||
interaction.noteListKeyboard.focusList()
|
||||
})
|
||||
}
|
||||
const {
|
||||
isPanelActive: isNoteListSearchActive,
|
||||
toggleSearchShortcut,
|
||||
} = interaction.noteListKeyboard
|
||||
|
||||
useEffect(() => {
|
||||
dispatchNoteListSearchAvailability(interaction.noteListKeyboard.isPanelActive)
|
||||
dispatchNoteListSearchAvailability(isNoteListSearchActive)
|
||||
return () => dispatchNoteListSearchAvailability(false)
|
||||
}, [interaction.noteListKeyboard.isPanelActive])
|
||||
}, [isNoteListSearchActive])
|
||||
|
||||
useEffect(() => {
|
||||
return addNoteListSearchToggleListener(() => {
|
||||
if (!interaction.noteListKeyboard.isPanelActive) return
|
||||
interaction.noteListKeyboard.toggleSearchShortcut()
|
||||
if (!isNoteListSearchActive) return
|
||||
toggleSearchShortcut()
|
||||
})
|
||||
}, [interaction.noteListKeyboard.isPanelActive, interaction.noteListKeyboard.toggleSearchShortcut])
|
||||
}, [isNoteListSearchActive, toggleSearchShortcut])
|
||||
|
||||
return buildNoteListLayoutModel({
|
||||
selection,
|
||||
|
||||
29
src/components/note-retargeting/DraggableNoteItem.tsx
Normal file
29
src/components/note-retargeting/DraggableNoteItem.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { DragEvent, ReactNode } from 'react'
|
||||
import { clearDraggedNotePath, writeDraggedNotePath } from './noteDragData'
|
||||
|
||||
interface DraggableNoteItemProps {
|
||||
notePath: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function DraggableNoteItem({ notePath, children }: DraggableNoteItemProps) {
|
||||
const handleDragStart = (event: DragEvent<HTMLDivElement>) => {
|
||||
writeDraggedNotePath(event, notePath)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
clearDraggedNotePath()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
data-testid={`draggable-note:${notePath}`}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
src/components/note-retargeting/NoteDropTarget.test.tsx
Normal file
99
src/components/note-retargeting/NoteDropTarget.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { DraggableNoteItem } from './DraggableNoteItem'
|
||||
import { NoteDropTarget } from './NoteDropTarget'
|
||||
import { clearDraggedNotePath } from './noteDragData'
|
||||
|
||||
const NOTE_DRAG_MIME = 'application/x-laputa-note-path'
|
||||
const NOTE_PATH = '/vault/inbox/tolaria-social-media.md'
|
||||
|
||||
function createMockDataTransfer(options?: {
|
||||
readable?: boolean
|
||||
seedData?: Record<string, string>
|
||||
}): DataTransfer {
|
||||
const data = new Map(Object.entries(options?.seedData ?? {}))
|
||||
const types = Array.from(data.keys())
|
||||
|
||||
return {
|
||||
effectAllowed: 'move',
|
||||
dropEffect: 'none',
|
||||
setData(type: string, value: string) {
|
||||
data.set(type, value)
|
||||
if (!types.includes(type)) types.push(type)
|
||||
},
|
||||
getData(type: string) {
|
||||
if (options?.readable === false) return ''
|
||||
return data.get(type) ?? ''
|
||||
},
|
||||
clearData(type?: string) {
|
||||
if (type) {
|
||||
data.delete(type)
|
||||
const typeIndex = types.indexOf(type)
|
||||
if (typeIndex >= 0) types.splice(typeIndex, 1)
|
||||
return
|
||||
}
|
||||
data.clear()
|
||||
types.splice(0, types.length)
|
||||
},
|
||||
get types() {
|
||||
return types
|
||||
},
|
||||
} as DataTransfer
|
||||
}
|
||||
|
||||
function serializedDragData(dataTransfer: DataTransfer) {
|
||||
return {
|
||||
[NOTE_DRAG_MIME]: dataTransfer.getData(NOTE_DRAG_MIME),
|
||||
'text/plain': dataTransfer.getData('text/plain'),
|
||||
}
|
||||
}
|
||||
|
||||
describe('NoteDropTarget', () => {
|
||||
afterEach(() => {
|
||||
clearDraggedNotePath()
|
||||
})
|
||||
|
||||
it('keeps a note drag valid when dragover cannot read DataTransfer payloads', async () => {
|
||||
const onDropNote = vi.fn()
|
||||
const canAcceptNotePath = vi.fn((notePath: string) => notePath === NOTE_PATH)
|
||||
|
||||
render(
|
||||
<>
|
||||
<DraggableNoteItem notePath={NOTE_PATH}>
|
||||
<span>Drag Tolaria Social media</span>
|
||||
</DraggableNoteItem>
|
||||
<NoteDropTarget canAcceptNotePath={canAcceptNotePath} onDropNote={onDropNote}>
|
||||
<span>CircleCI Series</span>
|
||||
</NoteDropTarget>
|
||||
</>,
|
||||
)
|
||||
|
||||
const dragStartData = createMockDataTransfer()
|
||||
fireEvent.dragStart(screen.getByTestId(`draggable-note:${NOTE_PATH}`), { dataTransfer: dragStartData })
|
||||
|
||||
const target = screen.getByText('CircleCI Series').parentElement
|
||||
expect(target).not.toBeNull()
|
||||
|
||||
const lockedDragData = createMockDataTransfer({
|
||||
readable: false,
|
||||
seedData: serializedDragData(dragStartData),
|
||||
})
|
||||
|
||||
fireEvent.dragEnter(target as HTMLElement, { dataTransfer: lockedDragData })
|
||||
fireEvent.dragOver(target as HTMLElement, { dataTransfer: lockedDragData })
|
||||
|
||||
expect(canAcceptNotePath).toHaveBeenCalledWith(NOTE_PATH)
|
||||
expect(target).toHaveAttribute('data-drop-state', 'valid')
|
||||
|
||||
const dropData = createMockDataTransfer({
|
||||
seedData: serializedDragData(dragStartData),
|
||||
})
|
||||
|
||||
fireEvent.drop(target as HTMLElement, { dataTransfer: dropData })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDropNote).toHaveBeenCalledWith(NOTE_PATH)
|
||||
})
|
||||
expect(target).not.toHaveAttribute('data-drop-state')
|
||||
})
|
||||
})
|
||||
92
src/components/note-retargeting/NoteDropTarget.tsx
Normal file
92
src/components/note-retargeting/NoteDropTarget.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useRef, useState, type DragEvent, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { clearDraggedNotePath, readDraggedNotePath } from './noteDragData'
|
||||
|
||||
type DropState = 'idle' | 'valid' | 'invalid'
|
||||
|
||||
interface NoteDropTargetProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
validClassName?: string
|
||||
invalidClassName?: string
|
||||
canAcceptNotePath: (notePath: string) => boolean
|
||||
onDropNote: (notePath: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function NoteDropTarget({
|
||||
children,
|
||||
className,
|
||||
validClassName = 'ring-1 ring-primary/40 bg-primary/10',
|
||||
invalidClassName = 'ring-1 ring-destructive/35 bg-destructive/5',
|
||||
canAcceptNotePath,
|
||||
onDropNote,
|
||||
}: NoteDropTargetProps) {
|
||||
const [dropState, setDropState] = useState<DropState>('idle')
|
||||
const dragDepthRef = useRef(0)
|
||||
|
||||
const updateDropState = (event: DragEvent<HTMLDivElement>): string | null => {
|
||||
const notePath = readDraggedNotePath(event.dataTransfer)
|
||||
if (!notePath) return null
|
||||
|
||||
const isValid = canAcceptNotePath(notePath)
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = isValid ? 'move' : 'none'
|
||||
setDropState(isValid ? 'valid' : 'invalid')
|
||||
return notePath
|
||||
}
|
||||
|
||||
const resetDropState = () => {
|
||||
dragDepthRef.current = 0
|
||||
setDropState('idle')
|
||||
}
|
||||
|
||||
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
|
||||
const notePath = readDraggedNotePath(event.dataTransfer)
|
||||
if (!notePath) return
|
||||
dragDepthRef.current += 1
|
||||
updateDropState(event)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
updateDropState(event)
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||
const notePath = readDraggedNotePath(event.dataTransfer)
|
||||
if (!notePath) return
|
||||
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0 && !event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setDropState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
||||
const notePath = updateDropState(event)
|
||||
if (!notePath) return
|
||||
|
||||
const isValid = canAcceptNotePath(notePath)
|
||||
resetDropState()
|
||||
clearDraggedNotePath()
|
||||
if (!isValid) return
|
||||
void onDropNote(notePath)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[5px] transition-colors',
|
||||
className,
|
||||
dropState === 'valid' && validClassName,
|
||||
dropState === 'invalid' && invalidClassName,
|
||||
)}
|
||||
data-drop-state={dropState === 'idle' ? undefined : dropState}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/note-retargeting/NoteRetargetingContext.tsx
Normal file
19
src/components/note-retargeting/NoteRetargetingContext.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
noteRetargetingContext,
|
||||
type NoteRetargetingContextValue,
|
||||
} from './noteRetargetingContext'
|
||||
|
||||
export function NoteRetargetingProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode
|
||||
value: NoteRetargetingContextValue | null
|
||||
}) {
|
||||
return (
|
||||
<noteRetargetingContext.Provider value={value}>
|
||||
{children}
|
||||
</noteRetargetingContext.Provider>
|
||||
)
|
||||
}
|
||||
62
src/components/note-retargeting/NoteRetargetingDialogs.tsx
Normal file
62
src/components/note-retargeting/NoteRetargetingDialogs.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { VaultEntry } from '../../types'
|
||||
import type { RetargetOption } from './RetargetNoteDialog'
|
||||
import { RetargetNoteDialog } from './RetargetNoteDialog'
|
||||
|
||||
interface NoteRetargetingDialogsProps {
|
||||
dialogState: { kind: 'type' | 'folder'; notePath: string } | null
|
||||
dialogEntry: VaultEntry | null
|
||||
typeOptions: RetargetOption[]
|
||||
folderOptions: RetargetOption[]
|
||||
onClose: () => void
|
||||
onSelectType: (type: string) => boolean | Promise<boolean>
|
||||
onSelectFolder: (folderPath: string) => boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
function typeDialogDescription(entry: VaultEntry | null): string {
|
||||
return entry
|
||||
? `Set a new type for "${entry.title}".`
|
||||
: 'Select a type for the active note.'
|
||||
}
|
||||
|
||||
function folderDialogDescription(entry: VaultEntry | null): string {
|
||||
return entry
|
||||
? `Choose a destination folder for "${entry.title}".`
|
||||
: 'Select a destination folder for the active note.'
|
||||
}
|
||||
|
||||
export function NoteRetargetingDialogs({
|
||||
dialogState,
|
||||
dialogEntry,
|
||||
typeOptions,
|
||||
folderOptions,
|
||||
onClose,
|
||||
onSelectType,
|
||||
onSelectFolder,
|
||||
}: NoteRetargetingDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<RetargetNoteDialog
|
||||
open={dialogState?.kind === 'type'}
|
||||
title="Change Note Type"
|
||||
description={typeDialogDescription(dialogEntry)}
|
||||
searchPlaceholder="Search types"
|
||||
emptyMessage="No other note types available."
|
||||
options={typeOptions}
|
||||
onClose={onClose}
|
||||
onSelect={onSelectType}
|
||||
testIdPrefix="retarget-note-type"
|
||||
/>
|
||||
<RetargetNoteDialog
|
||||
open={dialogState?.kind === 'folder'}
|
||||
title="Move Note to Folder"
|
||||
description={folderDialogDescription(dialogEntry)}
|
||||
searchPlaceholder="Search folders"
|
||||
emptyMessage="No other folders available."
|
||||
options={folderOptions}
|
||||
onClose={onClose}
|
||||
onSelect={onSelectFolder}
|
||||
testIdPrefix="retarget-note-folder"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
174
src/components/note-retargeting/RetargetNoteDialog.tsx
Normal file
174
src/components/note-retargeting/RetargetNoteDialog.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Check, StackSimple } from '@phosphor-icons/react'
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface RetargetOption {
|
||||
id: string
|
||||
label: string
|
||||
detail?: string
|
||||
current?: boolean
|
||||
}
|
||||
|
||||
interface RetargetNoteDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
searchPlaceholder: string
|
||||
emptyMessage: string
|
||||
options: RetargetOption[]
|
||||
onClose: () => void
|
||||
onSelect: (id: string) => boolean | Promise<boolean>
|
||||
testIdPrefix: string
|
||||
}
|
||||
|
||||
function matchesQuery(option: RetargetOption, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return true
|
||||
return option.label.toLowerCase().includes(normalized)
|
||||
|| option.detail?.toLowerCase().includes(normalized)
|
||||
|| false
|
||||
}
|
||||
|
||||
function initialHighlightIndex(options: RetargetOption[]): number {
|
||||
if (options.length === 0) return -1
|
||||
const currentIndex = options.findIndex((option) => option.current)
|
||||
return currentIndex >= 0 ? currentIndex : 0
|
||||
}
|
||||
|
||||
function nextHighlightIndex(current: number, total: number, direction: 'next' | 'previous'): number {
|
||||
if (total === 0) return -1
|
||||
if (current < 0) return direction === 'next' ? 0 : total - 1
|
||||
return direction === 'next'
|
||||
? (current + 1) % total
|
||||
: (current - 1 + total) % total
|
||||
}
|
||||
|
||||
export function RetargetNoteDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
options,
|
||||
onClose,
|
||||
onSelect,
|
||||
testIdPrefix,
|
||||
}: RetargetNoteDialogProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() => options.filter((option) => matchesQuery(option, query)),
|
||||
[options, query],
|
||||
)
|
||||
|
||||
const effectiveHighlightedIndex = highlightedIndex >= 0 && highlightedIndex < filteredOptions.length
|
||||
? highlightedIndex
|
||||
: initialHighlightIndex(filteredOptions)
|
||||
|
||||
const resetDialogState = () => {
|
||||
setQuery('')
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
|
||||
const submitSelection = async (optionId: string) => {
|
||||
const shouldClose = await onSelect(optionId)
|
||||
if (!shouldClose) return
|
||||
resetDialogState()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setHighlightedIndex((current) => nextHighlightIndex(current, filteredOptions.length, 'next'))
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setHighlightedIndex((current) => nextHighlightIndex(current, filteredOptions.length, 'previous'))
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && effectiveHighlightedIndex >= 0) {
|
||||
event.preventDefault()
|
||||
void submitSelection(filteredOptions[effectiveHighlightedIndex].id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) return
|
||||
resetDialogState()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-xl gap-3" showCloseButton={true}>
|
||||
<DialogHeader className="gap-1">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
data-testid={`${testIdPrefix}-search`}
|
||||
/>
|
||||
<ScrollArea className="max-h-80 rounded-md border">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground" data-testid={`${testIdPrefix}-empty`}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1" data-testid={`${testIdPrefix}-options`}>
|
||||
{filteredOptions.map((option, index) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-auto w-full justify-start rounded-md px-3 py-2 text-left',
|
||||
effectiveHighlightedIndex === index && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-option:${option.id}`}
|
||||
onMouseMove={() => setHighlightedIndex(index)}
|
||||
onClick={() => { void submitSelection(option.id) }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0 text-muted-foreground">
|
||||
{option.current ? <Check size={14} weight="bold" /> : <StackSimple size={14} />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium text-foreground">{option.label}</span>
|
||||
{option.detail && (
|
||||
<span className="truncate text-xs text-muted-foreground">{option.detail}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{option.current && (
|
||||
<span className="shrink-0 text-xs font-medium text-muted-foreground">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
27
src/components/note-retargeting/noteDragData.ts
Normal file
27
src/components/note-retargeting/noteDragData.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { DragEvent } from 'react'
|
||||
|
||||
const NOTE_DRAG_MIME = 'application/x-laputa-note-path'
|
||||
let activeDraggedNotePath: string | null = null
|
||||
|
||||
export function writeDraggedNotePath(event: DragEvent<HTMLElement>, notePath: string): void {
|
||||
activeDraggedNotePath = notePath
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData(NOTE_DRAG_MIME, notePath)
|
||||
event.dataTransfer.setData('text/plain', notePath)
|
||||
}
|
||||
|
||||
export function clearDraggedNotePath(): void {
|
||||
activeDraggedNotePath = null
|
||||
}
|
||||
|
||||
export function readDraggedNotePath(dataTransfer: DataTransfer | null): string | null {
|
||||
if (!dataTransfer) return activeDraggedNotePath
|
||||
|
||||
const customPath = dataTransfer.getData(NOTE_DRAG_MIME).trim()
|
||||
if (customPath) return customPath
|
||||
|
||||
const fallbackPath = dataTransfer.getData('text/plain').trim()
|
||||
if (fallbackPath) return fallbackPath
|
||||
|
||||
return activeDraggedNotePath
|
||||
}
|
||||
24
src/components/note-retargeting/noteRetargetingContext.ts
Normal file
24
src/components/note-retargeting/noteRetargetingContext.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext, createElement, useContext, type ReactNode } from 'react'
|
||||
|
||||
export interface NoteRetargetingContextValue {
|
||||
canDropNoteOnType: (notePath: string, type: string) => boolean
|
||||
dropNoteOnType: (notePath: string, type: string) => Promise<void>
|
||||
canDropNoteOnFolder: (notePath: string, folderPath: string) => boolean
|
||||
dropNoteOnFolder: (notePath: string, folderPath: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const noteRetargetingContext = createContext<NoteRetargetingContextValue | null>(null)
|
||||
|
||||
export function useNoteRetargetingContext() {
|
||||
return useContext(noteRetargetingContext)
|
||||
}
|
||||
|
||||
export function NoteRetargetingProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode
|
||||
value: NoteRetargetingContextValue | null
|
||||
}) {
|
||||
return createElement(noteRetargetingContext.Provider, { value }, children)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from '../SidebarParts'
|
||||
import { TypeCustomizePopover } from '../TypeCustomizePopover'
|
||||
import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
|
||||
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
|
||||
import { SidebarGroupHeader } from './SidebarGroupHeader'
|
||||
import { SidebarViewItem } from './SidebarViewItem'
|
||||
import { countByFilter } from '../../utils/noteListHelpers'
|
||||
@@ -95,9 +97,24 @@ function SortableSection({
|
||||
group: SectionGroup
|
||||
sectionProps: SidebarSectionProps
|
||||
}) {
|
||||
const noteRetargeting = useNoteRetargetingContext()
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const itemCount = countByFilter(sectionProps.entries, group.type).open
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
const content = (
|
||||
<SectionContent
|
||||
group={group}
|
||||
itemCount={itemCount}
|
||||
selection={sectionProps.selection}
|
||||
onSelect={sectionProps.onSelect}
|
||||
onContextMenu={sectionProps.onContextMenu}
|
||||
dragHandleProps={listeners}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
|
||||
onRenameSubmit={sectionProps.onRenameSubmit}
|
||||
onRenameCancel={sectionProps.onRenameCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -110,18 +127,14 @@ function SortableSection({
|
||||
}}
|
||||
{...attributes}
|
||||
>
|
||||
<SectionContent
|
||||
group={group}
|
||||
itemCount={itemCount}
|
||||
selection={sectionProps.selection}
|
||||
onSelect={sectionProps.onSelect}
|
||||
onContextMenu={sectionProps.onContextMenu}
|
||||
dragHandleProps={listeners}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
|
||||
onRenameSubmit={sectionProps.onRenameSubmit}
|
||||
onRenameCancel={sectionProps.onRenameCancel}
|
||||
/>
|
||||
{noteRetargeting ? (
|
||||
<NoteDropTarget
|
||||
canAcceptNotePath={(notePath) => noteRetargeting.canDropNoteOnType(notePath, group.type)}
|
||||
onDropNote={(notePath) => noteRetargeting.dropNoteOnType(notePath, group.type)}
|
||||
>
|
||||
{content}
|
||||
</NoteDropTarget>
|
||||
) : content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,9 @@ interface NoteCommandsConfig {
|
||||
onDeleteNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onChangeNoteType?: () => void
|
||||
onMoveNoteToFolder?: () => void
|
||||
canMoveNoteToFolder?: boolean
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
@@ -22,17 +25,18 @@ interface NoteCommandsConfig {
|
||||
canRestoreDeletedNote?: boolean
|
||||
}
|
||||
|
||||
interface ActivePathCommandConfig {
|
||||
interface NoteCommandConfig {
|
||||
id: string
|
||||
label: string
|
||||
keywords: string[]
|
||||
enabled: boolean
|
||||
path: string | null
|
||||
execute?: () => void
|
||||
shortcut?: string
|
||||
run: (path: string) => void
|
||||
path?: string | null
|
||||
run?: (path: string) => void
|
||||
}
|
||||
|
||||
function createActivePathCommand(config: ActivePathCommandConfig): CommandAction {
|
||||
function createNoteCommand(config: NoteCommandConfig): CommandAction {
|
||||
return {
|
||||
id: config.id,
|
||||
label: config.label,
|
||||
@@ -41,84 +45,165 @@ function createActivePathCommand(config: ActivePathCommandConfig): CommandAction
|
||||
keywords: config.keywords,
|
||||
enabled: config.enabled,
|
||||
execute: () => {
|
||||
if (config.path) config.run(config.path)
|
||||
if (config.path && config.run) {
|
||||
config.run(config.path)
|
||||
return
|
||||
}
|
||||
config.execute?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
hasActiveNote, activeTabPath, isArchived,
|
||||
onCreateNote, onCreateType, onSave,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
onToggleOrganized, isOrganized,
|
||||
onRestoreDeletedNote, canRestoreDeletedNote,
|
||||
} = config
|
||||
|
||||
function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
{ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'create', 'add'], enabled: true, execute: onCreateNote },
|
||||
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
|
||||
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
|
||||
createActivePathCommand({
|
||||
createNoteCommand({
|
||||
id: 'create-note',
|
||||
label: 'New Note',
|
||||
shortcut: '⌘N',
|
||||
keywords: ['new', 'create', 'add'],
|
||||
enabled: true,
|
||||
execute: config.onCreateNote,
|
||||
}),
|
||||
createNoteCommand({
|
||||
id: 'create-type',
|
||||
label: 'New Type',
|
||||
keywords: ['new', 'create', 'type', 'template'],
|
||||
enabled: !!config.onCreateType,
|
||||
execute: () => config.onCreateType?.(),
|
||||
}),
|
||||
createNoteCommand({
|
||||
id: 'save-note',
|
||||
label: 'Save Note',
|
||||
shortcut: '⌘S',
|
||||
keywords: ['write'],
|
||||
enabled: config.hasActiveNote,
|
||||
execute: config.onSave,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function buildPathNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
...buildDestructiveNoteCommands(config),
|
||||
...buildPinnedNoteCommands(config),
|
||||
]
|
||||
}
|
||||
|
||||
function buildDestructiveNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
createNoteCommand({
|
||||
id: 'delete-note',
|
||||
label: 'Delete Note',
|
||||
shortcut: '⌘⌫',
|
||||
keywords: ['delete', 'remove'],
|
||||
enabled: hasActiveNote,
|
||||
path: activeTabPath,
|
||||
run: onDeleteNote,
|
||||
enabled: config.hasActiveNote,
|
||||
path: config.activeTabPath,
|
||||
run: config.onDeleteNote,
|
||||
}),
|
||||
createActivePathCommand({
|
||||
createNoteCommand({
|
||||
id: 'archive-note',
|
||||
label: isArchived ? 'Unarchive Note' : 'Archive Note',
|
||||
label: config.isArchived ? 'Unarchive Note' : 'Archive Note',
|
||||
keywords: ['archive'],
|
||||
enabled: hasActiveNote,
|
||||
path: activeTabPath,
|
||||
run: isArchived ? onUnarchiveNote : onArchiveNote,
|
||||
enabled: config.hasActiveNote,
|
||||
path: config.activeTabPath,
|
||||
run: config.isArchived ? config.onUnarchiveNote : config.onArchiveNote,
|
||||
}),
|
||||
{
|
||||
id: 'restore-deleted-note', label: 'Restore Deleted Note', group: 'Note',
|
||||
keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'],
|
||||
enabled: !!canRestoreDeletedNote && !!onRestoreDeletedNote,
|
||||
execute: () => onRestoreDeletedNote?.(),
|
||||
},
|
||||
createActivePathCommand({
|
||||
id: 'toggle-favorite',
|
||||
label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
|
||||
shortcut: '⌘D',
|
||||
keywords: ['favorite', 'star', 'bookmark', 'pin'],
|
||||
enabled: hasActiveNote && !!onToggleFavorite,
|
||||
path: activeTabPath,
|
||||
run: (path) => onToggleFavorite?.(path),
|
||||
}),
|
||||
createActivePathCommand({
|
||||
id: 'toggle-organized',
|
||||
label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
|
||||
shortcut: '⌘E',
|
||||
keywords: ['organized', 'inbox', 'triage', 'done'],
|
||||
enabled: hasActiveNote && !!onToggleOrganized,
|
||||
path: activeTabPath,
|
||||
run: (path) => onToggleOrganized?.(path),
|
||||
}),
|
||||
{
|
||||
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
enabled: hasActiveNote && !!onSetNoteIcon,
|
||||
execute: () => onSetNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
|
||||
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
|
||||
execute: () => onRemoveNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
|
||||
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
|
||||
enabled: hasActiveNote,
|
||||
execute: () => onOpenInNewWindow?.(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
createNoteCommand({
|
||||
id: 'toggle-favorite',
|
||||
label: config.isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
|
||||
shortcut: '⌘D',
|
||||
keywords: ['favorite', 'star', 'bookmark', 'pin'],
|
||||
enabled: config.hasActiveNote && !!config.onToggleFavorite,
|
||||
path: config.activeTabPath,
|
||||
run: (path) => config.onToggleFavorite?.(path),
|
||||
}),
|
||||
createNoteCommand({
|
||||
id: 'toggle-organized',
|
||||
label: config.isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
|
||||
shortcut: '⌘E',
|
||||
keywords: ['organized', 'inbox', 'triage', 'done'],
|
||||
enabled: config.hasActiveNote && !!config.onToggleOrganized,
|
||||
path: config.activeTabPath,
|
||||
run: (path) => config.onToggleOrganized?.(path),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function buildOptionalNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
...buildRecoveryCommands(config),
|
||||
...buildRetargetingCommands(config),
|
||||
...buildPresentationCommands(config),
|
||||
]
|
||||
}
|
||||
|
||||
function buildRecoveryCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
createNoteCommand({
|
||||
id: 'restore-deleted-note',
|
||||
label: 'Restore Deleted Note',
|
||||
keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'],
|
||||
enabled: !!config.canRestoreDeletedNote && !!config.onRestoreDeletedNote,
|
||||
execute: () => config.onRestoreDeletedNote?.(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function buildRetargetingCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
createNoteCommand({
|
||||
id: 'set-note-icon',
|
||||
label: 'Set Note Icon',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
enabled: config.hasActiveNote && !!config.onSetNoteIcon,
|
||||
execute: () => config.onSetNoteIcon?.(),
|
||||
}),
|
||||
createNoteCommand({
|
||||
id: 'change-note-type',
|
||||
label: 'Change Note Type…',
|
||||
keywords: ['type', 'change', 'retarget', 'section', 'move'],
|
||||
enabled: config.hasActiveNote && !!config.onChangeNoteType,
|
||||
execute: () => config.onChangeNoteType?.(),
|
||||
}),
|
||||
createNoteCommand({
|
||||
id: 'move-note-to-folder',
|
||||
label: 'Move Note to Folder…',
|
||||
keywords: ['folder', 'move', 'retarget', 'organize'],
|
||||
enabled: config.hasActiveNote && !!config.onMoveNoteToFolder && !!config.canMoveNoteToFolder,
|
||||
execute: () => config.onMoveNoteToFolder?.(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function buildPresentationCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
createNoteCommand({
|
||||
id: 'remove-note-icon',
|
||||
label: 'Remove Note Icon',
|
||||
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
|
||||
enabled: config.hasActiveNote && !!config.activeNoteHasIcon && !!config.onRemoveNoteIcon,
|
||||
execute: () => config.onRemoveNoteIcon?.(),
|
||||
}),
|
||||
createNoteCommand({
|
||||
id: 'open-in-new-window',
|
||||
label: 'Open in New Window',
|
||||
shortcut: '⌘⇧O',
|
||||
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
|
||||
enabled: config.hasActiveNote,
|
||||
execute: () => config.onOpenInNewWindow?.(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
return [
|
||||
...buildCoreNoteCommands(config),
|
||||
...buildPathNoteCommands(config),
|
||||
...buildOptionalNoteCommands(config),
|
||||
]
|
||||
}
|
||||
|
||||
126
src/hooks/editorTabContent.ts
Normal file
126
src/hooks/editorTabContent.ts
Normal 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)
|
||||
}
|
||||
@@ -80,6 +80,9 @@ interface AppCommandsConfig {
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onChangeNoteType?: () => void
|
||||
onMoveNoteToFolder?: () => void
|
||||
canMoveNoteToFolder?: boolean
|
||||
activeNoteHasIcon?: boolean
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
@@ -169,6 +172,9 @@ type CommandRegistryNoteActions = Pick<
|
||||
CommandRegistryConfig,
|
||||
| 'onSetNoteIcon'
|
||||
| 'onRemoveNoteIcon'
|
||||
| 'onChangeNoteType'
|
||||
| 'onMoveNoteToFolder'
|
||||
| 'canMoveNoteToFolder'
|
||||
| 'activeNoteHasIcon'
|
||||
| 'noteListFilter'
|
||||
| 'onSetNoteListFilter'
|
||||
@@ -421,6 +427,9 @@ function createCommandRegistryNoteConfig(
|
||||
return {
|
||||
onSetNoteIcon: config.onSetNoteIcon,
|
||||
onRemoveNoteIcon: config.onRemoveNoteIcon,
|
||||
onChangeNoteType: config.onChangeNoteType,
|
||||
onMoveNoteToFolder: config.onMoveNoteToFolder,
|
||||
canMoveNoteToFolder: config.canMoveNoteToFolder,
|
||||
activeNoteHasIcon: config.activeNoteHasIcon,
|
||||
noteListFilter: config.noteListFilter,
|
||||
onSetNoteListFilter: config.onSetNoteListFilter,
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -152,6 +152,42 @@ describe('useCommandRegistry', () => {
|
||||
expect(onSetNoteIcon).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes Change Note Type when the active note can be retargeted', () => {
|
||||
const onChangeNoteType = vi.fn()
|
||||
const config = makeConfig({ onChangeNoteType })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'change-note-type')
|
||||
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
|
||||
cmd!.execute()
|
||||
expect(onChangeNoteType).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('enables Move Note to Folder only when another folder destination exists', () => {
|
||||
const onMoveNoteToFolder = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
(props) => useCommandRegistry(props),
|
||||
{
|
||||
initialProps: makeConfig({
|
||||
onMoveNoteToFolder,
|
||||
canMoveNoteToFolder: true,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
expect(findCommand(result.current, 'move-note-to-folder')?.enabled).toBe(true)
|
||||
findCommand(result.current, 'move-note-to-folder')!.execute()
|
||||
expect(onMoveNoteToFolder).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(makeConfig({
|
||||
onMoveNoteToFolder,
|
||||
canMoveNoteToFolder: false,
|
||||
}))
|
||||
expect(findCommand(result.current, 'move-note-to-folder')?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('includes restore deleted note command when provided', () => {
|
||||
const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: true })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
|
||||
@@ -40,6 +40,9 @@ interface CommandRegistryConfig {
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onChangeNoteType?: () => void
|
||||
onMoveNoteToFolder?: () => void
|
||||
canMoveNoteToFolder?: boolean
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
@@ -109,7 +112,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
|
||||
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
|
||||
onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
|
||||
onRestoreDeletedNote, canRestoreDeletedNote,
|
||||
@@ -150,6 +153,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
hasActiveNote, activeTabPath, isArchived,
|
||||
onCreateNote, onCreateType, onSave,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
|
||||
onRestoreDeletedNote, canRestoreDeletedNote,
|
||||
@@ -200,7 +204,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
|
||||
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
|
||||
isSectionGroup, noteListFilter, onSetNoteListFilter,
|
||||
selection,
|
||||
onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -70,18 +70,48 @@ describe('useNoteActions hook', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('handleCreateNote calls addEntry and creates correct entry', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
function renderActions(entries: VaultEntry[] = []) {
|
||||
return renderHook(() => useNoteActions(makeConfig(entries)))
|
||||
}
|
||||
|
||||
function createImmediateEntry(type?: string) {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderActions()
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate(type)
|
||||
})
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
vi.restoreAllMocks()
|
||||
return createdEntry as VaultEntry
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'handleCreateNote',
|
||||
run: (result: ReturnType<typeof renderActions>['result']) => result.current.handleCreateNote('Test Note', 'Note'),
|
||||
expectedTitle: 'Test Note',
|
||||
expectedType: 'Note',
|
||||
expectedPathFragment: 'test-note.md',
|
||||
},
|
||||
{
|
||||
name: 'handleCreateType',
|
||||
run: (result: ReturnType<typeof renderActions>['result']) => result.current.handleCreateType('Recipe'),
|
||||
expectedTitle: 'Recipe',
|
||||
expectedType: 'Type',
|
||||
expectedPathFragment: 'recipe.md',
|
||||
},
|
||||
])('$name creates the expected entry', ({ run, expectedTitle, expectedType, expectedPathFragment }) => {
|
||||
const { result } = renderActions()
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('Test Note', 'Note')
|
||||
run(result)
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
expect(createdEntry.title).toBe('Test Note')
|
||||
expect(createdEntry.isA).toBe('Note')
|
||||
expect(createdEntry.path).toContain('test-note.md')
|
||||
expect(createdEntry.title).toBe(expectedTitle)
|
||||
expect(createdEntry.isA).toBe(expectedType)
|
||||
expect(createdEntry.path).toContain(expectedPathFragment)
|
||||
})
|
||||
|
||||
it('handleCreateNote opens tab immediately (before addEntry resolves)', () => {
|
||||
@@ -102,19 +132,6 @@ describe('useNoteActions hook', () => {
|
||||
expect(result.current.activeTabPath).toContain('fast-note.md')
|
||||
})
|
||||
|
||||
it('handleCreateType creates type entry', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateType('Recipe')
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
expect(createdEntry.isA).toBe('Type')
|
||||
expect(createdEntry.title).toBe('Recipe')
|
||||
})
|
||||
|
||||
it('handleNavigateWikilink finds entry by title', async () => {
|
||||
const target = makeEntry({ title: 'Target Note', path: '/vault/target.md' })
|
||||
|
||||
@@ -178,19 +195,10 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate()
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
const createdEntry = createImmediateEntry()
|
||||
expect(createdEntry.title).toBe('Untitled Note 1700000000')
|
||||
expect(createdEntry.filename).toBe('untitled-note-1700000000.md')
|
||||
expect(createdEntry.isA).toBe('Note')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
|
||||
@@ -217,18 +225,9 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate accepts custom type', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Project')
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
const createdEntry = createImmediateEntry('Project')
|
||||
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
|
||||
expect(createdEntry.isA).toBe('Project')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('handleCreateNote uses default template for Project type', () => {
|
||||
@@ -435,7 +434,11 @@ describe('useNoteActions hook', () => {
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
|
||||
expect(setToastMessage).toHaveBeenCalledWith(
|
||||
type === 'Type'
|
||||
? 'Failed to create type — disk write error'
|
||||
: 'Failed to create note — disk write error',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not revert when disk write succeeds', async () => {
|
||||
|
||||
@@ -343,5 +343,6 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleAddProperty: frontmatterActions.handleAddProperty,
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
handleRenameFilename: rename.handleRenameFilename,
|
||||
handleMoveNoteToFolder: rename.handleMoveNoteToFolder,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +348,21 @@ describe('useNoteCreation hook', () => {
|
||||
expect(addEntry.mock.calls[0][0].title).toBe('Recipe')
|
||||
})
|
||||
|
||||
it('handleCreateType blocks when a non-type file already uses the target filename', async () => {
|
||||
const existing = makeEntry({ path: '/test/vault/briefing.md', filename: 'briefing.md', title: 'Briefing', isA: 'Note' })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
|
||||
|
||||
let created = true
|
||||
await act(async () => {
|
||||
created = await result.current.handleCreateType('Briefing')
|
||||
})
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Briefing" because briefing.md already exists')
|
||||
})
|
||||
|
||||
it('createTypeEntrySilent persists without opening tab', async () => {
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
const entry = await act(async () => result.current.createTypeEntrySilent('Recipe'))
|
||||
@@ -356,6 +371,32 @@ describe('useNoteCreation hook', () => {
|
||||
expect(entry.isA).toBe('Type')
|
||||
})
|
||||
|
||||
it('createTypeEntrySilent reuses an existing slug-equivalent type', async () => {
|
||||
const existing = makeEntry({ path: '/test/vault/briefing.md', filename: 'briefing.md', title: 'Briefing', isA: 'Type' })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
|
||||
|
||||
const entry = await act(async () => result.current.createTypeEntrySilent('briefing'))
|
||||
|
||||
expect(entry).toBe(existing)
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCreateNoteForRelationship blocks when the generated filename already exists', async () => {
|
||||
const existing = makeEntry({ path: '/test/vault/briefing.md', filename: 'briefing.md', title: 'Existing Briefing', isA: 'Note' })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
|
||||
|
||||
let created = true
|
||||
await act(async () => {
|
||||
created = await result.current.handleCreateNoteForRelationship('Briefing')
|
||||
})
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create note "Briefing" because briefing.md already exists')
|
||||
})
|
||||
|
||||
it('reverts optimistic creation when disk write fails (Tauri)', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
|
||||
|
||||
@@ -132,10 +132,104 @@ export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry:
|
||||
return { entry, content: `---\ntype: Type\n---\n` }
|
||||
}
|
||||
|
||||
type ResolvedEntry = { entry: VaultEntry; content: string }
|
||||
|
||||
interface BlockedCreationPlan {
|
||||
status: 'blocked'
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ReadyCreationPlan {
|
||||
status: 'create'
|
||||
resolved: ResolvedEntry
|
||||
}
|
||||
|
||||
interface ExistingTypeCreationPlan {
|
||||
status: 'existing'
|
||||
entry: VaultEntry
|
||||
}
|
||||
|
||||
export type NoteCreationPlan = BlockedCreationPlan | ReadyCreationPlan
|
||||
export type TypeCreationPlan = BlockedCreationPlan | ExistingTypeCreationPlan | ReadyCreationPlan
|
||||
|
||||
function normalizeComparablePath(path: string): string {
|
||||
return path.replace(/\\/g, '/').toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function findPathCollision(entries: VaultEntry[], path: string): VaultEntry | undefined {
|
||||
const target = normalizeComparablePath(path)
|
||||
return entries.find((entry) => normalizeComparablePath(entry.path) === target)
|
||||
}
|
||||
|
||||
function buildCreationCollisionMessage({ noun, title, path }: { noun: 'note' | 'type'; title: string; path: string }): string {
|
||||
const filename = path.split('/').pop() ?? path
|
||||
return `Cannot create ${noun} "${title}" because ${filename} already exists`
|
||||
}
|
||||
|
||||
function findEquivalentTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
const trimmed = typeName.trim()
|
||||
const targetSlug = slugify(trimmed)
|
||||
return entries.find((entry) =>
|
||||
entry.isA === 'Type' && (entry.title === trimmed || slugify(entry.title) === targetSlug)
|
||||
)
|
||||
}
|
||||
|
||||
export function planNewNoteCreation({
|
||||
entries,
|
||||
title,
|
||||
type,
|
||||
vaultPath,
|
||||
template,
|
||||
}: NewNoteParams & { entries: VaultEntry[] }): NoteCreationPlan {
|
||||
const resolved = resolveNewNote({ title, type, vaultPath, template })
|
||||
const collision = findPathCollision(entries, resolved.entry.path)
|
||||
if (collision) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
message: buildCreationCollisionMessage({ noun: 'note', title, path: resolved.entry.path }),
|
||||
}
|
||||
}
|
||||
return { status: 'create', resolved }
|
||||
}
|
||||
|
||||
export function planNewTypeCreation({
|
||||
entries,
|
||||
typeName,
|
||||
vaultPath,
|
||||
}: NewTypeParams & { entries: VaultEntry[] }): TypeCreationPlan {
|
||||
const existingType = findEquivalentTypeEntry(entries, typeName)
|
||||
if (existingType) return { status: 'existing', entry: existingType }
|
||||
|
||||
const resolved = resolveNewType({ typeName, vaultPath })
|
||||
const collision = findPathCollision(entries, resolved.entry.path)
|
||||
if (collision) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
message: buildCreationCollisionMessage({ noun: 'type', title: typeName, path: resolved.entry.path }),
|
||||
}
|
||||
}
|
||||
return { status: 'create', resolved }
|
||||
}
|
||||
|
||||
function isAlreadyExistsError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return /already exists|file exists|eexist/i.test(message)
|
||||
}
|
||||
|
||||
function createPersistFailureMessage(entry: VaultEntry, error: unknown): string {
|
||||
if (isAlreadyExistsError(error)) {
|
||||
const noun = entry.isA === 'Type' ? 'type' : 'note'
|
||||
return buildCreationCollisionMessage({ noun, title: entry.title, path: entry.path })
|
||||
}
|
||||
return entry.isA === 'Type'
|
||||
? 'Failed to create type — disk write error'
|
||||
: 'Failed to create note — disk write error'
|
||||
}
|
||||
|
||||
/** Persist a newly created note to disk. Returns a Promise for error handling. */
|
||||
export function persistNewNote(path: string, content: string): Promise<void> {
|
||||
if (!isTauri()) return Promise.resolve()
|
||||
return invoke<void>('save_note_content', { path, content }).then(() => {})
|
||||
return invoke<void>('create_note_content', { path, content }).then(() => {})
|
||||
}
|
||||
|
||||
// Rapid Cmd+N bursts can outpace the note-list render path on desktop. Keep
|
||||
@@ -156,33 +250,125 @@ function signalFocusEditor(opts?: { selectTitle?: boolean; path?: string }): voi
|
||||
}
|
||||
|
||||
interface PersistCallbacks {
|
||||
onFail: (p: string) => void
|
||||
onStart?: (p: string) => void
|
||||
onEnd?: (p: string) => void
|
||||
onPersisted?: () => void
|
||||
}
|
||||
|
||||
/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */
|
||||
function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void {
|
||||
/** Persist to disk; track pending state via onStart/onEnd. */
|
||||
async function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): Promise<void> {
|
||||
cbs.onStart?.(path)
|
||||
persistNewNote(path, content)
|
||||
.then(() => { cbs.onEnd?.(path); cbs.onPersisted?.() })
|
||||
.catch(() => { cbs.onEnd?.(path); cbs.onFail(path) })
|
||||
try {
|
||||
await persistNewNote(path, content)
|
||||
cbs.onPersisted?.()
|
||||
} finally {
|
||||
cbs.onEnd?.(path)
|
||||
}
|
||||
}
|
||||
|
||||
type ResolvedNote = { entry: VaultEntry; content: string }
|
||||
type PersistFn = (resolved: ResolvedNote) => void
|
||||
interface PersistResolvedOptions {
|
||||
openTab?: boolean
|
||||
}
|
||||
|
||||
/** Optimistically open note, add entry to vault, and persist to disk. */
|
||||
function createAndPersist(
|
||||
resolved: ResolvedNote,
|
||||
addFn: (e: VaultEntry) => void,
|
||||
openTab: (e: VaultEntry, c: string) => void,
|
||||
cbs: PersistCallbacks,
|
||||
): void {
|
||||
openTab(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addFn)
|
||||
persistOptimistic(resolved.entry.path, resolved.content, cbs)
|
||||
type PersistResolvedEntryFn = (
|
||||
resolved: ResolvedEntry,
|
||||
options?: PersistResolvedOptions,
|
||||
) => Promise<void>
|
||||
|
||||
interface CreationDeps {
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
setToastMessage: (msg: string | null) => void
|
||||
persistResolvedEntry: PersistResolvedEntryFn
|
||||
}
|
||||
|
||||
interface NoteCreationRequest extends CreationDeps {
|
||||
title: string
|
||||
type: string
|
||||
creationPath?: 'plus_button'
|
||||
}
|
||||
|
||||
async function createNamedNote({
|
||||
entries,
|
||||
title,
|
||||
type,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
creationPath,
|
||||
}: NoteCreationRequest): Promise<boolean> {
|
||||
const template = resolveTemplate({ entries, typeName: type })
|
||||
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template })
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await persistResolvedEntry(plan.resolved)
|
||||
if (creationPath) {
|
||||
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: creationPath })
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
setToastMessage(createPersistFailureMessage(plan.resolved.entry, error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface TypeCreationRequest extends CreationDeps {
|
||||
typeName: string
|
||||
}
|
||||
|
||||
async function createTypeFromName({
|
||||
entries,
|
||||
typeName,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
}: TypeCreationRequest): Promise<boolean> {
|
||||
const plan = planNewTypeCreation({ entries, typeName, vaultPath })
|
||||
if (plan.status === 'existing') {
|
||||
setToastMessage(`Type "${plan.entry.title}" already exists`)
|
||||
return false
|
||||
}
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await persistResolvedEntry(plan.resolved)
|
||||
trackEvent('type_created')
|
||||
return true
|
||||
} catch (error) {
|
||||
setToastMessage(createPersistFailureMessage(plan.resolved.entry, error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTypeSilently({
|
||||
entries,
|
||||
typeName,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
}: TypeCreationRequest): Promise<VaultEntry> {
|
||||
const plan = planNewTypeCreation({ entries, typeName, vaultPath })
|
||||
if (plan.status === 'existing') return plan.entry
|
||||
if (plan.status === 'blocked') {
|
||||
setToastMessage(plan.message)
|
||||
throw new Error(plan.message)
|
||||
}
|
||||
|
||||
try {
|
||||
await persistResolvedEntry(plan.resolved, { openTab: false })
|
||||
return plan.resolved.entry
|
||||
} catch (error) {
|
||||
const message = createPersistFailureMessage(plan.resolved.entry, error)
|
||||
setToastMessage(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
interface ImmediateCreateDeps {
|
||||
@@ -318,30 +504,6 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
|
||||
}, [syncDeps, executeRequest, scheduleQueuedBurst])
|
||||
}
|
||||
|
||||
interface RelationshipCreateDeps {
|
||||
entries: VaultEntry[]
|
||||
vaultPath: string
|
||||
openTabWithContent: (entry: VaultEntry, content: string) => void
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
removeEntry: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onNewNotePersisted?: () => void
|
||||
}
|
||||
|
||||
/** Create a note for a relationship link; persist in background. */
|
||||
function createNoteForRelationship(deps: RelationshipCreateDeps, title: string): void {
|
||||
const template = resolveTemplate({ entries: deps.entries, typeName: 'Note' })
|
||||
const resolved = resolveNewNote({ title, type: 'Note', vaultPath: deps.vaultPath, template })
|
||||
deps.openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
|
||||
persistNewNote(resolved.entry.path, resolved.content)
|
||||
.then(() => deps.onNewNotePersisted?.())
|
||||
.catch(() => {
|
||||
deps.removeEntry(resolved.entry.path)
|
||||
deps.setToastMessage('Failed to create note — disk write error')
|
||||
})
|
||||
}
|
||||
|
||||
export interface NoteCreationConfig {
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
removeEntry: (path: string) => void
|
||||
@@ -362,60 +524,59 @@ interface CreationTabDeps {
|
||||
}
|
||||
|
||||
export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTabDeps) {
|
||||
const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave } = config
|
||||
const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave, vaultPath } = config
|
||||
const { openTabWithContent } = tabDeps
|
||||
|
||||
const revertOptimisticNote = useCallback((path: string) => {
|
||||
removeEntry(path)
|
||||
setToastMessage('Failed to create note — disk write error')
|
||||
}, [removeEntry, setToastMessage])
|
||||
const persistResolvedEntry = useCallback(async (
|
||||
resolved: ResolvedEntry,
|
||||
options?: PersistResolvedOptions,
|
||||
): Promise<void> => {
|
||||
if (options?.openTab !== false) openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
try {
|
||||
await persistOptimistic(resolved.entry.path, resolved.content, {
|
||||
onStart: addPendingSave,
|
||||
onEnd: removePendingSave,
|
||||
onPersisted: config.onNewNotePersisted,
|
||||
})
|
||||
} catch (error) {
|
||||
removeEntry(resolved.entry.path)
|
||||
throw error
|
||||
}
|
||||
}, [openTabWithContent, addEntry, addPendingSave, removePendingSave, config.onNewNotePersisted, removeEntry])
|
||||
|
||||
const persistNew: PersistFn = useCallback(
|
||||
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, {
|
||||
onFail: revertOptimisticNote,
|
||||
onStart: addPendingSave,
|
||||
onEnd: removePendingSave,
|
||||
onPersisted: config.onNewNotePersisted,
|
||||
}),
|
||||
[openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave, config.onNewNotePersisted],
|
||||
)
|
||||
const creationDeps = {
|
||||
entries,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
}
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string): Promise<boolean> =>
|
||||
createNamedNote({ ...creationDeps, title, type, creationPath: 'plus_button' }),
|
||||
[creationDeps])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string): Promise<boolean> =>
|
||||
createTypeFromName({ ...creationDeps, typeName }),
|
||||
[creationDeps])
|
||||
|
||||
const createTypeEntrySilent = useCallback((typeName: string): Promise<VaultEntry> =>
|
||||
createTypeSilently({ ...creationDeps, typeName }),
|
||||
[creationDeps])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> =>
|
||||
createNamedNote({ ...creationDeps, title, type: 'Note' }),
|
||||
[creationDeps])
|
||||
|
||||
const handleCreateNoteImmediate = useImmediateCreateQueue({
|
||||
entries,
|
||||
vaultPath: config.vaultPath,
|
||||
vaultPath,
|
||||
addEntry,
|
||||
openTabWithContent,
|
||||
trackUnsaved: config.trackUnsaved,
|
||||
markContentPending: config.markContentPending,
|
||||
})
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate({ entries, typeName: type })
|
||||
persistNew(resolveNewNote({ title, type, vaultPath: config.vaultPath, template }))
|
||||
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' })
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
|
||||
createNoteForRelationship({
|
||||
entries, vaultPath: config.vaultPath, openTabWithContent, addEntry,
|
||||
removeEntry, setToastMessage, onNewNotePersisted: config.onNewNotePersisted,
|
||||
}, title)
|
||||
return Promise.resolve(true)
|
||||
}, [entries, openTabWithContent, addEntry, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => {
|
||||
persistNew(resolveNewType({ typeName, vaultPath: config.vaultPath }))
|
||||
trackEvent('type_created')
|
||||
}, [persistNew, config.vaultPath])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
const resolved = resolveNewType({ typeName, vaultPath: config.vaultPath })
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
return resolved.entry
|
||||
}, [addEntry, config.vaultPath])
|
||||
|
||||
return {
|
||||
handleCreateNote,
|
||||
handleCreateNoteImmediate,
|
||||
|
||||
@@ -248,4 +248,44 @@ describe('useNoteRename hook', () => {
|
||||
|
||||
expect(setToastMessage).toHaveBeenCalledWith('A note with that name already exists')
|
||||
})
|
||||
|
||||
it('handleMoveNoteToFolder moves the note and keeps its title intact', async () => {
|
||||
const entry = makeEntry({ path: '/vault/notes/project-kickoff.md', filename: 'project-kickoff.md', title: 'Project Kickoff' })
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'move_note_to_folder') {
|
||||
return {
|
||||
new_path: '/vault/projects/project-kickoff.md',
|
||||
updated_files: 1,
|
||||
failed_updates: 0,
|
||||
}
|
||||
}
|
||||
if (cmd === 'get_note_content') return '# Project Kickoff\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteRename(
|
||||
{ entries: [entry], setToastMessage },
|
||||
{ tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
|
||||
))
|
||||
|
||||
const onEntryRenamed = vi.fn()
|
||||
await act(async () => {
|
||||
await result.current.handleMoveNoteToFolder('/vault/notes/project-kickoff.md', 'projects', '/vault', onEntryRenamed)
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_folder', expect.objectContaining({
|
||||
old_path: '/vault/notes/project-kickoff.md',
|
||||
folder_path: 'projects',
|
||||
}))
|
||||
expect(onEntryRenamed).toHaveBeenCalledWith(
|
||||
'/vault/notes/project-kickoff.md',
|
||||
expect.objectContaining({
|
||||
path: '/vault/projects/project-kickoff.md',
|
||||
filename: 'project-kickoff.md',
|
||||
title: 'Project Kickoff',
|
||||
}),
|
||||
'# Project Kickoff\n',
|
||||
)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Moved to "projects" and updated 1 note')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,12 @@ interface FilenameRenameRequest {
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface FolderMoveRequest {
|
||||
path: string
|
||||
folderPath: string
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface LoadNoteContentRequest {
|
||||
path: string
|
||||
}
|
||||
@@ -34,6 +40,8 @@ interface ReloadTabsAfterRenameRequest {
|
||||
updateTabContent: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
type RenameCommand = 'rename_note' | 'rename_note_filename' | 'move_note_to_folder'
|
||||
|
||||
/** Check if a note's filename doesn't match the slug of its current title. */
|
||||
export function needsRenameOnSave(title: string, filename: string): boolean {
|
||||
if (!filename.toLowerCase().endsWith('.md')) return false
|
||||
@@ -46,10 +54,23 @@ export async function performRename({
|
||||
vaultPath,
|
||||
oldTitle,
|
||||
}: RenameRequest): Promise<RenameResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null })
|
||||
}
|
||||
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null })
|
||||
return invokeRenameCommand({
|
||||
command: 'rename_note',
|
||||
tauriArgs: { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null },
|
||||
mockArgs: { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
function invokeRenameCommand(
|
||||
params: {
|
||||
command: RenameCommand
|
||||
tauriArgs: Record<string, unknown>
|
||||
mockArgs: Record<string, unknown>
|
||||
},
|
||||
): Promise<RenameResult> {
|
||||
return isTauri()
|
||||
? invoke<RenameResult>(params.command, params.tauriArgs)
|
||||
: mockInvoke<RenameResult>(params.command, params.mockArgs)
|
||||
}
|
||||
|
||||
export async function performFilenameRename({
|
||||
@@ -57,17 +78,22 @@ export async function performFilenameRename({
|
||||
newFilenameStem,
|
||||
vaultPath,
|
||||
}: FilenameRenameRequest): Promise<RenameResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<RenameResult>('rename_note_filename', {
|
||||
vaultPath,
|
||||
oldPath: path,
|
||||
newFilenameStem,
|
||||
})
|
||||
}
|
||||
return mockInvoke<RenameResult>('rename_note_filename', {
|
||||
vault_path: vaultPath,
|
||||
old_path: path,
|
||||
new_filename_stem: newFilenameStem,
|
||||
return invokeRenameCommand({
|
||||
command: 'rename_note_filename',
|
||||
tauriArgs: { vaultPath, oldPath: path, newFilenameStem },
|
||||
mockArgs: { vault_path: vaultPath, old_path: path, new_filename_stem: newFilenameStem },
|
||||
})
|
||||
}
|
||||
|
||||
export async function performMoveNoteToFolder({
|
||||
path,
|
||||
folderPath,
|
||||
vaultPath,
|
||||
}: FolderMoveRequest): Promise<RenameResult> {
|
||||
return invokeRenameCommand({
|
||||
command: 'move_note_to_folder',
|
||||
tauriArgs: { vaultPath, oldPath: path, folderPath },
|
||||
mockArgs: { vault_path: vaultPath, old_path: path, folder_path: folderPath },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,16 +113,59 @@ export async function loadNoteContent({ path }: LoadNoteContentRequest): Promise
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
}
|
||||
|
||||
export function renameToastMessage(updatedFiles: number, failedUpdates = 0): string {
|
||||
function rewriteSummaryLabel(params: { updatedFiles: number }): string {
|
||||
return params.updatedFiles === 1 ? 'Updated 1 note' : `Updated ${params.updatedFiles} notes`
|
||||
}
|
||||
|
||||
function manualUpdateWarning(params: { failedUpdates: number }): string {
|
||||
const { failedUpdates } = params
|
||||
return `${failedUpdates} linked note${failedUpdates > 1 ? 's' : ''} need${failedUpdates === 1 ? 's' : ''} manual updates`
|
||||
}
|
||||
|
||||
function formatRewriteToast(
|
||||
params: {
|
||||
action: string
|
||||
updatedFiles: number
|
||||
failedUpdates?: number
|
||||
preferBareUpdate?: boolean
|
||||
},
|
||||
): string {
|
||||
const {
|
||||
action,
|
||||
updatedFiles,
|
||||
failedUpdates = 0,
|
||||
preferBareUpdate = false,
|
||||
} = params
|
||||
if (failedUpdates > 0) {
|
||||
const noteLabel = `${failedUpdates} linked note${failedUpdates > 1 ? 's' : ''}`
|
||||
if (updatedFiles === 0) {
|
||||
return `Renamed, but ${noteLabel} need${failedUpdates === 1 ? 's' : ''} manual updates`
|
||||
return `${action}, but ${manualUpdateWarning({ failedUpdates })}`
|
||||
}
|
||||
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}, but ${noteLabel} need${failedUpdates === 1 ? 's' : ''} manual updates`
|
||||
if (preferBareUpdate) {
|
||||
return `${rewriteSummaryLabel({ updatedFiles })}, but ${manualUpdateWarning({ failedUpdates })}`
|
||||
}
|
||||
return `${action} and ${rewriteSummaryLabel({ updatedFiles }).toLowerCase()}, but ${manualUpdateWarning({ failedUpdates })}`
|
||||
}
|
||||
if (updatedFiles === 0) return 'Renamed'
|
||||
return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}`
|
||||
if (updatedFiles === 0) return action
|
||||
return preferBareUpdate
|
||||
? rewriteSummaryLabel({ updatedFiles })
|
||||
: `${action} and ${rewriteSummaryLabel({ updatedFiles }).toLowerCase()}`
|
||||
}
|
||||
|
||||
export function renameToastMessage(updatedFiles: number, failedUpdates = 0): string {
|
||||
return formatRewriteToast({ action: 'Renamed', updatedFiles, failedUpdates, preferBareUpdate: true })
|
||||
}
|
||||
|
||||
function folderLabel(params: { folderPath: string }): string {
|
||||
const trimmed = params.folderPath.trim().replace(/^\/+|\/+$/g, '')
|
||||
return trimmed.split('/').filter(Boolean).at(-1) ?? trimmed
|
||||
}
|
||||
|
||||
function moveToastMessage(folderPath: string, updatedFiles: number, failedUpdates = 0): string {
|
||||
return formatRewriteToast({
|
||||
action: `Moved to "${folderLabel({ folderPath })}"`,
|
||||
updatedFiles,
|
||||
failedUpdates,
|
||||
})
|
||||
}
|
||||
|
||||
export async function reloadVaultAfterRename(reloadVault?: () => Promise<unknown>): Promise<void> {
|
||||
@@ -137,6 +206,15 @@ function renameErrorMessage(err: unknown): string {
|
||||
return 'Failed to rename note'
|
||||
}
|
||||
|
||||
function moveNoteErrorMessage(err: unknown): string {
|
||||
const message = typeof err === 'string'
|
||||
? err.trim()
|
||||
: err instanceof Error
|
||||
? err.message.trim()
|
||||
: ''
|
||||
return message || 'Failed to move note'
|
||||
}
|
||||
|
||||
export interface NoteRenameConfig {
|
||||
entries: VaultEntry[]
|
||||
setToastMessage: (msg: string | null) => void
|
||||
@@ -151,7 +229,14 @@ interface RenameTabDeps {
|
||||
updateTabContent: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps) {
|
||||
interface ApplyRenameOptions {
|
||||
successMessage?: (result: RenameResult) => string
|
||||
}
|
||||
|
||||
function useRenameResultApplier(
|
||||
config: NoteRenameConfig,
|
||||
tabDeps: RenameTabDeps,
|
||||
) {
|
||||
const { entries, setToastMessage, reloadVault } = config
|
||||
const { setTabs, activeTabPathRef, handleSwitchTab, updateTabContent } = tabDeps
|
||||
|
||||
@@ -164,6 +249,7 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
|
||||
result: RenameResult,
|
||||
buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
options?: ApplyRenameOptions,
|
||||
) => {
|
||||
const entry = entries.find((item) => item.path === oldPath)
|
||||
const newContent = await loadNoteContent({ path: result.new_path })
|
||||
@@ -174,26 +260,79 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
|
||||
onEntryRenamed(oldPath, newEntry, newContent)
|
||||
await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent })
|
||||
await reloadVaultAfterRename(reloadVault)
|
||||
setToastMessage(renameToastMessage(result.updated_files, result.failed_updates ?? 0))
|
||||
const successMessage = options?.successMessage
|
||||
? options.successMessage(result)
|
||||
: renameToastMessage(result.updated_files, result.failed_updates ?? 0)
|
||||
setToastMessage(successMessage)
|
||||
return result
|
||||
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, reloadVault, setToastMessage])
|
||||
|
||||
return {
|
||||
tabsRef,
|
||||
applyRenameResult,
|
||||
}
|
||||
}
|
||||
|
||||
async function runRenameAction({
|
||||
path,
|
||||
perform,
|
||||
applyRenameResult,
|
||||
buildEntry,
|
||||
onEntryRenamed,
|
||||
setToastMessage,
|
||||
errorMessage,
|
||||
logLabel,
|
||||
successMessage,
|
||||
allowUnchangedResult = false,
|
||||
}: {
|
||||
path: string
|
||||
perform: () => Promise<RenameResult>
|
||||
applyRenameResult: (
|
||||
oldPath: string,
|
||||
result: RenameResult,
|
||||
buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
options?: ApplyRenameOptions,
|
||||
) => Promise<RenameResult>
|
||||
buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
|
||||
setToastMessage: (message: string | null) => void
|
||||
errorMessage: (err: unknown) => string
|
||||
logLabel: string
|
||||
successMessage?: (result: RenameResult) => string
|
||||
allowUnchangedResult?: boolean
|
||||
}): Promise<RenameResult | null> {
|
||||
try {
|
||||
const result = await perform()
|
||||
if (allowUnchangedResult && result.new_path === path) return result
|
||||
await applyRenameResult(path, result, buildEntry, onEntryRenamed, { successMessage })
|
||||
return result
|
||||
} catch (err) {
|
||||
console.error(`${logLabel}:`, err)
|
||||
setToastMessage(errorMessage(err))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps) {
|
||||
const { entries, setToastMessage } = config
|
||||
const { tabsRef, applyRenameResult } = useRenameResultApplier(config, tabDeps)
|
||||
|
||||
const handleRenameNote = useCallback(async (
|
||||
path: string, newTitle: string, vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
const result = await performRename({ path, newTitle, vaultPath, oldTitle: entry?.title })
|
||||
await applyRenameResult(
|
||||
path,
|
||||
result,
|
||||
(currentEntry, newPath) => buildRenamedEntry(currentEntry ?? {} as VaultEntry, newTitle, newPath),
|
||||
onEntryRenamed,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note:', err)
|
||||
setToastMessage(renameErrorMessage(err))
|
||||
}
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
await runRenameAction({
|
||||
path,
|
||||
perform: () => performRename({ path, newTitle, vaultPath, oldTitle: entry?.title }),
|
||||
applyRenameResult,
|
||||
buildEntry: (currentEntry, newPath) => buildRenamedEntry(currentEntry ?? {} as VaultEntry, newTitle, newPath),
|
||||
onEntryRenamed,
|
||||
setToastMessage,
|
||||
errorMessage: renameErrorMessage,
|
||||
logLabel: 'Failed to rename note',
|
||||
})
|
||||
}, [entries, applyRenameResult, setToastMessage])
|
||||
|
||||
const handleRenameFilename = useCallback(async (
|
||||
@@ -202,19 +341,42 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const result = await performFilenameRename({ path, newFilenameStem, vaultPath })
|
||||
await applyRenameResult(
|
||||
path,
|
||||
result,
|
||||
(currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath),
|
||||
onEntryRenamed,
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note filename:', err)
|
||||
setToastMessage(renameErrorMessage(err))
|
||||
}
|
||||
await runRenameAction({
|
||||
path,
|
||||
perform: () => performFilenameRename({ path, newFilenameStem, vaultPath }),
|
||||
applyRenameResult,
|
||||
buildEntry: (currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath),
|
||||
onEntryRenamed,
|
||||
setToastMessage,
|
||||
errorMessage: renameErrorMessage,
|
||||
logLabel: 'Failed to rename note filename',
|
||||
})
|
||||
}, [applyRenameResult, setToastMessage])
|
||||
|
||||
return { handleRenameNote, handleRenameFilename, tabsRef }
|
||||
const handleMoveNoteToFolder = useCallback(async (
|
||||
path: string,
|
||||
folderPath: string,
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
const normalizedFolderPath = folderPath.trim().replace(/^\/+|\/+$/g, '')
|
||||
return runRenameAction({
|
||||
path,
|
||||
perform: () => performMoveNoteToFolder({ path, folderPath: normalizedFolderPath, vaultPath }),
|
||||
applyRenameResult,
|
||||
buildEntry: (currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath),
|
||||
onEntryRenamed,
|
||||
setToastMessage,
|
||||
errorMessage: moveNoteErrorMessage,
|
||||
logLabel: 'Failed to move note to folder',
|
||||
successMessage: (result) => moveToastMessage(
|
||||
normalizedFolderPath,
|
||||
result.updated_files,
|
||||
result.failed_updates ?? 0,
|
||||
),
|
||||
allowUnchangedResult: true,
|
||||
})
|
||||
}, [applyRenameResult, setToastMessage])
|
||||
|
||||
return { handleRenameNote, handleRenameFilename, handleMoveNoteToFolder, tabsRef }
|
||||
}
|
||||
|
||||
132
src/hooks/useNoteRetargeting.test.ts
Normal file
132
src/hooks/useNoteRetargeting.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FolderNode, SidebarSelection, VaultEntry } from '../types'
|
||||
import { useNoteRetargeting } from './useNoteRetargeting'
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/notes/alpha.md',
|
||||
filename: 'alpha.md',
|
||||
title: 'Alpha',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 10,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
sort: null,
|
||||
sidebarLabel: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const folders: FolderNode[] = [
|
||||
{ name: 'notes', path: 'notes', children: [] },
|
||||
{ name: 'projects', path: 'projects', children: [] },
|
||||
]
|
||||
|
||||
describe('useNoteRetargeting', () => {
|
||||
const setSelection = vi.fn()
|
||||
const setToastMessage = vi.fn()
|
||||
const updateFrontmatter = vi.fn()
|
||||
const moveNoteToFolder = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderUseNoteRetargeting(
|
||||
selection: SidebarSelection,
|
||||
entries: VaultEntry[] = [
|
||||
makeEntry(),
|
||||
makeEntry({ path: '/vault/type/project.md', filename: 'project.md', title: 'Project', isA: 'Type' }),
|
||||
],
|
||||
) {
|
||||
return renderHook(() => useNoteRetargeting({
|
||||
entries,
|
||||
folders,
|
||||
selection,
|
||||
setSelection,
|
||||
setToastMessage,
|
||||
vaultPath: '/vault',
|
||||
updateFrontmatter,
|
||||
moveNoteToFolder,
|
||||
}))
|
||||
}
|
||||
|
||||
it('rejects dropping a note onto its current type and allows other types', () => {
|
||||
const { result } = renderUseNoteRetargeting({ kind: 'filter', filter: 'all' })
|
||||
|
||||
expect(result.current.canDropNoteOnType('/vault/notes/alpha.md', 'Note')).toBe(false)
|
||||
expect(result.current.canDropNoteOnType('/vault/notes/alpha.md', 'Type')).toBe(true)
|
||||
})
|
||||
|
||||
it('changes the note type, updates entity selection, and shows a toast', async () => {
|
||||
updateFrontmatter.mockResolvedValue(undefined)
|
||||
const selection: SidebarSelection = { kind: 'entity', entry: makeEntry() }
|
||||
const { result } = renderUseNoteRetargeting(selection)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.changeNoteType('/vault/notes/alpha.md', 'Type')
|
||||
})
|
||||
|
||||
expect(updateFrontmatter).toHaveBeenCalledWith(
|
||||
'/vault/notes/alpha.md',
|
||||
'type',
|
||||
'Type',
|
||||
{ silent: true },
|
||||
)
|
||||
expect(setSelection).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: expect.objectContaining({
|
||||
path: '/vault/notes/alpha.md',
|
||||
isA: 'Type',
|
||||
}),
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Type set to "Type"')
|
||||
})
|
||||
|
||||
it('moves the note into another folder and updates the selected entity path', async () => {
|
||||
const selection: SidebarSelection = { kind: 'entity', entry: makeEntry() }
|
||||
moveNoteToFolder.mockImplementation(async (
|
||||
path: string,
|
||||
folderPath: string,
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }) => void,
|
||||
) => {
|
||||
onEntryRenamed(path, { path: '/vault/projects/alpha.md', filename: 'alpha.md' })
|
||||
return { new_path: '/vault/projects/alpha.md' }
|
||||
})
|
||||
const { result } = renderUseNoteRetargeting(selection)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.moveIntoFolder('/vault/notes/alpha.md', 'projects')
|
||||
})
|
||||
|
||||
expect(moveNoteToFolder).toHaveBeenCalledWith(
|
||||
'/vault/notes/alpha.md',
|
||||
'projects',
|
||||
'/vault',
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(setSelection).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: expect.objectContaining({
|
||||
path: '/vault/projects/alpha.md',
|
||||
filename: 'alpha.md',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
241
src/hooks/useNoteRetargeting.ts
Normal file
241
src/hooks/useNoteRetargeting.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { FolderNode, SidebarSelection, VaultEntry } from '../types'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { extractVaultTypes } from '../utils/vaultTypes'
|
||||
|
||||
type RetargetResult = 'updated' | 'noop' | 'error'
|
||||
|
||||
export interface RetargetFolderOption {
|
||||
path: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface NoteRetargetingInput {
|
||||
entries: VaultEntry[]
|
||||
folders: FolderNode[]
|
||||
selection: SidebarSelection
|
||||
setSelection: (selection: SidebarSelection) => void
|
||||
setToastMessage: (message: string | null) => void
|
||||
vaultPath: string
|
||||
updateFrontmatter: (
|
||||
path: string,
|
||||
key: string,
|
||||
value: string,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => Promise<void>
|
||||
moveNoteToFolder: (
|
||||
path: string,
|
||||
folderPath: string,
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (
|
||||
oldPath: string,
|
||||
newEntry: Partial<VaultEntry> & { path: string },
|
||||
newContent: string,
|
||||
) => void,
|
||||
) => Promise<{ new_path: string } | null>
|
||||
}
|
||||
|
||||
function normalizeFolderPath(params: { folderPath: string }): string {
|
||||
return params.folderPath.trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
function folderPathForEntry(params: { entry: VaultEntry; vaultPath: string }): string {
|
||||
const normalizedVaultPath = params.vaultPath.replace(/\/+$/, '')
|
||||
const relativePath = params.entry.path.startsWith(`${normalizedVaultPath}/`)
|
||||
? params.entry.path.slice(normalizedVaultPath.length + 1)
|
||||
: params.entry.filename
|
||||
const lastSlashIndex = relativePath.lastIndexOf('/')
|
||||
return lastSlashIndex >= 0 ? relativePath.slice(0, lastSlashIndex) : ''
|
||||
}
|
||||
|
||||
function flattenFolders(nodes: FolderNode[]): RetargetFolderOption[] {
|
||||
return nodes.flatMap((node) => [
|
||||
{ path: node.path, label: node.name },
|
||||
...flattenFolders(node.children),
|
||||
])
|
||||
}
|
||||
|
||||
function entryByPath(params: { entries: VaultEntry[]; notePath: string }): VaultEntry | undefined {
|
||||
return params.entries.find((entry) => entry.path === params.notePath)
|
||||
}
|
||||
|
||||
function canRetargetEntryToType(params: { entry: VaultEntry | undefined; type: string }): boolean {
|
||||
return !!params.entry && params.entry.isA !== params.type
|
||||
}
|
||||
|
||||
function canRetargetEntryToFolder(
|
||||
params: {
|
||||
entry: VaultEntry | undefined
|
||||
folderPath: string
|
||||
vaultPath: string
|
||||
},
|
||||
): boolean {
|
||||
if (!params.entry) return false
|
||||
return folderPathForEntry({ entry: params.entry, vaultPath: params.vaultPath })
|
||||
!== normalizeFolderPath({ folderPath: params.folderPath })
|
||||
}
|
||||
|
||||
function updateEntitySelection(
|
||||
selection: SidebarSelection,
|
||||
setSelection: (selection: SidebarSelection) => void,
|
||||
notePath: string,
|
||||
patch: Partial<VaultEntry> & { path: string },
|
||||
) {
|
||||
if (selection.kind !== 'entity' || selection.entry.path !== notePath) return
|
||||
setSelection({
|
||||
kind: 'entity',
|
||||
entry: {
|
||||
...selection.entry,
|
||||
...patch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function changeEntryType({
|
||||
entry,
|
||||
notePath,
|
||||
nextType,
|
||||
selection,
|
||||
setSelection,
|
||||
setToastMessage,
|
||||
updateFrontmatter,
|
||||
}: {
|
||||
entry: VaultEntry | undefined
|
||||
notePath: string
|
||||
nextType: string
|
||||
selection: SidebarSelection
|
||||
setSelection: (selection: SidebarSelection) => void
|
||||
setToastMessage: (message: string | null) => void
|
||||
updateFrontmatter: (
|
||||
path: string,
|
||||
key: string,
|
||||
value: string,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => Promise<void>
|
||||
}): Promise<RetargetResult> {
|
||||
const normalizedType = nextType.trim()
|
||||
if (!entry || !normalizedType) return 'error'
|
||||
if (entry.isA === normalizedType) return 'noop'
|
||||
|
||||
try {
|
||||
await updateFrontmatter(notePath, 'type', normalizedType, { silent: true })
|
||||
updateEntitySelection(selection, setSelection, notePath, { path: notePath, isA: normalizedType })
|
||||
setToastMessage(`Type set to "${normalizedType}"`)
|
||||
return 'updated'
|
||||
} catch (error) {
|
||||
console.error('Failed to change note type:', error)
|
||||
setToastMessage(typeof error === 'string' ? error : 'Failed to change note type')
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function moveEntryToFolder({
|
||||
entry,
|
||||
notePath,
|
||||
folderPath,
|
||||
vaultPath,
|
||||
selection,
|
||||
setSelection,
|
||||
moveNoteToFolder,
|
||||
}: {
|
||||
entry: VaultEntry | undefined
|
||||
notePath: string
|
||||
folderPath: string
|
||||
vaultPath: string
|
||||
selection: SidebarSelection
|
||||
setSelection: (selection: SidebarSelection) => void
|
||||
moveNoteToFolder: (
|
||||
path: string,
|
||||
folderPath: string,
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (
|
||||
oldPath: string,
|
||||
newEntry: Partial<VaultEntry> & { path: string },
|
||||
newContent: string,
|
||||
) => void,
|
||||
) => Promise<{ new_path: string } | null>
|
||||
}): Promise<RetargetResult> {
|
||||
const normalizedFolderPath = normalizeFolderPath({ folderPath })
|
||||
if (!entry || !normalizedFolderPath) return 'error'
|
||||
if (folderPathForEntry({ entry, vaultPath }) === normalizedFolderPath) return 'noop'
|
||||
|
||||
const result = await moveNoteToFolder(
|
||||
notePath,
|
||||
normalizedFolderPath,
|
||||
vaultPath,
|
||||
(oldPath, newEntry) => updateEntitySelection(selection, setSelection, oldPath, newEntry),
|
||||
)
|
||||
if (!result) return 'error'
|
||||
return result.new_path === notePath ? 'noop' : 'updated'
|
||||
}
|
||||
|
||||
export function useNoteRetargeting({
|
||||
entries,
|
||||
folders,
|
||||
selection,
|
||||
setSelection,
|
||||
setToastMessage,
|
||||
vaultPath,
|
||||
updateFrontmatter,
|
||||
moveNoteToFolder,
|
||||
}: NoteRetargetingInput) {
|
||||
const availableTypes = useMemo(
|
||||
() => extractVaultTypes(entries).sort((left, right) => left.localeCompare(right)),
|
||||
[entries],
|
||||
)
|
||||
const availableFolders = useMemo(() => flattenFolders(folders), [folders])
|
||||
|
||||
const canDropNoteOnType = useCallback((notePath: string, type: string) => {
|
||||
return canRetargetEntryToType({
|
||||
entry: entryByPath({ entries, notePath }),
|
||||
type,
|
||||
})
|
||||
}, [entries])
|
||||
|
||||
const canDropNoteOnFolder = useCallback((notePath: string, folderPath: string) => {
|
||||
return canRetargetEntryToFolder({
|
||||
entry: entryByPath({ entries, notePath }),
|
||||
folderPath,
|
||||
vaultPath,
|
||||
})
|
||||
}, [entries, vaultPath])
|
||||
|
||||
const changeNoteType = useCallback(async (
|
||||
notePath: string,
|
||||
nextType: string,
|
||||
): Promise<RetargetResult> => {
|
||||
return changeEntryType({
|
||||
entry: entryByPath({ entries, notePath }),
|
||||
notePath,
|
||||
nextType,
|
||||
selection,
|
||||
setSelection,
|
||||
setToastMessage,
|
||||
updateFrontmatter,
|
||||
})
|
||||
}, [entries, selection, setSelection, setToastMessage, updateFrontmatter])
|
||||
|
||||
const moveIntoFolder = useCallback(async (
|
||||
notePath: string,
|
||||
folderPath: string,
|
||||
): Promise<RetargetResult> => {
|
||||
return moveEntryToFolder({
|
||||
entry: entryByPath({ entries, notePath }),
|
||||
notePath,
|
||||
folderPath,
|
||||
vaultPath,
|
||||
selection,
|
||||
setSelection,
|
||||
moveNoteToFolder,
|
||||
})
|
||||
}, [entries, moveNoteToFolder, selection, setSelection, vaultPath])
|
||||
|
||||
return {
|
||||
availableTypes,
|
||||
availableFolders,
|
||||
canDropNoteOnType,
|
||||
canDropNoteOnFolder,
|
||||
changeNoteType,
|
||||
moveIntoFolder,
|
||||
}
|
||||
}
|
||||
249
src/hooks/useNoteRetargetingUi.ts
Normal file
249
src/hooks/useNoteRetargetingUi.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { NoteRetargetingContextValue } from '../components/note-retargeting/noteRetargetingContext'
|
||||
import type { RetargetOption } from '../components/note-retargeting/RetargetNoteDialog'
|
||||
import type { FolderNode, SidebarSelection, VaultEntry } from '../types'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { useNoteRetargeting } from './useNoteRetargeting'
|
||||
|
||||
type DialogState =
|
||||
| { kind: 'type'; notePath: string }
|
||||
| { kind: 'folder'; notePath: string }
|
||||
| null
|
||||
|
||||
interface NoteRetargetingUiInput {
|
||||
activeEntry: VaultEntry | null
|
||||
activeNoteBlocked: boolean
|
||||
entries: VaultEntry[]
|
||||
folders: FolderNode[]
|
||||
selection: SidebarSelection
|
||||
setSelection: (selection: SidebarSelection) => void
|
||||
setToastMessage: (message: string | null) => void
|
||||
vaultPath: string
|
||||
updateFrontmatter: (
|
||||
path: string,
|
||||
key: string,
|
||||
value: string,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => Promise<void>
|
||||
moveNoteToFolder: (
|
||||
path: string,
|
||||
folderPath: string,
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (
|
||||
oldPath: string,
|
||||
newEntry: Partial<VaultEntry> & { path: string },
|
||||
newContent: string,
|
||||
) => void,
|
||||
) => Promise<{ new_path: string } | null>
|
||||
}
|
||||
|
||||
function folderPathForNote(notePath: string, vaultPath: string): string {
|
||||
const normalizedVaultPath = vaultPath.replace(/\/+$/, '')
|
||||
const relativePath = notePath.startsWith(`${normalizedVaultPath}/`)
|
||||
? notePath.slice(normalizedVaultPath.length + 1)
|
||||
: notePath
|
||||
const lastSlashIndex = relativePath.lastIndexOf('/')
|
||||
return lastSlashIndex >= 0 ? relativePath.slice(0, lastSlashIndex) : ''
|
||||
}
|
||||
|
||||
function buildTypeOptions(types: string[], entry: VaultEntry | null): RetargetOption[] {
|
||||
if (!entry) return []
|
||||
return types.map((type) => ({
|
||||
id: type,
|
||||
label: type,
|
||||
current: entry.isA === type,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildFolderOptions(
|
||||
folders: Array<{ path: string; label: string }>,
|
||||
entry: VaultEntry | null,
|
||||
vaultPath: string,
|
||||
): RetargetOption[] {
|
||||
if (!entry) return []
|
||||
|
||||
const currentFolderPath = folderPathForNote(entry.path, vaultPath)
|
||||
return folders.map((folder) => ({
|
||||
id: folder.path,
|
||||
label: folder.label,
|
||||
detail: folder.path === folder.label ? undefined : folder.path,
|
||||
current: folder.path === currentFolderPath,
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveDialogEntry(
|
||||
dialogState: DialogState,
|
||||
entries: VaultEntry[],
|
||||
activeEntry: VaultEntry | null,
|
||||
): VaultEntry | null {
|
||||
if (!dialogState) return null
|
||||
return entries.find((entry) => entry.path === dialogState.notePath)
|
||||
?? (activeEntry?.path === dialogState.notePath ? activeEntry : null)
|
||||
}
|
||||
|
||||
function hasTypeRetargetDestination(activeEntry: VaultEntry | null, activeNoteBlocked: boolean, types: string[]): boolean {
|
||||
return !!activeEntry && !activeNoteBlocked && types.some((type) => type !== activeEntry.isA)
|
||||
}
|
||||
|
||||
function hasFolderRetargetDestination(
|
||||
activeEntry: VaultEntry | null,
|
||||
activeNoteBlocked: boolean,
|
||||
folders: Array<{ path: string; label: string }>,
|
||||
canDropNoteOnFolder: (notePath: string, folderPath: string) => boolean,
|
||||
): boolean {
|
||||
return !!activeEntry
|
||||
&& !activeNoteBlocked
|
||||
&& folders.some((folder) => canDropNoteOnFolder(activeEntry.path, folder.path))
|
||||
}
|
||||
|
||||
function openDialogForActiveEntry(
|
||||
setDialogState: Dispatch<SetStateAction<DialogState>>,
|
||||
activeEntry: VaultEntry | null,
|
||||
enabled: boolean,
|
||||
kind: 'type' | 'folder',
|
||||
) {
|
||||
if (!activeEntry || !enabled) return
|
||||
setDialogState({ kind, notePath: activeEntry.path })
|
||||
}
|
||||
|
||||
async function selectFromDialogState(
|
||||
dialogState: DialogState,
|
||||
kind: 'type' | 'folder',
|
||||
value: string,
|
||||
runSelection: (notePath: string, value: string) => Promise<'updated' | 'noop' | 'error'>,
|
||||
): Promise<boolean> {
|
||||
if (!dialogState || dialogState.kind !== kind) return false
|
||||
const result = await runSelection(dialogState.notePath, value)
|
||||
return result !== 'error'
|
||||
}
|
||||
|
||||
function useNoteRetargetDialogState({
|
||||
activeEntry,
|
||||
canChangeActiveNoteType,
|
||||
canMoveActiveNoteToFolder,
|
||||
changeNoteType,
|
||||
moveIntoFolder,
|
||||
}: {
|
||||
activeEntry: VaultEntry | null
|
||||
canChangeActiveNoteType: boolean
|
||||
canMoveActiveNoteToFolder: boolean
|
||||
changeNoteType: (notePath: string, type: string) => Promise<'updated' | 'noop' | 'error'>
|
||||
moveIntoFolder: (notePath: string, folderPath: string) => Promise<'updated' | 'noop' | 'error'>
|
||||
}) {
|
||||
const [dialogState, setDialogState] = useState<DialogState>(null)
|
||||
|
||||
const openChangeNoteTypeDialog = useCallback(() => {
|
||||
openDialogForActiveEntry(setDialogState, activeEntry, canChangeActiveNoteType, 'type')
|
||||
}, [activeEntry, canChangeActiveNoteType])
|
||||
|
||||
const openMoveNoteToFolderDialog = useCallback(() => {
|
||||
openDialogForActiveEntry(setDialogState, activeEntry, canMoveActiveNoteToFolder, 'folder')
|
||||
}, [activeEntry, canMoveActiveNoteToFolder])
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogState(null)
|
||||
}, [])
|
||||
|
||||
const selectType = useCallback(async (type: string) => {
|
||||
return selectFromDialogState(dialogState, 'type', type, changeNoteType)
|
||||
}, [changeNoteType, dialogState])
|
||||
|
||||
const selectFolder = useCallback(async (folderPath: string) => {
|
||||
return selectFromDialogState(dialogState, 'folder', folderPath, moveIntoFolder)
|
||||
}, [dialogState, moveIntoFolder])
|
||||
|
||||
return {
|
||||
dialogState,
|
||||
openChangeNoteTypeDialog,
|
||||
openMoveNoteToFolderDialog,
|
||||
closeDialog,
|
||||
selectType,
|
||||
selectFolder,
|
||||
}
|
||||
}
|
||||
|
||||
function useRetargetContextValue({
|
||||
canDropNoteOnType,
|
||||
changeNoteType,
|
||||
canDropNoteOnFolder,
|
||||
moveIntoFolder,
|
||||
}: {
|
||||
canDropNoteOnType: (notePath: string, type: string) => boolean
|
||||
changeNoteType: (notePath: string, type: string) => Promise<'updated' | 'noop' | 'error'>
|
||||
canDropNoteOnFolder: (notePath: string, folderPath: string) => boolean
|
||||
moveIntoFolder: (notePath: string, folderPath: string) => Promise<'updated' | 'noop' | 'error'>
|
||||
}) {
|
||||
return useMemo<NoteRetargetingContextValue>(() => ({
|
||||
canDropNoteOnType,
|
||||
dropNoteOnType: async (notePath, type) => {
|
||||
await changeNoteType(notePath, type)
|
||||
},
|
||||
canDropNoteOnFolder,
|
||||
dropNoteOnFolder: async (notePath, folderPath) => {
|
||||
await moveIntoFolder(notePath, folderPath)
|
||||
},
|
||||
}), [canDropNoteOnFolder, canDropNoteOnType, changeNoteType, moveIntoFolder])
|
||||
}
|
||||
|
||||
function buildDialogOptions(
|
||||
availableTypes: string[],
|
||||
availableFolders: Array<{ path: string; label: string }>,
|
||||
dialogEntry: VaultEntry | null,
|
||||
vaultPath: string,
|
||||
) {
|
||||
return {
|
||||
typeOptions: buildTypeOptions(availableTypes, dialogEntry),
|
||||
folderOptions: buildFolderOptions(availableFolders, dialogEntry, vaultPath),
|
||||
}
|
||||
}
|
||||
|
||||
function buildNoteRetargetingUiState(params: {
|
||||
contextValue: NoteRetargetingContextValue
|
||||
dialogState: DialogState
|
||||
dialogEntry: VaultEntry | null
|
||||
canChangeActiveNoteType: boolean
|
||||
canMoveActiveNoteToFolder: boolean
|
||||
openChangeNoteTypeDialog: () => void
|
||||
openMoveNoteToFolderDialog: () => void
|
||||
typeOptions: RetargetOption[]
|
||||
folderOptions: RetargetOption[]
|
||||
closeDialog: () => void
|
||||
selectType: (type: string) => Promise<boolean>
|
||||
selectFolder: (folderPath: string) => Promise<boolean>
|
||||
}) {
|
||||
return {
|
||||
contextValue: params.contextValue,
|
||||
isDialogOpen: params.dialogState !== null,
|
||||
dialogState: params.dialogState,
|
||||
dialogEntry: params.dialogEntry,
|
||||
canChangeActiveNoteType: params.canChangeActiveNoteType,
|
||||
canMoveActiveNoteToFolder: params.canMoveActiveNoteToFolder,
|
||||
openChangeNoteTypeDialog: params.openChangeNoteTypeDialog,
|
||||
openMoveNoteToFolderDialog: params.openMoveNoteToFolderDialog,
|
||||
typeOptions: params.typeOptions,
|
||||
folderOptions: params.folderOptions,
|
||||
closeDialog: params.closeDialog,
|
||||
selectType: params.selectType,
|
||||
selectFolder: params.selectFolder,
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteRetargetingUi({
|
||||
activeEntry, activeNoteBlocked, entries, folders, selection, setSelection, setToastMessage, vaultPath, updateFrontmatter, moveNoteToFolder,
|
||||
}: NoteRetargetingUiInput) {
|
||||
const {
|
||||
availableTypes, availableFolders, canDropNoteOnType, canDropNoteOnFolder, changeNoteType, moveIntoFolder,
|
||||
} = useNoteRetargeting({ entries, folders, selection, setSelection, setToastMessage, vaultPath, updateFrontmatter, moveNoteToFolder })
|
||||
const canChangeActiveNoteType = hasTypeRetargetDestination(activeEntry, activeNoteBlocked, availableTypes)
|
||||
const canMoveActiveNoteToFolder = hasFolderRetargetDestination(activeEntry, activeNoteBlocked, availableFolders, canDropNoteOnFolder)
|
||||
const { dialogState, openChangeNoteTypeDialog, openMoveNoteToFolderDialog, closeDialog, selectType, selectFolder } = useNoteRetargetDialogState({
|
||||
activeEntry, canChangeActiveNoteType, canMoveActiveNoteToFolder, changeNoteType, moveIntoFolder,
|
||||
})
|
||||
const dialogEntry = useMemo(() => resolveDialogEntry(dialogState, entries, activeEntry), [activeEntry, dialogState, entries])
|
||||
const contextValue = useRetargetContextValue({ canDropNoteOnType, changeNoteType, canDropNoteOnFolder, moveIntoFolder })
|
||||
const { typeOptions, folderOptions } = buildDialogOptions(availableTypes, availableFolders, dialogEntry, vaultPath)
|
||||
return buildNoteRetargetingUiState({
|
||||
contextValue, dialogState, dialogEntry, canChangeActiveNoteType, canMoveActiveNoteToFolder,
|
||||
openChangeNoteTypeDialog, openMoveNoteToFolderDialog, typeOptions, folderOptions, closeDialog, selectType, selectFolder,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
88
src/main.test.ts
Normal 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: '' })
|
||||
})
|
||||
})
|
||||
16
src/main.tsx
16
src/main.tsx
@@ -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 />
|
||||
|
||||
@@ -269,6 +269,42 @@ function handleRenameNoteFilename(args: {
|
||||
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
|
||||
}
|
||||
|
||||
function handleMoveNoteToFolder(args: {
|
||||
vault_path: string
|
||||
old_path: string
|
||||
folder_path: string
|
||||
}) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
const oldFilename = args.old_path.split('/').pop() ?? ''
|
||||
const normalizedFolderPath = args.folder_path.trim().replace(/^\/+|\/+$/g, '')
|
||||
|
||||
if (!normalizedFolderPath) {
|
||||
throw new Error('Folder path cannot be empty')
|
||||
}
|
||||
|
||||
const vaultRoot = args.vault_path.replace(/\/+$/, '')
|
||||
const newPath = `${vaultRoot}/${normalizedFolderPath}/${oldFilename}`
|
||||
if (newPath === args.old_path) {
|
||||
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(MOCK_CONTENT, newPath)) {
|
||||
throw new Error('A note with that name already exists')
|
||||
}
|
||||
|
||||
delete MOCK_CONTENT[args.old_path]
|
||||
MOCK_CONTENT[newPath] = oldContent
|
||||
|
||||
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
|
||||
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
|
||||
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
|
||||
export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
list_vault: () => MOCK_ENTRIES,
|
||||
@@ -386,6 +422,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
|
||||
rename_note: handleRenameNote,
|
||||
rename_note_filename: handleRenameNoteFilename,
|
||||
move_note_to_folder: handleMoveNoteToFolder,
|
||||
clone_repo: (args: { url: string; localPath?: string; local_path?: string }) => {
|
||||
const localPath = args.localPath ?? args.local_path ?? ''
|
||||
setMockRemoteState(localPath, true)
|
||||
|
||||
@@ -62,6 +62,16 @@ const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => Vaul
|
||||
new_filename_stem: args.new_filename_stem,
|
||||
},
|
||||
} : null,
|
||||
move_note_to_folder: (args) =>
|
||||
args.old_path && args.folder_path ? {
|
||||
url: '/api/vault/move-to-folder',
|
||||
method: 'POST',
|
||||
body: {
|
||||
vault_path: args.vault_path,
|
||||
old_path: args.old_path,
|
||||
folder_path: args.folder_path,
|
||||
},
|
||||
} : null,
|
||||
delete_note: (args) =>
|
||||
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
|
||||
search_vault: (args) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
165
src/utils/vaultImages.test.ts
Normal file
165
src/utils/vaultImages.test.ts
Normal 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 = ''
|
||||
|
||||
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('is a no-op when vaultPath is empty', () => {
|
||||
tauriMode = true
|
||||
const markdown = ''
|
||||
|
||||
expect(resolveImageUrls(markdown, '')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('converts relative attachment paths to asset URLs', () => {
|
||||
tauriMode = true
|
||||
const markdown = ''
|
||||
|
||||
expect(resolveImageUrls(markdown, '/vault')).toBe(
|
||||
`})`,
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves already-correct asset URLs unchanged', () => {
|
||||
tauriMode = true
|
||||
const url = assetUrl('/vault/attachments/file.png')
|
||||
const markdown = ``
|
||||
|
||||
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 = ``
|
||||
|
||||
expect(resolveImageUrls(markdown, '/Users/john/Documents/Getting Started')).toBe(
|
||||
`})`,
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves external URLs unchanged', () => {
|
||||
tauriMode = true
|
||||
const httpImage = ''
|
||||
const dataImage = ''
|
||||
|
||||
expect(resolveImageUrls(httpImage, '/vault')).toBe(httpImage)
|
||||
expect(resolveImageUrls(dataImage, '/vault')).toBe(dataImage)
|
||||
})
|
||||
|
||||
it('handles multiple images in one document', () => {
|
||||
tauriMode = true
|
||||
const markdown = `})\n\n`
|
||||
|
||||
const result = resolveImageUrls(markdown, '/vault')
|
||||
|
||||
expect(result).toContain(`})`)
|
||||
expect(result).toContain(`})`)
|
||||
})
|
||||
|
||||
it('preserves alt text and title attributes', () => {
|
||||
tauriMode = true
|
||||
const markdown = ''
|
||||
|
||||
expect(resolveImageUrls(markdown, '/vault')).toBe(
|
||||
`} "starter vault")`,
|
||||
)
|
||||
})
|
||||
|
||||
it('skips unknown asset URLs without an attachments segment', () => {
|
||||
tauriMode = true
|
||||
const url = assetUrl('/some/other/path/file.png')
|
||||
const markdown = ``
|
||||
|
||||
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 = ``
|
||||
|
||||
expect(portableImageUrls(markdown, '/vault')).toBe(
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
it('converts legacy asset protocol attachment URLs to relative paths', () => {
|
||||
const url = legacyAssetUrl('/vault/attachments/legacy.png')
|
||||
const markdown = ``
|
||||
|
||||
expect(portableImageUrls(markdown, '/vault')).toBe(
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
it('is a no-op when vaultPath is empty', () => {
|
||||
const url = assetUrl('/vault/attachments/file.png')
|
||||
const markdown = ``
|
||||
|
||||
expect(portableImageUrls(markdown, '')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('leaves asset URLs from other vaults unchanged', () => {
|
||||
const url = assetUrl('/other-vault/attachments/file.png')
|
||||
const markdown = ``
|
||||
|
||||
expect(portableImageUrls(markdown, '/vault')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('leaves relative and external paths unchanged', () => {
|
||||
const relativeImage = ''
|
||||
const httpImage = ''
|
||||
|
||||
expect(portableImageUrls(relativeImage, '/vault')).toBe(relativeImage)
|
||||
expect(portableImageUrls(httpImage, '/vault')).toBe(httpImage)
|
||||
})
|
||||
|
||||
it('handles multiple images', () => {
|
||||
const markdown = `})\n\n})`
|
||||
|
||||
const result = portableImageUrls(markdown, '/vault')
|
||||
|
||||
expect(result).toContain('')
|
||||
expect(result).toContain('')
|
||||
})
|
||||
|
||||
it('preserves title attributes when converting to portable paths', () => {
|
||||
const markdown = `} "starter vault")`
|
||||
|
||||
expect(portableImageUrls(markdown, '/vault')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveImageUrls / portableImageUrls round-trip', () => {
|
||||
it('keeps relative attachment markdown stable', () => {
|
||||
tauriMode = true
|
||||
const markdown = ''
|
||||
|
||||
expect(portableImageUrls(resolveImageUrls(markdown, '/vault'), '/vault')).toBe(markdown)
|
||||
})
|
||||
})
|
||||
100
src/utils/vaultImages.ts
Normal file
100
src/utils/vaultImages.ts
Normal 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:  or .
|
||||
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 ? `` : 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)}`
|
||||
})
|
||||
}
|
||||
121
tests/smoke/checklist-pull-refresh-regression.spec.ts
Normal file
121
tests/smoke/checklist-pull-refresh-regression.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
85
tests/smoke/collision-create-flows.spec.ts
Normal file
85
tests/smoke/collision-create-flows.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function writeFixtureNote(vaultPath: string, filename: string, content: string): string {
|
||||
const notePath = path.join(vaultPath, filename)
|
||||
fs.writeFileSync(notePath, content)
|
||||
return notePath
|
||||
}
|
||||
|
||||
function toast(page: import('@playwright/test').Page) {
|
||||
return page.locator('.fixed.bottom-8')
|
||||
}
|
||||
|
||||
test.describe('Collision-safe create flows', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('missing-type creation keeps the dialog open when a root filename already exists', async ({ page }) => {
|
||||
const collidingPath = writeFixtureNote(
|
||||
tempVaultDir,
|
||||
'hotel.md',
|
||||
'---\ntype: Note\n---\n# Existing Hotel Note\n',
|
||||
)
|
||||
writeFixtureNote(
|
||||
tempVaultDir,
|
||||
'hotel-guide.md',
|
||||
'---\ntype: Hotel\nstatus: Active\n---\n# Hotel Guide\n',
|
||||
)
|
||||
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.getByText('Hotel Guide', { exact: true }).click()
|
||||
await sendShortcut(page, 'i', ['Control', 'Shift'])
|
||||
|
||||
const warning = page.getByTestId('missing-type-warning')
|
||||
await expect(warning).toBeVisible()
|
||||
await warning.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByPlaceholder('e.g. Recipe, Book, Habit...')).toHaveValue('Hotel')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(toast(page)).toContainText('Cannot create type "Hotel" because hotel.md already exists')
|
||||
expect(fs.readFileSync(collidingPath, 'utf8')).toContain('# Existing Hotel Note')
|
||||
})
|
||||
|
||||
test('relationship create-and-open keeps the inline input active on filename collision', async ({ page }) => {
|
||||
const collidingPath = writeFixtureNote(
|
||||
tempVaultDir,
|
||||
'briefing.md',
|
||||
'---\ntype: Note\n---\n# Weekly Sync\n',
|
||||
)
|
||||
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.getByText('Team Meeting', { exact: true }).click()
|
||||
await sendShortcut(page, 'i', ['Control', 'Shift'])
|
||||
|
||||
const addButton = page.getByTestId('add-relation-ref').first()
|
||||
await expect(addButton).toBeVisible()
|
||||
await addButton.click()
|
||||
const input = page.getByTestId('add-relation-ref-input')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('Briefing!')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await page.getByTestId('create-and-open-option').click()
|
||||
|
||||
await expect(input).toBeVisible()
|
||||
await expect(toast(page)).toContainText('Cannot create note "Briefing!" because briefing.md already exists')
|
||||
expect(fs.readFileSync(collidingPath, 'utf8')).toContain('# Weekly Sync')
|
||||
})
|
||||
})
|
||||
162
vite.config.ts
162
vite.config.ts
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user