Compare commits

...

10 Commits

Author SHA1 Message Date
Test
17d8618c16 fix: use first available note in rename smoke test instead of hardcoded 'Refactoring'
The demo vault doesn't contain a note with 'Refactoring' in the title,
causing consistent timeout failures in the pre-push smoke tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:38:37 +01:00
Test
698c4cece1 fix: prevent image drop overlay from breaking internal DnD and block handle
Three fixes for the image drop overlay interference:

1. Block handle clipping: Add padding (0 4px) to editor container so
   BlockNote's side menu (42px) fits within the overflow clip edge.
   overflow-y:auto forces overflow-x:auto (CSS spec), which was clipping
   the menu 2px past the container's left boundary.

2. Block handle click interference: Extract isInteractiveTarget() to
   exclude .bn-side-menu from handleContainerClick — prevents the
   container from stealing focus when clicking drag handle or add button.

3. Internal drag isolation: Track document-level dragstart/dragend to
   flag internal HTML5 drags (tabs, blocks). Tauri onDragDropEvent
   handler skips entirely during internal drags to prevent interference.
   Extract useInternalDragFlag() and handleTauriDrop() to keep
   useImageDrop under CodeScene complexity threshold.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:38:37 +01:00
Test
acd048b144 test: update changing-type smoke test for flat vault (no file movement)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:03:44 +01:00
Test
9ce74e7081 style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:56:30 +01:00
Test
deec1be7e0 refactor: remove dead code from flat vault migration
- Remove move_note_to_type_folder function and all its tests
- Remove MoveResult, type_to_folder_slug from rename.rs
- Remove infer_type_from_folder, capitalize_first, title_case_folder
- These were all made dead by the flat vault migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:55:23 +01:00
Test
b2bb7cf661 feat: title = filename — wikilink resolution + slug collision detection
- Wikilink resolution order: filename stem (primary) → alias → title (fallback)
- Slug collision detection: auto-suffix (-2, -3, etc.) on note creation
- Add slugCollides utility for frontend collision checking
- resolveNewNote accepts entries param for collision-aware creation
- Title editing via H1 heading already triggers file rename (existing flow)
- Update docs/ARCHITECTURE.md with migrate_to_flat_vault command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:50:22 +01:00
Test
112f68c66d feat: flatten vault structure — remove type-based folders
- scan_vault now scans root + system folders (type/, config/, theme/) only
- Type determined purely by frontmatter, no folder inference
- Remove move_note_to_type_folder (Rust command + frontend logic)
- Remove TYPE_FOLDER_MAP — new notes created at vault root
- Add migrate_to_flat_vault command (moves subfolder notes to root,
  updates path-based wikilinks to title-based, cleans empty dirs)
- Migration runs automatically via Cmd+K > Repair Vault
- Update getting_started sample files for flat structure
- Update all tests (611 cargo + 2131 vitest passing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:43:00 +01:00
Test
28fa9673b7 test: fresh-install regression QA smoke tests for 7 Done tasks
Verifies AI panel (3-layer structure, blue glow, context bar, Escape close),
search UI accessibility, Repair Vault command, and no /api/ai/agent fetch calls.

All 7 audited tasks pass: qmd bundling, MCP foundation, AGENTS.md bootstrap,
AI panel rendering, Claude API wiring, AI panel UI, endpoint fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 07:14:30 +01:00
Test
18e173faca docs: remove 'Current state' section from VISION.md
Vision should be stable and timeless. Current state goes stale
immediately and belongs in ROADMAP.md, not here.
2026-03-14 09:34:46 +01:00
Test
a16b477878 docs: fix Mermaid syntax error in Vault Cache diagram
VaultEntry[] inside ([...]) causes parse error — brackets not allowed
in stadium-shape node labels. Changed to 'VaultEntry list ready'.
2026-03-14 09:31:11 +01:00
22 changed files with 1011 additions and 1219 deletions

View File

@@ -410,7 +410,7 @@ flowchart TD
C --> G[Write cache atomically\n.tmp → rename]
E --> G
F --> G
G --> H([VaultEntry[] ready])
G --> H([VaultEntry list ready])
```
## Theme System
@@ -569,7 +569,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days |
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | Frontmatter migration utilities |
| `migration.rs` | Frontmatter migration: `migrate_is_a_to_type`, `migrate_to_flat_vault` |
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
| `getting_started.rs` | Creates the Getting Started demo vault |
@@ -611,6 +611,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `purge_trash` | Delete notes trashed >30 days ago |
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `migrate_to_flat_vault` | Move notes from type subfolders to vault root, update wikilinks |
| `check_vault_exists` | Check if vault path exists |
| `create_getting_started_vault` | Bootstrap demo vault |

View File

@@ -182,35 +182,6 @@ Broader audiences will follow as the onboarding experience matures and the conve
---
## Current state
A living snapshot of what's built. Updated as features ship.
### ✅ Working today
- BlockNote editor with Markdown files on disk; Cmd+S save; dirty state indicator
- 4-panel layout: sidebar / note list / editor / inspector
- Tabs with drag-to-reorder; quick open (Cmd+P); virtual list (handles 9000+ notes)
- `type:` as canonical frontmatter field; inspector with editable properties
- Bidirectional relationships; editable relation chips; wikilink autocomplete (`[[`)
- Sidebar sections with custom icons and colors
- Git integration: commit & push, version history per note, dirty state tracking
- Dynamic vault picker; create or clone vaults from GitHub
- GitHub OAuth (device flow); AI chat panel; Claude CLI agent panel
- Auto-updater; universal macOS binary; CI with coverage gates
### 🚧 Ahead (consolidation sprint → features)
**Consolidation sprint (current):**
Fixing architectural foundations before building further — cache model, type field canonicalization, allContent removal, hardcoded paths.
**Next features:**
Inbox section, semantic properties (status chips, progress indicators), default relationships in properties panel, workspace filter, mobile apps (iPhone for capture, iPad as desktop mirror).
*For the full roadmap, see [ROADMAP.md](./ROADMAP.md).*
---
## Design principles
1. **Opinionated but not rigid** — ship the method and the defaults; allow customization where it matters

View File

@@ -13,7 +13,7 @@ use crate::indexing::{IndexStatus, IndexingProgress};
use crate::search::SearchResponse;
use crate::settings::Settings;
use crate::theme::{ThemeFile, VaultSettings};
use crate::vault::{MoveResult, RenameResult, VaultEntry};
use crate::vault::{MigrationResult, RenameResult, VaultEntry};
use crate::vault_config::VaultConfig;
use crate::vault_list::VaultList;
use crate::{
@@ -91,17 +91,6 @@ pub fn rename_note(
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
}
#[tauri::command]
pub fn move_note_to_type_folder(
vault_path: String,
note_path: String,
new_type: String,
) -> Result<MoveResult, String> {
let vault_path = expand_tilde(&vault_path);
let note_path = expand_tilde(&note_path);
vault::move_note_to_type_folder(&vault_path, &note_path, &new_type)
}
#[tauri::command]
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -132,6 +121,12 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn migrate_to_flat_vault(vault_path: String) -> Result<MigrationResult, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_to_flat_vault(&vault_path)
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = match target_path {
@@ -551,11 +546,20 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
// Migrate to flat vault (move notes from type folders to root)
let migration = vault::migrate_to_flat_vault(&vault_path)?;
// Repair themes
theme::restore_default_themes(&vault_path)?;
// Repair config files (config/agents.md, type/config.md, AGENTS.md stub)
vault::repair_config_files(&vault_path)?;
Ok("Vault repaired".to_string())
if migration.moved > 0 {
Ok(format!(
"Vault repaired — migrated {} notes to flat structure",
migration.moved
))
} else {
Ok("Vault repaired".to_string())
}
}
// ── Settings & config commands ──────────────────────────────────────────────

View File

@@ -118,7 +118,6 @@ pub fn run() {
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::move_note_to_type_folder,
commands::get_file_history,
commands::get_modified_files,
commands::get_file_diff,
@@ -146,6 +145,7 @@ pub fn run() {
commands::batch_delete_notes,
commands::empty_trash,
commands::migrate_is_a_to_type,
commands::migrate_to_flat_vault,
commands::batch_archive_notes,
commands::batch_trash_notes,
commands::get_settings,

View File

@@ -19,31 +19,23 @@ struct SampleFile {
}
/// Content for config/agents.md — vault instructions for AI agents.
/// This file has no YAML frontmatter — it is a convention file for AI agents,
/// not a vault note. The vault scanner will still pick it up as a regular entry.
pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
pub(super) const AGENTS_MD: &str = r#"---
type: Config
---
# AGENTS.md — Vault Instructions for AI Agents
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
## Structure
Files are organized in folders by type:
Notes live at the vault root as flat `.md` files. Type is determined by the `type` field in frontmatter, not by folder. System folders:
| Folder | Type | Purpose |
|--------|------|---------|
| `note/` | Note | General-purpose documents, research, meeting notes |
| `project/` | Project | Time-bounded efforts with clear goals |
| `person/` | Person | People — colleagues, collaborators, contacts |
| `topic/` | Topic | Subject areas that group related notes |
| `responsibility/` | Responsibility | Long-running duties with KPIs |
| `procedure/` | Procedure | Recurring workflows (weekly, monthly) |
| `event/` | Event | Something that happened on a specific date |
| `quarter/` | Quarter | Time containers (e.g. 24Q1) |
| `measure/` | Measure | Trackable metrics tied to responsibilities |
| `target/` | Target | Time-bound goals for a measure |
| `type/` | Type | Type definitions — icon, color, ordering |
Custom folders are valid — the folder name becomes the type (capitalized).
| Folder | Purpose |
|--------|---------|
| `type/` | Type definitions — icon, color, ordering |
| `config/` | Vault configuration files |
| `theme/` | Visual theme definitions |
## Frontmatter
@@ -53,11 +45,11 @@ YAML frontmatter between `---` delimiters defines metadata:
---
type: Project
Status: Active
Owner: "[[person/jane-doe]]"
Belongs to: "[[quarter/24q1]]"
Owner: "[[Jane Doe]]"
Belongs to: "[[24Q1]]"
Related to:
- "[[topic/growth]]"
- "[[note/research-findings]]"
- "[[Growth]]"
- "[[Research Findings]]"
---
```
@@ -65,7 +57,7 @@ Related to:
| Field | Purpose |
|-------|---------|
| `type` | Entity type (usually inferred from folder) |
| `type` | Entity type (required — determines note category) |
| `Status` | Active, Done, Paused, Archived, Dropped |
| `Owner` | Person responsible (wikilink) |
| `Belongs to` | Parent relationship(s) |
@@ -78,17 +70,17 @@ Related to:
Any YAML field containing `[[wikilinks]]` becomes a navigable relationship:
```yaml
Has Measures: ["[[measure/revenue]]", "[[measure/churn]]"]
Resources: "[[note/api-docs]]"
Has Measures: ["[[Revenue]]", "[[Churn]]"]
Resources: "[[API Docs]]"
```
## Wikilinks
Connect notes with double-bracket syntax:
- `[[note/my-note]]` — link by path
- `[[My Note Title]]` — link by title or alias
- `[[note/my-note|display text]]` — link with custom display text
- `[[My Note Title]]` — link by title (primary)
- `[[my-note-title]]` — link by filename stem
- `[[My Note|display text]]` — link with custom display text
Wikilinks work in both frontmatter values and markdown body. Backlinks are computed automatically — linking A to B makes B show a backlink to A.
@@ -111,8 +103,8 @@ Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor n
- First `# Heading` in a file becomes its title
- One entity per file
- Filenames use kebab-case: `my-note-title.md`
- Type is inferred from parent folder if not set in frontmatter
- Filenames use kebab-case: `my-note-title.md` (= slugified title)
- Type is set via `type:` in frontmatter (required for non-root notes)
- Relationships are bidirectional via automatic backlinks
"#;
@@ -142,13 +134,13 @@ const SAMPLE_FILES: &[SampleFile] = &[
content: "---\ntype: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
},
SampleFile {
rel_path: "note/welcome-to-laputa.md",
rel_path: "welcome-to-laputa.md",
content: r#"---
type: Note
Related to:
- "[[note/editor-basics]]"
- "[[note/using-properties]]"
- "[[note/wiki-links-and-relationships]]"
- "[[Editor Basics]]"
- "[[Using Properties]]"
- "[[Wiki-Links and Relationships]]"
---
# Welcome to Laputa
@@ -157,15 +149,15 @@ Welcome to your new knowledge vault! Laputa helps you organize your thoughts, pr
## How it works
Every note is a markdown file with optional YAML frontmatter at the top. Notes live in folders that define their **type** — a file in the `project/` folder is automatically a Project, a file in `person/` is a Person, and so on.
Every note is a markdown file with optional YAML frontmatter at the top. The `type` field in frontmatter determines what kind of note it is — Project, Person, Note, etc.
## What to explore
- [[note/editor-basics]] — Learn about headings, lists, checkboxes, and formatting
- [[note/using-properties]] — See how frontmatter properties work (status, dates, relationships)
- [[note/wiki-links-and-relationships]] — Connect your notes with `[[wiki-links]]`
- [[project/sample-project]] — A sample project with relationships and status
- [[person/sample-collaborator]] — A sample person entry
- [[Editor Basics]] — Learn about headings, lists, checkboxes, and formatting
- [[Using Properties]] — See how frontmatter properties work (status, dates, relationships)
- [[Wiki-Links and Relationships]] — Connect your notes with `[[wiki-links]]`
- [[Sample Project]] — A sample project with relationships and status
- [[Sample Collaborator]] — A sample person entry
## Tips
@@ -177,10 +169,10 @@ Every note is a markdown file with optional YAML frontmatter at the top. Notes l
"#,
},
SampleFile {
rel_path: "note/editor-basics.md",
rel_path: "editor-basics.md",
content: r#"---
type: Note
Related to: "[[note/welcome-to-laputa]]"
Related to: "[[Welcome to Laputa]]"
---
# Editor Basics
@@ -225,13 +217,13 @@ function hello() {
"#,
},
SampleFile {
rel_path: "note/using-properties.md",
rel_path: "using-properties.md",
content: r#"---
type: Note
Status: Active
Related to:
- "[[note/welcome-to-laputa]]"
- "[[note/wiki-links-and-relationships]]"
- "[[Welcome to Laputa]]"
- "[[Wiki-Links and Relationships]]"
---
# Using Properties
@@ -259,12 +251,12 @@ You can add any custom property. If the value contains `[[wiki-links]]`, Laputa
"#,
},
SampleFile {
rel_path: "note/wiki-links-and-relationships.md",
rel_path: "wiki-links-and-relationships.md",
content: r#"---
type: Note
Related to:
- "[[note/welcome-to-laputa]]"
- "[[note/using-properties]]"
- "[[Welcome to Laputa]]"
- "[[Using Properties]]"
---
# Wiki-Links and Relationships
@@ -273,7 +265,7 @@ Wiki-links are the core of Laputa's knowledge graph. They let you connect any no
## Creating links
Type `[[` in the editor to open the link suggestion menu. Start typing to search for a note, then select it. The link will look like this: [[note/welcome-to-laputa]].
Type `[[` in the editor to open the link suggestion menu. Start typing to search for a note, then select it. The link will look like this: [[Welcome to Laputa]].
## Backlinks
@@ -284,10 +276,10 @@ When note A links to note B, note B automatically shows a **backlink** to note A
You can also define relationships in the frontmatter:
```yaml
Belongs to: "[[project/sample-project]]"
Belongs to: "[[Sample Project]]"
Related to:
- "[[note/editor-basics]]"
- "[[note/using-properties]]"
- "[[Editor Basics]]"
- "[[Using Properties]]"
```
These appear as clickable pills in the inspector and are navigable with a single click.
@@ -298,12 +290,12 @@ Over time, your wiki-links form a rich web of connections. Use the **Referenced
"#,
},
SampleFile {
rel_path: "project/sample-project.md",
rel_path: "sample-project.md",
content: r#"---
type: Project
Status: Active
Owner: "[[person/sample-collaborator]]"
Related to: "[[topic/getting-started]]"
Owner: "[[Sample Collaborator]]"
Related to: "[[Getting Started]]"
---
# Sample Project
@@ -323,11 +315,11 @@ Projects are time-bounded efforts with clear goals. They have a **status** (Acti
## Notes
This project is owned by [[person/sample-collaborator]] and relates to [[topic/getting-started]]. You can see these relationships in the inspector panel on the right.
This project is owned by [[Sample Collaborator]] and relates to [[Getting Started]]. You can see these relationships in the inspector panel on the right.
"#,
},
SampleFile {
rel_path: "person/sample-collaborator.md",
rel_path: "sample-collaborator.md",
content: r#"---
type: Person
---
@@ -344,11 +336,11 @@ This is an example person entry. In your vault, you might create entries for col
## Connections
This person is the owner of [[project/sample-project]]. Check the **Referenced By** section in the inspector to see all notes that link back here.
This person is the owner of [[Sample Project]]. Check the **Referenced By** section in the inspector to see all notes that link back here.
"#,
},
SampleFile {
rel_path: "topic/getting-started.md",
rel_path: "getting-started.md",
content: r#"---
type: Topic
---
@@ -359,11 +351,11 @@ This topic groups notes related to learning and getting started with Laputa.
## Related notes
- [[note/welcome-to-laputa]] — Start here for an overview
- [[note/editor-basics]] — Formatting and editor features
- [[note/using-properties]] — Frontmatter and the inspector
- [[note/wiki-links-and-relationships]] — Building your knowledge graph
- [[project/sample-project]] — A sample project with relationships
- [[Welcome to Laputa]] — Start here for an overview
- [[Editor Basics]] — Formatting and editor features
- [[Using Properties]] — Frontmatter and the inspector
- [[Wiki-Links and Relationships]] — Building your knowledge graph
- [[Sample Project]] — A sample project with relationships
"#,
},
];
@@ -475,18 +467,16 @@ mod tests {
let result = create_getting_started_vault(vault_path.to_str().unwrap());
assert!(result.is_ok());
// Verify key files exist
// Verify key files exist (flat vault: notes at root, types in type/)
assert!(vault_path.join("config/agents.md").exists());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
assert!(vault_path.join("note/editor-basics.md").exists());
assert!(vault_path.join("note/using-properties.md").exists());
assert!(vault_path
.join("note/wiki-links-and-relationships.md")
.exists());
assert!(vault_path.join("project/sample-project.md").exists());
assert!(vault_path.join("person/sample-collaborator.md").exists());
assert!(vault_path.join("topic/getting-started.md").exists());
assert!(vault_path.join("welcome-to-laputa.md").exists());
assert!(vault_path.join("editor-basics.md").exists());
assert!(vault_path.join("using-properties.md").exists());
assert!(vault_path.join("wiki-links-and-relationships.md").exists());
assert!(vault_path.join("sample-project.md").exists());
assert!(vault_path.join("sample-collaborator.md").exists());
assert!(vault_path.join("getting-started.md").exists());
assert!(vault_path.join("type/project.md").exists());
assert!(vault_path.join("type/note.md").exists());
assert!(vault_path.join("type/person.md").exists());
@@ -546,7 +536,7 @@ mod tests {
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entries = crate::vault::scan_vault(&vault_path).unwrap();
// SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes
// SAMPLE_FILES (root + type/) + config/agents.md + AGENTS.md stub + 3 vault theme notes
assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3);
}

View File

@@ -1,3 +1,4 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
@@ -115,6 +116,231 @@ pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
Ok(migrated)
}
/// Folders that are system folders and should NOT be migrated (notes stay there).
const SYSTEM_FOLDERS: &[&str] = &["type", "config", "theme"];
/// Result of the flat vault migration.
#[derive(Debug, Serialize, Deserialize)]
pub struct MigrationResult {
/// Number of files moved to the vault root.
pub moved: usize,
/// Number of files skipped (already at root, or in system folders).
pub skipped: usize,
/// Files that could not be moved (collision, error).
pub errors: Vec<String>,
}
/// Determine a unique destination path at vault root, appending -2, -3, etc. if needed.
fn unique_root_path(vault: &Path, filename: &str) -> std::path::PathBuf {
let dest = vault.join(filename);
if !dest.exists() {
return dest;
}
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
let mut counter = 2;
loop {
let candidate = vault.join(format!("{}-{}{}", stem, counter, ext));
if !candidate.exists() {
return candidate;
}
counter += 1;
}
}
/// Check if a directory entry is inside a system folder.
fn is_in_system_folder(path: &Path, vault: &Path) -> bool {
let rel = path
.strip_prefix(vault)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
SYSTEM_FOLDERS
.iter()
.any(|sf| rel.starts_with(&format!("{}/", sf)) || rel == *sf)
}
/// Migrate an existing vault to flat structure: move all .md files from type-based
/// subfolders to the vault root. System folders (type/, config/, theme/) are skipped.
/// Wikilinks that used path-based references are updated to use the title.
pub fn migrate_to_flat_vault(vault_path: &str) -> Result<MigrationResult, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
// Collect all .md files in subfolders (not root, not system folders)
let mut files_to_move: Vec<std::path::PathBuf> = Vec::new();
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
// Skip files already at vault root
if path.parent() == Some(vault) {
continue;
}
// Skip system folders
if is_in_system_folder(path, vault) {
continue;
}
files_to_move.push(path.to_path_buf());
}
let mut result = MigrationResult {
moved: 0,
skipped: 0,
errors: Vec::new(),
};
// Build a map of old path stems → titles for wikilink updating
let mut path_stem_to_title: Vec<(String, String)> = Vec::new();
for file in &files_to_move {
let filename = file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
// Read content to extract title
let content = match fs::read_to_string(file) {
Ok(c) => c,
Err(e) => {
result
.errors
.push(format!("Failed to read {}: {}", file.display(), e));
continue;
}
};
let title = super::extract_title(&content, &filename);
// Compute old path stem for wikilink replacement
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = file
.to_string_lossy()
.strip_prefix(&vault_prefix)
.unwrap_or(&file.to_string_lossy())
.strip_suffix(".md")
.unwrap_or(&file.to_string_lossy())
.to_string();
path_stem_to_title.push((old_path_stem, title));
// Move file to vault root
let new_path = unique_root_path(vault, &filename);
match fs::rename(file, &new_path) {
Ok(()) => result.moved += 1,
Err(e) => {
// Try copy+delete for cross-device moves
match fs::read_to_string(file)
.and_then(|c| fs::write(&new_path, c))
.and_then(|()| fs::remove_file(file))
{
Ok(()) => result.moved += 1,
Err(_) => {
result.errors.push(format!(
"Failed to move {}{}: {}",
file.display(),
new_path.display(),
e
));
}
}
}
}
}
// Update wikilinks: replace path-based wikilinks [[folder/slug]] with [[Title]]
if !path_stem_to_title.is_empty() {
update_wikilinks_for_migration(vault, &path_stem_to_title);
}
// Clean up empty directories (except system folders)
cleanup_empty_dirs(vault);
Ok(result)
}
/// Update wikilinks across all vault .md files, replacing path-based references
/// with title-based ones.
fn update_wikilinks_for_migration(vault: &Path, replacements: &[(String, String)]) {
let all_md: Vec<std::path::PathBuf> = WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md"))
.map(|e| e.path().to_path_buf())
.collect();
for file in &all_md {
let Ok(mut content) = fs::read_to_string(file) else {
continue;
};
let mut changed = false;
for (old_stem, title) in replacements {
// Replace [[old/path/stem]] with [[Title]]
let old_link = format!("[[{}]]", old_stem);
if content.contains(&old_link) {
content = content.replace(&old_link, &format!("[[{}]]", title));
changed = true;
}
// Replace [[old/path/stem|display]] with [[Title|display]]
let old_prefix = format!("[[{}|", old_stem);
if content.contains(&old_prefix) {
content = content.replace(&old_prefix, &format!("[[{}|", title));
changed = true;
}
}
if changed {
let _ = fs::write(file, &content);
}
}
}
/// Remove empty directories in the vault (excluding system folders).
fn cleanup_empty_dirs(vault: &Path) {
let Ok(entries) = fs::read_dir(vault) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if SYSTEM_FOLDERS.contains(&name.as_str()) {
continue;
}
// Skip hidden directories (e.g., .git)
if name.starts_with('.') {
continue;
}
// Check if directory is empty
let is_empty = fs::read_dir(&path)
.map(|mut d| d.next().is_none())
.unwrap_or(false);
if is_empty {
let _ = fs::remove_dir(&path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -253,4 +479,144 @@ mod tests {
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0, "non-markdown files should be ignored");
}
// --- migrate_to_flat_vault tests ---
fn write_sub_file(dir: &Path, subdir: &str, name: &str, content: &str) -> std::path::PathBuf {
let sub = dir.join(subdir);
fs::create_dir_all(&sub).unwrap();
let path = sub.join(name);
fs::write(&path, content).unwrap();
path
}
#[test]
fn test_flat_migration_moves_files_to_root() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_sub_file(vault, "note", "my-note.md", "# My Note\n\nContent.\n");
write_sub_file(
vault,
"project",
"alpha.md",
"---\ntype: Project\n---\n# Alpha\n",
);
let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(result.moved, 2);
assert!(vault.join("my-note.md").exists());
assert!(vault.join("alpha.md").exists());
assert!(!vault.join("note/my-note.md").exists());
assert!(!vault.join("project/alpha.md").exists());
}
#[test]
fn test_flat_migration_skips_system_folders() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_sub_file(
vault,
"type",
"project.md",
"---\ntype: Type\n---\n# Project\n",
);
write_sub_file(vault, "config", "agents.md", "# Agents\n");
write_sub_file(vault, "note", "test.md", "# Test\n");
let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(result.moved, 1);
// System files should stay
assert!(vault.join("type/project.md").exists());
assert!(vault.join("config/agents.md").exists());
// Regular file should be moved
assert!(vault.join("test.md").exists());
}
#[test]
fn test_flat_migration_skips_root_files() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "already-at-root.md", "# Already Root\n");
write_sub_file(vault, "note", "in-sub.md", "# In Sub\n");
let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(result.moved, 1);
assert!(vault.join("already-at-root.md").exists());
assert!(vault.join("in-sub.md").exists());
}
#[test]
fn test_flat_migration_handles_filename_collision() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "test.md", "# Root Test\n");
write_sub_file(vault, "note", "test.md", "# Note Test\n");
let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(result.moved, 1);
assert!(vault.join("test.md").exists());
assert!(vault.join("test-2.md").exists());
}
#[test]
fn test_flat_migration_updates_path_wikilinks() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_sub_file(
vault,
"person",
"alice.md",
"---\ntype: Person\n---\n# Alice\n",
);
write_file(
vault,
"root-note.md",
"# Root\n\nSee [[person/alice]] for details.\n",
);
let result = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(result.moved, 1);
let content = fs::read_to_string(vault.join("root-note.md")).unwrap();
assert!(
content.contains("[[Alice]]"),
"should update path-based wikilink to title: {}",
content
);
assert!(!content.contains("[[person/alice]]"));
}
#[test]
fn test_flat_migration_cleans_up_empty_dirs() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_sub_file(vault, "note", "test.md", "# Test\n");
let _ = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap();
assert!(
!vault.join("note").exists(),
"empty note/ dir should be removed"
);
}
#[test]
fn test_flat_migration_preserves_nonempty_dirs() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_sub_file(vault, "custom", "note.md", "# Note\n");
// Add a non-md file so dir isn't empty after migration
write_sub_file(vault, "custom", "image.png", "binary data");
let _ = migrate_to_flat_vault(vault.to_str().unwrap()).unwrap();
assert!(
vault.join("custom").exists(),
"non-empty dir should be preserved"
);
}
#[test]
fn test_flat_migration_nonexistent_vault() {
let result = migrate_to_flat_vault("/tmp/nonexistent-vault-flat-test");
assert!(result.is_err());
}
}

View File

@@ -11,13 +11,13 @@ pub use cache::{invalidate_cache, scan_vault_cached};
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult};
pub use migration::{migrate_is_a_to_type, migrate_to_flat_vault, MigrationResult};
pub use rename::{rename_note, RenameResult};
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
use parsing::{
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
parse_iso_date, title_case_folder,
parse_iso_date,
};
use gray_matter::engine::YAML;
@@ -322,40 +322,10 @@ fn extract_properties(
properties
}
/// Infer entity type from a parent folder name.
fn infer_type_from_folder(folder: &str) -> String {
match folder {
"person" => "Person",
"project" => "Project",
"procedure" => "Procedure",
"responsibility" => "Responsibility",
"event" => "Event",
"topic" => "Topic",
"experiment" => "Experiment",
"type" => "Type",
"note" => "Note",
"quarter" => "Quarter",
"measure" => "Measure",
"target" => "Target",
"journal" => "Journal",
"month" => "Month",
"config" => "Config",
"essay" => "Essay",
"evergreen" => "Evergreen",
_ => return title_case_folder(folder),
}
.to_string()
}
/// Resolve `is_a` from frontmatter, falling back to parent folder inference.
fn resolve_is_a(fm_is_a: Option<StringOrList>, path: &Path) -> Option<String> {
fm_is_a
.and_then(|a| a.into_vec().into_iter().next())
.or_else(|| {
path.parent()
.and_then(|p| p.file_name())
.map(|f| infer_type_from_folder(&f.to_string_lossy()))
})
/// Resolve `is_a` from frontmatter only. Folder-based inference is no longer used
/// (flat vault: all notes live at vault root, type is purely frontmatter).
fn resolve_is_a(fm_is_a: Option<StringOrList>, _path: &Path) -> Option<String> {
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
}
/// Parse created_at from frontmatter (prefer "Created at" over "Created time").
@@ -548,7 +518,32 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
}
/// Scan a directory recursively for .md files and return VaultEntry for each.
/// Folders that are scanned recursively (system folders).
/// All other .md files must live at the vault root.
const SYSTEM_FOLDERS: &[&str] = &["type", "config", "theme"];
/// Check if a path is a .md file.
fn is_md_file(path: &Path) -> bool {
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
}
/// Parse all .md files in a single directory (non-recursive).
fn scan_dir_flat(dir: &Path, entries: &mut Vec<VaultEntry>) {
let Ok(read_dir) = fs::read_dir(dir) else {
return;
};
for item in read_dir.flatten() {
let p = item.path();
if is_md_file(&p) {
match parse_md_file(&p) {
Ok(e) => entries.push(e),
Err(e) => log::warn!("Skipping file: {}", e),
}
}
}
}
/// Scan a vault for .md files: root-level files plus system subfolders (type/, config/, theme/).
pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
if !vault_path.exists() {
return Err(format!(
@@ -564,22 +559,25 @@ pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
}
let mut entries = Vec::new();
for entry in WalkDir::new(vault_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let entry_path = entry.path();
if entry_path.is_file()
&& entry_path
.extension()
.map(|ext| ext == "md")
.unwrap_or(false)
{
match parse_md_file(entry_path) {
Ok(vault_entry) => entries.push(vault_entry),
Err(e) => {
log::warn!("Skipping file: {}", e);
// Scan root-level .md files
scan_dir_flat(vault_path, &mut entries);
// Scan system subfolders recursively
for folder in SYSTEM_FOLDERS {
let sub = vault_path.join(folder);
if sub.is_dir() {
for item in WalkDir::new(&sub)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let p = item.path();
if is_md_file(p) {
match parse_md_file(p) {
Ok(e) => entries.push(e),
Err(e) => log::warn!("Skipping file: {}", e),
}
}
}
}
@@ -707,22 +705,38 @@ mod tests {
}
#[test]
fn test_scan_vault_recursive() {
fn test_scan_vault_flat_root_plus_system_folders() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "root.md", "# Root Note\n");
create_test_file(
dir.path(),
"sub/nested.md",
"---\nIs A: Task\n---\n# Nested\n",
"type/project.md",
"---\ntype: Type\n---\n# Project\n",
);
create_test_file(dir.path(), "config/agents.md", "# Agents\n");
// Files in non-system subfolders should be IGNORED by scan_vault
create_test_file(
dir.path(),
"old-folder/nested.md",
"---\ntype: Task\n---\n# Nested\n",
);
create_test_file(dir.path(), "not-markdown.txt", "This should be ignored");
let entries = scan_vault(dir.path()).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(
entries.len(),
3,
"should find root + type/ + config/ files only"
);
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
assert!(filenames.contains(&"root.md"));
assert!(filenames.contains(&"nested.md"));
assert!(filenames.contains(&"project.md"));
assert!(filenames.contains(&"agents.md"));
assert!(
!filenames.contains(&"nested.md"),
"non-system subfolder files should be excluded"
);
}
#[test]
@@ -1024,78 +1038,32 @@ References:
);
}
// --- infer_type_from_folder tests ---
// --- flat vault: type is frontmatter-only ---
#[test]
fn test_infer_type_from_known_folders() {
fn test_no_type_inference_from_folder() {
let dir = TempDir::new().unwrap();
let known_folders = vec![
("person", "Person"),
("project", "Project"),
("procedure", "Procedure"),
("responsibility", "Responsibility"),
("event", "Event"),
("topic", "Topic"),
("experiment", "Experiment"),
("note", "Note"),
("quarter", "Quarter"),
("measure", "Measure"),
("target", "Target"),
("journal", "Journal"),
("month", "Month"),
("essay", "Essay"),
("evergreen", "Evergreen"),
];
for (folder, expected_type) in known_folders {
create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n");
let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap();
assert_eq!(
entry.is_a,
Some(expected_type.to_string()),
"folder '{}' should infer type '{}'",
folder,
expected_type
);
}
// File in a folder named "person" should NOT infer type from folder
create_test_file(dir.path(), "person/test.md", "# Test\n");
let entry = parse_md_file(&dir.path().join("person/test.md")).unwrap();
assert_eq!(entry.is_a, None, "flat vault: folder should not infer type");
}
#[test]
fn test_infer_type_from_unknown_folder_capitalizes() {
fn test_type_from_frontmatter_only() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "recipe/test.md", "# Test\n");
let entry = parse_md_file(&dir.path().join("recipe/test.md")).unwrap();
assert_eq!(entry.is_a, Some("Recipe".to_string()));
create_test_file(dir.path(), "test.md", "---\ntype: Person\n---\n# Test\n");
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
assert_eq!(entry.is_a, Some("Person".to_string()));
}
#[test]
fn test_infer_type_from_hyphenated_folder_title_cases() {
let dir = TempDir::new().unwrap();
let cases = vec![
("monday-ideas", "Monday Ideas"),
("key-result", "Key Result"),
("my_custom_type", "My Custom Type"),
("mix-and_match", "Mix And Match"),
];
for (folder, expected) in cases {
create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n");
let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap();
assert_eq!(
entry.is_a,
Some(expected.to_string()),
"folder '{}' should infer type '{}'",
folder,
expected
);
}
}
#[test]
fn test_infer_type_frontmatter_overrides_folder() {
fn test_type_from_frontmatter_in_subfolder() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"person/test.md",
"---\nIs A: Custom\n---\n# Test\n",
"---\ntype: Custom\n---\n# Test\n",
);
let entry = parse_md_file(&dir.path().join("person/test.md")).unwrap();
assert_eq!(entry.is_a, Some("Custom".to_string()));
@@ -1145,14 +1113,17 @@ References:
}
#[test]
fn test_type_relationship_from_folder_inference() {
fn test_no_type_relationship_without_frontmatter() {
let dir = TempDir::new().unwrap();
let content = "# A Person\n\nSome content.";
let entry = parse_test_entry(&dir, "person/someone.md", content);
assert_eq!(entry.is_a, Some("Person".to_string()));
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/person]]".to_string()]
entry.is_a, None,
"flat vault: no type inference from folder"
);
assert!(
entry.relationships.get("Type").is_none(),
"no type relationship without frontmatter type"
);
}
@@ -1168,11 +1139,14 @@ References:
}
#[test]
fn test_type_folder_inferred_as_type() {
fn test_type_folder_not_inferred_without_frontmatter() {
let dir = TempDir::new().unwrap();
let content = "# Some Type\n";
let entry = parse_test_entry(&dir, "type/some-type.md", content);
assert_eq!(entry.is_a, Some("Type".to_string()));
assert_eq!(
entry.is_a, None,
"flat vault: type folder no longer infers type"
);
}
// --- type key (post-migration) tests ---

View File

@@ -193,24 +193,6 @@ pub(super) fn extract_outgoing_links(content: &str) -> Vec<String> {
links
}
pub(super) fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
/// Title-case a folder name: split on hyphens and underscores, capitalize each word, join with spaces.
/// Example: "monday-ideas" → "Monday Ideas", "key_result" → "Key Result"
pub(super) fn title_case_folder(s: &str) -> String {
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(capitalize_first)
.collect::<Vec<_>>()
.join(" ")
}
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
@@ -491,51 +473,6 @@ mod tests {
assert_eq!(strip_markdown_chars(""), "");
}
// --- capitalize_first tests ---
#[test]
fn test_capitalize_first_normal() {
assert_eq!(capitalize_first("person"), "Person");
}
#[test]
fn test_capitalize_first_already_capitalized() {
assert_eq!(capitalize_first("Project"), "Project");
}
#[test]
fn test_capitalize_first_empty() {
assert_eq!(capitalize_first(""), "");
}
#[test]
fn test_capitalize_first_single_char() {
assert_eq!(capitalize_first("a"), "A");
}
// --- title_case_folder tests ---
#[test]
fn test_title_case_folder_hyphenated() {
assert_eq!(title_case_folder("monday-ideas"), "Monday Ideas");
assert_eq!(title_case_folder("key-result"), "Key Result");
}
#[test]
fn test_title_case_folder_underscored() {
assert_eq!(title_case_folder("my_custom_type"), "My Custom Type");
}
#[test]
fn test_title_case_folder_single_word() {
assert_eq!(title_case_folder("recipe"), "Recipe");
}
#[test]
fn test_title_case_folder_empty() {
assert_eq!(title_case_folder(""), "");
}
// --- without_h1_line tests ---
#[test]

View File

@@ -166,31 +166,6 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
.unwrap_or(abs_path)
}
/// Result of a move-to-type-folder operation.
#[derive(Debug, Serialize, Deserialize)]
pub struct MoveResult {
/// New absolute file path after move (same as old if no move happened).
pub new_path: String,
/// Number of other files updated (wikilink replacements).
pub updated_links: usize,
/// Whether the file was actually moved (false if already in the right folder).
pub moved: bool,
}
/// Convert a type name to a folder slug. All known types are single lowercase words;
/// unknown types are slugified (lowercase, non-alphanumeric → hyphen).
fn type_to_folder_slug(type_name: &str) -> String {
type_name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-")
}
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
let dest = dest_dir.join(filename);
@@ -215,134 +190,6 @@ fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
}
}
/// Move a note to the folder corresponding to its new type, and update wikilinks across the vault.
///
/// Returns `MoveResult` with `moved: false` if the note is already in the correct folder.
/// Creates the target folder if it does not exist.
pub fn move_note_to_type_folder(
vault_path: &str,
note_path: &str,
new_type: &str,
) -> Result<MoveResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(note_path);
if !old_file.exists() {
return Err(format!("File does not exist: {}", note_path));
}
let new_type = new_type.trim();
if new_type.is_empty() {
return Err("Type cannot be empty".to_string());
}
let folder_slug = type_to_folder_slug(new_type);
// Check if already in the correct folder
let current_folder = old_file
.parent()
.and_then(|p| p.file_name())
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
if current_folder == folder_slug {
return Ok(MoveResult {
new_path: note_path.to_string(),
updated_links: 0,
moved: false,
});
}
let filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
// Create target directory if needed
let dest_dir = vault.join(&folder_slug);
if !dest_dir.exists() {
fs::create_dir_all(&dest_dir)
.map_err(|e| format!("Failed to create directory {}: {}", dest_dir.display(), e))?;
}
// Determine destination path (handle collisions)
let new_file = unique_dest_path(&dest_dir, &filename);
let new_path_str = new_file.to_string_lossy().to_string();
// Read content and move
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
fs::write(&new_file, &content)
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
fs::remove_file(old_file)
.map_err(|e| format!("Failed to remove old file {}: {}", note_path, e))?;
// Extract title for wikilink matching
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let old_title = super::extract_title(&content, &old_filename);
// Update wikilinks across the vault (title stays the same, path changes)
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(note_path, &vault_prefix);
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix);
// Build pattern matching old path stem (e.g. "note/weekly-review")
let re = match build_wikilink_pattern(&old_title, old_path_stem) {
Some(r) => r,
None => {
return Ok(MoveResult {
new_path: new_path_str,
updated_links: 0,
moved: true,
})
}
};
// Determine the replacement: if path-style wikilinks were used, update to new path.
// Title-style wikilinks [[My Note]] stay the same (title hasn't changed).
let files = collect_md_files(vault, &new_file);
let updated_links = files
.iter()
.filter(|path| {
let file_content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
if !re.is_match(&file_content) {
return false;
}
// Replace path-based wikilinks (old_path_stem → new_path_stem)
// and keep title-based wikilinks as-is.
let replaced = re.replace_all(&file_content, |caps: &regex::Captures| {
let full_match = caps.get(0).map(|m| m.as_str()).unwrap_or("");
let pipe = caps.get(1);
// If the match used the path stem, replace with new path stem
if full_match.contains(old_path_stem) {
match pipe {
Some(p) => format!("[[{}{}]]", new_path_stem, p.as_str()),
None => format!("[[{}]]", new_path_stem),
}
} else {
// Title-based link — keep as-is (title hasn't changed)
full_match.to_string()
}
});
if replaced != file_content {
fs::write(path, replaced.as_ref()).is_ok()
} else {
false
}
})
.count();
Ok(MoveResult {
new_path: new_path_str,
updated_links,
moved: true,
})
}
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
///
/// When `old_title_hint` is provided it is used instead of extracting the title from
@@ -753,213 +600,6 @@ mod tests {
assert!(vault.join("note/my-note.md").exists());
}
// --- move_note_to_type_folder tests ---
#[test]
fn test_type_to_folder_slug_known_types() {
assert_eq!(type_to_folder_slug("Person"), "person");
assert_eq!(type_to_folder_slug("Project"), "project");
assert_eq!(type_to_folder_slug("Quarter"), "quarter");
assert_eq!(type_to_folder_slug("Note"), "note");
}
#[test]
fn test_type_to_folder_slug_unknown_types() {
assert_eq!(type_to_folder_slug("Key Result"), "key-result");
assert_eq!(type_to_folder_slug("My Custom Type"), "my-custom-type");
}
#[test]
fn test_move_note_basic() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent here.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
assert!(result.new_path.contains("/quarter/weekly-review.md"));
assert!(!old_path.exists());
assert!(Path::new(&result.new_path).exists());
}
#[test]
fn test_move_note_already_in_correct_folder() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"quarter/weekly-review.md",
"---\ntype: Quarter\n---\n# Weekly Review\n",
);
let path = vault.join("quarter/weekly-review.md");
let result =
move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "Quarter")
.unwrap();
assert!(!result.moved);
assert_eq!(result.new_path, path.to_str().unwrap());
assert_eq!(result.updated_links, 0);
}
#[test]
fn test_move_note_creates_target_folder() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/my-note.md",
"---\ntype: Quarter\n---\n# My Note\n",
);
let dest_dir = vault.join("quarter");
assert!(!dest_dir.exists());
let old_path = vault.join("note/my-note.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
assert!(dest_dir.exists());
assert!(Path::new(&result.new_path).exists());
}
#[test]
fn test_move_note_filename_collision() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/my-note.md",
"---\ntype: Quarter\n---\n# My Note\n",
);
create_test_file(
vault,
"quarter/my-note.md",
"---\ntype: Quarter\n---\n# Existing Note\n",
);
let old_path = vault.join("note/my-note.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
assert!(result.new_path.contains("/quarter/my-note-2.md"));
assert!(!old_path.exists());
assert!(Path::new(&result.new_path).exists());
// Original file should still exist
assert!(vault.join("quarter/my-note.md").exists());
}
#[test]
fn test_move_note_updates_path_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent.\n",
);
create_test_file(
vault,
"project/my-project.md",
"---\ntype: Project\n---\n# My Project\n\nSee [[note/weekly-review]] for details.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
assert_eq!(result.updated_links, 1);
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
assert!(project_content.contains("[[quarter/weekly-review]]"));
assert!(!project_content.contains("[[note/weekly-review]]"));
}
#[test]
fn test_move_note_preserves_title_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\ntype: Quarter\n---\n# Weekly Review\n",
);
create_test_file(
vault,
"note/other.md",
"---\ntype: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
// Title-based wikilinks should be unchanged
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Weekly Review]]"));
}
#[test]
fn test_move_note_collision_preserves_both_contents() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let moving_content =
"---\ntype: Quarter\n---\n# Migrate newsletter to Beehiiv\n\nImportant content.\n";
let existing_content =
"---\ntype: Quarter\n---\n# Feedback for Laputa\n\nCompletely different note.\n";
create_test_file(vault, "note/my-note.md", moving_content);
create_test_file(vault, "quarter/my-note.md", existing_content);
let old_path = vault.join("note/my-note.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
// Must get a unique path, not the existing file's path
assert!(result.new_path.contains("/quarter/my-note-2.md"));
// Moved note must retain its own content
let moved_content = fs::read_to_string(&result.new_path).unwrap();
assert_eq!(moved_content, moving_content);
// Existing note must be untouched
let untouched = fs::read_to_string(vault.join("quarter/my-note.md")).unwrap();
assert_eq!(untouched, existing_content);
}
#[test]
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
@@ -1054,46 +694,4 @@ mod tests {
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
}
#[test]
fn test_move_note_empty_type_error() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/test.md", "# Test\n");
let path = vault.join("note/test.md");
let result = move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "");
assert!(result.is_err());
}
#[test]
fn test_move_note_nonexistent_file_error() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
vault.join("note/nope.md").to_str().unwrap(),
"Quarter",
);
assert!(result.is_err());
}
#[test]
fn test_move_note_preserves_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let original = "---\ntype: Quarter\ntitle: My Note\n---\n# My Note\n\nImportant content.\n";
create_test_file(vault, "note/my-note.md", original);
let old_path = vault.join("note/my-note.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
let content = fs::read_to_string(&result.new_path).unwrap();
assert_eq!(content, original);
}
}

View File

@@ -23,7 +23,11 @@
opacity: 0.55;
}
/* BlockNote container */
/* BlockNote container
padding: 0 4px prevents BlockNote's side menu (≈42px wide) from being
clipped — the menu hangs left of the editor body (40px padding), and
overflow-y:auto forces overflow-x:auto (CSS spec). The 4px padding
keeps the menu inside the padding box, which is the overflow clip edge. */
.editor__blocknote-container {
flex: 1;
min-height: 0;
@@ -32,6 +36,7 @@
justify-content: center;
position: relative;
cursor: text;
padding: 0 4px;
}
/* Drag-over state: subtle border highlight */

View File

@@ -22,6 +22,11 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
}, [])
}
/** Returns true if the click target is an interactive area the container should not steal focus from. */
function isInteractiveTarget(target: HTMLElement): boolean {
return !!(target.closest('[contenteditable="true"]') || target.closest('.bn-side-menu'))
}
/** Single BlockNote editor view — content is swapped via replaceBlocks */
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: {
editor: ReturnType<typeof useCreateBlockNote>
@@ -40,9 +45,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!editable) return
const target = e.target as HTMLElement
if (target.closest('[contenteditable="true"]')) return
if (!editable || isInteractiveTarget(e.target as HTMLElement)) return
const blocks = editor.document
if (blocks.length > 0) {
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')

View File

@@ -250,4 +250,36 @@ describe('useImageDrop — Tauri native drag-drop', () => {
expect(result.current.isDragOver).toBe(false)
})
it('ignores Tauri events during internal HTML5 drags (tab/block reorder)', async () => {
const onImageUrl = vi.fn()
renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
// Simulate an internal drag start (e.g. tab drag)
act(() => { document.dispatchEvent(new Event('dragstart')) })
// Tauri drop event during internal drag should be ignored
act(() => {
capturedDragDropHandler!({
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
})
})
expect(onImageUrl).not.toHaveBeenCalled()
// End the internal drag
act(() => { document.dispatchEvent(new Event('dragend')) })
// After internal drag ends, Tauri events should work again
const { invoke } = await import('@tauri-apps/api/core')
vi.mocked(invoke).mockResolvedValue('/vault/attachments/photo.png')
act(() => {
capturedDragDropHandler!({
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
})
})
// onImageUrl is called async (after copyImageToVault resolves)
await waitFor(() => expect(onImageUrl).toHaveBeenCalled())
})
})

View File

@@ -53,89 +53,89 @@ interface UseImageDropOptions {
vaultPath?: string
}
/** Track whether an internal HTML5 drag (tab, block) is in progress.
* Internal drags fire `dragstart` on a DOM element; OS file drags do not. */
function useInternalDragFlag() {
const ref = useRef(false)
useEffect(() => {
const start = () => { ref.current = true }
const end = () => { ref.current = false }
document.addEventListener('dragstart', start)
document.addEventListener('dragend', end)
return () => { document.removeEventListener('dragstart', start); document.removeEventListener('dragend', end) }
}, [])
return ref
}
/** Process a Tauri native file drop payload — copy images to vault. */
function handleTauriDrop(
paths: string[],
vaultPath: string | undefined,
callback: ((url: string) => void) | undefined,
) {
const imagePaths = paths.filter(isImagePath)
if (imagePaths.length > 0 && vaultPath && callback) {
for (const p of imagePaths) void copyImageToVault(p, vaultPath).then(callback)
}
}
export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) {
const [isDragOver, setIsDragOver] = useState(false)
const onImageUrlRef = useRef(onImageUrl)
useEffect(() => { onImageUrlRef.current = onImageUrl }, [onImageUrl])
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
const internalDragRef = useInternalDragFlag()
// HTML5 DnD visual feedback (works in browser mode; BlockNote handles the actual upload)
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleDragOver = (e: DragEvent) => {
const onOver = (e: DragEvent) => {
if (!e.dataTransfer || !hasImageFiles(e.dataTransfer)) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
setIsDragOver(true)
}
const handleDragLeave = (e: DragEvent) => {
if (!container.contains(e.relatedTarget as Node)) {
setIsDragOver(false)
}
const onLeave = (e: DragEvent) => {
if (!container.contains(e.relatedTarget as Node)) setIsDragOver(false)
}
const onDrop = () => { setIsDragOver(false) }
const handleDrop = () => {
// Only reset visual state; BlockNote's native dropFile plugin handles
// the actual upload (via editor.uploadFile) and block insertion.
setIsDragOver(false)
}
container.addEventListener('dragover', handleDragOver)
container.addEventListener('dragleave', handleDragLeave)
container.addEventListener('drop', handleDrop)
container.addEventListener('dragover', onOver)
container.addEventListener('dragleave', onLeave)
container.addEventListener('drop', onDrop)
return () => {
container.removeEventListener('dragover', handleDragOver)
container.removeEventListener('dragleave', handleDragLeave)
container.removeEventListener('drop', handleDrop)
container.removeEventListener('dragover', onOver)
container.removeEventListener('dragleave', onLeave)
container.removeEventListener('drop', onDrop)
}
}, [containerRef])
// Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD
// Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD.
// Skipped entirely when an internal drag is in progress (tabs, blocks).
useEffect(() => {
if (!isTauri()) return
let unlisten: (() => void) | null = null
let mounted = true
void (async () => {
try {
const { getCurrentWebview } = await import('@tauri-apps/api/webview')
if (!mounted) return
unlisten = await getCurrentWebview().onDragDropEvent((event) => {
const payload = event.payload
if (payload.type === 'over') {
// Tauri 'over' events don't include paths and can't distinguish
// OS file drags from internal drags (tabs, blocks). Let the HTML5
// dragover handler drive isDragOver — it checks hasImageFiles().
} else if (payload.type === 'drop') {
unlisten = await getCurrentWebview().onDragDropEvent(({ payload }) => {
if (internalDragRef.current) return
if (payload.type === 'drop') {
setIsDragOver(false)
const imagePaths = payload.paths.filter(isImagePath)
const vault = vaultPathRef.current
const callback = onImageUrlRef.current
if (imagePaths.length > 0 && vault && callback) {
for (const sourcePath of imagePaths) {
void copyImageToVault(sourcePath, vault).then(callback)
}
}
} else {
handleTauriDrop(payload.paths, vaultPathRef.current, onImageUrlRef.current)
} else if (payload.type !== 'over') {
setIsDragOver(false)
}
})
} catch {
// Tauri webview API not available (e.g. older Tauri version)
}
} catch { /* Tauri webview API not available */ }
})()
return () => {
mounted = false
unlisten?.()
}
}, [])
return () => { mounted = false; unlisten?.() }
}, [internalDragRef])
return { isDragOver }
}

View File

@@ -20,6 +20,7 @@ import {
useNoteActions,
DEFAULT_TEMPLATES,
resolveTemplate,
slugCollides,
} from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -111,6 +112,38 @@ describe('needsRenameOnSave', () => {
})
})
describe('slugCollides', () => {
it('detects collision with existing filename', () => {
const entries = [makeEntry({ filename: 'my-note.md', path: '/vault/my-note.md' })]
expect(slugCollides('My Note', entries)).toBe(true)
})
it('returns false when no collision', () => {
const entries = [makeEntry({ filename: 'other.md', path: '/vault/other.md' })]
expect(slugCollides('My Note', entries)).toBe(false)
})
it('excludes the current note path from collision check', () => {
const entries = [makeEntry({ filename: 'my-note.md', path: '/vault/my-note.md' })]
expect(slugCollides('My Note', entries, '/vault/my-note.md')).toBe(false)
})
})
describe('resolveNewNote slug collision', () => {
it('auto-suffixes slug when collision detected', () => {
const entries = [makeEntry({ filename: 'my-project.md', path: '/vault/my-project.md' })]
const { entry } = resolveNewNote('My Project', 'Project', '/vault', null, entries)
expect(entry.path).toBe('/vault/my-project-2.md')
expect(entry.filename).toBe('my-project-2.md')
})
it('skips suffix when no collision', () => {
const entries = [makeEntry({ filename: 'other.md', path: '/vault/other.md' })]
const { entry } = resolveNewNote('My Project', 'Project', '/vault', null, entries)
expect(entry.path).toBe('/vault/my-project.md')
})
})
describe('buildNewEntry', () => {
it('creates a VaultEntry with correct fields', () => {
const entry = buildNewEntry({
@@ -190,8 +223,8 @@ describe('entryMatchesTarget', () => {
expect(entryMatchesTarget(entry, 'mp')).toBe(true)
})
it('matches by path suffix (type/slug)', () => {
const entry = makeEntry({ path: '/Users/luca/Laputa/project/my-project.md' })
it('matches by filename stem from path-style wikilink', () => {
const entry = makeEntry({ path: '/Users/luca/Laputa/my-project.md', filename: 'my-project.md' })
expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true)
})
@@ -270,18 +303,18 @@ describe('DEFAULT_TEMPLATES', () => {
})
describe('resolveNewNote', () => {
it('uses TYPE_FOLDER_MAP for known types', () => {
it('creates notes at vault root', () => {
const { entry, content } = resolveNewNote('My Project', 'Project', '/my/vault')
expect(entry.path).toBe('/my/vault/project/my-project.md')
expect(entry.path).toBe('/my/vault/my-project.md')
expect(entry.isA).toBe('Project')
expect(entry.status).toBe('Active')
expect(content).toContain('type: Project')
expect(content).toContain('status: Active')
})
it('falls back to slugified type for custom types', () => {
it('creates custom type notes at vault root', () => {
const { entry } = resolveNewNote('First Recipe', 'Recipe', '/my/vault')
expect(entry.path).toBe('/my/vault/recipe/first-recipe.md')
expect(entry.path).toBe('/my/vault/first-recipe.md')
})
it('omits status for Topic type', () => {
@@ -297,7 +330,7 @@ describe('resolveNewNote', () => {
it('uses provided vault path instead of hardcoded path', () => {
const { entry } = resolveNewNote('Test', 'Note', '/other/vault')
expect(entry.path).toBe('/other/vault/note/test.md')
expect(entry.path).toBe('/other/vault/test.md')
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
@@ -310,7 +343,6 @@ describe('resolveNewNote', () => {
it('produces a valid path when type is all special characters', () => {
const { entry } = resolveNewNote('My Note', '+++', '/vault')
// folder should not be empty, path should not have double slashes
expect(entry.path).not.toContain('//')
expect(entry.path).toMatch(/\.md$/)
})
@@ -420,9 +452,9 @@ describe('buildDailyNoteContent', () => {
})
describe('resolveDailyNote', () => {
it('creates entry in journal folder with date as filename', () => {
it('creates entry at vault root with date as filename', () => {
const { entry } = resolveDailyNote('2026-03-02', '/my/vault')
expect(entry.path).toBe('/my/vault/journal/2026-03-02.md')
expect(entry.path).toBe('/my/vault/2026-03-02.md')
expect(entry.filename).toBe('2026-03-02.md')
expect(entry.title).toBe('2026-03-02')
expect(entry.isA).toBe('Journal')
@@ -437,33 +469,32 @@ describe('resolveDailyNote', () => {
it('uses provided vault path instead of hardcoded path', () => {
const { entry } = resolveDailyNote('2026-03-02', '/other/vault')
expect(entry.path).toBe('/other/vault/journal/2026-03-02.md')
expect(entry.path).toBe('/other/vault/2026-03-02.md')
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
})
describe('findDailyNote', () => {
it('finds entry by journal path suffix', () => {
it('finds entry by filename and Journal type', () => {
const entries = [
makeEntry({ path: '/Users/luca/Laputa/journal/2026-03-02.md' }),
makeEntry({ path: '/Users/luca/Laputa/note/other.md' }),
makeEntry({ path: '/Users/luca/Laputa/2026-03-02.md', filename: '2026-03-02.md', isA: 'Journal' }),
makeEntry({ path: '/Users/luca/Laputa/other.md', filename: 'other.md' }),
]
const found = findDailyNote(entries, '2026-03-02')
expect(found).toBeDefined()
expect(found!.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
expect(found!.path).toBe('/Users/luca/Laputa/2026-03-02.md')
})
it('returns undefined when no matching entry exists', () => {
const entries = [makeEntry({ path: '/Users/luca/Laputa/note/other.md' })]
const entries = [makeEntry({ path: '/Users/luca/Laputa/other.md', filename: 'other.md' })]
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
})
it('works with different vault paths', () => {
it('does not match non-Journal entries with same filename', () => {
const entries = [
makeEntry({ path: '/other/vault/journal/2026-03-02.md' }),
makeEntry({ path: '/vault/2026-03-02.md', filename: '2026-03-02.md', isA: 'Note' }),
]
const found = findDailyNote(entries, '2026-03-02')
expect(found).toBeDefined()
expect(findDailyNote(entries, '2026-03-02')).toBeUndefined()
})
})
@@ -493,7 +524,7 @@ describe('useNoteActions hook', () => {
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.title).toBe('Test Note')
expect(createdEntry.isA).toBe('Note')
expect(createdEntry.path).toContain('note/test-note.md')
expect(createdEntry.path).toContain('test-note.md')
})
it('handleCreateNote opens tab immediately (before addEntry resolves)', () => {
@@ -511,7 +542,7 @@ describe('useNoteActions hook', () => {
// Tab should be open with the new note
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].entry.title).toBe('Fast Note')
expect(result.current.activeTabPath).toContain('note/fast-note.md')
expect(result.current.activeTabPath).toContain('fast-note.md')
})
it('handleCreateType creates type entry', () => {
@@ -717,13 +748,12 @@ describe('useNoteActions hook', () => {
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.isA).toBe('Journal')
expect(createdEntry.path).toContain('journal/')
expect(createdEntry.path).toMatch(/journal\/\d{4}-\d{2}-\d{2}\.md$/)
expect(createdEntry.path).toMatch(/\d{4}-\d{2}-\d{2}\.md$/)
})
it('handleOpenDailyNote opens existing daily note instead of creating', async () => {
const today = todayDateString()
const existing = makeEntry({ path: `/Users/luca/Laputa/journal/${today}.md`, title: today })
const existing = makeEntry({ path: `/Users/luca/Laputa/${today}.md`, filename: `${today}.md`, title: today, isA: 'Journal' })
const { result } = renderHook(() => useNoteActions(makeConfig([existing])))
await act(async () => {
@@ -733,7 +763,7 @@ describe('useNoteActions hook', () => {
// Should open existing note, not create a new one
expect(addEntry).not.toHaveBeenCalled()
expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/journal/${today}.md`)
expect(result.current.activeTabPath).toBe(`/Users/luca/Laputa/${today}.md`)
})
describe('pending save lifecycle', () => {
@@ -751,7 +781,7 @@ describe('useNoteActions hook', () => {
await new Promise((r) => setTimeout(r, 0))
})
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/pending-test.md'))
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('pending-test.md'))
})
it('createAndPersist calls removePendingSave when persist completes (non-Tauri)', async () => {
@@ -768,7 +798,7 @@ describe('useNoteActions hook', () => {
await new Promise((r) => setTimeout(r, 0))
})
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/persist-ok.md'))
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('persist-ok.md'))
})
it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => {
@@ -787,9 +817,9 @@ describe('useNoteActions hook', () => {
await new Promise((r) => setTimeout(r, 0))
})
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md'))
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md'))
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('note/fail-save.md'))
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('fail-save.md'))
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('fail-save.md'))
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('fail-save.md'))
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
@@ -807,8 +837,8 @@ describe('useNoteActions hook', () => {
await new Promise((r) => setTimeout(r, 0))
})
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note'))
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'))
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('untitled-note.md'), expect.stringContaining('Untitled note'))
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
@@ -850,7 +880,7 @@ describe('useNoteActions hook', () => {
})
it.each([
['handleCreateNote', 'Failing Note', 'Note', 'note/failing-note.md'],
['handleCreateNote', 'Failing Note', 'Note', 'failing-note.md'],
['handleCreateType', 'Recipe', 'Type', 'type/recipe.md'],
])('reverts optimistic creation via %s when disk write fails', async (method, title, type, pathFragment) => {
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
@@ -922,142 +952,20 @@ describe('useNoteActions hook', () => {
})
})
describe('move note to type folder on type change', () => {
it('calls move_note_to_type_folder when type key is updated', async () => {
const entry = makeEntry({ path: '/test/vault/note/my-note.md', filename: 'my-note.md', title: 'My Note', isA: 'Note' })
const replaceEntry = vi.fn()
const config = makeConfig([entry])
config.replaceEntry = replaceEntry
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: true }
if (cmd === 'get_note_content') return '---\ntype: Quarter\n---\n# My Note\n'
return ''
})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'type', 'Quarter')
})
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({
vault_path: '/test/vault',
note_path: '/test/vault/note/my-note.md',
new_type: 'Quarter',
}))
expect(replaceEntry).toHaveBeenCalledWith(
'/test/vault/note/my-note.md',
expect.objectContaining({ path: '/test/vault/quarter/my-note.md' }),
)
expect(setToastMessage).toHaveBeenCalledWith('Note moved to quarter/')
})
it('does not call move when type key is not being changed', async () => {
describe('type change is frontmatter-only (flat vault)', () => {
it('does not move file when type key is updated', async () => {
const config = makeConfig()
vi.mocked(mockInvoke).mockResolvedValue('')
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', 'Quarter')
})
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
})
it('does not move when result.moved is false (already in correct folder)', async () => {
const entry = makeEntry({ path: '/test/vault/quarter/my-note.md', filename: 'my-note.md', isA: 'Quarter' })
const replaceEntry = vi.fn()
const config = makeConfig([entry])
config.replaceEntry = replaceEntry
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: false }
return ''
})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/test/vault/quarter/my-note.md', 'type', 'Quarter')
})
expect(replaceEntry).not.toHaveBeenCalled()
// Should still show 'Property updated' toast (not move toast)
// In flat vault, type changes only update frontmatter — no file movement
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
})
it('handles Is A key (case-insensitive)', async () => {
const entry = makeEntry({ path: '/test/vault/note/my-note.md' })
const config = makeConfig([entry])
config.replaceEntry = vi.fn()
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/my-note.md', updated_links: 0, moved: true }
if (cmd === 'get_note_content') return '---\nIs A: Project\n---\n# My Note\n'
return ''
})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'Is A', 'Project')
})
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({
new_type: 'Project',
}))
})
it('preserves note content after type change — never loads another note (regression)', async () => {
// The mock updateMockFrontmatter returns '---\nupdated: true\n---\n' —
// this represents the note's own content after the frontmatter update.
const frontmatterUpdatedContent = '---\nupdated: true\n---\n'
const wrongContent = '---\ntype: Project\n---\n# Feedback for Laputa\n\nCompletely different note.\n'
const entry = makeEntry({ path: '/test/vault/note/migrate-newsletter.md', filename: 'migrate-newsletter.md', title: 'Migrate newsletter to Beehiiv', isA: 'Note' })
const replaceEntry = vi.fn()
const config = makeConfig([entry])
config.replaceEntry = replaceEntry
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/migrate-newsletter.md', updated_links: 0, moved: true }
// Simulate the bug: get_note_content returns a DIFFERENT note's content
// (e.g. path collision, stale cache, or filesystem race)
if (cmd === 'get_note_content') return wrongContent
return ''
})
const { result } = renderHook(() => useNoteActions(config))
// Open the tab first so we have a tab to check
act(() => { result.current.openTabWithContent(entry, '---\ntype: Note\n---\n# Migrate\n') })
await act(async () => {
await result.current.handleUpdateFrontmatter('/test/vault/note/migrate-newsletter.md', 'type', 'Project')
})
// The tab content must be the note's OWN content (from the frontmatter update),
// NEVER the content of a different note loaded via get_note_content.
const tab = result.current.tabs.find(t => t.entry.path === '/test/vault/project/migrate-newsletter.md')
expect(tab).toBeDefined()
expect(tab!.content).toBe(frontmatterUpdatedContent)
expect(tab!.content).not.toBe(wrongContent)
})
it('does not move when value is empty or null-like', async () => {
const config = makeConfig()
vi.mocked(mockInvoke).mockResolvedValue('')
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', '')
})
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
})
})
describe('rename note updates wikilinks', () => {

View File

@@ -20,12 +20,6 @@ interface RenameResult {
updated_files: number
}
interface MoveResult {
new_path: string
updated_links: number
moved: boolean
}
export interface NoteActionsConfig {
addEntry: (entry: VaultEntry) => void
removeEntry: (path: string) => void
@@ -46,21 +40,6 @@ export interface NoteActionsConfig {
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
}
async function performMoveToTypeFolder(
vaultPath: string, notePath: string, newType: string,
): Promise<MoveResult> {
if (isTauri()) {
return invoke<MoveResult>('move_note_to_type_folder', { vaultPath, notePath, newType })
}
return mockInvoke<MoveResult>('move_note_to_type_folder', { vault_path: vaultPath, note_path: notePath, new_type: newType })
}
/** Check if a frontmatter key represents the note type. */
function isTypeKey(key: string): boolean {
const k = key.toLowerCase().replace(/\s+/g, '_')
return k === 'type' || k === 'is_a'
}
/** Check if a frontmatter key represents the note title. */
function isTitleKey(key: string): boolean {
return key.toLowerCase().replace(/\s+/g, '_') === 'title'
@@ -116,6 +95,12 @@ export function needsRenameOnSave(title: string, filename: string): boolean {
return `${slugify(title)}.md` !== filename
}
/** Check if a slug would collide with an existing entry's filename. */
export function slugCollides(title: string, entries: VaultEntry[], excludePath?: string): boolean {
const slug = slugify(title)
return entries.some(e => e.filename === `${slug}.md` && e.path !== excludePath)
}
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set<string>): string {
const baseName = `Untitled ${type.toLowerCase()}`
@@ -152,13 +137,6 @@ function applyMockFrontmatterDelete(path: string, key: string): string {
return content
}
const TYPE_FOLDER_MAP: Record<string, string> = {
Note: 'note', Project: 'project', Experiment: 'experiment',
Responsibility: 'responsibility', Procedure: 'procedure',
Person: 'person', Event: 'event', Topic: 'topic',
Journal: 'journal',
}
const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal'])
/** Default templates for built-in types. Used when the type entry has no custom template. */
@@ -218,11 +196,18 @@ export function buildNoteContent(title: string, type: string, status: string | n
return `${lines.join('\n')}\n\n# ${title}\n${body}`
}
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } {
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
const slug = slugify(title)
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null, entries?: VaultEntry[]): { entry: VaultEntry; content: string } {
let slug = slugify(title)
// Detect slug collision and auto-suffix
if (entries) {
let counter = 2
while (entries.some(e => e.filename === `${slug}.md`)) {
slug = `${slugify(title)}-${counter}`
counter++
}
}
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
const entry = buildNewEntry({ path: `${vaultPath}/${folder}/${slug}.md`, slug, title, type, status })
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title, type, status })
return { entry, content: buildNoteContent(title, type, status, template) }
}
@@ -242,13 +227,12 @@ export function buildDailyNoteContent(date: string): string {
}
export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } {
const entry = buildNewEntry({ path: `${vaultPath}/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
const entry = buildNewEntry({ path: `${vaultPath}/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
return { entry, content: buildDailyNoteContent(date) }
}
export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry | undefined {
const suffix = `journal/${date}.md`
return entries.find(e => e.path.endsWith(suffix))
return entries.find(e => e.filename === `${date}.md` && e.isA === 'Journal')
}
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
@@ -393,7 +377,7 @@ export function useNoteActions(config: NoteActionsConfig) {
const handleCreateNote = useCallback((title: string, type: string) => {
const template = resolveTemplate(entries, type)
persistNew(resolveNewNote(title, type, config.vaultPath, template))
persistNew(resolveNewNote(title, type, config.vaultPath, template, entries))
}, [entries, persistNew, config.vaultPath])
const handleCreateNoteImmediate = useCallback((type?: string) => {
@@ -402,7 +386,7 @@ export function useNoteActions(config: NoteActionsConfig) {
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
const template = resolveTemplate(entries, noteType)
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
const resolved = resolveNewNote(title, noteType, config.vaultPath, template, entries)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
config.trackUnsaved?.(resolved.entry.path)
@@ -419,7 +403,7 @@ export function useNoteActions(config: NoteActionsConfig) {
* Returns true on success, false on failure (shows toast on error). */
const handleCreateNoteForRelationship = useCallback(async (title: string): Promise<boolean> => {
const template = resolveTemplate(entries, 'Note')
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template)
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template, entries)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
try {
@@ -516,30 +500,6 @@ export function useNoteActions(config: NoteActionsConfig) {
console.error('Failed to rename note after title change:', err)
}
}
if (isTypeKey(key) && typeof value === 'string' && value !== '') {
try {
const result = await performMoveToTypeFolder(config.vaultPath, path, value)
if (result.moved) {
const newFilename = result.new_path.split('/').pop() ?? ''
// Update the vault entry with the new path. Only pass the changed
// fields — avoid spreading a stale closure entry which would revert
// the isA update that runFrontmatterOp already applied.
config.replaceEntry?.(path, { path: result.new_path, filename: newFilename } as Partial<VaultEntry> & { path: string })
// Preserve the tab content already set by runFrontmatterOp.
// Re-reading from disk via loadNoteContent is unnecessary (the move
// does not change content) and dangerous: if the path collides or a
// stale cache intervenes it could return a different note's content.
setTabs(prev => prev.map(t => t.entry.path === path
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: t.content }
: t))
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
const folder = result.new_path.split('/').slice(-2, -1)[0] ?? ''
setToastMessage(`Note moved to ${folder}/`)
}
} catch (err) {
console.error('Failed to move note to type folder:', err)
}
}
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),

View File

@@ -224,28 +224,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
rename_note: handleRenameNote,
move_note_to_type_folder: (args: { vault_path: string; note_path: string; new_type: string }) => {
const slug = args.new_type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const currentFolder = args.note_path.replace(/\/[^/]+$/, '').split('/').pop() ?? ''
if (currentFolder === slug) return { new_path: args.note_path, updated_links: 0, moved: false }
const filename = args.note_path.split('/').pop() ?? ''
const vaultPath = args.vault_path.replace(/\/$/, '')
// Handle collisions: append -2, -3, etc. if the target path already exists
// (mirrors the Rust unique_dest_path logic).
let newPath = `${vaultPath}/${slug}/${filename}`
if (newPath in MOCK_CONTENT && newPath !== args.note_path) {
const stem = filename.replace(/\.md$/, '')
const ext = filename.endsWith('.md') ? '.md' : ''
let counter = 2
while (`${vaultPath}/${slug}/${stem}-${counter}${ext}` in MOCK_CONTENT) counter++
newPath = `${vaultPath}/${slug}/${stem}-${counter}${ext}`
}
const content = MOCK_CONTENT[args.note_path] ?? ''
delete MOCK_CONTENT[args.note_path]
MOCK_CONTENT[newPath] = content
syncWindowContent()
return { new_path: newPath, updated_links: 0, moved: true }
},
github_list_repos: () => [
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
{ name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },

View File

@@ -20,23 +20,28 @@ export function wikilinkDisplay(ref: string): string {
/**
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
* Handles pipe syntax, case-insensitive matching, title/alias/filename/path lookup.
* Resolution order: filename stem (primary) → alias → title (fallback).
* Handles pipe syntax, case-insensitive matching.
*/
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const keyLower = key.toLowerCase()
const suffix = '/' + key + '.md'
const lastSegment = key.split('/').pop() ?? key
const asWords = lastSegment.replace(/-/g, ' ').toLowerCase()
return entries.find(e => {
if (e.title.toLowerCase() === keyLower) return true
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return true
// 1. Filename stem match (primary — filename IS identity in flat vault)
const byStem = entries.find(e => {
const stem = e.filename.replace(/\.md$/, '')
if (stem.toLowerCase() === keyLower) return true
if (e.path.endsWith(suffix)) return true
if (stem.toLowerCase() === lastSegment.toLowerCase()) return true
if (e.title.toLowerCase() === asWords) return true
return false
return stem.toLowerCase() === keyLower || stem.toLowerCase() === lastSegment.toLowerCase()
})
if (byStem) return byStem
// 2. Alias match
const byAlias = entries.find(e => e.aliases.some(a => a.toLowerCase() === keyLower))
if (byAlias) return byAlias
// 3. Title match (fallback)
return entries.find(e =>
e.title.toLowerCase() === keyLower || e.title.toLowerCase() === asWords,
)
}

View File

@@ -1,13 +1,13 @@
import { test, expect } from '@playwright/test'
test.describe('Changing note type preserves content (data corruption fix)', () => {
test.describe('Changing note type is frontmatter-only (flat vault)', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('type change does not load a different note into the editor', async ({ page }) => {
test('type change preserves content and does not move file', async ({ page }) => {
// 1. Click the first note in the note list to open it
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
@@ -16,7 +16,6 @@ test.describe('Changing note type preserves content (data corruption fix)', () =
await page.waitForTimeout(500)
// 2. Get the editor's H1 heading text before the type change.
// The note's title is shown as an H1 in the BlockNote editor.
const editorContainer = page.locator('.bn-editor')
await expect(editorContainer).toBeVisible({ timeout: 5000 })
const headingBefore = await editorContainer.locator('h1').first().textContent()
@@ -36,13 +35,11 @@ test.describe('Changing note type preserves content (data corruption fix)', () =
await expect(option).toBeVisible({ timeout: 3000 })
await option.click()
// 5. Wait for the move to complete (toast confirms it)
const toastSlug = targetType.toLowerCase()
const toast = page.getByText(`Note moved to ${toastSlug}/`)
// 5. In flat vault, type change only updates frontmatter — shows "Property updated" toast
const toast = page.getByText('Property updated')
await expect(toast).toBeVisible({ timeout: 5000 })
// 6. CRITICAL: verify the editor still shows the SAME note's heading.
// The data-corruption bug would replace this with another note's content.
await page.waitForTimeout(300)
const headingAfter = await editorContainer.locator('h1').first().textContent()
expect(headingAfter).toBe(headingBefore)
@@ -60,53 +57,4 @@ test.describe('Changing note type preserves content (data corruption fix)', () =
await page.keyboard.press('Escape')
}
})
test('changing type of existing note preserves its content', async ({ page }) => {
// 1. Click the second note in the note list (different note than test 1)
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const notes = noteListContainer.locator('.cursor-pointer')
const noteCount = await notes.count()
const noteIndex = noteCount > 1 ? 1 : 0
await notes.nth(noteIndex).click()
await page.waitForTimeout(500)
// 2. Capture the H1 heading
const editorContainer = page.locator('.bn-editor')
await expect(editorContainer).toBeVisible({ timeout: 5000 })
const headingBefore = await editorContainer.locator('h1').first().textContent()
expect(headingBefore).toBeTruthy()
// 3. Change the type
const typeSelector = page.locator('[data-testid="type-selector"]')
await expect(typeSelector).toBeVisible({ timeout: 5000 })
const selectTrigger = typeSelector.locator('button[role="combobox"]')
const currentType = (await selectTrigger.textContent())?.trim() ?? ''
const targetType = currentType === 'Experiment' ? 'Person' : 'Experiment'
await selectTrigger.click()
await page.waitForTimeout(300)
const option = page.getByRole('option', { name: targetType, exact: true })
await expect(option).toBeVisible({ timeout: 3000 })
await option.click()
// 4. Wait for move
await page.waitForTimeout(1000)
// 5. CRITICAL: the H1 heading must still be the original note's title
const headingAfter = await editorContainer.locator('h1').first().textContent()
expect(headingAfter).toBe(headingBefore)
// 6. Restore the original type
const restoredTrigger = typeSelector.locator('button[role="combobox"]')
await restoredTrigger.click()
await page.waitForTimeout(300)
const restoreOption = page.getByRole('option', { name: currentType, exact: true })
if (await restoreOption.isVisible()) {
await restoreOption.click()
await page.waitForTimeout(1000)
} else {
await page.keyboard.press('Escape')
}
})
})

View File

@@ -64,7 +64,7 @@ test.describe('Note filename updates on title change + save', () => {
test('saving a note whose filename already matches does not trigger rename', async ({ page }) => {
// Click on an existing note in the note list that already has a matching filename.
// The default sidebar shows "Notes" section with several notes visible.
const noteItem = page.locator('.truncate.text-foreground.font-medium').filter({ hasText: /Refactoring/ }).first()
const noteItem = page.locator('.truncate.text-foreground.font-medium').first()
await noteItem.click()
await page.waitForTimeout(500)

View File

@@ -0,0 +1,183 @@
import { test, expect } from '@playwright/test'
import { sendShortcut, openCommandPalette, findCommand } from './helpers'
/**
* Fresh-install regression QA: verify all 7 Done tasks work on a fresh
* MacBook without pre-configured environment.
*
* Tasks under test:
* 1. Search/qmd bundling + auto-index
* 2. MCP server foundation + auto-registration
* 3. AGENTS.md vault-level instructions
* 4. AI Agent panel: vault-native AI
* 5. Claude API wiring + full agent loop
* 6. AI panel UI (3 layers + blue glow)
* 7. /api/ai/agent endpoint fix (uses Tauri invoke, not fetch)
*/
test.describe('Fresh-install regression: AI panel renders and works', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await page.waitForTimeout(500)
})
test('AI panel opens with Ctrl+I and has 3-layer structure', async ({ page }) => {
// Select a note for context
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(300)
// Open AI panel
await sendShortcut(page, 'i', ['Control'])
const panel = page.getByTestId('ai-panel')
await expect(panel).toBeVisible({ timeout: 3000 })
// Layer 1: Header with title and buttons
await expect(panel.locator('text=AI Chat')).toBeVisible()
await expect(panel.locator('button[title="New conversation"]')).toBeVisible()
await expect(panel.locator('button[title="Close AI panel"]')).toBeVisible()
// Layer 2: Message area (empty state with robot icon suggestion)
await expect(
panel.locator('text=Ask about this note').or(panel.locator('text=Open a note')),
).toBeVisible()
// Layer 3: Input area with send button
await expect(page.getByTestId('agent-send')).toBeVisible()
})
test('AI panel shows context bar when note is selected', async ({ page }) => {
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(300)
await sendShortcut(page, 'i', ['Control'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
// Context bar should show active note title
await expect(page.getByTestId('context-bar')).toBeVisible()
})
test('AI panel input is focusable and sendable', async ({ page }) => {
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(300)
await sendShortcut(page, 'i', ['Control'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
// Input should auto-focus
const input = page.locator('input[placeholder*="Ask"]')
await expect(input).toBeVisible()
await input.fill('Test message')
// Send button should be enabled when input has text
const sendBtn = page.getByTestId('agent-send')
await expect(sendBtn).toBeEnabled()
// Click send — should produce a response (mock in dev mode)
await sendBtn.click()
const response = page.getByTestId('ai-message').last()
await expect(response).toBeVisible({ timeout: 5000 })
})
test('AI panel close with Escape key', async ({ page }) => {
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(300)
await sendShortcut(page, 'i', ['Control'])
const panel = page.getByTestId('ai-panel')
await expect(panel).toBeVisible({ timeout: 3000 })
// Focus the panel and press Escape
await panel.focus()
await page.keyboard.press('Escape')
await expect(panel).not.toBeVisible({ timeout: 2000 })
})
test('AI panel blue glow animation CSS exists', async ({ page }) => {
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(300)
await sendShortcut(page, 'i', ['Control'])
const panel = page.getByTestId('ai-panel')
await expect(panel).toBeVisible({ timeout: 3000 })
// Verify the ai-border-pulse keyframe is defined in the page CSS
const hasAnimation = await page.evaluate(() => {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSKeyframesRule && rule.name === 'ai-border-pulse') {
return true
}
}
} catch { /* cross-origin sheets */ }
}
return false
})
expect(hasAnimation).toBe(true)
})
})
test.describe('Fresh-install regression: search and command palette', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await page.waitForTimeout(500)
})
test('search UI renders and is accessible via Cmd+P', async ({ page }) => {
// Cmd+P should open search/note switcher
await sendShortcut(page, 'p', ['Control'])
await page.waitForTimeout(500)
// Search input should be visible
const searchInput = page.locator('input[placeholder*="Search"]').or(
page.locator('input[placeholder*="command"]'),
)
await expect(searchInput).toBeVisible({ timeout: 2000 })
})
test('Cmd+K command palette includes Repair Vault', async ({ page }) => {
await openCommandPalette(page)
const found = await findCommand(page, 'Repair Vault')
expect(found).toBe(true)
})
})
test.describe('Fresh-install regression: no /api/ai/agent endpoint', () => {
test('fetching /api/ai/agent returns 404 or no response', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// In dev mode, the Vite proxy plugin handles /api/ai/agent,
// but in production Tauri there is no HTTP server at all.
// The key verification is that ai-agent.ts uses invoke(), not fetch().
// We verify this indirectly by checking the AiPanel doesn't make fetch calls.
const fetchCalls: string[] = []
page.on('request', req => {
if (req.url().includes('/api/ai/agent')) {
fetchCalls.push(req.url())
}
})
// Open AI panel and send a message
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(300)
await sendShortcut(page, 'i', ['Control'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
const input = page.locator('input[placeholder*="Ask"]')
await input.fill('Test')
await page.getByTestId('agent-send').click()
await page.waitForTimeout(1000)
// No fetch to /api/ai/agent should have been made
expect(fetchCalls).toHaveLength(0)
})
})

View File

@@ -3,112 +3,108 @@ import { test, expect } from '@playwright/test'
const DROP_OVERLAY = '.editor__drop-overlay'
const EDITOR_CONTAINER = '.editor__blocknote-container'
async function openFirstNote(page: import('@playwright/test').Page) {
await page.goto('/')
await page.waitForLoadState('networkidle')
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.waitFor({ timeout: 5_000 })
await noteList.locator('.cursor-pointer').first().click()
await page.waitForTimeout(300)
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
}
/** Dispatch a DragEvent with an image file on the editor container to show the overlay. */
async function showOverlayViaImageDragover(page: import('@playwright/test').Page) {
await page.evaluate((sel) => {
const el = document.querySelector(sel)
if (!el) throw new Error('Editor container not found')
const dt = new DataTransfer()
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }))
}, EDITOR_CONTAINER)
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
}
test.describe('Image drop overlay — internal drag does not trigger overlay', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Open the first note to mount the editor
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.waitFor({ timeout: 5_000 })
await noteList.locator('.cursor-pointer').first().click()
await page.waitForTimeout(300)
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
})
test.beforeEach(async ({ page }) => { await openFirstNote(page) })
test('internal drag (no image files) does not show the overlay', async ({ page }) => {
// Simulate an internal drag (e.g. block or tab) — dataTransfer has no files
await page.locator(EDITOR_CONTAINER).first().dispatchEvent('dragover', {
bubbles: true,
cancelable: true,
})
// The overlay should NOT appear for non-file drags
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
})
test('dragover with image file shows the overlay', async ({ page }) => {
// Simulate an OS file drag with an image file in dataTransfer
// Playwright dispatchEvent can't set dataTransfer items directly,
// so we use page.evaluate to dispatch a proper DragEvent with file items
await page.evaluate((selector) => {
const el = document.querySelector(selector)
if (!el) throw new Error('Editor container not found')
const dt = new DataTransfer()
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
const event = new DragEvent('dragover', {
dataTransfer: dt,
bubbles: true,
cancelable: true,
})
el.dispatchEvent(event)
}, EDITOR_CONTAINER)
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
await showOverlayViaImageDragover(page)
await expect(page.locator(DROP_OVERLAY)).toContainText('Drop image here')
})
test('dragleave after image dragover hides the overlay', async ({ page }) => {
// First show the overlay via image dragover
await page.evaluate((selector) => {
const el = document.querySelector(selector)
if (!el) throw new Error('Editor container not found')
await showOverlayViaImageDragover(page)
const dt = new DataTransfer()
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
el.dispatchEvent(new DragEvent('dragover', {
dataTransfer: dt,
bubbles: true,
cancelable: true,
}))
}, EDITOR_CONTAINER)
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
// Now simulate dragleave (cursor left the container)
await page.evaluate((selector) => {
const el = document.querySelector(selector)
if (!el) throw new Error('Editor container not found')
el.dispatchEvent(new DragEvent('dragleave', {
bubbles: true,
cancelable: true,
relatedTarget: document.body,
}))
await page.evaluate((sel) => {
const el = document.querySelector(sel)!
el.dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true, relatedTarget: document.body }))
}, EDITOR_CONTAINER)
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
})
test('drop resets the overlay', async ({ page }) => {
// Show overlay via image dragover
await page.evaluate((selector) => {
const el = document.querySelector(selector)
if (!el) throw new Error('Editor container not found')
await showOverlayViaImageDragover(page)
const dt = new DataTransfer()
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
el.dispatchEvent(new DragEvent('dragover', {
dataTransfer: dt,
bubbles: true,
cancelable: true,
}))
}, EDITOR_CONTAINER)
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
// Simulate drop
await page.evaluate((selector) => {
const el = document.querySelector(selector)
if (!el) throw new Error('Editor container not found')
el.dispatchEvent(new DragEvent('drop', {
bubbles: true,
cancelable: true,
}))
await page.evaluate((sel) => {
const el = document.querySelector(sel)!
el.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true }))
}, EDITOR_CONTAINER)
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
})
})
test.describe('Block handle (side menu) is not clipped by editor overflow', () => {
test.beforeEach(async ({ page }) => { await openFirstNote(page) })
test('side menu is fully visible within container bounds on hover', async ({ page }) => {
await page.locator('.bn-block-outer').first().hover()
await page.waitForTimeout(400)
const result = await page.evaluate(() => {
const menu = document.querySelector('[class*="sideMenu"], .bn-side-menu, [data-side-menu]')
const container = document.querySelector('.editor__blocknote-container')
if (!menu || !container) return null
const mr = menu.getBoundingClientRect()
const cr = container.getBoundingClientRect()
return { menuLeft: mr.left, containerLeft: cr.left }
})
expect(result).not.toBeNull()
expect(result!.menuLeft).toBeGreaterThanOrEqual(result!.containerLeft)
})
})
test.describe('Tab drag does not trigger image drop overlay', () => {
test('dragging a tab over the editor does not show the overlay', async ({ page }) => {
await openFirstNote(page)
const noteList = page.locator('[data-testid="note-list-container"]')
const secondNote = noteList.locator('.cursor-pointer').nth(1)
if (await secondNote.count() === 0) { test.skip(); return }
await secondNote.click()
await page.waitForTimeout(300)
await page.evaluate((sel) => {
document.dispatchEvent(new Event('dragstart', { bubbles: true }))
const el = document.querySelector(sel)!
const dt = new DataTransfer()
dt.setData('text/plain', '0')
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }))
}, EDITOR_CONTAINER)
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
await page.evaluate(() => { document.dispatchEvent(new Event('dragend', { bubbles: true })) })
})
})

View File

@@ -1,67 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Move note to type folder on type change', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('changing type shows move toast and note appears under new section', async ({ page }) => {
// Click the first note in the note list to open it
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const firstNote = noteListContainer.locator('.cursor-pointer').first()
await firstNote.click()
await page.waitForTimeout(500)
// The Properties panel should show a type selector
const typeSelector = page.locator('[data-testid="type-selector"]')
await expect(typeSelector).toBeVisible({ timeout: 5000 })
// Read the current type
const selectTrigger = typeSelector.locator('button[role="combobox"]')
const currentType = (await selectTrigger.textContent())?.trim() ?? 'Note'
// Pick a different type to change to
const targetType = currentType === 'Note' ? 'Experiment' : 'Note'
// Open the type selector dropdown and select the target type
await selectTrigger.click()
await page.waitForTimeout(300)
const option = page.getByRole('option', { name: targetType, exact: true })
await expect(option).toBeVisible({ timeout: 3000 })
await option.click()
// Toast should confirm the move
const toastSlug = targetType.toLowerCase()
const toast = page.getByText(`Note moved to ${toastSlug}/`)
await expect(toast).toBeVisible({ timeout: 5000 })
// Restore original type to avoid leaving vault dirty
await page.waitForTimeout(2500) // wait for toast to dismiss
const restoredTrigger = typeSelector.locator('button[role="combobox"]')
await restoredTrigger.click()
await page.waitForTimeout(300)
const restoreOption = page.getByRole('option', { name: currentType, exact: true })
await expect(restoreOption).toBeVisible({ timeout: 3000 })
await restoreOption.click()
await page.waitForTimeout(1000)
})
test('type selector is visible in properties panel for opened note', async ({ page }) => {
// Click the first note in the note list
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const firstNote = noteListContainer.locator('.cursor-pointer').first()
await firstNote.click()
await page.waitForTimeout(500)
// Type selector should be visible in the properties panel
const typeSelector = page.locator('[data-testid="type-selector"]')
await expect(typeSelector).toBeVisible({ timeout: 5000 })
// It should show "Type" label
await expect(typeSelector.getByText('Type')).toBeVisible()
})
})