Compare commits

...

28 Commits

Author SHA1 Message Date
lucaronin
cec1627a81 fix: preserve image toolbar hover bridge 2026-04-18 15:12:39 +02:00
lucaronin
b00183419e fix: show Note metadata in autocomplete 2026-04-18 13:37:15 +02:00
lucaronin
ed9ceff1d2 fix: align Notes counts with explicit type 2026-04-18 13:07:17 +02:00
lucaronin
e18491bcd3 fix: preserve untitled rename session continuity 2026-04-18 12:17:24 +02:00
lucaronin
fd0cfc7fb6 fix: exclude attachments from all notes totals 2026-04-18 11:53:06 +02:00
lucaronin
6541ce5755 test: stabilize wikilink smoke timing 2026-04-18 11:19:41 +02:00
lucaronin
4456801526 fix: keep h1 typing responsive 2026-04-18 11:06:55 +02:00
lucaronin
db7aaf1385 fix: restore note type during vault repair 2026-04-18 10:38:50 +02:00
lucaronin
54b2616fa4 fix: restore manual commit dialog when autogit is off 2026-04-18 09:58:49 +02:00
lucaronin
6a90e9f770 fix: correct editor layout diff callback type 2026-04-18 09:28:18 +02:00
lucaronin
b79c78a5ba fix: save raw note changes before switching notes 2026-04-18 09:28:18 +02:00
Luca Rossi
48a8882970 Reorder CodeScene badges in README 2026-04-18 09:16:06 +02:00
Luca Rossi
98276702a2 Added codescene badges 2026-04-18 09:14:59 +02:00
lucaronin
857d55a7cd fix: stop cmd+s from renaming note titles 2026-04-18 08:25:43 +02:00
lucaronin
b78dfad384 fix: resume onboarding after telemetry on fresh start 2026-04-18 03:10:41 +02:00
lucaronin
9ac51bab9a test: pin app test active vault 2026-04-18 02:35:41 +02:00
lucaronin
1fee994863 test: cover mock vault config path 2026-04-18 02:21:48 +02:00
lucaronin
764bbf0e4c test: scope inbox sidebar assertion 2026-04-18 02:10:54 +02:00
lucaronin
ff05ba0430 chore: ratchet codescene thresholds 2026-04-18 01:56:12 +02:00
lucaronin
347df47c62 fix: stabilize smoke vault harness 2026-04-18 01:50:29 +02:00
lucaronin
7536169508 fix: resume onboarding after telemetry consent 2026-04-18 00:21:52 +02:00
lucaronin
34de384738 fix: preserve yml views in raw editor 2026-04-17 23:32:36 +02:00
lucaronin
954ee42f0b fix: restore seeded vault guidance compatibility 2026-04-17 22:55:34 +02:00
lucaronin
f67aafe442 fix: guard optional filename marks 2026-04-17 22:23:38 +02:00
lucaronin
b2122ac24c fix: stop auto-linking plain filenames 2026-04-17 22:20:48 +02:00
lucaronin
6f8810146e Sync getting-started AGENTS guidance 2026-04-17 19:15:05 +02:00
lucaronin
929ba9241e Refresh getting-started view guidance 2026-04-17 19:08:59 +02:00
lucaronin
c5e84c8218 fix: keep image toolbar controls open 2026-04-17 18:29:37 +02:00
67 changed files with 3722 additions and 741 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.86
AVERAGE_THRESHOLD=9.68
AVERAGE_THRESHOLD=9.7

View File

@@ -1,3 +1,4 @@
[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865)
# Tolaria App
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.

View File

@@ -438,6 +438,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.

View File

@@ -578,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `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`, repairs missing config files |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing root config/type files such as `config.md` and `note.md` |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -615,7 +615,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.md` defaults |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `config.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
@@ -659,7 +659,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|---------|-------------|
| `get_vault_settings` | Read `.laputa/settings.json` |
| `save_vault_settings` | Write vault settings |
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore config |
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore root config/type defaults including `note.md` |
### AI & MCP

View File

@@ -19,6 +19,17 @@ sidebar label: Config
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
";
/// Content for `note.md` — restores the default Note type definition when missing.
const NOTE_TYPE_DEFINITION: &str = "\
---
type: Type
---
# Note
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
";
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
@@ -113,8 +124,8 @@ fn classify_guidance_file(
AiGuidanceFileState::Custom
}
fn guidance_paths(vault_path: &str) -> (PathBuf, PathBuf) {
let vault = Path::new(vault_path);
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
let vault = vault_path;
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
}
@@ -137,7 +148,7 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
)
}
fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus {
let (agents_path, claude_path) = guidance_paths(vault_path);
let agents_state = classify_agents_file(&agents_path);
let claude_state = classify_claude_file(&claude_path);
@@ -150,12 +161,12 @@ fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
}
}
fn sync_claude_shim_file(vault_path: &str) -> Result<bool, String> {
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
let (_, claude_path) = guidance_paths(vault_path);
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
}
fn sync_ai_guidance_files(vault_path: &str) -> Result<bool, String> {
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_agents = sync_default_agents_file(vault_path)?;
let wrote_claude = sync_claude_shim_file(vault_path)?;
Ok(wrote_agents || wrote_claude)
@@ -203,34 +214,45 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
Ok(true)
}
pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
let (agents_path, _) = guidance_paths(vault_path);
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
}
pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
pub fn get_ai_guidance_status(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
Ok(build_ai_guidance_status(Path::new(vault_path.as_ref())))
}
pub fn restore_ai_guidance_files(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = Path::new(vault_path.as_ref());
sync_ai_guidance_files(vault_path)?;
Ok(build_ai_guidance_status(vault_path))
}
pub fn restore_ai_guidance_files(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
sync_ai_guidance_files(vault_path)?;
get_ai_guidance_status(vault_path)
}
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
/// Also seeds `config.md` type definition for sidebar visibility.
pub fn seed_config_files(vault_path: &str) {
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
pub fn seed_config_files(vault_path: impl AsRef<str>) {
let vault_path = Path::new(vault_path.as_ref());
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded vault AI guidance files at vault root");
}
ensure_config_type_definition(vault_path);
ensure_root_type_definitions(vault_path);
}
/// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
fn ensure_config_type_definition(vault_path: &str) {
let path = Path::new(vault_path).join("config.md");
let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str) {
let path = vault_path.join(file_name);
let _ = write_if_missing(&path, content);
}
/// Ensure the default root type definitions exist for opened/repaired vaults.
fn ensure_root_type_definitions(vault_path: &Path) {
ensure_root_type_definition(vault_path, "config.md", CONFIG_TYPE_DEFINITION);
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
}
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
@@ -241,8 +263,8 @@ fn ensure_config_type_definition(vault_path: &str) {
/// - Cleans up empty `config/` directory after migration.
///
/// Always idempotent and silent.
pub fn migrate_agents_md(vault_path: &str) {
let vault = Path::new(vault_path);
pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
let vault = Path::new(vault_path.as_ref());
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
@@ -259,22 +281,23 @@ pub fn migrate_agents_md(vault_path: &str) {
log::info!("Removed empty config/ directory");
}
let _ = sync_ai_guidance_files(vault_path);
let _ = sync_ai_guidance_files(vault);
}
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition.
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
/// Migrates legacy `config/agents.md` to root if present.
/// Called by the "Repair Vault" command. Returns a status message.
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String> {
let vault = Path::new(vault_path.as_ref());
let root_agents = vault.join("AGENTS.md");
let config_agents = vault.join("config").join("agents.md");
migrate_legacy_agents_file(&root_agents, &config_agents)?;
let _ = cleanup_empty_config_dir(vault)?;
sync_ai_guidance_files(vault_path)?;
sync_ai_guidance_files(vault)?;
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
Ok("Config files repaired".to_string())
}
@@ -417,15 +440,19 @@ mod tests {
}
#[test]
fn test_seed_config_files_creates_type_definition() {
fn test_seed_config_files_creates_type_definitions() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("config.md").exists());
let content = fs::read_to_string(vault.join("config.md")).unwrap();
assert!(content.contains("type: Type"));
assert!(content.contains("icon: gear-six"));
assert!(vault.join("note.md").exists());
let config_content = fs::read_to_string(vault.join("config.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(config_content.contains("type: Type"));
assert!(config_content.contains("icon: gear-six"));
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
assert!(!vault.join("config").exists());
}
@@ -543,10 +570,14 @@ mod tests {
assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("CLAUDE.md").exists());
assert!(vault.join("config.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
let agents = read_root_agents(&vault);
assert!(agents.contains("Tolaria Vault"));
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("general-purpose document"));
}
#[test]

View File

@@ -208,107 +208,161 @@ const OUTDATED_AGENTS_MARKERS: [&str; 3] = [
"Tolaria still understands legacy aliases such as `Is A`.",
];
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
let is_outdated_managed_template = OUTDATED_AGENTS_MARKERS
.iter()
.all(|marker| content.contains(marker));
let is_stale_title_stub = content.contains("Do not add `title:` frontmatter.");
const STALE_TITLE_FRONTMATTER_MARKER: &str = "Do not add `title:` frontmatter.";
const LEGACY_VIEWS_SECTION_MARKER: &str = "## Views";
const LEGACY_VIEW_FILE_MARKERS: [&str; 2] = [".view.json", "```json"];
content.trim().is_empty()
|| content == PRE_TYPE_AGENTS_MD
|| content == LEGACY_AGENTS_MD
|| content == STALE_AGENTS_MD
|| is_stale_title_stub
|| is_outdated_managed_template
struct AgentsContent<'a>(&'a str);
impl<'a> AgentsContent<'a> {
fn new(content: &'a str) -> Self {
Self(content)
}
fn contains(&self, marker: &str) -> bool {
self.0.contains(marker)
}
fn contains_all(&self, markers: &[&str]) -> bool {
markers.iter().all(|marker| self.contains(marker))
}
fn is_known_legacy_template(&self) -> bool {
self.0.trim().is_empty()
|| self.0 == PRE_TYPE_AGENTS_MD
|| self.0 == LEGACY_AGENTS_MD
|| self.0 == STALE_AGENTS_MD
}
fn has_stale_title_stub(&self) -> bool {
self.contains(STALE_TITLE_FRONTMATTER_MARKER)
}
fn has_legacy_json_view_guidance(&self) -> bool {
self.contains(LEGACY_VIEWS_SECTION_MARKER)
&& LEGACY_VIEW_FILE_MARKERS
.iter()
.any(|marker| self.contains(marker))
}
fn can_be_refreshed(&self) -> bool {
self.is_known_legacy_template()
|| self.has_stale_title_stub()
|| self.has_legacy_json_view_guidance()
|| self.contains_all(&OUTDATED_AGENTS_MARKERS)
}
}
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
AgentsContent::new(content).can_be_refreshed()
}
/// Default AGENTS.md content — vault instructions for AI agents.
/// Describes Tolaria vault mechanics only; no user-specific structure.
/// The vault scanner will pick this up as a regular entry.
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
pub(super) const AGENTS_MD: &str = r##"---
type: Note
_organized: true
---
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault: a folder of Markdown files with YAML frontmatter forming a personal knowledge graph.
# AGENTS.md — Tolaria Vault
Keep edits compatible with Tolaria's current conventions. Prefer small, human-readable changes over heavy restructuring.
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault, a folder of Markdown files with YAML frontmatter forming a personal knowledge graph.
Keep edits compatible with this starter vault's current conventions. Prefer small, human-readable changes over heavy restructuring.
## Core conventions
- One Markdown note per file.
- The first H1 in the body is the preferred display title.
- Legacy `title:` frontmatter is still read as a fallback when a note has no H1. Do not add it to new notes unless you are maintaining an older file.
- The first H1 in the body is the preferred display title. Legacy `title:` frontmatter is still read as a fallback when a note has no H1, but do not add it to new notes.
- Store note type in the `type:` frontmatter field.
- Most notes live at the vault root as flat `.md` files. Type definitions live in `type/`. Saved views live in `views/`.
- Any frontmatter field containing `[[wikilinks]]` is treated as a relationship. Common names include `Belongs to:`, `Related to:`, `Workspace:`, and custom relationship names.
- In this starter vault, type definitions currently live at the vault root, for example `project.md`, `person.md`, `note.md`, and `config.md`. Keep new type files at the vault root unless the user explicitly asks to reorganize them.
- Saved views live in `views/*.yml`.
- Files in `attachments/` are assets, not notes. Reference them from notes, but do not treat them as notes or types.
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
## Notes
```yaml
---
type: Project
type: Note
related_to: "[[tolaria]]"
status: Active
icon: target
Workspace: "[[tolaria]]"
Belongs to:
- "[[25q2]]"
Related to:
- "[[person-luca-rossi]]"
aliases:
- Tolaria work
url: https://example.com
---
# Ship Tolaria
# Example note
Body content in Markdown.
```
## Types
Type definitions are regular notes stored in `type/`. Use `type: Type` for new ones:
In this starter vault, types are regular notes stored at the vault root and use `type: Type`.
```yaml
---
type: Type
icon: shapes
color: blue
sidebar label: Projects
template: |
## Outcome
## Next actions
_icon: rocket
_color: "#3b82f6"
_order: 0
_list_properties_display:
- related_to
_sort: "property:onboarding:asc"
---
# Project
```
Useful type metadata includes `icon`, `color`, `order`, `sidebar label`, `template`, `sort`, `view`, and `visible`.
Useful type metadata in this vault includes `icon`/`_icon`, `color`/`_color`, `order`/`_order`, `sidebar label`, `_list_properties_display`, `_sort`, `template`, `view`, and `visible`. When editing an existing file, preserve the key style already used there instead of mass-normalizing underscored keys.
## Relationships
Any frontmatter property whose value contains `[[wikilinks]]` is treated as a relationship. In this starter vault, common relationship keys include `related_to` and `belongs_to`, but custom relationship names are valid too.
Preserve older relationship labels such as `Belongs to:` when editing existing notes that already use them.
Use quoted wikilinks for scalar frontmatter values and YAML lists for multi-value relationships.
## Wikilinks
- `[[filename]]` or `[[Note Title]]` — link by filename or title
- `[[filename|display text]]` — with custom display text
- `[[filename]]` or `[[Note Title]]` for normal links
- `[[filename|display text]]` for custom display text
- Works in frontmatter values and Markdown body
## Views
Saved views live in `views/*.yml` and are written as YAML:
Saved views live in `views/*.yml` and are written as YAML. Tolaria scans every `.yml` file in `views/`, and the filename is the stable view id, so use kebab-case filenames such as `active-projects.yml`.
A view definition looks like this:
```yaml
name: Active Projects
icon: kanban
color: blue
sort: modified:desc
icon: null
color: null
sort: "property:onboarding:asc"
filters:
all:
any:
- field: type
op: equals
value: Project
- field: status
op: equals
value: Active
- field: related_to
op: contains
value: "[[tolaria]]"
```
View rules that matter when creating or editing files:
- `name` is required. `icon`, `color`, and `sort` are optional.
- `sort` uses `option:direction`. Built-in options are `modified`, `created`, `title`, and `status`. Custom-property sorts use `property:<Property Name>`, for example `property:onboarding:asc`.
- `filters` must be a tree whose root is exactly one `all:` group or one `any:` group.
- Each filter condition uses `field`, `op`, and usually `value`.
- `field` can target built-ins like `type`, `status`, `title`, `favorite`, and `body`, plus actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`.
- Supported operators are `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, and `after`.
- `any_of` and `none_of` expect `value` to be a YAML list.
- `regex: true` is supported with `equals`, `not_equals`, `contains`, and `not_contains` when pattern matching is needed.
- Relationship filters can use wikilinks in `value`, for example `"[[tolaria]]"`.
- Do not create JSON view files or `.view.json` filenames.
## Filenames
Use kebab-case: `my-note-title.md`. One note per file.
@@ -316,16 +370,17 @@ Use kebab-case: `my-note-title.md`. One note per file.
## What agents should do
- Create and edit notes using the frontmatter and H1 conventions above.
- Create and edit type documents in `type/`.
- Create and edit type documents at the vault root.
- Add or modify relationships without breaking existing wikilinks.
- Create and edit saved views in `views/`.
- Update `AGENTS.md` only when the user asks for vault-level guidance changes.
## What agents should avoid
- Do not infer note type from folders other than the dedicated `type/` directory for type definitions.
- Do not move existing type files out of the vault root unless the user explicitly asks.
- Do not treat files in `attachments/` as notes, types, or view definitions.
- Do not silently overwrite an existing custom `AGENTS.md`.
- Do not overwrite user-authored config or installation-specific app files unless the user explicitly asks.
- Do not rewrite installation-specific app configuration unless the user explicitly asks.
"##;
pub(super) const LEGACY_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
@@ -413,18 +468,23 @@ Do not modify app configuration files — those are local to each installation.
/// Clone the public starter vault into the requested path.
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
create_getting_started_vault_from_repo(target_path, &getting_started_repo_url())
let vault_path = create_getting_started_vault_from_repo(
Path::new(target_path),
&getting_started_repo_url(),
)?;
Ok(vault_path.to_string_lossy().to_string())
}
fn create_getting_started_vault_from_repo(
target_path: &str,
target_path: &Path,
repo_url: &str,
) -> Result<String, String> {
if target_path.trim().is_empty() {
) -> Result<PathBuf, String> {
let target_path_str = target_path.to_string_lossy();
if target_path_str.trim().is_empty() {
return Err("Target path is required".to_string());
}
crate::git::clone_repo(repo_url, target_path)?;
crate::git::clone_repo(repo_url, &target_path_str)?;
let vault_path = canonical_vault_path(target_path)?;
refresh_cloned_vault_config_files(&vault_path)?;
Ok(vault_path)
@@ -436,16 +496,23 @@ fn getting_started_repo_url() -> String {
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
}
fn canonical_vault_path(target_path: &str) -> Result<String, String> {
let path = Path::new(target_path);
let canonical = path
.canonicalize()
.map_err(|e| format!("Failed to resolve vault path '{}': {}", target_path, e))?;
Ok(canonical.to_string_lossy().to_string())
fn canonical_vault_path(target_path: &Path) -> Result<PathBuf, String> {
target_path.canonicalize().map_err(|e| {
format!(
"Failed to resolve vault path '{}': {}",
target_path.display(),
e
)
})
}
fn refresh_cloned_vault_config_files(vault_path: &str) -> Result<(), String> {
let agents_path = Path::new(vault_path).join("AGENTS.md");
fn path_to_utf8<'a>(path: &'a Path, context: &str) -> Result<&'a str, String> {
path.to_str()
.ok_or_else(|| format!("{context} '{}' is not valid UTF-8", path.display()))
}
fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
let agents_path = vault_path.join("AGENTS.md");
let refresh_agents = if !agents_path.exists() {
true
} else {
@@ -459,18 +526,21 @@ fn refresh_cloned_vault_config_files(vault_path: &str) -> Result<(), String> {
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
}
crate::vault::repair_config_files(vault_path)?;
crate::vault::repair_config_files(path_to_utf8(vault_path, "Vault path")?)?;
if !vault_has_pending_changes(vault_path)? {
return Ok(());
}
ensure_commit_identity(vault_path)?;
crate::git::git_commit(vault_path, "Initialize Tolaria config files")?;
crate::git::git_commit(
path_to_utf8(vault_path, "Vault path")?,
"Initialize Tolaria config files",
)?;
Ok(())
}
fn vault_has_pending_changes(vault_path: &str) -> Result<bool, String> {
fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(vault_path)
@@ -487,7 +557,7 @@ fn vault_has_pending_changes(vault_path: &str) -> Result<bool, String> {
))
}
fn ensure_commit_identity(vault_path: &str) -> Result<(), String> {
fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
for (key, fallback) in [
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
@@ -575,8 +645,7 @@ mod tests {
let dest = dir.path().join("Getting Started");
init_source_repo(&source, Some(agents_content));
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
.unwrap();
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
assert_eq!(content, AGENTS_MD);
@@ -605,13 +674,11 @@ mod tests {
let dest = dir.path().join("Getting Started");
init_source_repo(&source, None);
let result = create_getting_started_vault_from_repo(
dest.to_str().unwrap(),
source.to_str().unwrap(),
)
.unwrap();
let result =
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
.unwrap();
assert_eq!(result, dest.canonicalize().unwrap().to_string_lossy());
assert_eq!(result, dest.canonicalize().unwrap());
assert!(dest.join("welcome.md").exists());
assert!(dest.join("views").join("active-projects.yml").exists());
assert!(dest.join(".git").exists());
@@ -631,11 +698,8 @@ mod tests {
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("existing.md"), "# Existing\n").unwrap();
let err = create_getting_started_vault_from_repo(
dest.to_str().unwrap(),
source.to_str().unwrap(),
)
.unwrap_err();
let err = create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
.unwrap_err();
assert!(err.contains("already exists and is not empty"));
}
@@ -646,11 +710,9 @@ mod tests {
let missing_repo = dir.path().join("missing");
let dest = dir.path().join("Getting Started");
let err = create_getting_started_vault_from_repo(
dest.to_str().unwrap(),
missing_repo.to_str().unwrap(),
)
.unwrap_err();
let err =
create_getting_started_vault_from_repo(dest.as_path(), missing_repo.to_str().unwrap())
.unwrap_err();
assert!(err.contains("git clone failed"));
assert!(!dest.exists());
@@ -663,8 +725,7 @@ mod tests {
let dest = dir.path().join("Getting Started");
init_source_repo(&source, None);
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
.unwrap();
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
let output = StdCommand::new("git")
.args(["status", "--porcelain"])
@@ -689,16 +750,35 @@ mod tests {
assert!(agents_content_can_be_refreshed(PRE_TYPE_AGENTS_MD));
}
#[test]
fn test_agents_refresh_detection_accepts_legacy_json_view_guidance() {
let stale = r#"# AGENTS.md — Tolaria Vault
## Views
Saved filters live in `views/` as `.view.json` files:
```json
{"title":"Active Notes"}
```
"#;
assert!(agents_content_can_be_refreshed(stale));
}
#[test]
fn test_agents_template_matches_current_tolaria_vault_conventions() {
assert!(AGENTS_MD.starts_with("---\ntype: Note\n_organized: true\n---\n"));
assert!(AGENTS_MD.contains("# AGENTS.md — Tolaria Vault"));
assert!(AGENTS_MD.contains("type/"));
assert!(AGENTS_MD.contains("views/"));
assert!(AGENTS_MD.contains("sidebar label"));
assert!(AGENTS_MD.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(AGENTS_MD.contains("Store note type in the `type:` frontmatter field."));
assert!(AGENTS_MD.contains("type definitions currently live at the vault root"));
assert!(AGENTS_MD.contains("attachments/"));
assert!(AGENTS_MD.contains("views/*.yml"));
assert!(AGENTS_MD.contains("option:direction"));
assert!(AGENTS_MD.contains("property:<Property Name>"));
assert!(AGENTS_MD.contains("actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`."));
assert!(AGENTS_MD.contains("Belongs to:"));
assert!(AGENTS_MD.contains("Do not create JSON view files or `.view.json` filenames."));
assert!(!AGENTS_MD.contains("Laputa"));
assert!(!AGENTS_MD.contains("Is A"));
assert!(!AGENTS_MD.contains("is_a"));

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -76,8 +76,14 @@ const mockAllContent: Record<string, string> = {
'/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n',
}
const mockVaultList = {
vaults: [{ label: 'Test Vault', path: '/vault' }],
active_vault: '/vault',
hidden_defaults: [],
}
const mockCommandResults: Record<string, unknown> = {
load_vault_list: { vaults: [], active_vault: null, hidden_defaults: [] },
load_vault_list: mockVaultList,
list_vault: mockEntries,
list_vault_folders: [],
list_views: [],
@@ -94,9 +100,42 @@ const mockCommandResults: Record<string, unknown> = {
get_vault_settings: { theme: null },
}
function resetMockCommandResults() {
Object.assign(mockCommandResults, {
load_vault_list: mockVaultList,
list_vault: mockEntries,
list_vault_folders: [],
list_views: [],
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
get_file_history: [],
get_settings: {
auto_pull_interval_minutes: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
},
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Getting Started',
list_themes: [],
get_vault_settings: { theme: null },
})
}
function resolveMockCommandResult(cmd: string, args?: unknown) {
const result = mockCommandResults[cmd]
return typeof result === 'function'
? (result as (input?: unknown) => unknown)(args)
: result ?? null
}
vi.mock('./mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(async (cmd: string) => mockCommandResults[cmd] ?? null),
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
}))
@@ -156,6 +195,7 @@ const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dis
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMockCommandResults()
localStorage.clear()
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
})
@@ -204,6 +244,68 @@ describe('App', () => {
})
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
render(<App />)
await waitFor(() => {
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('telemetry-accept'))
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it.each([
['telemetry-accept', 'Allow anonymous reporting'],
['telemetry-decline', 'No thanks'],
])('ignores a remembered default vault after %s when onboarding was never completed', async (buttonTestId) => {
const rememberedDefaultVaultPath = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
localStorage.setItem('tolaria_welcome_dismissed', '1')
mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
mockCommandResults.load_vault_list = {
vaults: [],
active_vault: rememberedDefaultVaultPath,
hidden_defaults: [],
}
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === rememberedDefaultVaultPath
render(<App />)
await waitFor(() => {
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(buttonTestId))
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it('renders sidebar with correct default selection (All Notes)', async () => {
render(<App />)
await waitFor(() => {
@@ -214,6 +316,11 @@ describe('App', () => {
})
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
mockCommandResults.load_vault_list = {
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
active_vault: '/Users/mock/Documents/Getting Started',
hidden_defaults: [],
}
const disabledWorkflowConfig = JSON.stringify({
zoom: null,
view_mode: null,
@@ -223,13 +330,19 @@ describe('App', () => {
property_display_modes: null,
inbox: { noteListProperties: null, explicitOrganization: false },
})
localStorage.setItem('laputa:vault-config:/Users/mock/Documents/Getting Started', disabledWorkflowConfig)
localStorage.setItem('laputa:vault-config:/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', disabledWorkflowConfig)
const vaultPaths = [
'/Users/mock/Documents/Getting Started',
'/Users/mock/demo-vault-v2',
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
]
for (const path of vaultPaths) {
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
}
render(<App />)
await waitFor(() => {
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
expect(within(screen.getByTestId('sidebar-top-nav')).queryByText('Inbox')).not.toBeInTheDocument()
expect(screen.getByText('All Notes')).toBeInTheDocument()
})
})

View File

@@ -35,9 +35,10 @@ import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode, type ViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { triggerCommitEntryAction } from './utils/commitEntryAction'
import { generateCommitMessage } from './utils/commitMessage'
import { useDialogs } from './hooks/useDialogs'
import { GETTING_STARTED_LABEL, useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useAutoSync } from './hooks/useAutoSync'
@@ -206,22 +207,40 @@ function App() {
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onToast: (msg) => setToastMessage(msg),
})
const { handleVaultCloned } = vaultSwitcher
const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher
const handleGettingStartedVaultReady = useCallback((vaultPath: string, label: string) => {
const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => {
if (!vaultPath) return
if (allVaults.some((vault) => vault.path === vaultPath)) {
switchVault(vaultPath)
return
}
const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault'
handleVaultCloned(vaultPath, label)
}, [allVaults, handleVaultCloned, switchVault])
const handleGettingStartedVaultReady = useCallback((vaultPath: string) => {
rememberOnboardingVaultChoice(vaultPath)
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
}, [handleVaultCloned])
}, [rememberOnboardingVaultChoice])
const cloneGettingStartedVault = useGettingStartedClone({
onError: (message) => setToastMessage(message),
onSuccess: handleGettingStartedVaultReady,
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
handleGettingStartedVaultReady(vaultPath, GETTING_STARTED_LABEL)
handleGettingStartedVaultReady(vaultPath)
})
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
useEffect(() => {
if (onboarding.state.status !== 'ready') return
if (!onboarding.state.vaultPath || onboarding.state.vaultPath === vaultSwitcher.vaultPath) return
rememberOnboardingVaultChoice(onboarding.state.vaultPath)
}, [onboarding.state, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
// The active vault path can temporarily come from onboarding before the
// persisted vault switcher catches up to the newly cloned starter vault.
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
@@ -336,12 +355,20 @@ function App() {
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
const notes = useNoteActions({
addEntry: vault.addEntry,
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforePathRename: (path) => appSave.flushBeforeAction(path),
flushBeforeNoteSwitch: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
flushBeforePathRename: async (path) => {
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
},
reloadVault: vault.reloadVault,
setToastMessage,
updateEntry: vault.updateEntry,
@@ -634,6 +661,7 @@ function App() {
onCheckpoint: () => commitFlow.runAutomaticCheckpoint(),
})
const recordAutoGitActivity = autoGit.recordActivity
const openCommitDialog = commitFlow.openCommitDialog
const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint
const handleAppContentChange = appSave.handleContentChange
const handleAppSave = appSave.handleSave
@@ -644,9 +672,13 @@ function App() {
recordAutoGitActivity()
}, [modifiedFilesSignature, recordAutoGitActivity])
const handleQuickCommitPush = useCallback(() => {
void runAutomaticCheckpoint({ savePendingBeforeCommit: true })
}, [runAutomaticCheckpoint])
const handleCommitPush = useCallback(() => {
triggerCommitEntryAction({
autoGitEnabled: settings.autogit_enabled === true,
openCommitDialog,
runAutomaticCheckpoint,
})
}, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
const handleTrackedContentChange = useCallback((path: string, content: string) => {
recordAutoGitActivity()
@@ -899,6 +931,17 @@ function App() {
) ?? null
}, [notes.activeTabPath, vault.modifiedFiles])
const activeCommandEntry = useMemo(() => {
if (!notes.activeTabPath) return null
return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
?? null
}, [notes.activeTabPath, notes.tabs, vault.entries])
const canToggleRichEditor = !!activeCommandEntry
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
&& !activeDeletedFile
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
@@ -916,13 +959,13 @@ function App() {
onOpenFeedback: openFeedback,
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
onCommitPush: handleCommitPush,
onPull: autoSync.triggerSync,
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
onToggleInspector: handleToggleInspector,
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(),
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
@@ -987,16 +1030,45 @@ function App() {
return { type: null, query: '' }
}, [effectiveSelection])
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
return <WelcomeView onboarding={onboarding} isOffline={networkStatus.isOffline} />
}
const shouldResumeFreshStartOnboarding = useMemo(() => {
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath
return remembersOnlyDefaultVault
&& vaultSwitcher.allVaults.length === 1
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
// Show loading spinner while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {
return <LoadingView />
}
// Show telemetry consent dialog on first launch (skip for note windows).
// After the user answers, the next render can continue into onboarding.
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {
const id = crypto.randomUUID()
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
}}
onDecline={() => {
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
}}
/>
)
}
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
const welcomeOnboarding = shouldResumeFreshStartOnboarding
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
: onboarding
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
}
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
return (
<>
@@ -1026,21 +1098,6 @@ function App() {
return <LoadingView />
}
// Show telemetry consent dialog on first launch (skip for note windows)
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {
const id = crypto.randomUUID()
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
}}
onDecline={() => {
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
}}
/>
)
}
return (
<div className="app-shell">
<div className="app">
@@ -1117,12 +1174,13 @@ function App() {
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} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleQuickCommitPush} 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={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<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} 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={installMcp} 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} />

View File

@@ -658,7 +658,7 @@ describe('wikilink autocomplete', () => {
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})
it('shows correct noteType and color for typed entries, neutral for untyped', async () => {
it('shows Note chips and icons for explicit Note entries while keeping untyped entries neutral', async () => {
const mixedEntries: VaultEntry[] = [
{ ...mockEntry, title: 'Test Project', filename: 'proj.md', path: '/vault/proj.md', isA: 'Project', aliases: [] },
{ ...mockEntry, title: 'Test Plain', filename: 'plain.md', path: '/vault/plain.md', isA: null, aliases: [] },
@@ -675,20 +675,24 @@ describe('wikilink autocomplete', () => {
/>
)
const items = await capturedGetItems!('Test')
// Typed entries should have noteType and color
// Typed entries should have noteType, color, and a left-side icon
const project = items.find((i: { title: string }) => i.title === 'Test Project')
expect(project).toBeDefined()
expect(project!.noteType).toBe('Project')
expect(project!.typeColor).toBeTruthy()
// Untyped entries (isA: null or 'Note') should have no noteType (grey/neutral)
expect(project!.TypeIcon).toBeTruthy()
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
expect(explicitNote).toBeDefined()
expect(explicitNote!.noteType).toBe('Note')
expect(explicitNote!.typeColor).toBeTruthy()
expect(explicitNote!.TypeIcon).toBeTruthy()
// Untyped entries should remain neutral
const plainNote = items.find((i: { title: string }) => i.title === 'Test Plain')
expect(plainNote).toBeDefined()
expect(plainNote!.noteType).toBeUndefined()
expect(plainNote!.typeColor).toBeUndefined()
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
expect(explicitNote).toBeDefined()
expect(explicitNote!.noteType).toBeUndefined()
expect(explicitNote!.typeColor).toBeUndefined()
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})

View File

@@ -20,6 +20,7 @@ import {
resolveRawModeContent,
} from './editorRawModeSync'
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
import './Editor.css'
import './EditorTheme.css'
@@ -86,6 +87,8 @@ interface EditorProps {
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}
function useEditorModeExclusion({
@@ -167,6 +170,7 @@ function useEditorSetup({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
})
useFilenameAutolinkGuard(editor)
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
const {
rawMode,
@@ -222,39 +226,147 @@ function useEditorSetup({
}
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
} = props
function useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingRawContent = useCallback((path: string) => {
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
const latestContent = rawLatestContentRef.current
if (latestContent === null || latestContent === activeTab.content) return
onContentChange?.(path, latestContent)
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
useEffect(() => {
if (!flushPendingRawContentRef) return
flushPendingRawContentRef.current = flushPendingRawContent
return () => {
if (flushPendingRawContentRef.current === flushPendingRawContent) {
flushPendingRawContentRef.current = null
}
}
}, [flushPendingRawContent, flushPendingRawContentRef])
}
function EditorLayout({
tabs,
activeTab,
isLoadingNewTab,
entries,
editor,
diffMode,
diffContent,
diffLoading,
handleToggleDiffExclusive,
rawMode,
handleToggleRawExclusive,
onContentChange,
onSave,
activeStatus,
showDiffToggle,
showAIChat,
onToggleAIChat,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
handleEditorChange,
onToggleFavorite,
onToggleOrganized,
onDeleteNote,
onArchiveNote,
onUnarchiveNote,
vaultPath,
rawModeContent,
rawLatestContentRef,
onRenameFilename,
isConflicted,
onKeepMine,
onKeepTheirs,
onInspectorResize,
inspectorWidth,
defaultAiAgent,
defaultAiAgentReady,
inspectorEntry,
inspectorContent,
gitHistory,
noteList,
noteListFilter,
handleViewCommitDiff,
onUpdateFrontmatter,
onDeleteProperty,
onAddProperty,
onCreateMissingType,
onCreateAndOpenNote,
onInitializeProperties,
onFileCreated,
onFileModified,
onVaultChanged,
}: {
tabs: Tab[]
activeTab: Tab | null
isLoadingNewTab: boolean
entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean
diffContent: string | null
diffLoading: boolean
handleToggleDiffExclusive: () => void | Promise<void>
rawMode: boolean
handleToggleRawExclusive: () => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
activeStatus: NoteStatus
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
handleEditorChange: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
onInspectorResize: (delta: number) => void
inspectorWidth: number
defaultAiAgent: AiAgentId
defaultAiAgentReady: boolean
inspectorEntry: VaultEntry | null
inspectorContent: string | null
gitHistory: GitCommit[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
handleViewCommitDiff: (commitHash: string) => Promise<void>
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>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}) {
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
@@ -328,4 +440,103 @@ export const Editor = memo(function Editor(props: EditorProps) {
</div>
</div>
)
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
} = props
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
})
return (
<EditorLayout
tabs={tabs}
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
entries={entries}
editor={editor}
diffMode={diffMode}
diffContent={diffContent}
diffLoading={diffLoading}
handleToggleDiffExclusive={handleToggleDiffExclusive}
rawMode={rawMode}
handleToggleRawExclusive={handleToggleRawExclusive}
onContentChange={onContentChange}
onSave={onSave}
activeStatus={activeStatus}
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}
handleEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
onInspectorResize={onInspectorResize}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
gitHistory={gitHistory}
noteList={noteList}
noteListFilter={noteListFilter}
handleViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateMissingType={onCreateMissingType}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
/>
)
})

View File

@@ -262,6 +262,23 @@ describe('NoteList filter pills', () => {
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
})
it('shows only explicit Note entries for the Notes type filter', () => {
const noteEntries = [
makeEntry({ title: 'Note Type', isA: 'Type', path: '/types/note.md', filename: 'note.md' }),
makeEntry({ title: 'Explicit Note', isA: 'Note', path: '/explicit-note.md', filename: 'explicit-note.md' }),
makeEntry({ title: 'Untyped Note', isA: null, path: '/untyped-note.md', filename: 'untyped-note.md' }),
makeEntry({ title: 'Archived Explicit Note', isA: 'Note', archived: true, path: '/archived-note.md', filename: 'archived-note.md' }),
]
renderNoteList({ entries: noteEntries, selection: { kind: 'sectionGroup', type: 'Note' } })
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
expect(screen.queryByText('Untyped Note')).not.toBeInTheDocument()
expect(screen.queryByText('Archived Explicit Note')).not.toBeInTheDocument()
expect(screen.getByTestId('filter-pill-open')).toHaveTextContent('1')
expect(screen.getByTestId('filter-pill-archived')).toHaveTextContent('1')
})
it('shows the archived empty state when a section has no archived notes', () => {
renderNoteList({
entries: projectEntries.filter((entry) => !entry.archived),

View File

@@ -780,6 +780,14 @@ describe('Sidebar', () => {
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/binary-note.pdf', filename: 'binary-note.pdf', title: 'Binary Note',
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, modifiedAt: 1700000000,
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
outgoingLinks: [], properties: {}, fileKind: 'binary',
},
]
it('shows Notes section when Note entries exist', () => {
@@ -787,13 +795,40 @@ describe('Sidebar', () => {
expect(screen.getByText('Notes')).toBeInTheDocument()
})
it('counts both explicit and untyped notes in Notes section chip', () => {
it('counts only explicit Note entries in the Notes section chip', () => {
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
})
it('ignores non-markdown Note entries in the Notes section chip', () => {
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
expect(notesHeader.textContent).not.toContain('2')
})
it('keeps the Notes section count aligned when an entry changes to or from Note', () => {
const { rerender } = render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
let notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('1')
const withoutExplicitNote = noteEntries.map((entry) =>
entry.path === '/vault/explicit-note.md' ? { ...entry, isA: null } : entry,
)
rerender(<Sidebar entries={withoutExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toBe('Notes')
const withNewExplicitNote = noteEntries.map((entry) =>
entry.path === '/vault/untyped-note.md' ? { ...entry, isA: 'Note' } : entry,
)
rerender(<Sidebar entries={withNewExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
expect(notesHeader.textContent).toContain('2')
})
it('shows Notes section for untyped entries even without explicit Note entries', () => {
it('does not show Notes section for untyped entries without explicit Note entries', () => {
const untypedOnly: VaultEntry[] = [
{
path: '/vault/plain.md', filename: 'plain.md', title: 'Plain Note',
@@ -805,7 +840,7 @@ describe('Sidebar', () => {
},
]
render(<Sidebar entries={untypedOnly} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Notes')).toBeInTheDocument()
expect(screen.queryByText('Notes')).not.toBeInTheDocument()
})
})
@@ -874,6 +909,88 @@ describe('Sidebar', () => {
expect(topNav.children[0].textContent).toContain('All Notes')
})
it('excludes attachments-folder markdown from top-nav note totals', () => {
const entries: VaultEntry[] = [
{
path: '/vault/note/real-note.md',
filename: 'real-note.md',
title: 'Real Note',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 310, snippet: '', wordCount: 120,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/reference.md',
filename: 'reference.md',
title: 'Attachment Markdown',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 220, snippet: '', wordCount: 50,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/nested/archive.md',
filename: 'archive.md',
title: 'Attachment Archive',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: true,
modifiedAt: 1700000000, createdAt: null,
fileSize: 180, snippet: '', wordCount: 25,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/archive/real-archive.md',
filename: 'real-archive.md',
title: 'Real Archive',
isA: 'Note',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: true,
modifiedAt: 1700000000, createdAt: null,
fileSize: 280, snippet: '', wordCount: 90,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/attachments/image.png',
filename: 'image.png',
title: 'image.png',
isA: null,
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false,
modifiedAt: 1700000000, createdAt: null,
fileSize: 1024, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
fileKind: 'binary',
outgoingLinks: [], properties: {},
},
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
const topNav = screen.getByTestId('sidebar-top-nav')
expect(topNav.children[1].textContent).toContain('All Notes1')
expect(topNav.children[2].textContent).toContain('Archive1')
})
it('does not show inline entries — no child items in type sections', () => {
const entriesWithEmoji: VaultEntry[] = [
{

View File

@@ -179,6 +179,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
}))),

View File

@@ -27,4 +27,9 @@ describe('TelemetryConsentDialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByText(/no vault content, note titles/i)).toBeDefined()
})
it('focuses the first action for keyboard users', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByTestId('telemetry-decline')).toHaveFocus()
})
})

View File

@@ -46,6 +46,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
style={{ flex: 1, fontSize: 13, padding: '10px 16px' }}
onClick={onDecline}
data-testid="telemetry-decline"
autoFocus
>
No thanks
</button>

View File

@@ -30,6 +30,11 @@ describe('WelcomeScreen', () => {
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
})
it('focuses the first action for keyboard users', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
})
it('shows default vault path in template option description', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()

View File

@@ -160,6 +160,7 @@ interface OptionButtonProps {
disabled: boolean
loading?: boolean
testId: string
autoFocus?: boolean
}
function OptionButton({
@@ -173,6 +174,7 @@ function OptionButton({
disabled,
loading,
testId,
autoFocus = false,
}: OptionButtonProps) {
const [hover, setHover] = useState(false)
return (
@@ -188,6 +190,7 @@ function OptionButton({
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
data-testid={testId}
autoFocus={autoFocus}
>
<div style={{ ...OPTION_ICON_STYLE, background: iconBg }}>
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
@@ -278,6 +281,7 @@ export function WelcomeScreen({
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"
autoFocus
/>
<OptionButton

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
isWithinFormattingToolbarHoverBridge,
shouldSuppressFormattingToolbarHoverUpdate,
} from './blockNoteFormattingToolbarHoverGuard'
function rect(left: number, top: number, width: number, height: number) {
return DOMRect.fromRect({ x: left, y: top, width, height })
}
function setRect(element: HTMLElement, nextRect: DOMRect) {
element.getBoundingClientRect = () => nextRect
}
describe('blockNoteFormattingToolbarHoverGuard', () => {
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
expect(
isWithinFormattingToolbarHoverBridge(
{ x: 368, y: 104 },
rect(300, 130, 140, 90),
rect(322, 78, 96, 24),
),
).toBe(true)
})
it('suppresses hover updates when the pointer is already over the toolbar', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
const toolbarButton = document.createElement('button')
toolbar.appendChild(toolbarButton)
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: toolbarButton,
point: { x: 350, y: 90 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 368, y: 104 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('leaves unrelated pointer movement alone', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 520, y: 220 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(false)
})
})

View File

@@ -0,0 +1,179 @@
import { useEffect, useRef, type RefObject } from 'react'
type RectLike = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
const HOVER_BRIDGE_PADDING_X = 8
const HOVER_BRIDGE_PADDING_Y = 8
function isVisibleRect(rect: RectLike) {
return rect.right > rect.left && rect.bottom > rect.top
}
function getSelectedFileBlockBridgeElement(
container: HTMLElement,
blockId: string,
) {
const selectedBlock = container.querySelector<HTMLElement>(
`.bn-block[data-id="${blockId}"]`,
)
if (!selectedBlock) return null
return (
selectedBlock.querySelector<HTMLElement>(
'[data-file-block] .bn-visual-media-wrapper',
) ??
selectedBlock.querySelector<HTMLElement>(
'[data-file-block] .bn-file-name-with-icon',
) ??
selectedBlock.querySelector<HTMLElement>('[data-file-block] .bn-add-file-button') ??
selectedBlock.querySelector<HTMLElement>('[data-file-block]')
)
}
export function isWithinFormattingToolbarHoverBridge(
point: { x: number; y: number },
fileBlockRect: RectLike,
toolbarRect: RectLike,
) {
if (!isVisibleRect(fileBlockRect) || !isVisibleRect(toolbarRect)) {
return false
}
const left = Math.min(fileBlockRect.left, toolbarRect.left) - HOVER_BRIDGE_PADDING_X
const right = Math.max(fileBlockRect.right, toolbarRect.right) + HOVER_BRIDGE_PADDING_X
const top = Math.min(fileBlockRect.top, toolbarRect.top) - HOVER_BRIDGE_PADDING_Y
const bottom = Math.max(fileBlockRect.bottom, toolbarRect.bottom) + HOVER_BRIDGE_PADDING_Y
return (
point.x >= left &&
point.x <= right &&
point.y >= top &&
point.y <= bottom
)
}
export function shouldSuppressFormattingToolbarHoverUpdate({
eventTarget,
point,
container,
doc,
selectedFileBlockId,
}: {
eventTarget: EventTarget | null
point: { x: number; y: number }
container: HTMLElement | null
doc: Document
selectedFileBlockId: string | null
}) {
if (!container || !selectedFileBlockId) return false
if (
eventTarget instanceof Element &&
eventTarget.closest('.bn-formatting-toolbar')
) {
return true
}
const selectedFileBlock = getSelectedFileBlockBridgeElement(
container,
selectedFileBlockId,
)
const toolbar = doc.querySelector<HTMLElement>('.bn-formatting-toolbar')
if (!selectedFileBlock || !toolbar) return false
return isWithinFormattingToolbarHoverBridge(
point,
selectedFileBlock.getBoundingClientRect(),
toolbar.getBoundingClientRect(),
)
}
function useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId: string | null,
isOpen: boolean,
) {
const lastSelectedFileBlockIdRef = useRef<string | null>(selectedFileBlockId)
useEffect(() => {
if (selectedFileBlockId) {
lastSelectedFileBlockIdRef.current = selectedFileBlockId
return
}
if (!isOpen) {
lastSelectedFileBlockIdRef.current = null
}
}, [isOpen, selectedFileBlockId])
return lastSelectedFileBlockIdRef
}
function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null) {
const doc = container?.ownerDocument
const view = doc?.defaultView
if (!container || !doc || !view) return null
return { container, doc, view }
}
function createFormattingToolbarHoverGuardHandler({
container,
doc,
selectedFileBlockIdRef,
}: {
container: HTMLElement
doc: Document
selectedFileBlockIdRef: RefObject<string | null>
}) {
return (event: MouseEvent) => {
if (
!shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: event.target,
point: { x: event.clientX, y: event.clientY },
container,
doc,
selectedFileBlockId: selectedFileBlockIdRef.current,
})
) {
return
}
event.stopPropagation()
}
}
export function useBlockNoteFormattingToolbarHoverGuard({
container,
selectedFileBlockId,
isOpen,
}: {
container: HTMLElement | null
selectedFileBlockId: string | null
isOpen: boolean
}) {
const lastSelectedFileBlockIdRef = useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId,
isOpen,
)
useEffect(() => {
if (!isOpen) return
const environment = getFormattingToolbarHoverGuardEnvironment(container)
if (!environment) return
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
container: environment.container,
doc: environment.doc,
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
})
environment.view.addEventListener('mousemove', handleMouseMove, true)
return () => {
environment.view.removeEventListener('mousemove', handleMouseMove, true)
}
}, [container, isOpen, lastSelectedFileBlockIdRef])
}

View File

@@ -8,7 +8,7 @@ import type {
ViewFile,
} from '../../types'
import type { NoteListFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
import { NoteItem } from '../NoteItem'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
import { resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
@@ -76,7 +76,7 @@ function useFilterCounts(entries: VaultEntry[], selection: SidebarSelection) {
return useMemo(() => {
if (selection.kind === 'sectionGroup') return countByFilter(entries, selection.type)
if (selection.kind === 'folder') return countAllByFilter(entries)
if (selection.kind === 'filter' && selection.filter === 'all') return countAllByFilter(entries)
if (selection.kind === 'filter' && selection.filter === 'all') return countAllNotesByFilter(entries)
return { open: 0, archived: 0 }
}, [entries, selection])
}

View File

@@ -20,6 +20,7 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { countByFilter } from '../../utils/noteListHelpers'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
@@ -94,9 +95,7 @@ function SortableSection({
sectionProps: SidebarSectionProps
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const itemCount = sectionProps.entries.filter((entry) =>
!entry.archived && (group.type === 'Note' ? (entry.isA === 'Note' || !entry.isA) : entry.isA === group.type),
).length
const itemCount = countByFilter(sectionProps.entries, group.type).open
const isRenaming = sectionProps.renamingType === group.type
return (

View File

@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react
import type { VaultEntry } from '../../types'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { countAllNotesByFilter } from '../../utils/noteListHelpers'
import { buildDynamicSections, sortSections } from '../../utils/sidebarSections'
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
@@ -58,13 +59,8 @@ export function useSidebarCollapsed() {
export function useEntryCounts(entries: VaultEntry[]) {
return useMemo(() => {
let active = 0
let archived = 0
for (const entry of entries) {
if (entry.archived) archived++
else active++
}
return { activeCount: active, archivedCount: archived }
const counts = countAllNotesByFilter(entries)
return { activeCount: counts.open, archivedCount: counts.archived }
}, [entries])
}

View File

@@ -25,7 +25,18 @@ import type {
StyleSchema,
} from '@blocknote/core'
import { FormattingToolbarExtension } from '@blocknote/core/extensions'
import { useCallback, useMemo, useState, type FC, type ReactElement } from 'react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type FC,
type MutableRefObject,
type ReactElement,
type SetStateAction,
} from 'react'
import {
Button as MantineButton,
CheckIcon as MantineCheckIcon,
@@ -43,9 +54,82 @@ import {
filterTolariaFormattingToolbarItems,
getTolariaBlockTypeSelectItems,
} from './tolariaEditorFormattingConfig'
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
const FORMATTER_CLOSE_GRACE_MS = 160
function isFocusStillWithinToolbar(
currentTarget: EventTarget & Element,
nextTarget: EventTarget | null,
) {
return nextTarget instanceof Node && currentTarget.contains(nextTarget)
}
function clearToolbarCloseGrace(
timeoutRef: MutableRefObject<number | null>,
setCloseGraceActive: Dispatch<SetStateAction<boolean>>,
) {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setCloseGraceActive(false)
}
function startToolbarCloseGrace(
timeoutRef: MutableRefObject<number | null>,
setCloseGraceActive: Dispatch<SetStateAction<boolean>>,
) {
setCloseGraceActive(true)
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null
setCloseGraceActive(false)
}, FORMATTER_CLOSE_GRACE_MS)
}
function useFormattingToolbarCloseGrace({
show,
toolbarHasFocus,
toolbarHovered,
}: {
show: boolean
toolbarHasFocus: boolean
toolbarHovered: boolean
}) {
const [closeGraceActive, setCloseGraceActive] = useState(false)
const closeGraceTimeoutRef = useRef<number | null>(null)
const previousShowRef = useRef(show)
const clearCloseGrace = useCallback(() => {
clearToolbarCloseGrace(closeGraceTimeoutRef, setCloseGraceActive)
}, [])
useEffect(() => {
const toolbarInteractionActive = show || toolbarHasFocus || toolbarHovered
if (toolbarInteractionActive) {
clearCloseGrace()
} else if (previousShowRef.current) {
startToolbarCloseGrace(closeGraceTimeoutRef, setCloseGraceActive)
}
previousShowRef.current = show
}, [clearCloseGrace, show, toolbarHasFocus, toolbarHovered])
useEffect(() => () => {
if (closeGraceTimeoutRef.current !== null) {
window.clearTimeout(closeGraceTimeoutRef.current)
}
}, [])
return { closeGraceActive, clearCloseGrace }
}
const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
bold: {
label: 'Bold',
@@ -83,6 +167,13 @@ type TolariaSelectedBlock = ReturnType<
BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>['getTextCursorPosition']
>['block']
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
'audio',
'file',
'image',
'video',
])
type TolariaBlockTypeSelectOption = ReturnType<
typeof getTolariaBlockTypeSelectItems
>[number] & {
@@ -180,6 +271,17 @@ function getTolariaBlockTypeSelectOptions(
}))
}
function getFormattingToolbarBridgeBlockId(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const selectedBlock =
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
? selectedBlock.id
: null
}
function updateSelectedBlocksToType(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedBlocks: TolariaSelectedBlock[],
@@ -360,7 +462,27 @@ export function TolariaFormattingToolbarController(props: {
editor,
})
const [toolbarHasFocus, setToolbarHasFocus] = useState(false)
const isOpen = show || toolbarHasFocus
const [toolbarHovered, setToolbarHovered] = useState(false)
const { closeGraceActive, clearCloseGrace } = useFormattingToolbarCloseGrace({
show,
toolbarHasFocus,
toolbarHovered,
})
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
const currentBridgeBlockId = useEditorState({
editor,
selector: ({ editor }) => getFormattingToolbarBridgeBlockId(editor),
})
useBlockNoteFormattingToolbarHoverGuard({
container:
editor.domElement?.closest('.editor__blocknote-container') ??
editor.domElement ??
null,
selectedFileBlockId: currentBridgeBlockId,
isOpen,
})
const position = useEditorState({
editor,
@@ -398,6 +520,8 @@ export function TolariaFormattingToolbarController(props: {
formattingToolbar.store.setState(open)
if (!open) {
setToolbarHasFocus(false)
setToolbarHovered(false)
clearCloseGrace()
}
if (reason === 'escape-key') {
editor.focus()
@@ -413,7 +537,14 @@ export function TolariaFormattingToolbarController(props: {
...props.floatingUIOptions?.elementProps,
},
}),
[editor, formattingToolbar.store, isOpen, placement, props.floatingUIOptions],
[
clearCloseGrace,
editor,
formattingToolbar.store,
isOpen,
placement,
props.floatingUIOptions,
],
)
const Component = props.formattingToolbar || TolariaFormattingToolbar
@@ -422,12 +553,21 @@ export function TolariaFormattingToolbarController(props: {
<PositionPopover position={position} {...floatingUIOptions}>
{isOpen && (
<div
onPointerEnter={() => {
setToolbarHovered(true)
}}
onPointerLeave={(event) => {
if (isFocusStillWithinToolbar(event.currentTarget, event.relatedTarget)) {
return
}
setToolbarHovered(false)
}}
onFocusCapture={() => {
setToolbarHasFocus(true)
}}
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
if (isFocusStillWithinToolbar(event.currentTarget, event.relatedTarget)) {
return
}

View File

@@ -0,0 +1,132 @@
import { useEffect } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import { shouldStripAutoLinkedLocalFileMark } from '../utils/editorLinkAutolink'
const FILENAME_AUTOLINK_GUARD_META = 'tolaria-filename-autolink-guard'
type TiptapLinkMark = {
attrs?: {
href?: string
}
type: unknown
}
type TiptapTextNode = {
isText?: boolean
marks?: TiptapLinkMark[]
nodeSize: number
text?: string | null
}
function hasTextContent(node: TiptapTextNode) {
return Boolean(node.isText && node.text && node.marks?.length)
}
function isAccidentalFilenameLinkMark(
mark: TiptapLinkMark,
linkMarkType: unknown,
text: string,
) {
if (mark.type !== linkMarkType) {
return false
}
if (typeof mark.attrs?.href !== 'string') {
return false
}
return shouldStripAutoLinkedLocalFileMark({
href: { raw: mark.attrs.href },
text: { raw: text },
})
}
function hasEditorEventApi(
tiptap: ReturnType<typeof useCreateBlockNote>['_tiptapEditor'] | undefined,
) {
return Boolean(tiptap && typeof tiptap.on === 'function' && typeof tiptap.off === 'function')
}
function collectAccidentalFilenameLinkRanges(editor: ReturnType<typeof useCreateBlockNote>) {
const linkMarkType = editor._tiptapEditor?.schema?.marks?.link
if (!linkMarkType) {
return []
}
const ranges: Array<{ from: number; to: number }> = []
editor._tiptapEditor.state.doc.descendants((node: TiptapTextNode, pos: number) => {
if (!hasTextContent(node)) {
return
}
const hasAccidentalFilenameLink = node.marks?.some((mark) => (
isAccidentalFilenameLinkMark(mark, linkMarkType, node.text ?? '')
)) ?? false
if (hasAccidentalFilenameLink) {
ranges.push({ from: pos, to: pos + node.nodeSize })
}
})
return ranges
}
function shouldSkipGuardRun(
applyingGuard: boolean,
transaction: { docChanged?: boolean; getMeta: (key: string) => unknown },
) {
return (
applyingGuard
|| !transaction.docChanged
|| transaction.getMeta(FILENAME_AUTOLINK_GUARD_META)
)
}
function removeLinkRanges(
editor: ReturnType<typeof useCreateBlockNote>,
ranges: Array<{ from: number; to: number }>,
) {
const tr = editor._tiptapEditor.state.tr
for (const range of ranges) {
tr.removeMark(range.from, range.to, editor._tiptapEditor.schema.marks.link)
}
return tr
}
export function useFilenameAutolinkGuard(editor: ReturnType<typeof useCreateBlockNote>) {
useEffect(() => {
const tiptap = editor._tiptapEditor
if (!hasEditorEventApi(tiptap)) {
return
}
let applyingGuard = false
const stripAccidentalFilenameAutolinks = ({ transaction }: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => {
if (shouldSkipGuardRun(applyingGuard, transaction)) {
return
}
const ranges = collectAccidentalFilenameLinkRanges(editor)
if (ranges.length === 0) {
return
}
const tr = removeLinkRanges(editor, ranges)
if (!tr.docChanged) {
return
}
tr.setMeta(FILENAME_AUTOLINK_GUARD_META, true)
applyingGuard = true
tiptap.view.dispatch(tr)
applyingGuard = false
}
tiptap.on('update', stripAccidentalFilenameAutolinks)
return () => {
tiptap.off('update', stripAccidentalFilenameAutolinks)
}
}, [editor])
}

View File

@@ -32,7 +32,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },

View File

@@ -461,6 +461,57 @@ describe('useAppSave', () => {
expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending')
})
it('does not run markdown title-sync renames for non-markdown text files', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const viewPath = '/vault/views/active-projects.yml'
const viewContent = 'name: Active Projects\nicon: rocket\ncolor: blue\n'
const viewEntry = {
...makeEntry(viewPath, 'Active Projects', 'active-projects.yml'),
fileKind: 'text' as const,
}
const { result } = renderSave({
tabs: [{ entry: viewEntry, content: viewContent }],
activeTabPath: viewPath,
unsavedPaths: new Set([viewPath]),
})
await act(async () => {
await result.current.handleSave()
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
path: viewPath,
content: viewContent,
})
expect(deps.handleRenameNote).not.toHaveBeenCalled()
})
it('does not rename an existing markdown note when Cmd+S saves a desynced H1/title state', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const notePath = '/vault/note-b.md'
const noteContent = '# Breadcrumb Sync Target\n\nBody'
const entry = makeEntry(notePath, 'Breadcrumb Sync Target', 'note-b.md')
const { result } = renderSave({
tabs: [{ entry, content: noteContent }],
activeTabPath: notePath,
unsavedPaths: new Set([notePath]),
})
await act(async () => {
await result.current.handleSave()
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
path: notePath,
content: noteContent,
})
expect(deps.handleRenameNote).not.toHaveBeenCalled()
})
it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => {
const initialContent = '# Fresh Title\n\nInitial body'
const bufferedContent = '# Fresh Title\n\nBody typed right before rename'

View File

@@ -1,7 +1,6 @@
import { startTransition, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
import { needsRenameOnSave } from './useNoteRename'
import { flushEditorContent } from '../utils/autoSave'
import { extractH1TitleFromContent } from '../utils/noteTitle'
import { isTauri } from '../mock-tauri'
@@ -77,20 +76,6 @@ function findUnsavedFallback({
return { path: activeTab.entry.path, content: activeTab.content }
}
function activeTabNeedsRename({
tabs,
activeTabPath,
}: {
tabs: TabState[]
activeTabPath: string | null
}): { path: string; title: string } | null {
const activeTab = tabs.find(t => t.entry.path === activeTabPath)
if (!activeTab) return null
return needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)
? { path: activeTab.entry.path, title: activeTab.entry.title }
: null
}
function isUntitledRenameCandidate(path: string): boolean {
const filename = path.split('/').pop() ?? ''
const stem = filename.replace(/\.md$/, '')
@@ -546,16 +531,6 @@ function useRenameHandlers({
replaceRenamedEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
loadModifiedFiles: AppSaveDeps['loadModifiedFiles']
}) {
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
const currentPath = await preparePathForManualRename({
path,
resolveCurrentPath,
savePendingForPath,
cancelPendingUntitledRename,
})
await handleRenameNote(currentPath, newTitle, resolvedPath, replaceRenamedEntry).then(loadModifiedFiles)
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
const handleFilenameRename = useCallback(async (path: string, newFilenameStem: string) => {
const currentPath = await preparePathForManualRename({
path,
@@ -578,12 +553,11 @@ function useRenameHandlers({
.catch((err) => console.error('Title rename failed:', err))
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
return { handleRenameTab, handleFilenameRename, handleTitleSync }
return { handleFilenameRename, handleTitleSync }
}
function useHandleSaveAction({
handleSaveRaw,
handleRenameTab,
tabs,
activeTabPath,
unsavedPaths,
@@ -591,7 +565,6 @@ function useHandleSaveAction({
resolveCurrentPath,
}: {
handleSaveRaw: (unsavedFallback?: { path: string; content: string }) => Promise<void>
handleRenameTab: (path: string, newTitle: string) => Promise<void>
tabs: TabState[]
activeTabPath: string | null
unsavedPaths: Set<string>
@@ -605,10 +578,8 @@ function useHandleSaveAction({
activeTabPath: resolvedActiveTabPath,
unsavedPaths,
}))
const flushedUntitledRename = await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
const rename = activeTabNeedsRename({ tabs, activeTabPath: resolvedActiveTabPath })
if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title)
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
}, [handleSaveRaw, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
}
function useEditorPersistence({
@@ -741,7 +712,7 @@ function useAppSaveHandlers({
setToastMessage,
flushPendingUntitledRename,
})
const { handleRenameTab, handleFilenameRename, handleTitleSync } = useRenameHandlers({
const { handleFilenameRename, handleTitleSync } = useRenameHandlers({
resolveCurrentPath,
savePendingForPath,
cancelPendingUntitledRename,
@@ -753,7 +724,6 @@ function useAppSaveHandlers({
})
const handleSave = useHandleSaveAction({
handleSaveRaw,
handleRenameTab,
tabs,
activeTabPath,
unsavedPaths,

View File

@@ -214,6 +214,12 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
})
it('disables Toggle Raw Editor when the active file cannot switch to rich mode', () => {
const config = makeConfig({ onToggleRawEditor: undefined })
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
})
it('omits Inbox navigation when the explicit workflow is disabled', () => {
const config = makeConfig({ showInbox: false })
const { result } = renderHook(() => useCommandRegistry(config))

View File

@@ -2,6 +2,18 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
const { startTransitionMock } = vi.hoisted(() => ({
startTransitionMock: vi.fn((callback: () => void) => callback()),
}))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
startTransition: startTransitionMock,
}
})
const mockHandleContentChange = vi.fn()
const mockHandleSave = vi.fn()
const mockSavePendingForPath = vi.fn()
@@ -25,6 +37,7 @@ describe('useEditorSaveWithLinks', () => {
setTabs = vi.fn()
setToastMessage = vi.fn()
onAfterSave = vi.fn()
startTransitionMock.mockClear()
mockHandleContentChange.mockClear()
mockHandleSave.mockClear()
mockSavePendingForPath.mockClear()
@@ -147,6 +160,8 @@ describe('useEditorSaveWithLinks', () => {
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Project',
status: 'Active',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -172,6 +187,8 @@ describe('useEditorSaveWithLinks', () => {
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Essay',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -181,6 +198,8 @@ describe('useEditorSaveWithLinks', () => {
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Note',
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
@@ -199,6 +218,20 @@ describe('useEditorSaveWithLinks', () => {
expect(updateEntry).toHaveBeenCalledWith(path, expected)
})
it('defers H1 title sync updates in a transition so typing stays responsive', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/old-title.md', '# Renamed Note\n\nBody')
})
expect(startTransitionMock).toHaveBeenCalledTimes(1)
expect(updateEntry).toHaveBeenCalledWith('/old-title.md', {
title: 'Renamed Note',
hasH1: true,
})
})
it('spreads all properties from useEditorSave onto the return value', () => {
const { result } = renderHookWithLinks()

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { startTransition, useCallback, useRef } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
import { contentToEntryPatch } from './frontmatterOps'
@@ -35,6 +35,7 @@ export function useEditorSaveWithLinks(config: {
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const prevFmKeyRef = useRef('')
const prevTitleKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
@@ -45,19 +46,25 @@ export function useEditorSaveWithLinks(config: {
}
const frontmatterPatch = contentToEntryPatch(content)
const filename = path.split('/').pop() ?? path
const fmPatch = {
...frontmatterPatch,
...deriveDisplayTitleState({
content,
filename,
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
}),
}
const titlePatch = deriveDisplayTitleState({
content,
filename,
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
})
const fmPatch = { ...frontmatterPatch }
delete fmPatch.title
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
}
const titleKey = JSON.stringify(titlePatch)
if (titleKey !== prevTitleKeyRef.current) {
prevTitleKeyRef.current = titleKey
startTransition(() => {
updateEntry(path, titlePatch)
})
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}

View File

@@ -67,6 +67,7 @@ describe('useEditorTabSwap untitled rename continuity', () => {
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
@@ -104,4 +105,46 @@ describe('useEditorTabSwap untitled rename continuity', () => {
expect(editor.tryParseMarkdownToBlocks).toHaveBeenCalled()
})
it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
const onContentChange = vi.fn()
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
const { result, rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: editor as never,
onContentChange,
}),
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
editor.replaceBlocks.mockClear()
editor.tryParseMarkdownToBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(editor.replaceBlocks).not.toHaveBeenCalled()
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'fresh-title.md',
expect.stringContaining('Body typed live'),
)
})
})

View File

@@ -35,23 +35,36 @@ export function extractEditorBody(rawFileContent: string): string {
return rawBody.trimStart()
}
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
type HeadingTextInline = { type?: string; text?: string }
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
const first = blocks?.[0] as {
type?: string
props?: { level?: number }
content?: Array<{ type?: string; text?: string }>
content?: HeadingTextInline[]
} | undefined
const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content)
? first.content
: null
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
const text = content
.filter(item => item.type === 'text')
.map(item => item.text || '')
.join('')
return text.trim() || 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. */
@@ -274,9 +287,11 @@ function normalizeTabBody(content: string): string {
}
function renameBodiesOverlap(currentBody: string, nextBody: string): boolean {
return currentBody === nextBody
|| currentBody.startsWith(nextBody)
|| nextBody.startsWith(currentBody)
const current = currentBody.trimEnd()
const next = nextBody.trimEnd()
return current === next
|| current.startsWith(next)
|| next.startsWith(current)
}
function isUntitledRenameTransition(
@@ -377,21 +392,52 @@ function cachePreviousTabOnPathChange(options: {
cacheEditorState(cache, prevPath, editor.document)
}
function rememberPendingTabArrival(
function shouldWaitForActiveTab(
pathChanged: boolean,
activeTabPath: string | null,
activeTab: Tab | undefined,
pendingTabArrivalPathRef: MutableRefObject<string | null>,
) {
if (!activeTabPath) {
pendingTabArrivalPathRef.current = null
return pathChanged && !!activeTabPath && !activeTab
}
function syncActivePathTransition(options: {
prevPath: string | null
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
}) {
const {
prevPath,
pathChanged,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
} = options
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true
if (!preserveUntitledRenameState({
prevPath,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
})) {
prevActivePathRef.current = activeTabPath
return false
}
if (activeTab) {
pendingTabArrivalPathRef.current = null
return true
}
pendingTabArrivalPathRef.current = activeTabPath
return false
prevActivePathRef.current = activeTabPath
return true
}
function handleStableActivePath(options: {
@@ -399,7 +445,6 @@ function handleStableActivePath(options: {
rawModeJustEnded: boolean
activeTabPath: string | null
activeTab: Tab | undefined
pendingTabArrival: boolean
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
@@ -410,7 +455,6 @@ function handleStableActivePath(options: {
rawModeJustEnded,
activeTabPath,
activeTab,
pendingTabArrival,
cache,
editor,
editorMountedRef,
@@ -423,7 +467,6 @@ function handleStableActivePath(options: {
rawSwapPendingRef.current = true
return false
}
if (pendingTabArrival) return false
if (rawSwapPendingRef.current) return true
cacheStableActivePath({
@@ -638,7 +681,81 @@ function scheduleTabSwap(options: {
pendingSwapRef.current = doSwap
}
function useTabSwapEffect(options: {
function runTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
rawMode?: boolean
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
tabs,
activeTabPath,
editor,
rawMode,
tabCacheRef,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
} = options
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
const activeTab = findActiveTab(tabs, activeTabPath)
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
if (syncActivePathTransition({
prevPath,
pathChanged,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
})) {
return
}
if (handleStableActivePath({
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})) {
return
}
if (!activeTabPath || !activeTab) return
scheduleTabSwap({
editor,
cache,
targetPath: activeTabPath,
activeTab,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
suppressChangeRef,
})
}
function useTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
@@ -647,7 +764,6 @@ function useTabSwapEffect(options: {
prevActivePathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
pendingSwapRef: MutableRefObject<(() => void) | null>
pendingTabArrivalPathRef: MutableRefObject<string | null>
prevRawModeRef: MutableRefObject<boolean>
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
@@ -661,64 +777,22 @@ function useTabSwapEffect(options: {
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
} = options
useEffect(() => {
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
const activeTab = findActiveTab(tabs, activeTabPath)
const pendingTabArrival = activeTabPath !== null
&& pendingTabArrivalPathRef.current === activeTabPath
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
prevActivePathRef.current = activeTabPath
if (preserveUntitledRenameState({
prevPath,
runTabSwapEffect({
tabs,
activeTabPath,
activeTab,
cache,
editor,
rawMode,
tabCacheRef,
editorMountedRef,
})) {
return
}
if (handleStableActivePath({
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
pendingTabArrival,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})) {
return
}
if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) {
return
}
const targetPath = activeTabPath
const readyActiveTab = activeTab
if (!targetPath || !readyActiveTab) return
scheduleTabSwap({
editor,
cache,
targetPath,
activeTab: readyActiveTab,
pendingSwapRef,
prevActivePathRef,
pendingSwapRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,
})
@@ -727,7 +801,6 @@ function useTabSwapEffect(options: {
editor,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevActivePathRef,
prevRawModeRef,
rawMode,
@@ -771,7 +844,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const prevActivePathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
const pendingTabArrivalPathRef = useRef<string | null>(null)
const prevRawModeRef = useRef(!!rawMode)
const rawSwapPendingRef = useRef(false)
const suppressChangeRef = useRef(false)
@@ -795,7 +867,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
prevActivePathRef,
editorMountedRef,
pendingSwapRef,
pendingTabArrivalPathRef,
prevRawModeRef,
rawSwapPendingRef,
suppressChangeRef,

View File

@@ -14,6 +14,7 @@ export interface NoteActionsConfig {
addEntry: (entry: VaultEntry) => void
removeEntry: (path: string) => void
entries: VaultEntry[]
flushBeforeNoteSwitch?: (path: string) => Promise<void>
flushBeforePathRename?: (path: string) => Promise<void>
reloadVault?: () => Promise<unknown>
setToastMessage: (msg: string | null) => void
@@ -176,9 +177,89 @@ async function updateFrontmatterAndMaybeRename({
config.onFrontmatterPersisted?.()
}
function buildTabManagementOptions(
flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'],
) {
return flushBeforeNoteSwitch
? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) }
: undefined
}
function useFrontmatterActionHandlers({
config,
renameTabsRef,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
runFrontmatterOp,
}: {
config: NoteActionsConfig
renameTabsRef: TitleRenameDeps['tabsRef']
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
activeTabPathRef: React.MutableRefObject<string | null>
handleSwitchTab: (path: string) => void
setToastMessage: (msg: string | null) => void
updateTabContent: (path: string, newContent: string) => void
runFrontmatterOp: (
op: 'update' | 'delete',
path: string,
key: string,
value?: FrontmatterValue,
options?: FrontmatterOpOptions,
) => Promise<string | undefined>
}) {
const handleUpdateFrontmatter = useCallback(async (
path: string,
key: string,
value: FrontmatterValue,
options?: FrontmatterOpOptions,
) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: renameTabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [config, runFrontmatterOp])
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [config, runFrontmatterOp])
return {
handleUpdateFrontmatter,
handleDeleteProperty,
handleAddProperty,
}
}
export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
const tabMgmt = useTabManagement(buildTabManagementOptions(config.flushBeforeNoteSwitch))
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
const updateTabContent = useCallback((path: string, newContent: string) => {
@@ -201,6 +282,16 @@ export function useNoteActions(config: NoteActionsConfig) {
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
[updateTabContent, updateEntry, setToastMessage, entries],
)
const frontmatterActions = useFrontmatterActionHandlers({
config,
renameTabsRef: rename.tabsRef,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
runFrontmatterOp,
})
return {
...tabMgmt,
@@ -210,38 +301,9 @@ export function useNoteActions(config: NoteActionsConfig) {
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
handleCreateType: creation.handleCreateType,
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: rename.tabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter,
handleDeleteProperty: frontmatterActions.handleDeleteProperty,
handleAddProperty: frontmatterActions.handleAddProperty,
handleRenameNote: rename.handleRenameNote,
handleRenameFilename: rename.handleRenameFilename,
}

View File

@@ -35,6 +35,7 @@ interface ReloadTabsAfterRenameRequest {
/** 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
return `${slugify(title)}.md` !== filename
}

View File

@@ -72,6 +72,22 @@ async function expectStatus(
})
}
async function expectCancelledPickerLeavesWelcome(
action: (onboarding: ReturnType<typeof useOnboarding>) => Promise<void>,
) {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await action(result.current)
})
expect(result.current.state.status).toBe('welcome')
}
describe('useOnboarding', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -94,9 +110,15 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
})
it('shows vault-missing when the welcome screen was previously dismissed', async () => {
it('shows vault-missing when a previously configured active vault is missing', async () => {
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
mockCommands()
mockCommands({
load_vault_list: {
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
active_vault: '/vault/deleted',
hidden_defaults: [],
},
})
const { result } = await renderOnboarding('/vault/deleted')
@@ -153,17 +175,9 @@ describe('useOnboarding', () => {
})
it('does nothing when the template folder picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateVault()
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleCreateVault()
})
expect(result.current.state.status).toBe('welcome')
expect(mockInvokeFn).not.toHaveBeenCalledWith('create_getting_started_vault', expect.anything())
})
@@ -233,17 +247,9 @@ describe('useOnboarding', () => {
})
it('does nothing when the empty-vault picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateNewVault()
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleCreateNewVault()
})
expect(result.current.state.status).toBe('welcome')
})
it('opens an existing folder and transitions to ready', async () => {
@@ -262,17 +268,9 @@ describe('useOnboarding', () => {
})
it('does nothing when the open-folder picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleOpenFolder()
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleOpenFolder()
})
expect(result.current.state.status).toBe('welcome')
})
it('marks the welcome screen dismissed and keeps the initial vault path', async () => {

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
import { cacheNoteContent } from './useTabManagement'
export async function persistContent(path: string, content: string): Promise<void> {
if (isTauri()) {
@@ -19,6 +20,7 @@ export async function persistContent(path: string, content: string): Promise<voi
export function useSaveNote(updateContent: (path: string, content: string) => void) {
const saveNote = useCallback(async (path: string, content: string) => {
await persistContent(path, content)
cacheNoteContent(path, content)
if (!isTauri()) {
updateMockContent(path, content)
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { VaultEntry } from '../types'
import { useTabManagement, prefetchNoteContent, clearPrefetchCache } from './useTabManagement'
import { useTabManagement, prefetchNoteContent, cacheNoteContent, clearPrefetchCache } from './useTabManagement'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
@@ -47,15 +47,32 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
})
}
async function prefetchResolvedContent(path: string, content: string) {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue(content)
prefetchNoteContent(path)
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
return mockInvoke
}
function expectSingleActiveTab(result: HookState, path: string) {
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.path).toBe(path)
expect(result.current.activeTabPath).toBe(path)
}
function createDeferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('useTabManagement (single-note model)', () => {
beforeEach(() => {
vi.clearAllMocks()
clearPrefetchCache()
})
it('starts with no note and null active path', () => {
@@ -204,11 +221,7 @@ describe('useTabManagement (single-note model)', () => {
describe('content prefetch cache', () => {
it('prefetch serves content to loadNoteContent (no extra IPC)', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')
prefetchNoteContent('/vault/note/pre.md')
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content')
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })
@@ -218,11 +231,7 @@ describe('useTabManagement (single-note model)', () => {
})
it('clearPrefetchCache prevents stale content from being served', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue('# Stale')
prefetchNoteContent('/vault/note/stale.md')
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
const mockInvoke = await prefetchResolvedContent('/vault/note/stale.md', '# Stale')
clearPrefetchCache()
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
@@ -244,6 +253,18 @@ describe('useTabManagement (single-note model)', () => {
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
})
it('serves refreshed cached content after a save replaces stale prefetched data', async () => {
const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content')
cacheNoteContent('/vault/note/saved.md', '# Persisted content')
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/saved.md', title: 'Saved' })
expect(result.current.tabs[0].content).toBe('# Persisted content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
})
})
describe('rapid switching safety', () => {
@@ -277,5 +298,91 @@ describe('useTabManagement (single-note model)', () => {
expect(result.current.activeTabPath).toBe('/vault/b.md')
})
it('waits for beforeNavigate before switching away from the current note', async () => {
const beforeNavigate = vi.fn(() => createDeferred<void>().promise)
const deferred = createDeferred<void>()
beforeNavigate.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
let replaceDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
.then(() => { replaceDone = true })
await Promise.resolve()
})
expect(beforeNavigate).toHaveBeenCalledWith('/vault/a.md', '/vault/b.md')
expect(result.current.activeTabPath).toBe('/vault/a.md')
expect(replaceDone).toBe(false)
await act(async () => {
deferred.resolve(undefined)
await Promise.resolve()
})
await vi.waitFor(() => expect(replaceDone).toBe(true))
expectSingleActiveTab(result, '/vault/b.md')
})
it('keeps only the latest target when note switches overlap during beforeNavigate', async () => {
const first = createDeferred<void>()
const second = createDeferred<void>()
const beforeNavigate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
let switchToBDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
.then(() => { switchToBDone = true })
await Promise.resolve()
})
let switchToCDone = false
await act(async () => {
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/c.md', title: 'C' }))
.then(() => { switchToCDone = true })
await Promise.resolve()
})
await act(async () => {
first.resolve(undefined)
await Promise.resolve()
})
expect(result.current.activeTabPath).toBe('/vault/a.md')
await act(async () => {
second.resolve(undefined)
await Promise.resolve()
})
await vi.waitFor(() => expect(switchToBDone && switchToCDone).toBe(true))
expect(result.current.activeTabPath).toBe('/vault/c.md')
})
it('keeps the current note active when beforeNavigate fails', async () => {
const beforeNavigate = vi.fn().mockRejectedValueOnce(new Error('save failed'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
await selectNote(result, { path: '/vault/a.md', title: 'A' })
await act(async () => {
await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
})
expect(result.current.activeTabPath).toBe('/vault/a.md')
expect(warnSpy).toHaveBeenCalledWith(
'Failed to persist note before navigation:',
expect.any(Error),
)
warnSpy.mockRestore()
})
})
})

View File

@@ -30,6 +30,10 @@ export function prefetchNoteContent(path: string): void {
prefetchCache.set(path, promise)
}
export function cacheNoteContent(path: string, content: string): void {
prefetchCache.set(path, Promise.resolve(content))
}
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
export function clearPrefetchCache(): void {
prefetchCache.clear()
@@ -49,6 +53,10 @@ async function loadNoteContent(path: string): Promise<string> {
export type { Tab }
interface TabManagementOptions {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
}
function syncActiveTabPath(
activeTabPathRef: React.MutableRefObject<string | null>,
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
@@ -112,7 +120,7 @@ async function navigateToEntry(options: {
}
}
export function useTabManagement() {
export function useTabManagement(options: TabManagementOptions = {}) {
// Single-note model: tabs has 0 or 1 elements.
const [tabs, setTabs] = useState<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
@@ -123,18 +131,38 @@ export function useTabManagement() {
// Sequence counter for rapid-switch safety: only the latest navigation wins.
const navSeqRef = useRef(0)
const beforeNavigateSeqRef = useRef(0)
const beforeNavigate = options.beforeNavigate
const executeNavigationWithBoundary = useCallback(async (
targetPath: string,
navigate: () => void | Promise<void>,
) => {
const seq = ++beforeNavigateSeqRef.current
const currentPath = activeTabPathRef.current
if (beforeNavigate && currentPath && currentPath !== targetPath) {
try {
await beforeNavigate(currentPath, targetPath)
} catch (err) {
console.warn('Failed to persist note before navigation:', err)
return
}
if (beforeNavigateSeqRef.current !== seq) return
}
await navigate()
}, [beforeNavigate])
/** Open a note — replaces the current note (single-note model). */
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
await navigateToEntry({
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
}, [])
}))
}, [executeNavigationWithBoundary])
const handleSwitchTab = useCallback((path: string) => {
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
@@ -142,20 +170,22 @@ export function useTabManagement() {
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
setSingleTab(tabsRef, setTabs, { entry, content })
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
}, [])
void executeNavigationWithBoundary(entry.path, () => {
setSingleTab(tabsRef, setTabs, { entry, content })
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
})
}, [executeNavigationWithBoundary])
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
await navigateToEntry({
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
}, [])
}))
}, [executeNavigationWithBoundary])
const closeAllTabs = useCallback(() => {
tabsRef.current = []

View File

@@ -148,6 +148,16 @@ describe('useVaultSwitcher', () => {
expect(onSwitch).toHaveBeenCalled()
})
it('does not persist the implicit default vault as an active selection', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.selectedVaultPath).toBeNull()
expect(mockVaultListStore.active_vault).toBeNull()
})
it('handles load error gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockImplementation((cmd: string) => {
@@ -413,6 +423,20 @@ describe('useVaultSwitcher', () => {
expect(result.current.vaultPath).toBe(persistedPath)
})
it('treats a remembered default vault as unselected when it is the only vault', async () => {
mockVaultListStore = {
vaults: [],
active_vault: expectedDefaultVaultPath,
hidden_defaults: [],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.selectedVaultPath).toBeNull()
})
})
describe('isGettingStartedHidden', () => {

View File

@@ -31,8 +31,10 @@ interface PersistedVaultState {
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
selectedVaultPath: string | null
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
@@ -48,10 +50,12 @@ interface PersistedVaultStore {
extraVaults: VaultOption[]
hiddenDefaults: string[]
loaded: boolean
selectedVaultPath: string | null
setDefaultPath: Dispatch<SetStateAction<string>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setLoaded: Dispatch<SetStateAction<boolean>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
@@ -77,10 +81,13 @@ interface RemainingVaultOptions {
}
interface RemoveVaultStateOptions extends RemainingVaultOptions {
selectedVaultPath: string | null
onSwitchRef: MutableRefObject<() => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
vaultPath: string
}
interface RemoveVaultActionOptions {
@@ -91,10 +98,17 @@ interface RemoveVaultActionOptions {
onToastRef: MutableRefObject<(msg: string) => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
selectedVaultPath: string | null
vaultPath: string
}
function labelFromPath(path: string): string {
interface VaultPathInput {
path: string
}
function labelFromPath({ path }: VaultPathInput): string {
return path.split('/').pop() || 'Local Vault'
}
@@ -127,22 +141,37 @@ async function loadInitialVaultState() {
return { activeVault, hiddenDefaults, resolvedDefaultPath, vaults }
}
function buildDefaultVaults(defaultPath: string): VaultOption[] {
function buildDefaultVaults({ defaultPath }: { defaultPath: string }): VaultOption[] {
return [{ label: GETTING_STARTED_LABEL, path: defaultPath }]
}
function buildVisibleDefaultVaults(defaultVaults: VaultOption[], hiddenDefaults: string[]): VaultOption[] {
function buildVisibleDefaultVaults({
defaultVaults,
hiddenDefaults,
}: {
defaultVaults: VaultOption[]
hiddenDefaults: string[]
}): VaultOption[] {
return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path))
}
function buildAllVaults(visibleDefaults: VaultOption[], extraVaults: VaultOption[]): VaultOption[] {
function buildAllVaults({
visibleDefaults,
extraVaults,
}: {
visibleDefaults: VaultOption[]
extraVaults: VaultOption[]
}): VaultOption[] {
return [...visibleDefaults, ...extraVaults]
}
function applyResolvedDefaultPath(
resolvedDefaultPath: string,
setDefaultPath: Dispatch<SetStateAction<string>>,
) {
function applyResolvedDefaultPath({
resolvedDefaultPath,
setDefaultPath,
}: {
resolvedDefaultPath: string
setDefaultPath: Dispatch<SetStateAction<string>>
}) {
if (!resolvedDefaultPath) {
return
}
@@ -151,14 +180,35 @@ function applyResolvedDefaultPath(
syncDefaultVaultExport(resolvedDefaultPath)
}
function applyInitialVaultTarget(
function normalizeInitialSelectedVaultPath(
activeVault: string | null,
resolvedDefaultPath: string,
setVaultPath: Dispatch<SetStateAction<string>>,
onSwitchRef: MutableRefObject<() => void>,
) {
vaults: VaultOption[],
): string | null {
if (!activeVault) {
return null
}
const isRememberedDefaultOnlySelection = activeVault === resolvedDefaultPath && vaults.length === 0
return isRememberedDefaultOnlySelection ? null : activeVault
}
function applyInitialVaultTarget({
activeVault,
resolvedDefaultPath,
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
}: {
activeVault: string | null
resolvedDefaultPath: string
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
onSwitchRef: MutableRefObject<() => void>
}) {
if (activeVault) {
setVaultPath(activeVault)
setSelectedVaultPath(activeVault)
onSwitchRef.current()
return
}
@@ -174,15 +224,15 @@ function useVaultCollections(
extraVaults: VaultOption[],
): VaultCollections {
const defaultVaults = useMemo(
() => buildDefaultVaults(defaultPath),
() => buildDefaultVaults({ defaultPath }),
[defaultPath],
)
const visibleDefaults = useMemo(
() => buildVisibleDefaultVaults(defaultVaults, hiddenDefaults),
() => buildVisibleDefaultVaults({ defaultVaults, hiddenDefaults }),
[defaultVaults, hiddenDefaults],
)
const allVaults = useMemo(
() => buildAllVaults(visibleDefaults, extraVaults),
() => buildAllVaults({ visibleDefaults, extraVaults }),
[extraVaults, visibleDefaults],
)
const isGettingStartedHidden = useMemo(
@@ -202,6 +252,7 @@ function useLoadPersistedVaultState(
setExtraVaults,
setHiddenDefaults,
setLoaded,
setSelectedVaultPath,
setVaultPath,
} = store
@@ -214,8 +265,14 @@ function useLoadPersistedVaultState(
setExtraVaults(vaults)
setHiddenDefaults(hidden)
applyResolvedDefaultPath(resolvedDefaultPath, setDefaultPath)
applyInitialVaultTarget(activeVault, resolvedDefaultPath, setVaultPath, onSwitchRef)
applyResolvedDefaultPath({ resolvedDefaultPath, setDefaultPath })
applyInitialVaultTarget({
activeVault: normalizeInitialSelectedVaultPath(activeVault, resolvedDefaultPath, vaults),
resolvedDefaultPath,
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
})
})
.catch(err => console.warn('Failed to load vault list:', err))
.finally(() => {
@@ -225,23 +282,24 @@ function useLoadPersistedVaultState(
})
return () => { cancelled = true }
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setVaultPath])
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
}
function usePersistedVaultStorage(store: PersistedVaultStore) {
const { extraVaults, hiddenDefaults, loaded, vaultPath } = store
const { extraVaults, hiddenDefaults, loaded, selectedVaultPath } = store
useEffect(() => {
if (!loaded) return
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, hiddenDefaults, loaded, vaultPath])
}, [extraVaults, hiddenDefaults, loaded, selectedVaultPath])
}
function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState {
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
const [selectedVaultPath, setSelectedVaultPath] = useState<string | null>(null)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)
@@ -252,10 +310,12 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
setDefaultPath,
setExtraVaults,
setHiddenDefaults,
setLoaded,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}
@@ -268,8 +328,10 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}
@@ -304,23 +366,34 @@ async function ensureGettingStartedVaultReady(path: string): Promise<void> {
}
}
function addVaultToList(
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>,
path: string,
label: string,
) {
function addVaultToList({
setExtraVaults,
path,
label,
}: {
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
path: string
label: string
}) {
setExtraVaults(previousVaults => {
const exists = previousVaults.some(vault => vault.path === path)
return exists ? previousVaults : [...previousVaults, { label, path, available: true }]
})
}
function switchVaultPath(
setVaultPath: Dispatch<SetStateAction<string>>,
onSwitchRef: MutableRefObject<() => void>,
path: string,
) {
function switchVaultPath({
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
path,
}: {
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
onSwitchRef: MutableRefObject<() => void>
path: string
}) {
trackEvent('vault_switched')
setSelectedVaultPath(path)
setVaultPath(path)
onSwitchRef.current()
}
@@ -349,7 +422,10 @@ function removeVaultFromState({
removedPath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}: RemoveVaultStateOptions) {
if (isDefault) {
setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath])
@@ -357,43 +433,52 @@ function removeVaultFromState({
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
}
setVaultPath(currentPath => {
if (currentPath !== removedPath) {
return currentPath
if (vaultPath !== removedPath) {
if (selectedVaultPath === removedPath) {
setSelectedVaultPath(null)
}
return
}
const remainingVaults = listRemainingVaults({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
removedPath,
})
if (remainingVaults.length === 0) {
return currentPath
}
onSwitchRef.current()
return remainingVaults[0].path
const remainingVaults = listRemainingVaults({
defaultVaults,
extraVaults,
hiddenDefaults,
isDefault,
removedPath,
})
if (remainingVaults.length === 0) {
setSelectedVaultPath(null)
return
}
const nextPath = remainingVaults[0].path
setSelectedVaultPath(nextPath)
setVaultPath(nextPath)
onSwitchRef.current()
}
function getRemovedVaultLabel(
path: string,
defaultVaults: VaultOption[],
extraVaults: VaultOption[],
): string {
function getRemovedVaultLabel({
path,
defaultVaults,
extraVaults,
}: {
path: string
defaultVaults: VaultOption[]
extraVaults: VaultOption[]
}): string {
const removedVault = [...defaultVaults, ...extraVaults].find(vault => vault.path === path)
return removedVault?.label ?? labelFromPath(path)
return removedVault?.label ?? labelFromPath({ path })
}
function useSwitchVaultAction(
onSwitchRef: MutableRefObject<() => void>,
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>,
setVaultPath: Dispatch<SetStateAction<string>>,
) {
return useCallback((path: string) => {
switchVaultPath(setVaultPath, onSwitchRef, path)
}, [onSwitchRef, setVaultPath])
switchVaultPath({ setSelectedVaultPath, setVaultPath, onSwitchRef, path })
}, [onSwitchRef, setSelectedVaultPath, setVaultPath])
}
function useVaultClonedAction(
@@ -414,7 +499,7 @@ function useOpenLocalFolderAction(
const path = await pickFolder('Open vault folder')
if (!path) return
const label = labelFromPath(path)
const label = labelFromPath({ path })
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" opened`)
}, [addAndSwitch, onToastRef])
@@ -428,7 +513,10 @@ function useRemoveVaultAction({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}: RemoveVaultActionOptions) {
return useCallback((path: string) => {
const isDefault = defaultVaults.some(vault => vault.path === path)
@@ -442,9 +530,12 @@ function useRemoveVaultAction({
removedPath: path,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
})
onToastRef.current(`Vault "${getRemovedVaultLabel(path, defaultVaults, extraVaults)}" removed from list`)
onToastRef.current(`Vault "${getRemovedVaultLabel({ path, defaultVaults, extraVaults })}" removed from list`)
}, [
defaultVaults,
extraVaults,
@@ -453,7 +544,10 @@ function useRemoveVaultAction({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
])
}
@@ -479,13 +573,16 @@ function useVaultActions({
onToastRef,
setExtraVaults,
setHiddenDefaults,
selectedVaultPath,
setSelectedVaultPath,
setVaultPath,
vaultPath,
}: VaultActionOptions) {
const addVault = useCallback((path: string, label: string) => {
addVaultToList(setExtraVaults, path, label)
addVaultToList({ setExtraVaults, path, label })
}, [setExtraVaults])
const switchVault = useSwitchVaultAction(onSwitchRef, setVaultPath)
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
const addAndSwitch = useCallback((path: string, label: string) => {
addVault(path, label)
switchVault(path)
@@ -502,7 +599,10 @@ function useVaultActions({
onToastRef,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
selectedVaultPath,
vaultPath,
}),
restoreGettingStarted: useRestoreGettingStartedAction({
defaultPath,
@@ -543,7 +643,14 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
const persistedState = usePersistedVaultState(onSwitchRef)
const { defaultPath, extraVaults, hiddenDefaults, loaded, vaultPath } = persistedState
const {
defaultPath,
extraVaults,
hiddenDefaults,
loaded,
selectedVaultPath,
vaultPath,
} = persistedState
const { allVaults, defaultVaults, isGettingStartedHidden } = useVaultCollections(
defaultPath,
hiddenDefaults,
@@ -559,7 +666,16 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
})
return {
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
removeVault, restoreGettingStarted, isGettingStartedHidden,
allVaults,
defaultPath,
handleOpenLocalFolder,
handleVaultCloned,
isGettingStartedHidden,
loaded,
removeVault,
restoreGettingStarted,
selectedVaultPath,
switchVault,
vaultPath,
}
}

View File

@@ -24,11 +24,18 @@ if (typeof window !== 'undefined') {
window.__mockHandlers = mockHandlers
}
function resolveMockHandler(command: string) {
if (typeof window !== 'undefined' && window.__mockHandlers?.[command]) {
return window.__mockHandlers[command]
}
return mockHandlers[command]
}
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const vaultResult = await tryVaultApi<T>(cmd, args)
if (vaultResult !== undefined) return vaultResult
const handler = mockHandlers[cmd]
const handler = resolveMockHandler(cmd)
if (handler) {
await new Promise((r) => setTimeout(r, 100))
return handler(args) as T

View File

@@ -103,11 +103,17 @@ let mockSettings: Settings = {
default_ai_agent: 'claude_code',
}
let mockLastVaultPath: string | null = null
const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2'
const DEFAULT_MOCK_VAULT = {
label: 'demo-vault-v2',
path: DEFAULT_MOCK_VAULT_PATH,
}
let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
vaults: [],
active_vault: null,
vaults: [DEFAULT_MOCK_VAULT],
active_vault: DEFAULT_MOCK_VAULT_PATH,
}
let mockVaultAiGuidanceStatus = {

View File

@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalFetch = globalThis.fetch
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
describe('tryVaultApi', () => {
afterEach(() => {
vi.resetModules()
vi.restoreAllMocks()
globalThis.fetch = originalFetch
})
it('retries vault API discovery after an unavailable response', async () => {
let vaultApiOnline = false
const fetchMock = vi.fn(async (input: string | URL) => {
const url = String(input)
if (url === '/api/vault/ping') {
return jsonResponse({ ok: vaultApiOnline }, vaultApiOnline ? 200 : 503)
}
if (url === '/api/vault/list?path=%2Ffixture') {
return jsonResponse([{ title: 'Alpha Project' }])
}
throw new Error(`Unexpected fetch: ${url}`)
})
globalThis.fetch = fetchMock as typeof fetch
const { tryVaultApi } = await import('./vault-api')
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toBeUndefined()
vaultApiOnline = true
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toEqual([{ title: 'Alpha Project' }])
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(2)
})
it('unwraps note content responses from the vault API', async () => {
const fetchMock = vi.fn(async (input: string | URL) => {
const url = String(input)
if (url === '/api/vault/ping') {
return jsonResponse({ ok: true })
}
if (url === '/api/vault/content?path=%2Ffixture%2Falpha.md') {
return jsonResponse({ content: '# Alpha Project' })
}
throw new Error(`Unexpected fetch: ${url}`)
})
globalThis.fetch = fetchMock as typeof fetch
const { tryVaultApi } = await import('./vault-api')
await expect(tryVaultApi('get_note_content', { path: '/fixture/alpha.md' })).resolves.toBe('# Alpha Project')
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(1)
})
})

View File

@@ -6,16 +6,22 @@
let vaultApiAvailable: boolean | null = null
async function checkVaultApi(): Promise<boolean> {
if (vaultApiAvailable !== null) return vaultApiAvailable
async function detectVaultApiAvailability(): Promise<boolean> {
try {
const res = await fetch('/api/vault/ping', { signal: AbortSignal.timeout(500) })
vaultApiAvailable = res.ok
return res.ok
} catch {
vaultApiAvailable = false
return false
}
}
async function checkVaultApi(): Promise<boolean> {
if (vaultApiAvailable === true) return true
const available = await detectVaultApiAvailability()
vaultApiAvailable = available
console.info(`[mock-tauri] Vault API available: ${vaultApiAvailable}`)
return vaultApiAvailable
return available
}
interface VaultApiRequest {
@@ -65,25 +71,38 @@ const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => Vaul
},
}
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const available = await checkVaultApi()
if (!available) return undefined
function buildVaultApiRequest(cmd: string, args?: Record<string, unknown>) {
if (!args) return null
const requestBuilder = VAULT_API_COMMANDS[cmd]
if (!requestBuilder || !args) return undefined
return requestBuilder?.(args) ?? null
}
const request = requestBuilder(args)
function buildFetchOptions(request: VaultApiRequest): RequestInit {
if (!request.body) {
return { method: request.method || 'GET' }
}
return {
method: request.method || 'GET',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request.body),
}
}
async function fetchVaultApiResponse(request: VaultApiRequest) {
const res = await fetch(request.url, buildFetchOptions(request))
if (!res.ok) return undefined
return res.json()
}
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const request = buildVaultApiRequest(cmd, args)
if (!request) return undefined
if (!await checkVaultApi()) return undefined
try {
const fetchOpts: RequestInit = { method: request.method || 'GET' }
if (request.body) {
fetchOpts.headers = { 'Content-Type': 'application/json' }
fetchOpts.body = JSON.stringify(request.body)
}
const res = await fetch(request.url, fetchOpts)
if (!res.ok) return undefined
const data = await res.json()
const data = await fetchVaultApiResponse(request)
if (data === undefined) return undefined
return (cmd === 'get_note_content' ? data.content : data) as T
} catch (err) {
console.warn(`[mock-tauri] Vault API call failed for ${cmd}, falling back to mock:`, err)

View File

@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest'
import { triggerCommitEntryAction } from './commitEntryAction'
describe('triggerCommitEntryAction', () => {
it('runs the automatic checkpoint when AutoGit is enabled', () => {
const openCommitDialog = vi.fn().mockResolvedValue(undefined)
const runAutomaticCheckpoint = vi.fn().mockResolvedValue(true)
triggerCommitEntryAction({
autoGitEnabled: true,
openCommitDialog,
runAutomaticCheckpoint,
})
expect(runAutomaticCheckpoint).toHaveBeenCalledWith({ savePendingBeforeCommit: true })
expect(openCommitDialog).not.toHaveBeenCalled()
})
it('opens the manual commit dialog when AutoGit is disabled', () => {
const openCommitDialog = vi.fn().mockResolvedValue(undefined)
const runAutomaticCheckpoint = vi.fn().mockResolvedValue(true)
triggerCommitEntryAction({
autoGitEnabled: false,
openCommitDialog,
runAutomaticCheckpoint,
})
expect(openCommitDialog).toHaveBeenCalledTimes(1)
expect(runAutomaticCheckpoint).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,21 @@
type RunAutomaticCheckpoint = (options?: { savePendingBeforeCommit?: boolean }) => Promise<boolean>
type OpenCommitDialog = () => Promise<void>
interface CommitEntryActionConfig {
autoGitEnabled: boolean
openCommitDialog: OpenCommitDialog
runAutomaticCheckpoint: RunAutomaticCheckpoint
}
export function triggerCommitEntryAction({
autoGitEnabled,
openCommitDialog,
runAutomaticCheckpoint,
}: CommitEntryActionConfig): void {
if (autoGitEnabled) {
void runAutomaticCheckpoint({ savePendingBeforeCommit: true })
return
}
void openCommitDialog()
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
looksLikeLocalFileReference,
shouldStripAutoLinkedLocalFileMark,
shouldAutoLinkTolariaHref,
} from './editorLinkAutolink'
describe('looksLikeLocalFileReference', () => {
it('treats bare filenames with file extensions as local file references', () => {
expect(looksLikeLocalFileReference({ raw: 'AGENTS.md' })).toBe(true)
expect(looksLikeLocalFileReference({ raw: 'README.txt' })).toBe(true)
})
it('treats local path-like filenames as local file references', () => {
expect(looksLikeLocalFileReference({ raw: 'docs/README.md' })).toBe(true)
expect(looksLikeLocalFileReference({ raw: './docs/README.md' })).toBe(true)
expect(looksLikeLocalFileReference({ raw: '/vault/README.md' })).toBe(true)
})
it('does not classify domain-based urls as local file references', () => {
expect(looksLikeLocalFileReference({ raw: 'https://example.com/README.md' })).toBe(false)
expect(looksLikeLocalFileReference({ raw: 'example.com/README.md' })).toBe(false)
expect(looksLikeLocalFileReference({ raw: 'www.example.com/README.md' })).toBe(false)
})
})
describe('shouldAutoLinkTolariaHref', () => {
it('rejects plain filename-like text', () => {
expect(shouldAutoLinkTolariaHref({ raw: 'AGENTS.md' })).toBe(false)
expect(shouldAutoLinkTolariaHref({ raw: 'docs/README.md' })).toBe(false)
})
it('keeps normal url-like values eligible for autolinking', () => {
expect(shouldAutoLinkTolariaHref({ raw: 'https://example.com/docs' })).toBe(true)
expect(shouldAutoLinkTolariaHref({ raw: 'example.com' })).toBe(true)
expect(shouldAutoLinkTolariaHref({ raw: 'example.com/README.md' })).toBe(true)
})
})
describe('shouldStripAutoLinkedLocalFileMark', () => {
it('strips accidental link marks that mirror local file text', () => {
expect(shouldStripAutoLinkedLocalFileMark({
href: { raw: 'https://AGENTS.md' },
text: { raw: 'AGENTS.md' },
})).toBe(true)
expect(shouldStripAutoLinkedLocalFileMark({
href: { raw: 'https://docs/README.md' },
text: { raw: 'docs/README.md' },
})).toBe(true)
})
it('keeps intentional external links', () => {
expect(shouldStripAutoLinkedLocalFileMark({
href: { raw: 'https://example.com/docs' },
text: { raw: 'Tolaria Docs' },
})).toBe(false)
expect(shouldStripAutoLinkedLocalFileMark({
href: { raw: 'https://example.com/agents' },
text: { raw: 'AGENTS.md' },
})).toBe(false)
})
})

View File

@@ -0,0 +1,143 @@
const FILE_LIKE_EXTENSION_PATTERN =
/\.(?:md|markdown|txt|ya?ml|json|toml|csv|tsv|pdf|png|jpe?g|gif|svg|webp|avif|mp3|wav|ogg|mp4|mov|zip|tar|gz|tsx?|jsx?|cjs|mjs|rs|py|sh|css|html?)$/i
const EXPLICIT_PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i
const MAYBE_PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:/i
const LOCAL_PATH_PREFIX_PATTERN = /^(?:\.{1,2}\/|~\/|\/)/
const IPV4_HOST_PATTERN = /^\d{1,3}(\.\d{1,3}){3}$/
const WINDOWS_PATH_SEPARATOR = '\\'
const WWW_PREFIX = 'www.'
export type LinkValue = {
raw: string
}
export type LinkMarkCandidate = {
href: LinkValue
text: LinkValue
}
function withRaw(raw: string): LinkValue {
return { raw }
}
function stripUrlDecorators(value: LinkValue) {
return withRaw(value.raw.split(/[?#]/, 1)[0] ?? value.raw)
}
function stripProtocol(value: LinkValue) {
return withRaw(value.raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''))
}
function isExplicitProtocolUrl(value: LinkValue) {
return EXPLICIT_PROTOCOL_PATTERN.test(value.raw)
}
function isMaybeProtocolUrl(value: LinkValue) {
return MAYBE_PROTOCOL_PATTERN.test(value.raw)
}
function hasWindowsPathSeparator(value: LinkValue) {
return value.raw.includes(WINDOWS_PATH_SEPARATOR)
}
function hasPathSeparator(value: LinkValue) {
return value.raw.includes('/')
}
function startsWithWebHostPrefix(value: LinkValue) {
return value.raw.startsWith(WWW_PREFIX)
}
function firstPathSegment(value: LinkValue) {
return value.raw.split('/', 1)[0] ?? value.raw
}
function isDomainLikePath(value: LinkValue) {
return firstPathSegment(value).includes('.')
}
function hostnameFromUrlLikeValue(value: LinkValue) {
const withoutUserinfo = value.raw.includes('@') ? value.raw.split('@').pop() ?? value.raw : value.raw
return withoutUserinfo.split(/[/?#:]/, 1)[0] ?? withoutUserinfo
}
function isExplicitWebUrl(value: LinkValue) {
return isExplicitProtocolUrl(value) || startsWithWebHostPrefix(value)
}
function isSingleSegmentValue(value: LinkValue) {
return !hasPathSeparator(value)
}
function isLocalPathPrefix(value: LinkValue) {
return LOCAL_PATH_PREFIX_PATTERN.test(value.raw)
}
function isPathLikeFileReference(value: LinkValue) {
return hasPathSeparator(value) && !isDomainLikePath(value)
}
function hasBareProtocolPrefix(value: LinkValue) {
return isMaybeProtocolUrl(value) && !value.raw.includes('@')
}
function hostnameHasTld(hostname: string) {
return hostname.includes('.')
}
function normalizeInput(value: LinkValue) {
return stripUrlDecorators(withRaw(value.raw.trim()))
}
export function looksLikeLocalFileReference(value: LinkValue) {
const normalized = normalizeInput(value)
if (!normalized.raw) return false
if (isLocalPathPrefix(normalized) || hasWindowsPathSeparator(normalized)) {
return true
}
if (!FILE_LIKE_EXTENSION_PATTERN.test(normalized.raw)) {
return false
}
if (isExplicitWebUrl(normalized)) {
return false
}
if (isSingleSegmentValue(normalized)) {
return true
}
return isPathLikeFileReference(normalized)
}
export function shouldAutoLinkTolariaHref(url: LinkValue) {
if (looksLikeLocalFileReference(url)) {
return false
}
if (isExplicitProtocolUrl(url) || hasBareProtocolPrefix(url)) {
return true
}
const hostname = hostnameFromUrlLikeValue(url)
if (IPV4_HOST_PATTERN.test(hostname)) {
return false
}
return hostnameHasTld(hostname)
}
export function shouldStripAutoLinkedLocalFileMark(mark: LinkMarkCandidate) {
const normalizedText = normalizeInput(mark.text)
if (!looksLikeLocalFileReference(normalizedText)) {
return false
}
const normalizedHref = stripUrlDecorators(
stripProtocol(normalizeInput(mark.href)),
)
return normalizedHref.raw === normalizedText.raw
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { countAllByFilter, countByFilter, filterEntries } from './noteListHelpers'
import { countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers'
import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils'
describe('filterEntries', () => {
@@ -62,6 +62,17 @@ describe('filterEntries', () => {
const result = filterEntries(entries, allSelection, 'archived')
expect(result.map((entry) => entry.title)).toEqual(['Archived'])
})
it('excludes attachments-folder markdown from the All Notes view', () => {
const entries = [
makeEntry({ path: '/vault/note/real-note.md', title: 'Real Note', isA: 'Note' }),
makeEntry({ path: '/vault/attachments/reference.md', title: 'Attachment Markdown', isA: 'Note' }),
makeEntry({ path: '/vault/attachments/nested/diagram.md', title: 'Nested Attachment Markdown', isA: 'Note' }),
]
const result = filterEntries(entries, allSelection, 'open')
expect(result.map((entry) => entry.title)).toEqual(['Real Note'])
})
})
describe('countByFilter', () => {
@@ -102,3 +113,17 @@ describe('countAllByFilter', () => {
expect(countAllByFilter(entries)).toEqual({ open: 1, archived: 0 })
})
})
describe('countAllNotesByFilter', () => {
it('excludes attachments-folder files from All Notes totals', () => {
const entries = [
makeEntry({ path: '/vault/note/real-note.md', isA: 'Note' }),
makeEntry({ path: '/vault/attachments/reference.md', isA: 'Note' }),
makeEntry({ path: '/vault/attachments/archive.md', isA: 'Note', archived: true }),
makeEntry({ path: '/vault/attachments/image.png', fileKind: 'binary' }),
makeEntry({ path: '/vault/archive/real-archive.md', isA: 'Note', archived: true }),
]
expect(countAllNotesByFilter(entries)).toEqual({ open: 1, archived: 1 })
})
})

View File

@@ -310,6 +310,7 @@ export function buildRelationshipGroups(
const isActive = (e: VaultEntry) => !e.archived
const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
const ATTACHMENTS_FOLDER = 'attachments'
function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): VaultEntry[] {
if (subFilter === 'archived') return entries.filter((e) => e.archived)
@@ -321,26 +322,46 @@ function isInFolder(entryPath: string, folderRelPath: string): boolean {
return entryPath.includes(needle) || entryPath.startsWith(folderRelPath + '/')
}
export function isAllNotesEntry(entry: VaultEntry): boolean {
return isMarkdown(entry) && !isInFolder(entry.path, ATTACHMENTS_FOLDER)
}
function filterViewEntries(entries: VaultEntry[], filename: string, views?: ViewFile[]): VaultEntry[] {
const view = views?.find((candidate) => candidate.filename === filename)
if (!view) return []
return evaluateView(view.definition, entries.filter(isMarkdown))
}
function filterFolderEntries(entries: VaultEntry[], folderPath: string, subFilter?: NoteListFilter): VaultEntry[] {
// Folder view shows ALL files (text + binary), not just markdown
const folderEntries = entries.filter((entry) => isInFolder(entry.path, folderPath))
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
}
function filterSectionGroupEntries(entries: VaultEntry[], type: string, subFilter?: NoteListFilter): VaultEntry[] {
const typeEntries = entries.filter((entry) => isMarkdown(entry) && entry.isA === type)
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
}
function filterTopLevelEntries(
entries: VaultEntry[],
selection: Extract<SidebarSelection, { kind: 'filter' }>,
subFilter?: NoteListFilter,
): VaultEntry[] {
const filterableEntries = selection.filter === 'all'
? entries.filter(isAllNotesEntry)
: entries.filter(isMarkdown)
if (selection.filter === 'all' && subFilter) return applySubFilter(filterableEntries, subFilter)
return filterByFilterType(filterableEntries, selection.filter)
}
function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter, views?: ViewFile[]): VaultEntry[] {
if (selection.kind === 'entity') return []
if (selection.kind === 'view') {
const view = views?.find((v) => v.filename === selection.filename)
if (!view) return []
return evaluateView(view.definition, entries.filter(isMarkdown))
}
if (selection.kind === 'folder') {
// Folder view shows ALL files (text + binary), not just markdown
const folderEntries = entries.filter((e) => isInFolder(e.path, selection.path))
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
}
if (selection.kind === 'sectionGroup') {
const typeEntries = entries.filter((e) => isMarkdown(e) && e.isA === selection.type)
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
}
// Non-folder views: only markdown files
const mdEntries = entries.filter(isMarkdown)
if (selection.filter === 'all' && subFilter) return applySubFilter(mdEntries, subFilter)
return filterByFilterType(mdEntries, selection.filter)
if (selection.kind === 'view') return filterViewEntries(entries, selection.filename, views)
if (selection.kind === 'folder') return filterFolderEntries(entries, selection.path, subFilter)
if (selection.kind === 'sectionGroup') return filterSectionGroupEntries(entries, selection.type, subFilter)
if (selection.kind === 'filter') return filterTopLevelEntries(entries, selection, subFilter)
return []
}
function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] {
@@ -366,17 +387,25 @@ export function countByFilter(entries: VaultEntry[], type: string): Record<NoteL
return { open, archived }
}
/** Count notes per sub-filter across all entries (no type filter). */
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
function countEntriesByArchiveStatus(entries: VaultEntry[]): Record<NoteListFilter, number> {
let open = 0, archived = 0
for (const e of entries) {
if (!isMarkdown(e)) continue
if (e.archived) archived++
for (const entry of entries) {
if (entry.archived) archived++
else open++
}
return { open, archived }
}
/** Count notes per sub-filter across all entries (no type filter). */
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
return countEntriesByArchiveStatus(entries.filter(isMarkdown))
}
/** Count All Notes-eligible documents per sub-filter, excluding files under attachments/. */
export function countAllNotesByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
return countEntriesByArchiveStatus(entries.filter(isAllNotesEntry))
}
// --- Inbox ---
/** Check if entry belongs in the Inbox (markdown only, not organized, not archived, not a Type). */

View File

@@ -99,6 +99,7 @@ describe('buildRawEditorBaseItems', () => {
title: 'Project Alpha',
aliases: ['project-alpha', 'Alpha'],
group: 'Project',
entryType: 'Project',
entryTitle: 'Project Alpha',
path: 'projects/project-alpha.md',
},

View File

@@ -8,6 +8,7 @@ export interface RawEditorBaseItem {
title: string
aliases: string[]
group: string
entryType?: string | null
entryTitle: string
path: string
}
@@ -59,6 +60,7 @@ export function buildRawEditorBaseItems(entries: VaultEntry[]): RawEditorBaseIte
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
})))

View File

@@ -31,13 +31,10 @@ const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
const isActive = (e: VaultEntry) => !e.archived
const isSupportedSectionType = (type: string) => !isLegacyJournalingType(type)
function resolveEntrySectionType(entry: VaultEntry): string {
return entry.isA || 'Note'
}
function shouldCollectActiveType(entry: VaultEntry): boolean {
if (!isActive(entry) || !isMarkdown(entry)) return false
return isSupportedSectionType(resolveEntrySectionType(entry))
if (!entry.isA) return false
return isSupportedSectionType(entry.isA)
}
function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
@@ -45,12 +42,12 @@ function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
return isSupportedSectionType(name)
}
/** Collect unique isA values from active (non-archived) markdown entries. Untyped entries count as 'Note'. */
/** Collect unique explicit isA values from active (non-archived) markdown entries. */
export function collectActiveTypes(entries: VaultEntry[]): Set<string> {
const types = new Set<string>()
for (const e of entries) {
if (!shouldCollectActiveType(e)) continue
types.add(resolveEntrySectionType(e))
types.add(e.isA!)
}
return types
}

View File

@@ -76,8 +76,8 @@ describe('enrichSuggestionItems', () => {
Project: makeEntry({ isA: 'Type', title: 'Project', color: 'blue', icon: 'wrench' }),
}
function makeItem(title: string, group: string, path: string) {
return { title, aliases: [] as string[], group, entryTitle: title, path, onItemClick: vi.fn() }
function makeItem(title: string, group: string, path: string, entryType?: string | null) {
return { title, aliases: [] as string[], group, entryType, entryTitle: title, path, onItemClick: vi.fn() }
}
it('filters items by query', () => {
@@ -88,7 +88,7 @@ describe('enrichSuggestionItems', () => {
})
it('adds type metadata for non-Note groups', () => {
const items = [makeItem('My Project', 'Project', '/p.md')]
const items = [makeItem('My Project', 'Project', '/p.md', 'Project')]
const result = enrichSuggestionItems(items, '', typeEntryMap)
expect(result[0].noteType).toBe('Project')
expect(result[0].typeColor).toBeDefined()
@@ -96,7 +96,15 @@ describe('enrichSuggestionItems', () => {
expect(result[0].TypeIcon).toBeDefined()
})
it('omits type metadata for Note group', () => {
it('preserves Note type metadata for explicit Note entries', () => {
const items = [makeItem('Explicit Note', 'Note', '/n.md', 'Note')]
const result = enrichSuggestionItems(items, '', {})
expect(result[0].noteType).toBe('Note')
expect(result[0].typeColor).toBeDefined()
expect(result[0].TypeIcon).toBeDefined()
})
it('keeps untyped Note-group entries neutral', () => {
const items = [makeItem('Plain Note', 'Note', '/n.md')]
const result = enrichSuggestionItems(items, '', {})
expect(result[0].noteType).toBeUndefined()

View File

@@ -13,6 +13,7 @@ interface BaseSuggestionItem {
title: string
aliases: string[]
group: string
entryType?: string | null
entryTitle: string
path: string
}
@@ -48,15 +49,15 @@ export function enrichSuggestionItems(
)
const sliced = filtered.slice(0, MAX_RESULTS)
const final = disambiguateTitles(deduplicateByPath(sliced))
return final.map(({ group, ...rest }) => {
const noteType = group !== 'Note' ? group : undefined
const te = typeEntryMap[group]
return final.map(({ entryType, ...rest }) => {
const noteType = entryType ?? undefined
const te = noteType ? typeEntryMap[noteType] : undefined
return {
...rest,
noteType,
typeColor: noteType ? getTypeColor(group, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(group, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(group, te?.icon) : undefined,
typeColor: noteType ? getTypeColor(noteType, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(noteType, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(noteType, te?.icon) : undefined,
}
})
}

View File

@@ -28,7 +28,7 @@ export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVa
return { vaults: checked, activeVault: data?.active_vault ?? null, hiddenDefaults: data?.hidden_defaults ?? [] }
}
export function saveVaultList(vaults: VaultOption[], activeVault: string, hiddenDefaults: string[] = []): Promise<void> {
export function saveVaultList(vaults: VaultOption[], activeVault: string | null, hiddenDefaults: string[] = []): Promise<void> {
const list: PersistedVaultList = {
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
active_vault: activeVault,

View File

@@ -2,20 +2,40 @@ import { expect, type Page } from '@playwright/test'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { installFixtureVaultDesktopBridgeInBrowser } from './fixtureVaultDesktopBridge'
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
const FIXTURE_VAULT_READY_TIMEOUT = 30_000
const FIXTURE_VAULT_REMOVE_RETRIES = 10
const FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS = 100
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
type FixtureCommandArgs = Record<string, unknown> | undefined
function copyDirSync(src: string, dest: string): void {
interface FixtureVaultPageArgs {
page: Page
vaultPath: string
}
interface FixturePageArgs {
page: Page
}
interface CopyDirArgs {
src: string
dest: string
}
interface RemoveFixtureVaultArgs {
tempVaultDir: string
}
function copyDirSync({ src, dest }: CopyDirArgs): void {
fs.mkdirSync(dest, { recursive: true })
for (const item of fs.readdirSync(src, { withFileTypes: true })) {
const sourcePath = path.join(src, item.name)
const destinationPath = path.join(dest, item.name)
if (item.isDirectory()) {
copyDirSync(sourcePath, destinationPath)
copyDirSync({ src: sourcePath, dest: destinationPath })
continue
}
fs.copyFileSync(sourcePath, destinationPath)
@@ -24,12 +44,11 @@ function copyDirSync(src: string, dest: string): void {
export function createFixtureVaultCopy(): string {
const tempVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'laputa-test-vault-'))
copyDirSync(FIXTURE_VAULT, tempVaultDir)
copyDirSync({ src: FIXTURE_VAULT, dest: tempVaultDir })
return tempVaultDir
}
export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void {
if (!tempVaultDir) return
function removeFixtureVaultDirectory({ tempVaultDir }: RemoveFixtureVaultArgs): void {
fs.rmSync(tempVaultDir, {
recursive: true,
force: true,
@@ -38,15 +57,21 @@ export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined):
})
}
export async function openFixtureVault(
page: Page,
vaultPath: string,
): Promise<void> {
export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void {
if (!tempVaultDir) return
removeFixtureVaultDirectory({ tempVaultDir })
}
async function installFixtureVaultInitScript({ page, vaultPath }: FixtureVaultPageArgs): Promise<void> {
await page.addInitScript(({ dismissedKey, resolvedVaultPath }: { dismissedKey: string; resolvedVaultPath: string }) => {
localStorage.clear()
localStorage.setItem(dismissedKey, '1')
const jsonHeaders = { 'Content-Type': 'application/json' }
const FRONTMATTER_OPEN = '---\n'
const FRONTMATTER_CLOSE = '\n---\n'
const nativeFetch = window.fetch.bind(window)
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const requestUrl = typeof input === 'string'
? input
@@ -64,85 +89,21 @@ export async function openFixtureVault(
return nativeFetch(input, init)
}
const applyFixtureVaultOverrides = (
handlers: Record<string, ((args?: unknown) => unknown)> | null | undefined,
) => {
if (!handlers) return handlers
handlers.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
handlers.check_vault_exists = () => true
handlers.get_last_vault_path = () => resolvedVaultPath
handlers.get_default_vault_path = () => resolvedVaultPath
handlers.save_vault_list = () => null
return handlers
const readJson = async (url: string, init?: RequestInit) => {
const response = await nativeFetch(url, init)
if (!response.ok) {
let message = `HTTP ${response.status}`
try {
const body = await response.json() as { error?: string }
message = body.error ?? message
} catch {
// Preserve the HTTP status fallback when the body is not JSON.
}
throw new Error(message)
}
return response.json()
}
let ref = applyFixtureVaultOverrides(
(window.__mockHandlers as Record<string, ((args?: unknown) => unknown)> | undefined),
) ?? null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = applyFixtureVaultOverrides(
value as Record<string, ((args?: unknown) => unknown)> | undefined,
) ?? null
},
get() {
return applyFixtureVaultOverrides(ref) ?? ref
},
})
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath })
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers))
await page.evaluate((resolvedVaultPath: string) => {
const handlers = window.__mockHandlers
if (!handlers) {
throw new Error('Mock handlers unavailable for fixture vault override')
}
handlers.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
hidden_defaults: [],
})
handlers.check_vault_exists = () => true
handlers.get_last_vault_path = () => resolvedVaultPath
handlers.get_default_vault_path = () => resolvedVaultPath
handlers.save_vault_list = () => null
}, vaultPath)
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
timeout: FIXTURE_VAULT_READY_TIMEOUT,
})
}
/**
* Browser harness for desktop command-routing tests.
*
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
* renderer shortcut dispatch and desktop menu-command dispatch without a native
* shell. It is deterministic, but it is not a substitute for real native QA.
*/
export async function openFixtureVaultDesktopHarness(
page: Page,
vaultPath: string,
): Promise<void> {
await openFixtureVault(page, vaultPath)
await page.evaluate((resolvedVaultPath: string) => {
const jsonHeaders = { 'Content-Type': 'application/json' }
const nativeFetch = window.fetch.bind(window)
const FRONTMATTER_OPEN = '---\n'
const FRONTMATTER_CLOSE = '\n---\n'
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const splitFrontmatter = (content: string) => {
if (!content.startsWith(FRONTMATTER_OPEN)) {
return { frontmatter: null as string | null, body: content }
@@ -206,16 +167,12 @@ export async function openFixtureVaultDesktopHarness(
return `${FRONTMATTER_OPEN}${nextEntryLines.join('\n')}${FRONTMATTER_CLOSE}${body}`
}
const entries = splitFrontmatterEntries(frontmatter).filter((entry) => entry.key !== '')
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
let replaced = false
const nextEntries = entries.map((entry) => {
if (!keyPattern.test(entry.key)) return entry
replaced = true
return { key, lines: nextEntryLines }
})
const nextEntries = splitFrontmatterEntries(frontmatter)
.filter((entry) => entry.key !== '')
.map((entry) => (entry.key === key ? { key, lines: nextEntryLines } : entry))
if (!replaced) {
const hasEntry = nextEntries.some((entry) => entry.key === key)
if (!hasEntry) {
nextEntries.push({ key, lines: nextEntryLines })
}
@@ -226,9 +183,8 @@ export async function openFixtureVaultDesktopHarness(
const { frontmatter, body } = splitFrontmatter(content)
if (frontmatter === null) return content
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
const nextEntries = splitFrontmatterEntries(frontmatter)
.filter((entry) => entry.key !== '' && !keyPattern.test(entry.key))
.filter((entry) => entry.key !== '' && entry.key !== key)
if (nextEntries.length === 0) {
return body
@@ -237,32 +193,19 @@ export async function openFixtureVaultDesktopHarness(
return `${FRONTMATTER_OPEN}${nextEntries.flatMap((entry) => entry.lines).join('\n')}${FRONTMATTER_CLOSE}${body}`
}
const persistFrontmatterChange = async (path: string, transform: (content: string) => string) => {
const current = await readJson(`/api/vault/content?path=${encodeURIComponent(path)}`) as { content: string }
const persistFrontmatterChange = async (notePath: string, transform: (content: string) => string) => {
const current = await readJson(
`/api/vault/content?path=${encodeURIComponent(notePath)}`,
) as { content: string }
const updatedContent = transform(current.content)
await readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ path, content: updatedContent }),
body: JSON.stringify({ path: notePath, content: updatedContent }),
})
return updatedContent
}
const readJson = async (url: string, init?: RequestInit) => {
const response = await nativeFetch(url, init)
if (!response.ok) {
let message = `HTTP ${response.status}`
try {
const body = await response.json() as { error?: string }
message = body.error ?? message
} catch {
// Keep the HTTP status fallback when the body is not JSON.
}
throw new Error(message)
}
return response.json()
}
const activeVaultList = {
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
active_vault: resolvedVaultPath,
@@ -283,16 +226,16 @@ export async function openFixtureVaultDesktopHarness(
body: JSON.stringify(payload),
})
const commandHandlers: Record<string, (commandArgs?: Record<string, unknown>) => Promise<unknown> | unknown> = {
trigger_menu_command: (commandArgs) => {
const commandId = String(commandArgs?.id ?? '')
const bridge = window.__laputaTest?.dispatchBrowserMenuCommand
if (!bridge) throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand')
bridge(commandId)
return null
},
const readCommandValue = (commandArgs: FixtureCommandArgs, key: string, fallback?: unknown) =>
commandArgs?.[key] ?? fallback
const readCommandString = (commandArgs: FixtureCommandArgs, key: string, fallback = '') =>
String(readCommandValue(commandArgs, key, fallback))
const buildFixtureStateHandlers = () => ({
load_vault_list: () => activeVaultList,
check_vault_exists: () => true,
check_vault_exists: (commandArgs?: FixtureCommandArgs) =>
readCommandString(commandArgs, 'path') === resolvedVaultPath,
is_git_repo: () => true,
get_last_vault_path: () => resolvedVaultPath,
get_default_vault_path: () => resolvedVaultPath,
@@ -309,97 +252,164 @@ export async function openFixtureVaultDesktopHarness(
anonymous_id: null,
release_channel: null,
}),
list_vault: (commandArgs) => readVaultList(commandArgs),
reload_vault: (commandArgs) => readVaultList(commandArgs, true),
})
const buildFixtureReadHandlers = () => ({
list_vault: (commandArgs?: FixtureCommandArgs) => readVaultList(commandArgs),
reload_vault: (commandArgs?: FixtureCommandArgs) => readVaultList(commandArgs, true),
list_vault_folders: () => [],
list_views: () => [],
get_modified_files: () => [],
detect_renames: () => [],
reload_vault_entry: (commandArgs) =>
readJson(`/api/vault/entry?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`),
get_note_content: async (commandArgs) => {
reload_vault_entry: (commandArgs?: FixtureCommandArgs) =>
readJson(`/api/vault/entry?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`),
get_note_content: async (commandArgs?: FixtureCommandArgs) => {
const data = await readJson(
`/api/vault/content?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`,
`/api/vault/content?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`,
) as { content: string }
return data.content
},
get_all_content: (commandArgs) =>
get_all_content: (commandArgs?: FixtureCommandArgs) =>
readJson(
`/api/vault/all-content?path=${encodeURIComponent(String(commandArgs?.path ?? resolvedVaultPath))}`,
`/api/vault/all-content?path=${encodeURIComponent(readCommandString(commandArgs, 'path', resolvedVaultPath))}`,
),
save_note_content: (commandArgs) =>
readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ path: commandArgs?.path, content: commandArgs?.content }),
}),
update_frontmatter: (commandArgs) =>
persistFrontmatterChange(
String(commandArgs?.path ?? ''),
(content) => replaceFrontmatterEntry(content, String(commandArgs?.key ?? ''), commandArgs?.value),
),
delete_frontmatter_property: (commandArgs) =>
persistFrontmatterChange(
String(commandArgs?.path ?? ''),
(content) => removeFrontmatterEntry(content, String(commandArgs?.key ?? '')),
),
rename_note: (commandArgs) =>
renameNoteRequest({
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
old_path: commandArgs?.oldPath,
new_title: commandArgs?.newTitle,
old_title: commandArgs?.oldTitle ?? null,
}),
rename_note_filename: (commandArgs) =>
readJson('/api/vault/rename-filename', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
old_path: commandArgs?.oldPath,
new_filename_stem: commandArgs?.newFilenameStem,
}),
}),
search_vault: (commandArgs) => {
const resolvedPath = String(commandArgs?.path ?? commandArgs?.vaultPath ?? resolvedVaultPath)
const query = encodeURIComponent(String(commandArgs?.query ?? ''))
const mode = encodeURIComponent(String(commandArgs?.mode ?? 'all'))
search_vault: (commandArgs?: FixtureCommandArgs) => {
const resolvedPath = readCommandString(
commandArgs,
'path',
readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
)
const query = encodeURIComponent(readCommandString(commandArgs, 'query'))
const mode = encodeURIComponent(readCommandString(commandArgs, 'mode', 'all'))
return readJson(
`/api/vault/search?vault_path=${encodeURIComponent(resolvedPath)}&query=${query}&mode=${mode}`,
)
},
auto_rename_untitled: async (commandArgs) => {
const notePath = String(commandArgs?.notePath ?? '')
})
const buildFixtureWriteHandlers = () => ({
save_note_content: (commandArgs?: FixtureCommandArgs) =>
readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
path: readCommandValue(commandArgs, 'path'),
content: readCommandValue(commandArgs, 'content'),
}),
}),
update_frontmatter: (commandArgs?: FixtureCommandArgs) =>
persistFrontmatterChange(
readCommandString(commandArgs, 'path'),
(content) => replaceFrontmatterEntry(
content,
readCommandString(commandArgs, 'key'),
readCommandValue(commandArgs, 'value'),
),
),
delete_frontmatter_property: (commandArgs?: FixtureCommandArgs) =>
persistFrontmatterChange(
readCommandString(commandArgs, 'path'),
(content) => removeFrontmatterEntry(content, readCommandString(commandArgs, 'key')),
),
rename_note: (commandArgs?: FixtureCommandArgs) =>
renameNoteRequest({
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
old_path: readCommandValue(commandArgs, 'oldPath'),
new_title: readCommandValue(commandArgs, 'newTitle'),
old_title: readCommandValue(commandArgs, 'oldTitle', null),
}),
rename_note_filename: (commandArgs?: FixtureCommandArgs) =>
readJson('/api/vault/rename-filename', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
old_path: readCommandValue(commandArgs, 'oldPath'),
new_filename_stem: readCommandValue(commandArgs, 'newFilenameStem'),
}),
}),
auto_rename_untitled: async (commandArgs?: FixtureCommandArgs) => {
const notePath = readCommandString(commandArgs, 'notePath')
const contentData = await readJson(
`/api/vault/content?path=${encodeURIComponent(notePath)}`,
) as { content: string }
const match = contentData.content.match(/^#\s+(.+)$/m)
if (!match) return null
return renameNoteRequest({
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
old_path: notePath,
new_title: match[1].trim(),
})
},
})
const applyFixtureVaultOverrides = (
handlers: Record<string, ((args?: unknown) => unknown)> | null | undefined,
) => {
if (!handlers) return handlers
Object.assign(
handlers,
buildFixtureStateHandlers(),
buildFixtureReadHandlers(),
buildFixtureWriteHandlers(),
)
return handlers
}
const invoke = async (command: string, args?: Record<string, unknown>) => {
const handler = commandHandlers[command] ?? window.__mockHandlers?.[command]
if (!handler) throw new Error(`Unhandled invoke: ${command}`)
return handler(args)
}
let ref = applyFixtureVaultOverrides(
(window.__mockHandlers as Record<string, ((args?: unknown) => unknown)> | undefined),
) ?? null
Object.defineProperty(window, '__TAURI__', {
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
value: {},
set(value) {
ref = applyFixtureVaultOverrides(
value as Record<string, ((args?: unknown) => unknown)> | undefined,
) ?? null
},
get() {
return applyFixtureVaultOverrides(ref) ?? ref
},
})
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: { invoke },
})
}, vaultPath)
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath })
}
async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise<void> {
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers?.list_vault))
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
timeout: FIXTURE_VAULT_READY_TIMEOUT,
})
}
export async function openFixtureVault(
page: Page,
vaultPath: string,
): Promise<void> {
await installFixtureVaultInitScript({ page, vaultPath })
await waitForFixtureVaultReady({ page })
}
async function installFixtureVaultDesktopBridge({ page }: FixturePageArgs): Promise<void> {
await page.evaluate(installFixtureVaultDesktopBridgeInBrowser)
await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__))
}
/**
* Browser harness for desktop command-routing tests.
*
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
* renderer shortcut dispatch and desktop menu-command dispatch without a native
* shell. It is deterministic, but it is not a substitute for real native QA.
*/
export async function openFixtureVaultDesktopHarness(
page: Page,
vaultPath: string,
): Promise<void> {
await openFixtureVault(page, vaultPath)
await installFixtureVaultDesktopBridge({ page })
}
export const openFixtureVaultTauri = openFixtureVaultDesktopHarness

View File

@@ -0,0 +1,27 @@
export function installFixtureVaultDesktopBridgeInBrowser(): void {
const dispatchBrowserMenuCommand =
window.__laputaTest?.dispatchBrowserMenuCommand
?? (() => { throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand') })
const specialHandlers: Record<string, (args?: Record<string, unknown>) => unknown> = {
trigger_menu_command: (args) => {
dispatchBrowserMenuCommand(String(args?.id ?? ''))
return null
},
}
const invoke = (command: string, args?: Record<string, unknown>) => {
const handler = specialHandlers[command] ?? window.__mockHandlers?.[command]
if (!handler) throw new Error(`Unhandled invoke: ${command}`)
return handler(args)
}
Object.defineProperty(window, '__TAURI__', {
configurable: true,
value: {},
})
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: { invoke },
})
}

View File

@@ -57,7 +57,7 @@ async function setRawEditorContent(page: Page, content: string) {
}, content)
}
test('breadcrumb sync button and inline rename keep the file path aligned with H1 title changes', async ({ page }) => {
test('@smoke Cmd+S preserves the existing filename until the explicit breadcrumb sync action', async ({ page }) => {
const syncedPath = path.join(tempVaultDir, 'note', 'breadcrumb-sync-target.md')
const manuallyRenamedPath = path.join(tempVaultDir, 'note', 'manual-breadcrumb-name.md')
const originalPath = path.join(tempVaultDir, 'note', 'note-b.md')

View File

@@ -0,0 +1,81 @@
import { test, expect, type Page } from '@playwright/test'
const REMEMBERED_DEFAULT_VAULT_PATH = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
async function mockFreshStart(
page: Page,
options: {
activeVault: string | null
checkExistingPath: string
rememberWelcomeDismissal?: boolean
},
) {
await page.addInitScript((config) => {
localStorage.clear()
if (config.rememberWelcomeDismissal) {
localStorage.setItem('tolaria_welcome_dismissed', '1')
}
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
const originalGetSettings = ref.get_settings as (() => Record<string, unknown>) | undefined
ref.get_settings = () => ({
...(originalGetSettings ? originalGetSettings() : {}),
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
})
ref.load_vault_list = () => ({
vaults: [],
active_vault: config.activeVault,
hidden_defaults: [],
})
ref.get_default_vault_path = () => config.checkExistingPath
ref.check_vault_exists = (args: { path?: string }) => args?.path === config.checkExistingPath
},
get() {
return ref
},
})
}, options)
}
test('accepting telemetry consent on a fresh start opens the vault choice wizard @smoke', async ({ page }) => {
await mockFreshStart(page, {
activeVault: null,
checkExistingPath: '/Users/mock/Documents/Getting Started',
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Help improve Tolaria')).toBeVisible()
await page.getByTestId('telemetry-accept').click()
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
})
for (const action of ['accept', 'decline'] as const) {
test(`${action} telemetry still resumes onboarding with only a remembered default vault @smoke`, async ({ page }) => {
await mockFreshStart(page, {
activeVault: REMEMBERED_DEFAULT_VAULT_PATH,
checkExistingPath: REMEMBERED_DEFAULT_VAULT_PATH,
rememberWelcomeDismissal: true,
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Help improve Tolaria')).toBeVisible()
await page.getByTestId(`telemetry-${action}`).click()
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
})
}

View File

@@ -65,6 +65,13 @@ function quickOpenSelectedTitle(page: Page) {
return page.getByTestId('quick-open-palette').locator('[class*="bg-accent"] span.truncate').first()
}
async function focusHeadingEnd(page: Page, title: string) {
const heading = page.getByRole('heading', { name: title, level: 1 })
await expect(heading).toBeVisible({ timeout: 5_000 })
await heading.click()
await page.keyboard.press('End')
}
test('creating an untitled draft hides the legacy title section in the editor', async ({ page }) => {
await page.locator('button[title="Create new note"]').click()
@@ -139,3 +146,29 @@ test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toContainText(updatedTitle, { timeout: 5_000 })
})
test('@smoke rapid H1 typing stays stable while editing an existing note', async ({ page }) => {
const noteList = page.locator('[data-testid="note-list-container"]')
const firstTitle = 'Alpha Project Fast Typing Check'
const finalTitle = 'Alpha Project Fast Typing Flow'
await openNote(page, 'Alpha Project')
await focusHeadingEnd(page, 'Alpha Project')
await page.keyboard.type(' Fast Typing Check')
await expect(page.getByRole('heading', { name: firstTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
for (let i = 0; i < ' Check'.length; i += 1) {
await page.keyboard.press('Backspace')
}
await page.keyboard.type(' Flow')
await page.keyboard.press('Meta+s')
await expect(page.getByRole('heading', { name: finalTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText(finalTitle, { exact: true })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText('Alpha Project', { exact: true })).toHaveCount(0)
await openNote(page, 'Spring 2026')
await openNote(page, finalTitle)
await expect(page.getByRole('heading', { name: finalTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
})

View File

@@ -0,0 +1,142 @@
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { openCommandPalette, executeCommand } from './helpers'
const IMAGE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yK9sAAAAASUVORK5CYII='
let tempVaultDir: string
async function openNote(page: Page, title: string) {
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function openBlockNoteMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
const view = (el as any).cmTile?.view
if (!view) return el.textContent ?? ''
return view.state.doc.toString() as string
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((nextContent) => {
const el = document.querySelector('.cm-content')
if (!el) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
const view = (el as any).cmTile?.view
if (!view) return
const fullDocumentRange = { from: 0, to: view.state.doc.length }
view.dispatch({
changes: { ...fullDocumentRange, insert: nextContent },
})
}, content)
}
async function seedImageBlock(page: Page) {
await openNote(page, 'Note B')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
const imageMarkdown = `\n\n![Toolbar hover regression](${IMAGE_DATA_URL})\n`
await setRawEditorContent(page, `${rawContent}${imageMarkdown}`)
await page.waitForTimeout(700)
await openBlockNoteMode(page)
const image = page.locator('.bn-editor img.bn-visual-media').last()
await expect(image).toBeVisible({ timeout: 5_000 })
return image
}
async function moveMouseInSteps(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ steps, stepDelayMs }: { steps: number; stepDelayMs: number },
) {
await page.mouse.move(from.x, from.y)
for (let step = 1; step <= steps; step += 1) {
const progress = step / steps
await page.mouse.move(
from.x + (to.x - from.x) * progress,
from.y + (to.y - from.y) * progress,
)
await page.waitForTimeout(stepDelayMs)
}
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('image toolbar stays usable while the pointer crosses onto its controls', async ({ page }) => {
const image = await seedImageBlock(page)
const toolbar = page.locator('.bn-formatting-toolbar')
const replaceButton = page.getByRole('button', { name: /Replace image/i })
const imageBox = await image.boundingBox()
expect(imageBox).not.toBeNull()
await page.mouse.move(
imageBox!.x + imageBox!.width / 2,
imageBox!.y + imageBox!.height / 2,
)
await expect(toolbar).toBeVisible({ timeout: 5_000 })
await expect(replaceButton).toBeVisible()
const replaceButtonBox = await replaceButton.boundingBox()
expect(replaceButtonBox).not.toBeNull()
await moveMouseInSteps(
page,
{
x: imageBox!.x + imageBox!.width / 2,
y: imageBox!.y + imageBox!.height / 2,
},
{
x: replaceButtonBox!.x + replaceButtonBox!.width / 2,
y: replaceButtonBox!.y + replaceButtonBox!.height / 2,
},
{ steps: 12, stepDelayMs: 35 },
)
await expect(toolbar).toBeVisible()
await expect(replaceButton).toBeVisible()
await replaceButton.click()
await expect(page.locator('.bn-panel-popover')).toBeVisible()
})

View File

@@ -0,0 +1,168 @@
import { expect, test, type Page } from '@playwright/test'
import { executeCommand, openCommandPalette } from './helpers'
import { seedAutoGitSavedChange } from './testBridge'
type MockHandler = (args?: Record<string, unknown>) => unknown
function installCommitFlowMocks() {
type BrowserWindow = Window & typeof globalThis & {
__gitCommitMessages?: string[]
__gitPushCalls?: number
__mockHandlers?: Record<string, MockHandler>
}
const browserWindow = window as BrowserWindow
const dirtyPaths = new Set<string>()
let ahead = 0
const createModifiedFiles = () => [...dirtyPaths].map((path) => ({
path,
relativePath: path.split('/').pop() ?? path,
status: 'modified',
}))
const isPatched = (handlers?: Record<string, MockHandler> | null) =>
!handlers || (handlers as Record<string, unknown>).__manualCommitPatched === true
const patchSaveNoteContent = (handlers: Record<string, MockHandler>) => {
const originalSaveNoteContent = handlers.save_note_content
handlers.save_note_content = (args?: Record<string, unknown>) => {
const path = typeof args?.path === 'string' ? args.path : null
if (path) dirtyPaths.add(path)
return originalSaveNoteContent?.(args)
}
}
const patchGitHandlers = (handlers: Record<string, MockHandler>) => {
handlers.get_modified_files = () => createModifiedFiles()
handlers.git_commit = (args?: Record<string, unknown>) => {
const message = typeof args?.message === 'string' ? args.message : ''
browserWindow.__gitCommitMessages?.push(message)
dirtyPaths.clear()
ahead = 1
return `[main abc1234] ${message}`
}
handlers.git_push = () => {
browserWindow.__gitPushCalls = (browserWindow.__gitPushCalls ?? 0) + 1
ahead = 0
return { status: 'ok', message: 'Pushed to remote' }
}
handlers.git_remote_status = () => ({
branch: 'main',
ahead,
behind: 0,
hasRemote: true,
})
}
const markPatched = (handlers: Record<string, MockHandler>) => {
Object.defineProperty(handlers, '__manualCommitPatched', {
configurable: true,
enumerable: false,
value: true,
})
}
const patchHandlers = (handlers?: Record<string, MockHandler> | null) => {
if (isPatched(handlers)) {
return handlers ?? null
}
patchSaveNoteContent(handlers)
patchGitHandlers(handlers)
markPatched(handlers)
return handlers
}
browserWindow.__gitCommitMessages = []
browserWindow.__gitPushCalls = 0
let ref = patchHandlers(browserWindow.__mockHandlers) ?? null
Object.defineProperty(browserWindow, '__mockHandlers', {
configurable: true,
get() {
return patchHandlers(ref) ?? ref
},
set(value) {
ref = patchHandlers(value as Record<string, MockHandler> | undefined) ?? null
},
})
}
async function openFirstNote(page: Page) {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.waitFor({ timeout: 5_000 })
await noteList.locator('.cursor-pointer').first().click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function setAutoGitEnabled(page: Page, enabled: boolean) {
await openCommandPalette(page)
await executeCommand(page, 'Open Settings')
const settingsPanel = page.getByTestId('settings-panel')
await expect(settingsPanel).toBeVisible({ timeout: 5_000 })
const toggle = page.getByRole('switch', { name: 'AutoGit' })
const isEnabled = (await toggle.getAttribute('aria-checked')) === 'true'
if (isEnabled !== enabled) {
await toggle.click()
}
await page.getByTestId('settings-save').click()
await expect(settingsPanel).not.toBeVisible({ timeout: 5_000 })
}
async function triggerCommitButton(page: Page) {
const commitButton = page.getByTestId('status-commit-push')
await commitButton.focus()
await expect(commitButton).toBeFocused()
await page.keyboard.press('Enter')
}
async function expectCommitMessage(page: Page, index: number, expectedMessage: string) {
await expect.poll(async () =>
page.evaluate(
({ targetIndex }) => (window as Window & { __gitCommitMessages?: string[] }).__gitCommitMessages?.[targetIndex] ?? '',
{ targetIndex: index },
),
).toBe(expectedMessage)
}
async function expectPushCount(page: Page, expectedCount: number) {
await expect.poll(async () =>
page.evaluate(() => (window as Window & { __gitPushCalls?: number }).__gitPushCalls ?? 0),
).toBe(expectedCount)
}
test('@smoke commit entry opens the manual modal when AutoGit is off and switches back immediately when enabled', async ({ page }) => {
await page.addInitScript(installCommitFlowMocks)
await page.goto('/')
await page.waitForLoadState('networkidle')
await openFirstNote(page)
await setAutoGitEnabled(page, false)
await seedAutoGitSavedChange(page)
await triggerCommitButton(page)
await expect(page.getByRole('heading', { name: 'Commit & Push' })).toBeVisible()
const messageInput = page.locator('textarea[placeholder="Commit message..."]')
await expect(messageInput).toBeFocused()
await page.keyboard.press('Meta+A')
await page.keyboard.type('Manual commit from keyboard')
await page.keyboard.press('Meta+Enter')
await expect(page.locator('.fixed.bottom-8')).toContainText('Committed and pushed', { timeout: 5_000 })
await expectCommitMessage(page, 0, 'Manual commit from keyboard')
await expectPushCount(page, 1)
await setAutoGitEnabled(page, true)
await seedAutoGitSavedChange(page)
await triggerCommitButton(page)
await expect(messageInput).not.toBeVisible()
await expectCommitMessage(page, 1, 'Updated 1 note')
await expectPushCount(page, 2)
})

View File

@@ -0,0 +1,108 @@
import { expect, test, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
async function openNote(page: Page, title: string) {
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function openBlockNoteMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
const view = (el as any).cmTile?.view
if (!view) return el.textContent ?? ''
return view.state.doc.toString() as string
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((nextContent) => {
const el = document.querySelector('.cm-content')
if (!el) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
const view = (el as any).cmTile?.view
if (!view) return
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: nextContent,
},
})
}, content)
}
async function appendPlainFilenameParagraph(page: Page, value: string) {
const lastBlock = page.locator('.bn-block-content').last()
await expect(lastBlock).toBeVisible({ timeout: 5_000 })
await lastBlock.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.keyboard.type(`${value} `)
await page.waitForTimeout(700)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('plain filename-like text typed in BlockNote stays plain through raw-mode round trips', async ({ page }) => {
await openNote(page, 'Note B')
await appendPlainFilenameParagraph(page, 'AGENTS.md')
await appendPlainFilenameParagraph(page, 'docs/README.md')
await openRawMode(page)
const raw = await getRawEditorContent(page)
expect(raw).toContain('AGENTS.md')
expect(raw).toContain('docs/README.md')
expect(raw).not.toMatch(/\[AGENTS\.md\]\(/)
expect(raw).not.toMatch(/\[docs\/README\.md\]\(/)
})
test('explicit markdown links still render and round-trip intentionally', async ({ page }) => {
await openNote(page, 'Note B')
await openRawMode(page)
const raw = await getRawEditorContent(page)
const intentionalLink = '[Tolaria Docs](https://example.com/docs)'
await setRawEditorContent(page, `${raw}\n\n${intentionalLink}\n`)
await page.waitForTimeout(700)
await openBlockNoteMode(page)
await expect(page.locator('.bn-editor a[href="https://example.com/docs"]').last()).toContainText('Tolaria Docs')
await openRawMode(page)
await expect.poll(() => getRawEditorContent(page)).toContain(intentionalLink)
})

View File

@@ -0,0 +1,184 @@
import { test, expect, type Page } from '@playwright/test'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { triggerShortcutCommand } from './testBridge'
const VIEW_RELATIVE_PATH = path.join('views', 'active-projects.yml')
const DUPLICATE_RELATIVE_PATH = path.join('views', 'active-projects.md')
const VIEW_TITLE = 'Active Projects'
const VIEW_CONTENT = `name: Active Projects
icon: rocket
color: blue
sort: "modified:desc"
filters:
all:
- field: type
op: equals
value: Project
`
let tempVaultDir: string
function seedViewFile(vaultPath: string): void {
const viewPath = path.join(vaultPath, VIEW_RELATIVE_PATH)
fs.mkdirSync(path.dirname(viewPath), { recursive: true })
fs.writeFileSync(viewPath, VIEW_CONTENT)
}
function buildViewEntry(vaultPath: string) {
const viewPath = path.join(vaultPath, VIEW_RELATIVE_PATH)
return {
path: viewPath,
filename: path.basename(viewPath),
title: VIEW_TITLE,
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: Date.now(),
createdAt: null,
fileSize: Buffer.byteLength(VIEW_CONTENT),
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: false,
fileKind: 'text',
}
}
async function injectViewEntryIntoVaultList(page: Page, vaultPath: string): Promise<void> {
const viewEntry = buildViewEntry(vaultPath)
await page.route('**/api/vault/list*', async (route) => {
const response = await route.fetch()
const entries = await response.json()
if (!Array.isArray(entries)) {
await route.fulfill({ response })
return
}
const hasViewEntry = entries.some((entry) => entry?.path === viewEntry.path)
const nextEntries = hasViewEntry ? entries : [...entries, viewEntry]
await route.fulfill({ response, json: nextEntries })
})
}
async function openQuickOpenEntry(page: Page, title: string): Promise<void> {
await triggerShortcutCommand(page, APP_COMMAND_IDS.fileQuickOpen)
const input = page.locator('input[placeholder="Search notes..."]')
await expect(input).toBeVisible({ timeout: 5_000 })
await input.fill(title)
await page.keyboard.press('Enter')
}
async function appendRawEditorLine(page: Page, line: string): Promise<void> {
await page.evaluate((nextLine) => {
const host = document.querySelector('.cm-content')
if (!host) {
throw new Error('CodeMirror content element is missing')
}
type CodeMirrorHost = Element & {
cmTile?: {
view?: {
state: { doc: { toString(): string; length: number } }
dispatch(transaction: { changes: { from: number; to: number; insert: string } }): void
}
}
}
const view = (host as CodeMirrorHost).cmTile?.view
if (!view) {
throw new Error('CodeMirror view is missing')
}
const doc = view.state.doc.toString()
view.dispatch({
changes: {
from: doc.length,
to: doc.length,
insert: `\n${nextLine}\n`,
},
})
}, line)
}
async function readRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const host = document.querySelector('.cm-content')
if (!host) return ''
type CodeMirrorHost = Element & {
cmTile?: {
view?: {
state: { doc: { toString(): string } }
}
}
}
return (host as CodeMirrorHost).cmTile?.view?.state.doc.toString() ?? host.textContent ?? ''
})
}
test.describe('raw .yml view save regression', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
seedViewFile(tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('saving a raw-edited .yml view preserves the .yml file and blocks the raw toggle shortcut @smoke', async ({ page }) => {
const viewPath = path.join(tempVaultDir, VIEW_RELATIVE_PATH)
const duplicatePath = path.join(tempVaultDir, DUPLICATE_RELATIVE_PATH)
const marker = `# raw-yml-save-${Date.now()}-${os.userInfo().username}`
await injectViewEntryIntoVaultList(page, tempVaultDir)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await openQuickOpenEntry(page, VIEW_TITLE)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).toHaveCount(0)
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('.bn-editor')).toHaveCount(0)
await appendRawEditorLine(page, marker)
await triggerShortcutCommand(page, APP_COMMAND_IDS.fileSave)
await expect.poll(() => fs.readFileSync(viewPath, 'utf-8')).toContain(marker)
await expect.poll(() => fs.existsSync(duplicatePath)).toBe(false)
await openQuickOpenEntry(page, 'Alpha Project')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await openQuickOpenEntry(page, VIEW_TITLE)
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await expect.poll(async () => readRawEditorContent(page)).toContain(marker)
})
})

View File

@@ -0,0 +1,83 @@
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
async function openNote(page: Page, title: string) {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.getByText(title, { exact: true }).click()
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (view) return view.state.doc.toString() as string
return el.textContent ?? ''
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((nextContent) => {
const el = document.querySelector('.cm-content')
if (!el) {
throw new Error('CodeMirror content element is missing')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (!view) {
throw new Error('CodeMirror view is missing')
}
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: nextContent },
})
}, content)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
await page.goto('/')
await page.waitForLoadState('networkidle')
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultDesktopHarness(page, tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke switching notes persists unsaved raw edits without waiting for the debounce window', async ({ page }) => {
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
const appendedText = `Flushed before note switch ${Date.now()}`
await openNote(page, 'Note B')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
await setRawEditorContent(page, `${rawContent}\n\n${appendedText}`)
await page.waitForTimeout(100)
await openNote(page, 'Alpha Project')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project', { timeout: 5_000 })
await expect.poll(async () => getRawEditorContent(page)).toContain('# Alpha Project')
await expect.poll(
() => fs.readFileSync(noteBPath, 'utf8'),
{ timeout: 450, intervals: [50, 100, 100, 100, 100] },
).toContain(appendedText)
})

View File

@@ -56,6 +56,7 @@ test.describe('Wikilink insertion and navigation', () => {
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
await page.keyboard.type('[[Ma')
await page.waitForTimeout(800)