Compare commits

..

30 Commits

Author SHA1 Message Date
Test
ab02aa5e96 feat: wire create-open relationship note to all panel contexts 2026-03-11 21:25:20 +01:00
Test
4dd27cf0c3 feat: add 'Create & open' option to relationship input dropdowns
When typing a non-existent note title in the relationship target input,
a 'Create & open' option now appears at the bottom of the dropdown.
Selecting it creates the note, adds the wikilink, and opens the new note.

- Added SearchDropdownWithCreate with create option
- Modified InlineAddNote and NoteTargetInput to support create flow
- Added onCreateAndOpenNote prop to DynamicRelationshipsPanel
- Keyboard accessible (arrow keys + Enter)
- 6 new tests covering create-and-open behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:25:20 +01:00
Test
c499ef30f0 fix: case-insensitive type entry lookup + Playwright smoke test
buildTypeEntryMap now stores both original title and lowercase key so
isA: 'config' matches type entry titled 'Config'. Adds Playwright smoke
test that blocks the vault API to test against mock data fixtures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:24:51 +01:00
Test
b3bf2bf76e fix: sidebar section header reflects type icon, color, and label
- Add GearSix icon ('gear-six') to icon registry — was missing, causing
  Config type to show FileText fallback instead of its configured icon
- Add 'gray' to ACCENT_COLORS palette with CSS variables — was missing,
  causing Config type color to fall back to muted foreground
- Extract sidebar section logic to utils/sidebarSections.ts for testability
- Add Config type + instance to mock entries for browser dev mode
- Add tests: icon resolution, gray color, sidebar section builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:24:51 +01:00
Test
13622bc236 test: add real filesystem vault integration tests
Replace mock vault approach with real filesystem I/O for integration
tests. Each test copies tests/fixtures/test-vault/ to a temp directory,
overrides mock handlers to point at it, and verifies actual file
operations through the vite dev server middleware.

Tests cover: vault loading, archive/trash filtering, note creation,
rename with filesystem update, wikilink cascade on rename, relationship
display, and real file content loading.

Also extends vite.config.ts vault API middleware with write endpoints
(save, rename, delete, search, entry) and fixes getBool to handle
YAML 1.2 string values like "Yes"/"yes" (js-yaml 4.x compatibility).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:24:44 +01:00
Test
80ad5cfad7 fix: update fix-note-filename-on-rename smoke test for title-sync rename
Title sync now triggers a full rename flow instead of in-memory update,
so the Cmd+S test expectations needed updating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:55:51 +01:00
Test
068d70c264 fix: add missing old_title_hint arg to tests added on main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:47:46 +01:00
Test
86ffb43eb7 style: cargo fmt on rename.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:47:46 +01:00
Test
3504bb221a fix: remove needless borrow flagged by clippy in rename.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:47:46 +01:00
Test
f2136f17ef test: Playwright smoke test for rename-wikilink-update
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:47:46 +01:00
Test
75ca18d4d0 test: unit tests for rename-wikilink-update feature
- handleRenameNote passes entry title as old_title to Rust
- handleUpdateFrontmatter triggers rename on title key change
- non-title keys don't trigger rename
- null old_title when entry not found

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:47:46 +01:00
Test
47c408dc50 feat: wire H1 sync and frontmatter title change to full rename flow
handleTitleSync now saves pending content and calls rename_note
(which renames the file and updates wikilinks) instead of only
updating in-memory state. handleUpdateFrontmatter also triggers
rename when the title: key is changed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:47:46 +01:00
Test
96d7df368a feat: add old_title_hint to rename_note for H1 sync rename support
When the editor saves content with a new H1 before triggering rename,
the on-disk H1 already matches the new title, causing rename_note to
noop. The old_title_hint parameter lets the caller provide the
original title so wikilinks are still found and updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:47:46 +01:00
Test
c7b0c15537 test: Playwright smoke test for serializer blank lines fix
Verifies tight lists stay tight, headings don't gain extra blank lines,
and saving without editing doesn't add whitespace changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:38:05 +01:00
Test
0ee6e76d10 fix: post-process BlockNote serializer to remove extra blank lines
blocksToMarkdownLossy() inserts blank lines between every block, making
tight lists loose and polluting git history. Add compactMarkdown() that
collapses inter-list-item blanks and excessive blank line runs while
preserving code blocks and intentional paragraph spacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:38:05 +01:00
Test
25c44910d1 test: add Playwright smoke tests for wikilink insertion and navigation
- Test [[ autocomplete inserts wikilink with correct data-target attribute
- Test inserted wikilink does not show as broken (correct color resolution)
- Test clicking inserted wikilink navigates to the correct note

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:22:42 +01:00
Test
61760c4a41 fix: unify wikilink resolution and disambiguate duplicate titles
- Create resolveEntry() in wikilink.ts: single case-insensitive resolution
  function that handles title, alias, filename stem, path suffix, and
  pipe syntax matching
- Replace findEntryByTarget (case-sensitive) and entryMatchesTarget
  (hardcoded /Laputa/ path) with unified resolveEntry
- Fix attachClickHandlers to insert path|title pipe syntax when multiple
  candidates share the same title (disambiguation)
- Update ai-context.ts resolveTarget to use unified resolution
- Add comprehensive tests for resolveEntry and disambiguation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:12:05 +01:00
Test
8104a8380c fix: image drop overlay no longer triggers on internal drags (tabs, blocks)
Remove setIsDragOver(true) from Tauri onDragDropEvent 'over' handler —
Tauri over events can't distinguish OS file drags from internal drags.
The HTML5 dragover handler already checks hasImageFiles() correctly and
now solely drives the overlay state. Tauri handler only processes drops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:36:16 +01:00
Test
593a0d3d54 test: Playwright smoke test for note filename rename on save
Covers: title change + save renames file, no rename when filename matches,
rapid title edits rename to final title.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:09:55 +01:00
Test
a50aae70e8 feat: rename file on save when title slug doesn't match filename
When Cmd+S is pressed, after saving content, checks if the note's
title slug differs from its current filename. If so, triggers
rename_note to update the file on disk, tabs, breadcrumbs, and
wikilinks. Adds needsRenameOnSave() utility with tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:09:55 +01:00
Test
2387b9a637 fix: rename_note handles filename-slug mismatch and collisions
When the note content already has the correct title but the filename
doesn't match (e.g. untitled-note-9.md after user changed H1), the
rename was a no-op. Now checks both title AND filename slug before
early-returning. Also uses unique_dest_path for collision handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:09:55 +01:00
Test
27452515d7 test: Playwright smoke test for changing-type data corruption regression
Verifies that changing a note's type preserves the editor content —
the bug caused the tab to load a different note's content after the move.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:50:22 +01:00
Test
14a4d371e6 fix: mock move_note_to_type_folder collision handling + Rust collision content test
The mock handler now appends -2, -3, etc. when the target path already
exists, matching the Rust unique_dest_path logic.  Previously it would
silently overwrite the existing note's content in MOCK_CONTENT.

Also adds a Rust test that verifies both the moved note and the
pre-existing note retain their respective content after a collision.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:39:18 +01:00
Test
9199ceaa35 fix: prevent data corruption when changing note type — preserve tab content instead of re-reading from disk
After runFrontmatterOp updates the frontmatter and sets the tab content,
move_note_to_type_folder only changes the file location (not its content).
Re-reading via loadNoteContent(result.new_path) was redundant and dangerous:
if the path collided or a stale cache intervened, it could load a different
note's content into the tab — the root cause of the data-corruption bug.

Also fixes stale-closure issue: replaceEntry no longer spreads the captured
`entries` array (which could be stale after the await), avoiding reverting
the isA field that runFrontmatterOp already updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:37:53 +01:00
Test
6af18655de style: rustfmt formatting fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:09:36 +01:00
Test
90bf73524c fix: bump cache version + handle Yes/No in TS frontmatter parser
Root cause: commit 4743537 added a custom Rust deserializer for
Archived/Trashed Yes/No strings but did not bump CACHE_VERSION.
Existing vaults had stale cached entries with archived: false from the
old parser, and since the cache version (5) matched, stale values were
served without re-parsing from disk.

- Bump CACHE_VERSION 5 → 6 to force full rescan on next vault load
- Add Yes/No handling to TypeScript parseScalar (Inspector display)
- Add integration tests: cached vault path with Archived/Trashed: Yes
- Add stale cache version invalidation test
- Add frontmatter.test.ts for TS Yes/No boolean parsing
- Add Playwright smoke test for archived note filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:07:22 +01:00
Test
c1f7f7ec6f test: Playwright smoke test for create note crash fix
Covers all acceptance criteria:
- Click '+' next to type section → note created, no crash
- Cmd+N → note created, no crash
- Custom type → note created, no crash
- Rapid double-click → both notes created, no crash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:50:05 +01:00
Test
d1021b9131 fix: prevent crash in handleCreateNoteImmediate — slugify fallback + try/catch
- slugify now returns 'untitled' instead of empty string when input has only
  special characters, preventing invalid paths like '/vault//note.md'
- handleCreateNoteImmediate wrapped in try/catch — worst case shows a toast
  error instead of crashing the app
- Added Rust test for deeply nested directory creation in save_note_content
- Regression tests for slugify edge cases and handleCreateNoteImmediate with
  special-character types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:50:05 +01:00
Test
b9139e2d57 test: Playwright smoke test for theme live reload on save
Verifies that editing a theme note frontmatter in raw mode and pressing
Ctrl+S immediately updates CSS vars on the DOM. Also verifies saving a
non-theme note does not affect the active theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:36:56 +01:00
Test
c692c5d067 fix: live-reload CSS vars when saving active theme note in editor
When user edits a theme note directly in the editor and presses Cmd+S,
the app now immediately re-applies CSS variables — no manual reload
needed. Added notifyThemeSaved(path, content) to ThemeManager; wired
into onNotePersisted callback so saving the active theme updates
cachedThemeContent, triggering useThemeApplier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:31:03 +01:00
64 changed files with 3400 additions and 235 deletions

View File

@@ -14,6 +14,7 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",
"prepare": "husky"
},

View File

@@ -0,0 +1,18 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/integration',
timeout: 30_000,
retries: 1,
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5365',
headless: true,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5365'}`,
url: process.env.BASE_URL || 'http://localhost:5365',
reuseExistingServer: true,
},
})

View File

@@ -84,10 +84,11 @@ pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
old_title: Option<String>,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title)
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
}
#[tauri::command]

View File

@@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
/// Bump this when VaultEntry fields change to force a full rescan.
const CACHE_VERSION: u32 = 5;
const CACHE_VERSION: u32 = 6;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {
@@ -870,4 +870,80 @@ mod tests {
"note must be trashed after invalidate + rescan"
);
}
/// Integration test: a note with `Archived: Yes` (string, not boolean)
/// must be recognized as archived through the full cached vault load path.
/// This catches the scenario where a stale cache stores `archived: false`
/// and the cache version bump forces a correct re-parse.
#[test]
fn test_cached_vault_archived_yes_string() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(
vault,
"archived-note.md",
"---\nArchived: Yes\n---\n# Old Note\n",
);
git_add_commit(vault, "init");
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0].archived,
"'Archived: Yes' must be parsed as true through the cached vault path"
);
}
/// Integration test: `Trashed: Yes` (string) through full cached path.
#[test]
fn test_cached_vault_trashed_yes_string() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(vault, "trashed-note.md", "---\nTrashed: Yes\n---\n# Gone\n");
git_add_commit(vault, "init");
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0].trashed,
"'Trashed: Yes' must be parsed as true through the cached vault path"
);
}
/// Integration test: stale cache with old version is invalidated and
/// re-parses `Archived: Yes` correctly after cache version bump.
#[test]
fn test_stale_cache_version_forces_rescan_of_archived_yes() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(vault, "note.md", "---\nArchived: Yes\n---\n# Note\n");
git_add_commit(vault, "init");
let hash = git_head_hash(vault).unwrap();
// Simulate a stale cache written by old code that parsed Archived: Yes as false
let stale_entry = {
let mut e = parse_md_file(&vault.join("note.md")).unwrap();
e.archived = false; // simulate old parser behavior
e
};
let stale_cache = VaultCache {
version: CACHE_VERSION - 1, // old version
vault_path: vault.to_string_lossy().to_string(),
commit_hash: hash,
entries: vec![stale_entry],
};
write_cache(vault, &stale_cache);
// Load via cached path — stale version must trigger full rescan
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0].archived,
"stale cache with old version must be invalidated, re-parsing 'Archived: Yes' as true"
);
}
}

View File

@@ -1261,6 +1261,17 @@ References:
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_save_note_content_deeply_nested_new_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a/b/c/deep-note.md");
let content = "---\ntitle: Deep\n---\n";
save_note_content(path.to_str().unwrap(), content).unwrap();
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
// --- sidebar_label tests ---
#[test]

View File

@@ -344,10 +344,16 @@ pub fn move_note_to_type_folder(
}
/// 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
/// the file's H1 heading. This is needed when the caller has already saved updated
/// content to disk (e.g. the editor saved a new H1 before triggering the rename)
/// so the on-disk H1 already matches `new_title`.
pub fn rename_note(
vault_path: &str,
old_path: &str,
new_title: &str,
old_title_hint: Option<&str>,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
@@ -366,23 +372,34 @@ pub fn rename_note(
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let old_title = super::extract_title(&content, &old_filename);
let extracted_title = super::extract_title(&content, &old_filename);
let old_title = old_title_hint.unwrap_or(&extracted_title);
if old_title == new_title {
// Check both title and filename: even if the title in content matches,
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
let expected_filename = format!("{}.md", title_to_slug(new_title));
let title_unchanged = old_title == new_title;
let filename_matches = old_filename == expected_filename;
if title_unchanged && filename_matches {
return Ok(RenameResult {
new_path: old_path.to_string(),
updated_files: 0,
});
}
// Update content (H1 + frontmatter title)
let updated_content = update_note_title_in_content(&content, new_title);
// Update content only if the title actually changed
let updated_content = if title_unchanged {
content.clone()
} else {
update_note_title_in_content(&content, new_title)
};
// Compute new path and write file
// Compute new path, handling collisions with numeric suffix
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title)));
let new_file = unique_dest_path(parent_dir, &expected_filename);
let new_path_str = new_file.to_string_lossy().to_string();
fs::write(&new_file, &updated_content)
@@ -397,7 +414,7 @@ pub fn rename_note(
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
vault_path: vault,
old_title: &old_title,
old_title,
new_title,
old_path_stem,
exclude_path: &new_file,
@@ -455,6 +472,7 @@ mod tests {
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
None,
)
.unwrap();
@@ -491,6 +509,7 @@ mod tests {
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
None,
)
.unwrap();
@@ -515,6 +534,7 @@ mod tests {
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
None,
)
.unwrap();
@@ -529,7 +549,12 @@ mod tests {
create_test_file(vault, "note/test.md", "# Test\n");
let old_path = vault.join("note/test.md");
let result = rename_note(vault.to_str().unwrap(), old_path.to_str().unwrap(), " ");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
" ",
None,
);
assert!(result.is_err());
}
@@ -549,6 +574,7 @@ mod tests {
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
None,
)
.unwrap();
@@ -572,6 +598,7 @@ mod tests {
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"New Name",
None,
)
.unwrap();
@@ -594,6 +621,7 @@ mod tests {
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
new_title,
None,
)
.expect("rename_note should succeed");
@@ -649,6 +677,82 @@ mod tests {
assert!(content.contains("# Renamed Note"));
}
// --- rename-on-save: filename doesn't match title slug ---
#[test]
fn test_rename_note_filename_mismatch_same_title() {
// Simulates: user created "Untitled note", changed H1 to "My New Note",
// saved content (H1 now correct), but filename is still "untitled-note.md".
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/untitled-note.md",
"---\ntitle: My New Note\ntype: Note\n---\n\n# My New Note\n\nContent.\n",
);
let old_path = vault.join("note/untitled-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My New Note",
None,
)
.unwrap();
// File should be renamed to match the title slug
assert!(
result.new_path.ends_with("my-new-note.md"),
"expected my-new-note.md, got {}",
result.new_path
);
assert!(!old_path.exists(), "old file should be removed");
assert!(Path::new(&result.new_path).exists());
// Content should be preserved (title was already correct)
let content = fs::read_to_string(&result.new_path).unwrap();
assert!(content.contains("# My New Note"));
assert!(content.contains("title: My New Note"));
}
#[test]
fn test_rename_note_collision_appends_suffix() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
// Existing file with the slug we want
create_test_file(
vault,
"note/my-note.md",
"---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nExisting.\n",
);
// File with wrong name that should be renamed to my-note.md
create_test_file(
vault,
"note/untitled-note.md",
"---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nNew content.\n",
);
let old_path = vault.join("note/untitled-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
None,
)
.unwrap();
// Should get a suffixed name to avoid collision
assert!(
result.new_path.ends_with("my-note-2.md"),
"expected my-note-2.md, got {}",
result.new_path
);
assert!(!old_path.exists());
assert!(Path::new(&result.new_path).exists());
// Original file should be untouched
assert!(vault.join("note/my-note.md").exists());
}
// --- move_note_to_type_folder tests ---
#[test]
@@ -826,6 +930,131 @@ mod tests {
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.
let dir = TempDir::new().unwrap();
let vault = dir.path();
// Note file already has the NEW H1 (simulating savePendingForPath before rename)
create_test_file(
vault,
"note/weekly-review.md",
"---\nIs A: Note\n---\n# Sprint Retrospective\n\nContent.\n",
);
create_test_file(
vault,
"note/other.md",
"---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
);
create_test_file(
vault,
"project/my-project.md",
"---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n",
);
let old_path = vault.join("note/weekly-review.md");
// Without old_title_hint, rename_note would see H1 = "Sprint Retrospective" == new_title → noop
// With old_title_hint = "Weekly Review", it knows to search for [[Weekly Review]] and replace
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
Some("Weekly Review"),
)
.unwrap();
assert_eq!(result.updated_files, 2);
assert!(result.new_path.ends_with("sprint-retrospective.md"));
assert!(!vault.join("note/weekly-review.md").exists());
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
assert!(!other_content.contains("[[Weekly Review]]"));
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
assert!(project_content.contains("[[Sprint Retrospective]]"));
}
#[test]
fn test_rename_note_without_hint_backward_compatible() {
// Existing behavior: no hint, extracts title from H1
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
);
create_test_file(
vault,
"note/other.md",
"See [[Weekly Review]] for details.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
None,
)
.unwrap();
assert_eq!(result.updated_files, 1);
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Sprint Retrospective]]"));
}
#[test]
fn test_rename_note_hint_same_as_new_title_noop() {
// If old_title_hint == new_title, should be a noop
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
let old_path = vault.join("note/my-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
Some("My Note"),
)
.unwrap();
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();

View File

@@ -17,7 +17,7 @@ import { WelcomeScreen } from './components/WelcomeScreen'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { useNoteActions, needsRenameOnSave } from './hooks/useNoteActions'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
@@ -306,10 +306,16 @@ function App() {
triggerIncrementalIndex()
}, [vault, triggerIncrementalIndex])
const { notifyThemeSaved } = themeManager
const onNotePersisted = useCallback((path: string, content: string) => {
vault.clearUnsaved(path)
notifyThemeSaved(path, content)
}, [vault, notifyThemeSaved])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateEntry: vault.updateEntry,
setTabs: notes.setTabs, setToastMessage, onAfterSave,
onNotePersisted: vault.clearUnsaved,
onNotePersisted,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
@@ -352,14 +358,25 @@ function App() {
}
}, [resolvedPath, vault.entries, notes, dialogs])
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
await savePendingForPath(path)
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
}, [notes, resolvedPath, vault, savePendingForPath])
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
// and trigger file rename when the title slug doesn't match the filename.
const handleSave = useCallback(async () => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
? { path: activeTab.entry.path, content: activeTab.content }
: undefined
await handleSaveRaw(fallback)
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
// After saving, check if filename needs to match the current title
if (activeTab && needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)) {
await handleRenameTab(activeTab.entry.path, activeTab.entry.title)
}
}, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
@@ -391,19 +408,12 @@ function App() {
setToastMessage(`Type "${name}" created`)
}, [notes])
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
/** H1→title sync: save pending content then rename file + update wikilinks. */
const handleTitleSync = useCallback(async (path: string, newTitle: string) => {
await savePendingForPath(path)
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
}, [notes, resolvedPath, vault, savePendingForPath])
/** H1→title sync: update VaultEntry.title and tab entry in memory. */
const handleTitleSync = useCallback((path: string, newTitle: string) => {
vault.updateEntry(path, { title: newTitle })
notes.setTabs(prev => prev.map(t =>
t.entry.path === path ? { ...t, entry: { ...t.entry, title: newTitle } } : t
))
}, [vault, notes])
const bulkActions = useBulkActions(entryActions, setToastMessage)
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
@@ -611,6 +621,7 @@ function App() {
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}

View File

@@ -45,6 +45,7 @@ interface EditorProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
showAIChat?: boolean
onToggleAIChat?: () => void
vaultPath?: string
@@ -120,7 +121,7 @@ export const Editor = memo(function Editor({
onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote,
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
@@ -245,6 +246,7 @@ export const Editor = memo(function Editor({
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
onOpenNote={onNavigateWikilink}
onFileCreated={onFileCreated}
onFileModified={onFileModified}

View File

@@ -22,6 +22,7 @@ interface EditorRightPanelProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
@@ -33,7 +34,7 @@ export function EditorRightPanel({
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, openTabs,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
if (showAIChat) {
@@ -79,6 +80,7 @@ export function EditorRightPanel({
onUpdateFrontmatter={onUpdateFrontmatter}
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
</div>
)

View File

@@ -23,6 +23,7 @@ interface InspectorProps {
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}
function useBacklinks(
@@ -114,7 +115,7 @@ function EmptyInspector() {
export function Inspector({
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
}: InspectorProps) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
@@ -157,6 +158,7 @@ export function Inspector({
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />

View File

@@ -407,6 +407,190 @@ describe('DynamicRelationshipsPanel', () => {
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
})
})
describe('create & open from inline add', () => {
const onUpdateProperty = vi.fn()
const onDeleteProperty = vi.fn()
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
beforeEach(() => {
vi.clearAllMocks()
onCreateAndOpenNote.mockResolvedValue(true)
})
it('shows "Create & open" option when typed title does not match any note', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
expect(screen.getByText(/Create & open/)).toBeInTheDocument()
expect(screen.getByText(/Brand New Note/)).toBeInTheDocument()
})
it('does not show "Create & open" when typed title matches an existing note', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'AI' } })
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
})
it('calls onCreateAndOpenNote and adds wikilink when "Create & open" clicked', async () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
await vi.waitFor(() => {
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]'])
})
})
it('does not add wikilink when note creation fails', async () => {
onCreateAndOpenNote.mockResolvedValue(false)
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Failing Note' } })
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Failing Note')
// Give async handler time to resolve
await vi.waitFor(() => {
expect(onCreateAndOpenNote).toHaveBeenCalled()
})
expect(onUpdateProperty).not.toHaveBeenCalled()
})
it('shows both existing matches and "Create & open" for partial matches', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
// "My" partially matches "My Project" but is not an exact match
fireEvent.change(input, { target: { value: 'My' } })
// Should show search results AND create option
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
})
it('does not show "Create & open" when onCreateAndOpenNote is not provided', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
entries={entries}
onNavigate={onNavigate}
onUpdateProperty={onUpdateProperty}
onDeleteProperty={onDeleteProperty}
/>
)
fireEvent.click(screen.getByTestId('add-relation-ref'))
const input = screen.getByTestId('add-relation-ref-input')
fireEvent.change(input, { target: { value: 'Brand New Note' } })
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
})
})
describe('create & open from AddRelationshipForm', () => {
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
beforeEach(() => {
vi.clearAllMocks()
onCreateAndOpenNote.mockResolvedValue(true)
})
it('shows "Create & open" option in target input when title does not exist', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{}}
entries={entries}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
const noteInput = screen.getByPlaceholderText('Note title')
fireEvent.focus(noteInput)
fireEvent.change(noteInput, { target: { value: 'New Person' } })
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
})
it('creates note and adds relationship via form', async () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{}}
entries={entries}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
const noteInput = screen.getByPlaceholderText('Note title')
fireEvent.focus(noteInput)
fireEvent.change(noteInput, { target: { value: 'New Person' } })
fireEvent.click(screen.getByTestId('create-and-open-option'))
expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person')
await vi.waitFor(() => {
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]')
})
})
})
})
describe('BacklinksPanel', () => {

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import { buildSectionGroup } from '../utils/sidebarSections'
import { resolveIcon } from '../utils/iconRegistry'
import type { VaultEntry } from '../types'
import { GearSix, CookingPot, FileText } from '@phosphor-icons/react'
const baseEntry: VaultEntry = {
path: '', filename: '', title: '', isA: null, aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
wordCount: 0,
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
view: null, visible: null, outgoingLinks: [], properties: {},
}
describe('buildSectionGroup', () => {
it('uses type entry icon/color/sidebarLabel for custom type', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'blue', sidebarLabel: 'Config' },
}
const group = buildSectionGroup('Config', typeEntryMap)
expect(group.label).toBe('Config')
expect(group.customColor).toBe('blue')
expect(group.Icon).toBe(GearSix)
})
it('uses type entry icon/color for custom type with custom icon', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Recipe: { ...baseEntry, title: 'Recipe', isA: 'Type', icon: 'cooking-pot', color: 'orange' },
}
const group = buildSectionGroup('Recipe', typeEntryMap)
expect(group.label).toBe('Recipes')
expect(group.customColor).toBe('orange')
expect(group.Icon).toBe(CookingPot)
})
it('falls back to pluralized name and FileText when no type entry', () => {
const group = buildSectionGroup('Widget', {})
expect(group.label).toBe('Widgets')
expect(group.customColor).toBeNull()
expect(group.Icon).toBe(FileText)
})
it('overrides built-in type icon/color when type entry has custom values', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Project: { ...baseEntry, title: 'Project', isA: 'Type', icon: 'rocket', color: 'green', sidebarLabel: 'My Projects' },
}
const group = buildSectionGroup('Project', typeEntryMap)
expect(group.label).toBe('My Projects')
expect(group.customColor).toBe('green')
expect(group.Icon).toBe(resolveIcon('rocket'))
})
it('uses gray color for Config type', () => {
const typeEntryMap: Record<string, VaultEntry> = {
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
}
const group = buildSectionGroup('Config', typeEntryMap)
expect(group.customColor).toBe('gray')
})
it('resolves type entry via lowercase key (case-insensitive isA)', () => {
// When instances have isA: 'config' (lowercase) but type entry title is 'Config'
const typeEntryMap: Record<string, VaultEntry> = {
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
}
const group = buildSectionGroup('config', typeEntryMap)
expect(group.label).toBe('Config')
expect(group.customColor).toBe('gray')
expect(group.Icon).toBe(GearSix)
})
})

View File

@@ -1,8 +1,7 @@
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { resolveIcon } from '../utils/iconRegistry'
import { buildTypeEntryMap } from '../utils/typeColors'
import { pluralizeType } from '../hooks/useCommandRegistry'
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
@@ -13,8 +12,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
@@ -41,20 +39,6 @@ interface SidebarProps {
isGitVault?: boolean
}
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
{ label: 'Projects', type: 'Project', Icon: Wrench },
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
{ label: 'People', type: 'Person', Icon: Users },
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
{ label: 'Topics', type: 'Topic', Icon: Tag },
{ label: 'Types', type: 'Type', Icon: StackSimple },
]
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
// --- Hooks ---
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
@@ -68,42 +52,6 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
}, [ref, isOpen, onClose])
}
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
function collectActiveTypes(entries: VaultEntry[]): Set<string> {
const types = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
}
return types
}
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, label, Icon, customColor }
}
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
const activeTypes = collectActiveTypes(entries)
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
}
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return [...groups].sort((a, b) => {
const orderA = typeEntryMap[a.type]?.order ?? Infinity
const orderB = typeEntryMap[b.type]?.order ?? Infinity
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
})
}
function useSidebarSections(entries: VaultEntry[]) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {

View File

@@ -1,7 +1,8 @@
/* eslint-disable react-refresh/only-export-components -- module-level schema, not a component file */
import { BlockNoteSchema, defaultInlineContentSpecs } from '@blocknote/core'
import { createReactInlineContentSpec } from '@blocknote/react'
import { resolveWikilinkColor as resolveColor, findEntryByTarget } from '../utils/wikilinkColors'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { resolveEntry } from '../utils/wikilink'
import type { VaultEntry } from '../types'
// Module-level cache so the WikiLink renderer (defined outside React) can access entries
@@ -16,7 +17,7 @@ function resolveWikilinkColor(target: string) {
function resolveDisplayText(target: string): string {
const pipeIdx = target.indexOf('|')
if (pipeIdx !== -1) return target.slice(pipeIdx + 1)
const entry = findEntryByTarget(_wikilinkEntriesRef.current, target)
const entry = resolveEntry(_wikilinkEntriesRef.current, target)
if (entry) return entry.title
const last = target.split('/').pop() ?? target
return last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())

View File

@@ -1,57 +1,138 @@
import { useMemo, useCallback, useState, useRef } from 'react'
import type { VaultEntry } from '../../types'
import { X } from '@phosphor-icons/react'
import { Plus, X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../../utils/frontmatter'
import { containsWikilinks } from '../DynamicPropertiesPanel'
import type { FrontmatterValue } from '../Inspector'
import { NoteSearchList } from '../NoteSearchList'
import { useNoteSearch } from '../../hooks/useNoteSearch'
import { resolveEntry } from '../../utils/wikilink'
import { isWikilink, resolveRefProps } from './shared'
import { LinkButton } from './LinkButton'
function SearchDropdown({ search, onSelect }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (title: string) => void
/** Check whether any entry resolves for the given title (exact match via wikilink resolution). */
function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean {
return resolveEntry(entries, title) !== undefined
}
function CreateAndOpenOption({ title, selected, onClick, onHover }: {
title: string
selected: boolean
onClick: () => void
onHover: () => void
}) {
return (
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
<NoteSearchList
items={search.results}
selectedIndex={search.selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => onSelect(item.entry.title)}
onItemHover={(i) => search.setSelectedIndex(i)}
className="max-h-[160px] overflow-y-auto"
/>
<div
className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm transition-colors ${selected ? 'bg-accent' : 'hover:bg-secondary'}`}
data-testid="create-and-open-option"
onMouseDown={e => e.preventDefault()}
onClick={onClick}
onMouseEnter={onHover}
>
<Plus size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate text-foreground">
Create &amp; open <strong>{title}</strong>
</span>
</div>
)
}
function InlineAddNote({ entries, onAdd }: {
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (title: string) => void
query: string
entries: VaultEntry[]
onCreateAndOpen?: (title: string) => void
}) {
const trimmed = query.trim()
const showCreate = !!onCreateAndOpen && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
const hasResults = search.results.length > 0
const createIndex = search.results.length
if (!hasResults && !showCreate) return null
return (
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
{hasResults && (
<NoteSearchList
items={search.results}
selectedIndex={search.selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => onSelect(item.entry.title)}
onItemHover={(i) => search.setSelectedIndex(i)}
className="max-h-[160px] overflow-y-auto"
/>
)}
{showCreate && (
<CreateAndOpenOption
title={trimmed}
selected={search.selectedIndex === createIndex}
onClick={() => onCreateAndOpen(trimmed)}
onHover={() => search.setSelectedIndex(createIndex)}
/>
)}
</div>
)
}
function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
entries: VaultEntry[]
onAdd: (noteTitle: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const [active, setActive] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const search = useNoteSearch(entries, query, 8)
const trimmed = query.trim()
const showCreate = !!onCreateAndOpenNote && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
const createIndex = search.results.length
const selectAndClose = useCallback((title: string) => {
onAdd(title)
setQuery('')
setActive(false)
}, [onAdd])
const handleCreateAndOpen = useCallback(async () => {
if (!onCreateAndOpenNote) return
const title = trimmed
if (!title) return
const ok = await onCreateAndOpenNote(title)
if (ok) {
onAdd(title)
setQuery('')
setActive(false)
}
}, [onCreateAndOpenNote, trimmed, onAdd])
const handleConfirm = useCallback(() => {
const title = search.selectedEntry?.title ?? query.trim()
if (showCreate && search.selectedIndex === createIndex) {
handleCreateAndOpen()
return
}
const title = search.selectedEntry?.title ?? trimmed
if (title) selectAndClose(title)
}, [search.selectedEntry, query, selectAndClose])
}, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen])
const totalItems = search.results.length + (showCreate ? 1 : 0)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
search.handleKeyDown(e)
if (e.key === 'Enter') { e.preventDefault(); handleConfirm() }
else if (e.key === 'Escape') { setQuery(''); setActive(false) }
}, [search, handleConfirm])
if (e.key === 'ArrowDown') {
e.preventDefault()
search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
search.setSelectedIndex((i: number) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
handleConfirm()
} else if (e.key === 'Escape') {
setQuery('')
setActive(false)
}
}, [search, totalItems, handleConfirm])
if (!active) {
return (
@@ -66,6 +147,8 @@ function InlineAddNote({ entries, onAdd }: {
)
}
const showDropdown = query.trim().length > 0 && (search.results.length > 0 || showCreate)
return (
<div className="relative mt-1">
<div className="group/add relative flex items-center">
@@ -87,17 +170,31 @@ function InlineAddNote({ entries, onAdd }: {
<X size={12} />
</button>
</div>
{query.trim() && search.results.length > 0 && (
<SearchDropdown search={search} onSelect={selectAndClose} />
{showDropdown && (
<SearchDropdownWithCreate
search={search}
onSelect={selectAndClose}
query={query}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => {
const fn = async () => {
const ok = await onCreateAndOpenNote(title)
if (ok) { onAdd(title); setQuery(''); setActive(false) }
}
fn()
} : undefined}
/>
)}
</div>
)
}
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef }: {
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
onRemoveRef?: (ref: string) => void; onAddRef?: (noteTitle: string) => void
onRemoveRef?: (ref: string) => void
onAddRef?: (noteTitle: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
if (refs.length === 0) return null
return (
@@ -116,7 +213,13 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
)
})}
</div>
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
{onAddRef && (
<InlineAddNote
entries={entries}
onAdd={onAddRef}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)}
</div>
)
}
@@ -133,26 +236,46 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
.filter(({ refs }) => refs.length > 0)
}
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
entries: VaultEntry[]
value: string
onChange: (v: string) => void
onSubmit?: () => void
onCancel?: () => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onSubmitWithCreate?: (title: string) => void
}) {
const [focused, setFocused] = useState(false)
const search = useNoteSearch(entries, value, 8)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
search.handleKeyDown(e)
if (e.key === 'Enter') {
e.preventDefault()
if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) }
else onSubmit?.()
} else if (e.key === 'Escape') { onCancel?.() }
}, [search, onChange, onSubmit, onCancel])
const trimmed = value.trim()
const showCreate = !!onCreateAndOpenNote && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
const createIndex = search.results.length
const totalItems = search.results.length + (showCreate ? 1 : 0)
const showDropdown = focused && value.trim() && search.results.length > 0
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
search.setSelectedIndex((i: number) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
if (showCreate && search.selectedIndex === createIndex) {
onSubmitWithCreate?.(trimmed)
} else if (search.selectedEntry) {
onChange(search.selectedEntry.title)
setFocused(false)
} else {
onSubmit?.()
}
} else if (e.key === 'Escape') {
onCancel?.()
}
}, [search, totalItems, showCreate, createIndex, trimmed, onChange, onSubmit, onCancel, onSubmitWithCreate])
const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate)
return (
<div className="relative">
@@ -167,15 +290,22 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
onKeyDown={handleKeyDown}
/>
{showDropdown && (
<SearchDropdown search={search} onSelect={(title) => { onChange(title); setFocused(false) }} />
<SearchDropdownWithCreate
search={search}
onSelect={(title) => { onChange(title); setFocused(false) }}
query={value}
entries={entries}
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
/>
)}
</div>
)
}
function AddRelationshipForm({ entries, onAddProperty }: {
function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
entries: VaultEntry[]
onAddProperty: (key: string, value: FrontmatterValue) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
@@ -190,6 +320,17 @@ function AddRelationshipForm({ entries, onAddProperty }: {
setRelKey(''); setRelTarget(''); setShowForm(false)
}, [relKey, relTarget, onAddProperty])
const handleCreateAndSubmit = useCallback(async (title: string) => {
if (!onCreateAndOpenNote) return
const key = relKey.trim()
if (!key) return
const ok = await onCreateAndOpenNote(title)
if (ok) {
onAddProperty(key, `[[${title}]]`)
setRelKey(''); setRelTarget(''); setShowForm(false)
}
}, [onCreateAndOpenNote, relKey, onAddProperty])
const resetForm = useCallback(() => {
setShowForm(false); setRelKey(''); setRelTarget('')
}, [])
@@ -212,7 +353,15 @@ function AddRelationshipForm({ entries, onAddProperty }: {
onChange={e => setRelKey(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
/>
<NoteTargetInput entries={entries} value={relTarget} onChange={setRelTarget} onSubmit={submitForm} onCancel={resetForm} />
<NoteTargetInput
entries={entries}
value={relTarget}
onChange={setRelTarget}
onSubmit={submitForm}
onCancel={resetForm}
onCreateAndOpenNote={onCreateAndOpenNote}
onSubmitWithCreate={handleCreateAndSubmit}
/>
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
@@ -234,12 +383,13 @@ function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterVa
return updated.length === 1 ? updated[0] : updated
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: {
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
@@ -268,10 +418,11 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
/>
))}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
}
</div>

View File

@@ -13,8 +13,8 @@ interface EditorSaveConfig {
setTabs: (fn: SetStateAction<any[]>) => void
setToastMessage: (msg: string | null) => void
onAfterSave?: () => void
/** Called after content is persisted — used to clear unsaved state. */
onNotePersisted?: (path: string) => void
/** Called after content is persisted — used to clear unsaved state and live-reload themes. */
onNotePersisted?: (path: string, content: string) => void
}
/**
@@ -41,8 +41,9 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
if (!pending) return false
if (pathFilter && pending.path !== pathFilter) return false
await saveNote(pending.path, pending.content)
const savedContent = pending.content
pendingContentRef.current = null
onNotePersisted?.(pending.path)
onNotePersisted?.(pending.path, savedContent)
return true
}, [saveNote, onNotePersisted])
@@ -53,7 +54,7 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
const saved = await flushPending()
if (!saved && unsavedFallback) {
await saveNote(unsavedFallback.path, unsavedFallback.content)
onNotePersisted?.(unsavedFallback.path)
onNotePersisted?.(unsavedFallback.path, unsavedFallback.content)
setToastMessage('Saved')
onAfterSave()
return

View File

@@ -8,7 +8,7 @@ export function useEditorSaveWithLinks(config: {
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
setToastMessage: (msg: string | null) => void
onAfterSave: () => void
onNotePersisted?: (path: string) => void
onNotePersisted?: (path: string, content: string) => void
}) {
const { updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {

View File

@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
interface Tab {
entry: VaultEntry
@@ -111,7 +112,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
// Convert blocks → markdown, restoring wikilinks first
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
const [frontmatter] = splitFrontmatter(tab.content)

View File

@@ -1,15 +1,30 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { uploadImageFile, useImageDrop } from './useImageDrop'
import { createRef } from 'react'
let tauriMode = false
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
isTauri: () => tauriMode,
}))
type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } }
type DragDropCallback = (event: DragDropEvent) => void
let capturedDragDropHandler: DragDropCallback | null = null
vi.mock('@tauri-apps/api/webview', () => ({
getCurrentWebview: () => ({
onDragDropEvent: vi.fn((cb: DragDropCallback) => {
capturedDragDropHandler = cb
return Promise.resolve(() => { capturedDragDropHandler = null })
}),
}),
}))
// JSDOM lacks DragEvent and File.arrayBuffer — polyfill for tests
@@ -68,10 +83,7 @@ describe('uploadImageFile', () => {
})
it('passes file to Tauri save_image in Tauri mode', async () => {
const mockTauri = await import('../mock-tauri')
const originalIsTauri = mockTauri.isTauri
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(mockTauri as any).isTauri = () => true
tauriMode = true
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-test.png')
@@ -88,8 +100,7 @@ describe('uploadImageFile', () => {
})
expect(url).toBe('asset://localhost/vault/attachments/123-test.png')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(mockTauri as any).isTauri = originalIsTauri
tauriMode = false
})
})
@@ -168,3 +179,75 @@ describe('useImageDrop', () => {
expect(result.current.isDragOver).toBe(false)
})
})
describe('useImageDrop — Tauri native drag-drop', () => {
let container: HTMLDivElement
beforeEach(() => {
tauriMode = true
capturedDragDropHandler = null
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
tauriMode = false
capturedDragDropHandler = null
container.remove()
})
function renderImageDropTauri(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) {
const ref = createRef<HTMLDivElement>()
Object.defineProperty(ref, 'current', { value: container, writable: true })
return renderHook(() => useImageDrop({ containerRef: ref, ...opts }))
}
it('does not set isDragOver on Tauri over event (internal drags are indistinguishable)', async () => {
const { result } = renderImageDropTauri()
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
act(() => {
capturedDragDropHandler!({ payload: { type: 'over', paths: [], position: { x: 100, y: 100 } } })
})
expect(result.current.isDragOver).toBe(false)
})
it('resets isDragOver on Tauri drop event', async () => {
const onImageUrl = vi.fn()
const { result } = renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
// Set isDragOver via HTML5 dragover (simulates real OS file drag)
const file = new File(['data'], 'photo.png', { type: 'image/png' })
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
expect(result.current.isDragOver).toBe(true)
act(() => {
capturedDragDropHandler!({
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
})
})
expect(result.current.isDragOver).toBe(false)
})
it('resets isDragOver on Tauri cancel event', async () => {
const { result } = renderImageDropTauri()
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
// Set isDragOver via HTML5 dragover first
const file = new File(['data'], 'photo.png', { type: 'image/png' })
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
expect(result.current.isDragOver).toBe(true)
act(() => {
capturedDragDropHandler!({ payload: { type: 'cancel', paths: [], position: { x: 0, y: 0 } } })
})
expect(result.current.isDragOver).toBe(false)
})
})

View File

@@ -109,8 +109,9 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr
unlisten = await getCurrentWebview().onDragDropEvent((event) => {
const payload = event.payload
if (payload.type === 'over') {
// Tauri 'over' events don't include paths — show overlay for any drag
setIsDragOver(true)
// 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') {
setIsDragOver(false)
const imagePaths = payload.paths.filter(isImagePath)

View File

@@ -5,6 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import {
slugify,
needsRenameOnSave,
buildNewEntry,
generateUntitledName,
entryMatchesTarget,
@@ -77,13 +78,37 @@ describe('slugify', () => {
expect(slugify('--hello--')).toBe('hello')
})
it('handles empty string', () => {
expect(slugify('')).toBe('')
it('handles empty string with fallback', () => {
expect(slugify('')).toBe('untitled')
})
it('collapses multiple separators into one hyphen', () => {
expect(slugify('hello world---foo')).toBe('hello-world-foo')
})
it('returns fallback for strings with only special characters', () => {
// slugify('+++') should not return empty string — it causes invalid paths
expect(slugify('+++')).not.toBe('')
expect(slugify('!!!')).not.toBe('')
expect(slugify('---')).not.toBe('')
expect(slugify('@#$')).not.toBe('')
})
})
describe('needsRenameOnSave', () => {
it('returns true when filename does not match title slug', () => {
expect(needsRenameOnSave('My New Note', 'untitled-note.md')).toBe(true)
expect(needsRenameOnSave('Run good ads for newsletter', 'untitled-note-9.md')).toBe(true)
})
it('returns false when filename matches title slug', () => {
expect(needsRenameOnSave('My Note', 'my-note.md')).toBe(false)
expect(needsRenameOnSave('Hello World', 'hello-world.md')).toBe(false)
})
it('returns false for untitled note with matching slug', () => {
expect(needsRenameOnSave('Untitled note', 'untitled-note.md')).toBe(false)
})
})
describe('buildNewEntry', () => {
@@ -157,32 +182,37 @@ describe('generateUntitledName', () => {
describe('entryMatchesTarget', () => {
it('matches by exact title (case-insensitive)', () => {
const entry = makeEntry({ title: 'My Project' })
expect(entryMatchesTarget(entry, 'my project', 'my project')).toBe(true)
expect(entryMatchesTarget(entry, 'my project')).toBe(true)
})
it('matches by alias', () => {
const entry = makeEntry({ aliases: ['MP', 'TheProject'] })
expect(entryMatchesTarget(entry, 'mp', 'mp')).toBe(true)
expect(entryMatchesTarget(entry, 'mp')).toBe(true)
})
it('matches by path stem (relative to Laputa)', () => {
it('matches by path suffix (type/slug)', () => {
const entry = makeEntry({ path: '/Users/luca/Laputa/project/my-project.md' })
expect(entryMatchesTarget(entry, 'project/my-project', 'project/my-project')).toBe(true)
expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true)
})
it('matches by filename stem', () => {
const entry = makeEntry({ filename: 'my-project.md' })
expect(entryMatchesTarget(entry, 'my-project', 'my-project')).toBe(true)
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
})
it('matches when target as words matches title', () => {
const entry = makeEntry({ title: 'my project' })
expect(entryMatchesTarget(entry, 'project/my-project', 'my project')).toBe(true)
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
})
it('returns false when nothing matches', () => {
const entry = makeEntry({ title: 'Something Else', aliases: [], filename: 'else.md' })
expect(entryMatchesTarget(entry, 'nonexistent', 'nonexistent')).toBe(false)
expect(entryMatchesTarget(entry, 'nonexistent')).toBe(false)
})
it('handles pipe syntax targets', () => {
const entry = makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha' })
expect(entryMatchesTarget(entry, 'project/alpha|Alpha Project')).toBe(true)
})
})
@@ -270,6 +300,20 @@ describe('resolveNewNote', () => {
expect(entry.path).toBe('/other/vault/note/test.md')
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
it('produces a valid path for custom types with special characters', () => {
const { entry } = resolveNewNote('My Note', 'Q&A', '/vault')
expect(entry.path).not.toContain('//')
expect(entry.path).toMatch(/\.md$/)
expect(entry.filename).not.toBe('.md')
})
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$/)
})
})
describe('resolveNewType', () => {
@@ -612,6 +656,34 @@ describe('useNoteActions hook', () => {
expect(tabContent).toContain('## Steps')
})
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('Q&A')
})
}).not.toThrow()
const [entry] = addEntry.mock.calls[0]
expect(entry.isA).toBe('Q&A')
expect(entry.path).not.toContain('//')
})
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('+++')
})
}).not.toThrow()
const [entry] = addEntry.mock.calls[0]
expect(entry.path).not.toContain('//')
expect(entry.filename).not.toBe('.md')
})
it('handleCreateNoteImmediate uses template for typed notes', () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
@@ -938,6 +1010,42 @@ describe('useNoteActions hook', () => {
}))
})
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('')
@@ -951,4 +1059,113 @@ describe('useNoteActions hook', () => {
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
})
})
describe('rename note updates wikilinks', () => {
it('handleRenameNote passes entry title as old_title to rename_note', async () => {
const entry = makeEntry({
path: '/test/vault/note/weekly-review.md',
filename: 'weekly-review.md',
title: 'Weekly Review',
})
const replaceEntry = vi.fn()
const config = makeConfig([entry])
config.replaceEntry = replaceEntry
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'rename_note') return { new_path: '/test/vault/note/sprint-retro.md', updated_files: 2 }
if (cmd === 'get_note_content') return '---\nIs A: Note\n---\n# Sprint Retro\n'
return ''
})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleRenameNote(
'/test/vault/note/weekly-review.md',
'Sprint Retro',
'/test/vault',
replaceEntry,
)
})
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
vault_path: '/test/vault',
old_path: '/test/vault/note/weekly-review.md',
new_title: 'Sprint Retro',
old_title: 'Weekly Review',
}))
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
})
it('handleRenameNote passes null old_title when entry not found', async () => {
const config = makeConfig([])
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'rename_note') return { new_path: '/test/vault/note/new.md', updated_files: 0 }
if (cmd === 'get_note_content') return '# New\n'
return ''
})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleRenameNote(
'/test/vault/note/old.md', 'New', '/test/vault', vi.fn(),
)
})
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
old_title: null,
}))
})
it('handleUpdateFrontmatter triggers rename when title key is changed', async () => {
const entry = makeEntry({
path: '/test/vault/note/old-name.md',
filename: 'old-name.md',
title: 'Old Name',
})
const replaceEntry = vi.fn()
const config = makeConfig([entry])
config.replaceEntry = replaceEntry
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'rename_note') return { new_path: '/test/vault/note/new-name.md', updated_files: 1 }
if (cmd === 'get_note_content') return '---\ntitle: New Name\n---\n# New Name\n'
return ''
})
const { result } = renderHook(() => useNoteActions(config))
// Open a tab for the entry so the rename can find it via tabsRef
await act(async () => { result.current.handleSelectNote(entry) })
await act(async () => {
await result.current.handleUpdateFrontmatter('/test/vault/note/old-name.md', 'title', 'New Name')
})
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
old_path: '/test/vault/note/old-name.md',
new_title: 'New Name',
old_title: 'Old Name',
}))
expect(replaceEntry).toHaveBeenCalledWith(
'/test/vault/note/old-name.md',
expect.objectContaining({ path: '/test/vault/note/new-name.md', title: 'New Name' }),
)
})
it('handleUpdateFrontmatter does not trigger rename for non-title keys', 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')
})
expect(mockInvoke).not.toHaveBeenCalledWith('rename_note', expect.anything())
})
})
})

View File

@@ -5,6 +5,7 @@ import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { useTabManagement } from './useTabManagement'
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
import { resolveEntry } from '../utils/wikilink'
interface NewEntryParams {
path: string
@@ -60,15 +61,21 @@ function isTypeKey(key: string): boolean {
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'
}
async function performRename(
path: string,
newTitle: string,
vaultPath: string,
oldTitle?: string,
): Promise<RenameResult> {
if (isTauri()) {
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle })
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null })
}
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle })
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null })
}
function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
@@ -100,7 +107,13 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
}
export function slugify(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const result = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return result || 'untitled'
}
/** Check if a note's filename doesn't match the slug of its current title. */
export function needsRenameOnSave(title: string, filename: string): boolean {
return `${slugify(title)}.md` !== filename
}
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
@@ -117,14 +130,8 @@ export function generateUntitledName(entries: VaultEntry[], type: string, pendin
return title
}
export function entryMatchesTarget(e: VaultEntry, targetLower: string, targetAsWords: string): boolean {
if (e.title.toLowerCase() === targetLower) return true
if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true
const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (pathStem.toLowerCase() === targetLower) return true
const fileStem = e.filename.replace(/\.md$/, '')
if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true
return e.title.toLowerCase() === targetAsWords
export function entryMatchesTarget(e: VaultEntry, target: string): boolean {
return resolveEntry([e], target) === e
}
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
@@ -256,9 +263,7 @@ function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => voi
}
function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
const targetLower = target.toLowerCase()
const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower
return entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords))
return resolveEntry(entries, target)
}
/** Navigate to a wikilink target, logging a warning if not found. */
@@ -392,18 +397,42 @@ export function useNoteActions(config: NoteActionsConfig) {
}, [entries, persistNew, config.vaultPath])
const handleCreateNoteImmediate = useCallback((type?: string) => {
const noteType = type || 'Note'
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
const template = resolveTemplate(entries, noteType)
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
try {
const noteType = type || 'Note'
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
const template = resolveTemplate(entries, noteType)
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
config.trackUnsaved?.(resolved.entry.path)
config.markContentPending?.(resolved.entry.path, resolved.content)
signalFocusEditor({ selectTitle: true })
setTimeout(() => pendingNamesRef.current.delete(title), 500)
} catch (err) {
console.error('Failed to create note:', err)
setToastMessage('Failed to create note')
}
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
/** Create a note with the given title, open it in a tab, and persist to disk.
* 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)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
config.trackUnsaved?.(resolved.entry.path)
config.markContentPending?.(resolved.entry.path, resolved.content)
signalFocusEditor({ selectTitle: true })
setTimeout(() => pendingNamesRef.current.delete(title), 500)
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
try {
await persistNewNote(resolved.entry.path, resolved.content)
config.onNewNotePersisted?.()
return true
} catch {
handleCloseTab(resolved.entry.path)
removeEntry(resolved.entry.path)
setToastMessage('Failed to create note — disk write error')
return false
}
}, [entries, openTabWithContent, addEntry, handleCloseTab, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
/** Close tab and discard entry+unsaved state if the note was never persisted. */
const handleCloseTabWithCleanup = useCallback((path: string) => {
@@ -439,9 +468,9 @@ export function useNoteActions(config: NoteActionsConfig) {
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
) => {
try {
const result = await performRename(path, newTitle, vaultPath)
const newContent = await loadNoteContent(result.new_path)
const entry = entries.find((e) => e.path === path)
const result = await performRename(path, newTitle, vaultPath, entry?.title)
const newContent = await loadNoteContent(result.new_path)
const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path)
const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path).map(t => t.entry.path)
setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t))
@@ -461,25 +490,49 @@ export function useNoteActions(config: NoteActionsConfig) {
handleNavigateWikilink,
handleCreateNote,
handleCreateNoteImmediate,
handleCreateNoteForRelationship,
handleOpenDailyNote,
handleCreateType,
createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
await runFrontmatterOp('update', path, key, value)
if (isTitleKey(key) && typeof value === 'string' && value !== '') {
try {
const oldTitle = tabsRef.current.find(t => t.entry.path === path)?.entry.title
const result = await performRename(path, value, config.vaultPath, oldTitle)
if (result.new_path !== path) {
const newFilename = result.new_path.split('/').pop() ?? ''
config.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: value } as Partial<VaultEntry> & { path: string })
const newContent = await loadNoteContent(result.new_path)
setTabs(prev => prev.map(t => t.entry.path === path
? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: value }, content: newContent }
: t))
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path)
await reloadTabsAfterRename(otherTabPaths, updateTabContent)
}
setToastMessage(renameToastMessage(result.updated_files))
} catch (err) {
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 entry = entries.find(e => e.path === path)
if (entry) {
const newFilename = result.new_path.split('/').pop() ?? entry.filename
const newContent = await loadNoteContent(result.new_path)
config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename })
setTabs(prev => prev.map(t => t.entry.path === path
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: newContent }
: t))
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
}
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}/`)
}
@@ -487,7 +540,7 @@ export function useNoteActions(config: NoteActionsConfig) {
console.error('Failed to move note to type folder:', err)
}
}
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]),
}, [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]),
handleRenameNote,

View File

@@ -441,6 +441,55 @@ describe('useThemeManager', () => {
expect(result.current.isDark).toBe(false)
})
it('notifyThemeSaved updates CSS vars when active theme is saved', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
const updatedContent = `---
type: Theme
Description: Light theme
background: "#1a1a2e"
foreground: "#e0e0e0"
primary: "#155DFF"
sidebar: "#2a2a3e"
text-primary: "#e0e0e0"
---
# Default Theme
`
act(() => {
result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent)
})
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#1a1a2e')
})
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#2a2a3e')
})
it('notifyThemeSaved is a no-op for non-active theme path', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
act(() => {
result.current.notifyThemeSaved(THEME_PATH_DARK, DARK_THEME_CONTENT)
})
// Background should still be the default theme's white, not dark
await new Promise(r => setTimeout(r, 50))
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
})
it('calls ensure_vault_themes on mount with vaultPath', async () => {
renderHook(() => useThemeManager('/vault', entries, allContent))
await waitFor(() => {

View File

@@ -124,6 +124,8 @@ export interface ThemeManager {
reloadThemes: () => Promise<void>
/** Update a single frontmatter property on the active theme note. */
updateThemeProperty: (key: string, value: string) => Promise<void>
/** Notify that the active theme note was saved with new content (live-reload on Cmd+S). */
notifyThemeSaved: (path: string, content: string) => void
}
/** Manages loading and persisting the active theme path from vault settings. */
@@ -275,6 +277,10 @@ export function useThemeManager(
const reloadThemes = useCallback(async () => { await reload() }, [reload])
const notifyThemeSaved = useCallback((path: string, content: string) => {
if (path === activeThemeId) setCachedThemeContent(content)
}, [activeThemeId])
const updateThemeProperty = useCallback(async (key: string, value: string) => {
if (!activeThemeId) return
try {
@@ -290,6 +296,6 @@ export function useThemeManager(
return {
themes, activeThemeId, activeTheme,
activeThemeContent: cachedThemeContent,
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty,
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty, notifyThemeSaved,
}
}

View File

@@ -82,6 +82,8 @@
--accent-teal-light: rgba(49, 151, 149, 0.1);
--accent-pink: #D53F8C;
--accent-pink-light: rgba(213, 63, 140, 0.1);
--accent-gray: #718096;
--accent-gray-light: rgba(113, 128, 150, 0.1);
--border-primary: #E9E9E7;
--border-subtle: #E9E9E7;
--border-input: #E9E9E7;

View File

@@ -826,6 +826,34 @@ export const MOCK_ENTRIES: VaultEntry[] = [
properties: {},
},
// --- Custom type documents ---
{
path: '/Users/luca/Laputa/type/config.md',
filename: 'config.md',
title: 'Config',
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 365,
fileSize: 300,
snippet: 'Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.',
wordCount: 25,
relationships: {},
icon: 'gear-six',
color: 'gray',
order: 90,
sidebarLabel: 'Config',
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/recipe.md',
filename: 'recipe.md',
@@ -883,6 +911,36 @@ export const MOCK_ENTRIES: VaultEntry[] = [
properties: {},
},
// --- Instances of custom types ---
{
path: '/Users/luca/Laputa/config/agents.md',
filename: 'agents.md',
title: 'Agent Instructions',
isA: 'Config',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 5,
createdAt: now - 86400 * 30,
fileSize: 1200,
snippet: 'Vault instructions for AI agents. Defines how tools and integrations interact with this vault.',
wordCount: 200,
relationships: {
'Type': ['[[type/config]]'],
},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/recipe/pasta-carbonara.md',
filename: 'pasta-carbonara.md',

View File

@@ -114,7 +114,13 @@ const mockThemes: ThemeFile[] = [
let mockDeviceFlowPollCount = 0
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string }) {
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldTitle = args.old_title ?? oldEntry?.title ?? ''
if (oldTitle === args.new_title) {
return { new_path: args.old_path, updated_files: 0 }
}
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
@@ -123,9 +129,6 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl
delete MOCK_CONTENT[args.old_path]
MOCK_CONTENT[newPath] = newContent
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldTitle = oldEntry?.title ?? ''
let updatedFiles = 0
if (oldTitle) {
const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g')
@@ -227,7 +230,16 @@ export const mockHandlers: Record<string, (args: any) => any> = {
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(/\/$/, '')
const newPath = `${vaultPath}/${slug}/${filename}`
// 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

View File

@@ -1,7 +1,7 @@
/**
* Vault API detection and proxy for browser dev mode.
* When a local vault API server is running, routes read commands through it
* instead of returning hardcoded mock data.
* When a local vault API server is running, routes read and write commands
* through it instead of returning hardcoded mock data.
*/
let vaultApiAvailable: boolean | null = null
@@ -18,25 +18,60 @@ async function checkVaultApi(): Promise<boolean> {
return vaultApiAvailable
}
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => string | null> = {
list_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}` : null,
reload_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` : null,
get_note_content: (args) => args.path ? `/api/vault/content?path=${encodeURIComponent(args.path as string)}` : null,
get_all_content: (args) => args.path ? `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` : null,
interface VaultApiRequest {
url: string
method?: string
body?: unknown
}
/** Tracks last vault path for commands that don't receive it as an argument. */
let lastVaultPath: string | null = null
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => VaultApiRequest | null> = {
list_vault: (args) => {
if (args.path) lastVaultPath = args.path as string
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}` } : null
},
reload_vault: (args) => {
if (args.path) lastVaultPath = args.path as string
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` } : null
},
reload_vault_entry: (args) =>
args.path ? { url: `/api/vault/entry?path=${encodeURIComponent(args.path as string)}` } : null,
get_note_content: (args) =>
args.path ? { url: `/api/vault/content?path=${encodeURIComponent(args.path as string)}` } : null,
get_all_content: (args) =>
args.path ? { url: `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` } : null,
save_note_content: (args) =>
args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null,
rename_note: (args) =>
args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null,
delete_note: (args) =>
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
search_vault: (args) => {
const q = args.query as string
if (!q || !lastVaultPath) return null
return { url: `/api/vault/search?vault_path=${encodeURIComponent(lastVaultPath)}&query=${encodeURIComponent(q)}&mode=${encodeURIComponent((args.mode as string) || 'all')}` }
},
}
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const available = await checkVaultApi()
if (!available) return undefined
const urlBuilder = VAULT_API_COMMANDS[cmd]
if (!urlBuilder || !args) return undefined
const requestBuilder = VAULT_API_COMMANDS[cmd]
if (!requestBuilder || !args) return undefined
const url = urlBuilder(args)
if (!url) return undefined
const request = requestBuilder(args)
if (!request) return undefined
try {
const res = await fetch(url)
const fetchOpts: RequestInit = { method: request.method || 'GET' }
if (request.body) {
fetchOpts.headers = { 'Content-Type': 'application/json' }
fetchOpts.body = JSON.stringify(request.body)
}
const res = await fetch(request.url, fetchOpts)
if (!res.ok) return undefined
const data = await res.json()
return (cmd === 'get_note_content' ? data.content : data) as T

View File

@@ -4,7 +4,7 @@
*/
import type { VaultEntry } from '../types'
import { wikilinkTarget } from './wikilink'
import { wikilinkTarget, resolveEntry } from './wikilink'
import { splitFrontmatter } from './wikilinks'
/** Extract only the body text from raw file content (strips YAML frontmatter). */
@@ -13,16 +13,10 @@ function extractBody(rawContent: string): string {
return body.trim()
}
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. */
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem.
* Delegates to the unified resolveEntry for consistent matching. */
export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined {
const lower = target.toLowerCase()
return entries.find(e => {
if (e.title.toLowerCase() === lower) return true
if (e.aliases.some(a => a.toLowerCase() === lower)) return true
const stem = e.filename.replace(/\.md$/, '')
if (stem.toLowerCase() === lower) return true
return false
})
return resolveEntry(entries, target)
}
/** Collect first-degree linked notes from the active entry. */

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import { compactMarkdown } from './compact-markdown'
describe('compactMarkdown', () => {
it('collapses blank lines between bullet list items (tight list)', () => {
const input = '* Item one\n\n* Item two\n\n* Item three\n'
expect(compactMarkdown(input)).toBe('* Item one\n* Item two\n* Item three\n')
})
it('collapses blank lines between dash list items', () => {
const input = '- Item one\n\n- Item two\n\n- Item three\n'
expect(compactMarkdown(input)).toBe('- Item one\n- Item two\n- Item three\n')
})
it('collapses blank lines between numbered list items', () => {
const input = '1. First\n\n2. Second\n\n3. Third\n'
expect(compactMarkdown(input)).toBe('1. First\n2. Second\n3. Third\n')
})
it('removes extra blank line after heading', () => {
const input = '## Personal\n\n* Back on track\n\n* Good health\n'
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track\n* Good health\n')
})
it('preserves single blank line between heading and content', () => {
const input = '## Title\n\nSome paragraph text.\n'
expect(compactMarkdown(input)).toBe('## Title\n\nSome paragraph text.\n')
})
it('collapses multiple blank lines after heading to one', () => {
const input = '## Title\n\n\n\nSome paragraph text.\n'
expect(compactMarkdown(input)).toBe('## Title\n\nSome paragraph text.\n')
})
it('preserves blank lines between paragraphs', () => {
const input = 'First paragraph.\n\nSecond paragraph.\n'
expect(compactMarkdown(input)).toBe('First paragraph.\n\nSecond paragraph.\n')
})
it('preserves blank lines inside fenced code blocks', () => {
const input = '```\nline one\n\nline two\n\nline three\n```\n'
expect(compactMarkdown(input)).toBe('```\nline one\n\nline two\n\nline three\n```\n')
})
it('preserves blank lines inside fenced code blocks with language', () => {
const input = '```js\nconst a = 1\n\nconst b = 2\n```\n'
expect(compactMarkdown(input)).toBe('```js\nconst a = 1\n\nconst b = 2\n```\n')
})
it('does not add trailing blank lines', () => {
const input = '## Title\n\nText.\n\n\n'
expect(compactMarkdown(input)).toBe('## Title\n\nText.\n')
})
it('handles the exact bug scenario from the issue', () => {
const input = '## Personal\n\n* Back on track with Flavia\n\n* Good health vitals in place\n\n'
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track with Flavia\n* Good health vitals in place\n')
})
it('handles heading followed immediately by list (no blank line)', () => {
const input = '## Title\n* Item one\n\n* Item two\n'
expect(compactMarkdown(input)).toBe('## Title\n* Item one\n* Item two\n')
})
it('handles mixed content: heading, paragraph, list', () => {
const input = '# Title\n\nSome intro.\n\n* Item one\n\n* Item two\n\nConclusion.\n'
expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n* Item one\n* Item two\n\nConclusion.\n')
})
it('preserves empty input', () => {
expect(compactMarkdown('')).toBe('')
})
it('preserves single line', () => {
expect(compactMarkdown('Hello\n')).toBe('Hello\n')
})
it('collapses 3+ consecutive blank lines to one blank line', () => {
const input = 'Paragraph one.\n\n\n\nParagraph two.\n'
expect(compactMarkdown(input)).toBe('Paragraph one.\n\nParagraph two.\n')
})
it('handles list after code block', () => {
const input = '```\ncode\n```\n\n* Item one\n\n* Item two\n'
expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n* Item one\n* Item two\n')
})
it('handles nested list items', () => {
const input = '* Parent\n\n * Child one\n\n * Child two\n'
expect(compactMarkdown(input)).toBe('* Parent\n * Child one\n * Child two\n')
})
it('handles checklist items', () => {
const input = '* [ ] Todo one\n\n* [ ] Todo two\n\n* [x] Done\n'
expect(compactMarkdown(input)).toBe('* [ ] Todo one\n* [ ] Todo two\n* [x] Done\n')
})
it('handles blockquotes normally', () => {
const input = '> Quote line one\n\n> Quote line two\n'
expect(compactMarkdown(input)).toBe('> Quote line one\n\n> Quote line two\n')
})
})

View File

@@ -0,0 +1,82 @@
/**
* Post-process BlockNote's blocksToMarkdownLossy output to produce
* standard-convention Markdown:
* - Tight lists (no blank lines between consecutive list items)
* - No runs of 3+ blank lines (collapsed to one blank line)
* - No trailing blank lines
* - Code block content is never modified
*/
export function compactMarkdown(md: string): string {
if (!md) return md
const lines = md.split('\n')
const result: string[] = []
let inCodeBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Track fenced code blocks — never modify content inside them
if (line.trimStart().startsWith('```')) {
inCodeBlock = !inCodeBlock
result.push(line)
continue
}
if (inCodeBlock) {
result.push(line)
continue
}
// Skip blank lines that sit between two list items (tight list rule)
if (line.trim() === '') {
if (isBlankBetweenListItems(lines, i)) continue
// Collapse runs of 3+ blank lines to a single blank line
if (isExcessiveBlankLine(lines, i)) continue
result.push(line)
continue
}
result.push(line)
}
// Trim trailing blank lines, keep exactly one trailing newline
while (result.length > 0 && result[result.length - 1].trim() === '') {
result.pop()
}
if (result.length > 0) result.push('')
return result.join('\n')
}
const LIST_RE = /^(\s*)([-*+]|\d+\.)\s/
/** True if this blank line sits between two list items (including nested) */
function isBlankBetweenListItems(lines: string[], idx: number): boolean {
const prev = findPrevNonBlank(lines, idx)
const next = findNextNonBlank(lines, idx)
if (prev === null || next === null) return false
return LIST_RE.test(lines[prev]) && LIST_RE.test(lines[next])
}
/** True if this blank line is part of a run of 2+ consecutive blank lines
* (i.e. would create 3+ newlines in a row — collapse to just one blank line) */
function isExcessiveBlankLine(lines: string[], idx: number): boolean {
// Keep the first blank line in a run, skip subsequent ones
if (idx > 0 && lines[idx - 1].trim() === '') return true
return false
}
function findPrevNonBlank(lines: string[], idx: number): number | null {
for (let i = idx - 1; i >= 0; i--) {
if (lines[i].trim() !== '') return i
}
return null
}
function findNextNonBlank(lines: string[], idx: number): number | null {
for (let i = idx + 1; i < lines.length; i++) {
if (lines[i].trim() !== '') return i
}
return null
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { parseFrontmatter } from './frontmatter'
describe('parseFrontmatter', () => {
describe('boolean-like Yes/No values', () => {
it('parses Archived: Yes as true', () => {
const fm = parseFrontmatter('---\nArchived: Yes\n---\nBody')
expect(fm['Archived']).toBe(true)
})
it('parses Archived: No as false', () => {
const fm = parseFrontmatter('---\nArchived: No\n---\nBody')
expect(fm['Archived']).toBe(false)
})
it('parses Trashed: Yes as true', () => {
const fm = parseFrontmatter('---\nTrashed: Yes\n---\nBody')
expect(fm['Trashed']).toBe(true)
})
it('parses Trashed: No as false', () => {
const fm = parseFrontmatter('---\nTrashed: No\n---\nBody')
expect(fm['Trashed']).toBe(false)
})
it('parses yes (lowercase) as true', () => {
const fm = parseFrontmatter('---\nArchived: yes\n---\nBody')
expect(fm['Archived']).toBe(true)
})
it('parses no (lowercase) as false', () => {
const fm = parseFrontmatter('---\nArchived: no\n---\nBody')
expect(fm['Archived']).toBe(false)
})
it('still parses true as true', () => {
const fm = parseFrontmatter('---\nArchived: true\n---\nBody')
expect(fm['Archived']).toBe(true)
})
it('still parses false as false', () => {
const fm = parseFrontmatter('---\nArchived: false\n---\nBody')
expect(fm['Archived']).toBe(false)
})
})
})

View File

@@ -23,8 +23,9 @@ function parseInlineArray(value: string): FrontmatterValue {
function parseScalar(value: string): FrontmatterValue {
const clean = unquote(value)
if (clean.toLowerCase() === 'true') return true
if (clean.toLowerCase() === 'false') return false
const lower = clean.toLowerCase()
if (lower === 'true' || lower === 'yes') return true
if (lower === 'false' || lower === 'no') return false
return clean
}

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { resolveIcon, ICON_OPTIONS } from './iconRegistry'
import { FileText, GearSix, CookingPot } from '@phosphor-icons/react'
describe('resolveIcon', () => {
it('returns FileText for null', () => {
expect(resolveIcon(null)).toBe(FileText)
})
it('returns FileText for unknown icon name', () => {
expect(resolveIcon('nonexistent-icon')).toBe(FileText)
})
it('resolves gear-six to GearSix', () => {
expect(resolveIcon('gear-six')).toBe(GearSix)
})
it('resolves cooking-pot to CookingPot', () => {
expect(resolveIcon('cooking-pot')).toBe(CookingPot)
})
})
describe('ICON_OPTIONS', () => {
it('includes gear-six', () => {
expect(ICON_OPTIONS.some((o) => o.name === 'gear-six')).toBe(true)
})
})

View File

@@ -13,7 +13,7 @@ import {
Door, Drop, Envelope, Eye, Eyeglasses, Factory, Fan, Farm, Feather, File,
FileCode, FileText, FilmReel, Fingerprint, Fire, FirstAid, Fish, Flag, Flame, Flashlight,
Flask, Flower, FlowerLotus, Folder, FolderOpen, Football, ForkKnife, Function, Funnel, GameController,
Gauge, Gavel, Gear, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
Gauge, Gavel, Gear, GearSix, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
Hand, Handshake, Headphones, Headset, Heart, Heartbeat, Horse, Hospital, Hourglass, House,
IceCream, IdentificationBadge, Image, Infinity as InfinityIcon, Island, Joystick, Kanban, Key, Keyboard, Knife,
Ladder, Lamp, Laptop, Leaf, Lifebuoy, Lightbulb, Lighthouse, Lightning, Link, List,
@@ -174,6 +174,7 @@ export const ICON_OPTIONS: IconEntry[] = [
{ name: 'gauge', Icon: Gauge },
{ name: 'gavel', Icon: Gavel },
{ name: 'gear', Icon: Gear },
{ name: 'gear-six', Icon: GearSix },
{ name: 'ghost', Icon: Ghost },
{ name: 'gift', Icon: Gift },
{ name: 'git-branch', Icon: GitBranch },

View File

@@ -0,0 +1,65 @@
/**
* Pure functions for building sidebar section groups from vault entries.
* Extracted from Sidebar.tsx for testability.
*/
import type { VaultEntry } from '../types'
import type { SectionGroup } from '../components/SidebarParts'
import { resolveIcon } from './iconRegistry'
import { pluralizeType } from '../hooks/useCommandRegistry'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, StackSimple,
} from '@phosphor-icons/react'
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
{ label: 'Projects', type: 'Project', Icon: Wrench },
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
{ label: 'People', type: 'Person', Icon: Users },
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
{ label: 'Topics', type: 'Topic', Icon: Tag },
{ label: 'Types', type: 'Type', Icon: StackSimple },
]
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
export function collectActiveTypes(entries: VaultEntry[]): Set<string> {
const types = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
}
return types
}
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
export function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, label, Icon, customColor }
}
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
const activeTypes = collectActiveTypes(entries)
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
}
export function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return [...groups].sort((a, b) => {
const orderA = typeEntryMap[a.type]?.order ?? Infinity
const orderB = typeEntryMap[b.type]?.order ?? Infinity
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
})
}
export { BUILT_IN_SECTION_GROUPS }

View File

@@ -42,6 +42,34 @@ describe('attachClickHandlers', () => {
)
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/x.md' })
})
it('uses path|title target when candidates have duplicate titles', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Status Update', aliases: [], group: 'Project', entryTitle: 'Status Update', path: '/vault/project/status-update.md' },
{ title: 'Status Update', aliases: [], group: 'Journal', entryTitle: 'Status Update', path: '/vault/journal/status-update.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('project/status-update|Status Update')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('journal/status-update|Status Update')
})
it('uses title-only target when titles are unique', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Alpha', aliases: [], group: 'Note', entryTitle: 'Alpha', path: '/vault/note/alpha.md' },
{ title: 'Beta', aliases: [], group: 'Note', entryTitle: 'Beta', path: '/vault/note/beta.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Alpha')
})
})
describe('enrichSuggestionItems', () => {

View File

@@ -16,14 +16,31 @@ interface BaseSuggestionItem {
path: string
}
/** Add onItemClick to raw suggestion candidates */
/** Build a vault-relative path target with pipe display: "type/slug|Title" */
function buildPathTarget(item: BaseSuggestionItem): string {
const parts = item.path.split('/')
const vaultRelPath = parts.slice(-2).join('/').replace(/\.md$/, '')
return `${vaultRelPath}|${item.entryTitle}`
}
/** Add onItemClick to raw suggestion candidates.
* When multiple candidates share the same title, inserts a path-based
* target with pipe syntax so the wikilink uniquely identifies the note. */
export function attachClickHandlers(
candidates: BaseSuggestionItem[],
insertWikilink: (target: string) => void,
) {
const titleCounts = new Map<string, number>()
for (const item of candidates) {
titleCounts.set(item.entryTitle, (titleCounts.get(item.entryTitle) ?? 0) + 1)
}
return candidates.map(item => ({
...item,
onItemClick: () => insertWikilink(item.entryTitle),
onItemClick: () => {
const isDuplicate = (titleCounts.get(item.entryTitle) ?? 0) > 1
insertWikilink(isDuplicate ? buildPathTarget(item) : item.entryTitle)
},
}))
}

View File

@@ -28,6 +28,10 @@ describe('getTypeColor', () => {
it('ignores invalid custom color key', () => {
expect(getTypeColor('Project', 'invalid')).toBe('var(--accent-red)')
})
it('uses gray custom color key', () => {
expect(getTypeColor('Config', 'gray')).toBe('var(--accent-gray)')
})
})
describe('getTypeLightColor', () => {
@@ -47,6 +51,10 @@ describe('getTypeLightColor', () => {
it('uses custom color key for light variant', () => {
expect(getTypeLightColor('Recipe', 'purple')).toBe('var(--accent-purple-light)')
})
it('uses gray custom color key for light variant', () => {
expect(getTypeLightColor('Config', 'gray')).toBe('var(--accent-gray-light)')
})
})
const baseEntry: VaultEntry = {
@@ -54,20 +62,22 @@ const baseEntry: VaultEntry = {
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
wordCount: 0,
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
view: null, visible: null, outgoingLinks: [], properties: {},
}
describe('buildTypeEntryMap', () => {
it('indexes Type entries by title', () => {
it('indexes Type entries by title and lowercase', () => {
const entries: VaultEntry[] = [
{ ...baseEntry, title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' },
{ ...baseEntry, title: 'My Note', isA: 'Note' },
{ ...baseEntry, title: 'Evergreen', isA: 'Type', color: 'green', icon: 'leaf' },
]
const map = buildTypeEntryMap(entries)
expect(Object.keys(map)).toEqual(['Recipe', 'Evergreen'])
expect(map['Recipe'].color).toBe('orange')
expect(map['recipe'].color).toBe('orange')
expect(map['Evergreen'].icon).toBe('leaf')
expect(map['evergreen'].icon).toBe('leaf')
})
it('returns empty map when no Type entries exist', () => {
@@ -76,4 +86,15 @@ describe('buildTypeEntryMap', () => {
]
expect(buildTypeEntryMap(entries)).toEqual({})
})
it('preserves sidebarLabel in type entry via exact and lowercase keys', () => {
const entries: VaultEntry[] = [
{ ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
]
const map = buildTypeEntryMap(entries)
expect(map['Config'].sidebarLabel).toBe('Config')
expect(map['config'].sidebarLabel).toBe('Config')
expect(map['config'].icon).toBe('gear-six')
expect(map['config'].color).toBe('gray')
})
})

View File

@@ -5,10 +5,18 @@
import type { VaultEntry } from '../types'
/** Builds a map from type name → Type document entry (for custom color/icon lookup) */
/** Builds a map from type name → Type document entry (for custom color/icon lookup).
* Stores both original title and lowercase version so lookups work regardless
* of whether instances use `isA: Config` or `isA: config`. */
export function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
const map: Record<string, VaultEntry> = {}
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
for (const e of entries) {
if (e.isA === 'Type') {
map[e.title] = e
const lower = e.title.toLowerCase()
if (lower !== e.title) map[lower] = e
}
}
return map
}
@@ -47,6 +55,7 @@ export const ACCENT_COLORS: { key: string; label: string; css: string; cssLight:
{ key: 'purple', label: 'Purple', css: 'var(--accent-purple)', cssLight: 'var(--accent-purple-light)' },
{ key: 'teal', label: 'Teal', css: 'var(--accent-teal)', cssLight: 'var(--accent-teal-light)' },
{ key: 'pink', label: 'Pink', css: 'var(--accent-pink)', cssLight: 'var(--accent-pink-light)' },
{ key: 'gray', label: 'Gray', css: 'var(--accent-gray)', cssLight: 'var(--accent-gray-light)' },
]
const COLOR_KEY_TO_CSS: Record<string, string> = Object.fromEntries(

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest'
import type { VaultEntry } from '../types'
import { wikilinkTarget, wikilinkDisplay, resolveEntry } from './wikilink'
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
return {
path: '/vault/note.md', filename: 'note.md', title: 'Note', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, template: null,
sort: null, outgoingLinks: [], sidebarLabel: null, view: null, visible: null,
properties: {},
...overrides,
}
}
describe('wikilinkTarget', () => {
it('strips brackets', () => {
expect(wikilinkTarget('[[foo]]')).toBe('foo')
})
it('returns target before pipe', () => {
expect(wikilinkTarget('[[path|display]]')).toBe('path')
})
it('handles bare text without brackets', () => {
expect(wikilinkTarget('just text')).toBe('just text')
})
})
describe('wikilinkDisplay', () => {
it('returns text after pipe', () => {
expect(wikilinkDisplay('[[path|My Title]]')).toBe('My Title')
})
it('humanises slug when no pipe', () => {
expect(wikilinkDisplay('[[my-note]]')).toBe('My Note')
})
})
describe('resolveEntry', () => {
const alice = makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice', isA: 'Person', aliases: ['Alice Smith'] })
const bob = makeEntry({ path: '/vault/person/bob.md', filename: 'bob.md', title: 'Bob', isA: 'Person' })
const project = makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', isA: 'Project' })
const entries = [alice, bob, project]
it('matches by title (case-insensitive)', () => {
expect(resolveEntry(entries, 'alice')).toBe(alice)
expect(resolveEntry(entries, 'ALICE')).toBe(alice)
expect(resolveEntry(entries, 'Alice')).toBe(alice)
})
it('matches by alias (case-insensitive)', () => {
expect(resolveEntry(entries, 'alice smith')).toBe(alice)
expect(resolveEntry(entries, 'Alice Smith')).toBe(alice)
})
it('matches by filename stem (case-insensitive)', () => {
expect(resolveEntry(entries, 'my-project')).toBe(project)
expect(resolveEntry(entries, 'My-Project')).toBe(project)
})
it('matches by relative path suffix (type/slug)', () => {
expect(resolveEntry(entries, 'person/alice')).toBe(alice)
expect(resolveEntry(entries, 'project/my-project')).toBe(project)
})
it('handles pipe syntax: uses target part for lookup', () => {
expect(resolveEntry(entries, 'person/alice|Alice S.')).toBe(alice)
expect(resolveEntry(entries, 'Alice|A')).toBe(alice)
})
it('returns undefined for non-existent target', () => {
expect(resolveEntry(entries, 'Does Not Exist')).toBeUndefined()
})
it('returns undefined for empty entries', () => {
expect(resolveEntry([], 'Alice')).toBeUndefined()
})
it('matches by filename stem from last segment of path target', () => {
// If target is "person/alice", the last segment "alice" should match filename stem
expect(resolveEntry(entries, 'person/alice')).toBe(alice)
})
it('matches title-as-words from kebab-case target', () => {
// "my-project" → "my project" should match title "My Project"
expect(resolveEntry(entries, 'my-project')).toBe(project)
})
})

View File

@@ -1,5 +1,7 @@
/** Utility functions for parsing wikilink syntax: [[target|display]] */
import type { VaultEntry } from '../types'
/** Extracts the target path from a wikilink reference (strips [[ ]] and display text). */
export function wikilinkTarget(ref: string): string {
const inner = ref.replace(/^\[\[|\]\]$/g, '')
@@ -15,3 +17,26 @@ export function wikilinkDisplay(ref: string): string {
const last = inner.split('/').pop() ?? inner
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
/**
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
* Handles pipe syntax, case-insensitive matching, title/alias/filename/path lookup.
*/
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
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
})
}

View File

@@ -4,21 +4,15 @@
*/
import type { VaultEntry } from '../types'
import { getTypeColor } from './typeColors'
import { resolveEntry } from './wikilink'
/** Broken-link color: muted text to signal the target note doesn't exist */
const BROKEN_LINK_COLOR = 'var(--text-muted)'
/** Find a vault entry matching a wikilink target string */
/** Find a vault entry matching a wikilink target string.
* Delegates to the unified resolveEntry for consistent case-insensitive matching. */
export function findEntryByTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
// Handle pipe syntax: [[path|display name]] → use path part for matching
const key = target.includes('|') ? target.split('|')[0] : target
const suffix = '/' + key + '.md'
return entries.find(e =>
e.title === key ||
e.filename.replace(/\.md$/, '') === key ||
e.aliases.includes(key) ||
e.path.endsWith(suffix),
)
return resolveEntry(entries, target)
}
/** Resolve the accent color for a given entry based on its type */

View File

@@ -0,0 +1,12 @@
---
Is A: Event
Status: Active
Belongs to:
- "[[Alpha Project]]"
Attendees:
- "[[Test User]]"
---
# Team Meeting
Weekly team meeting for the Alpha Project.

View File

@@ -0,0 +1,8 @@
---
Is A: Note
Archived: Yes
---
# Archived Note
This note is archived and should not appear in the main sidebar.

View File

@@ -0,0 +1,10 @@
---
Is A: Note
Status: Active
---
# Note B
This is Note B, referenced by Alpha Project.
It also links to [[Note C]].

View File

@@ -0,0 +1,12 @@
---
Is A: Note
Status: Active
Related to:
- "[[Alpha Project]]"
Topics:
- "[[design]]"
---
# Note C
This is Note C with relationships to Alpha Project.

View File

@@ -0,0 +1,9 @@
---
Is A: Note
Trashed: true
Trashed at: 2026-03-01
---
# Trashed Note
This note is trashed and should not appear in search results or the main sidebar.

View File

@@ -0,0 +1,16 @@
---
Is A: Project
Status: Active
Owner: "Test User"
Related to:
- "[[Note B]]"
- "[[Note C]]"
---
# Alpha Project
This is a test project that references other notes.
## Notes
See [[Note B]] for details and [[Note C]] for additional context.

View File

@@ -0,0 +1,9 @@
---
Is A: Type
icon: calendar
color: orange
order: 2
sidebarLabel: Events
---
# Event

View File

@@ -0,0 +1,9 @@
---
Is A: Type
icon: file-text
color: green
order: 1
sidebarLabel: Notes
---
# Note

View File

@@ -0,0 +1,9 @@
---
Is A: Type
icon: folder
color: blue
order: 0
sidebarLabel: Projects
---
# Project

View File

@@ -0,0 +1,230 @@
/**
* Integration tests against a real filesystem vault.
*
* Each test copies tests/fixtures/test-vault/ to a temp directory,
* points the app at it, and verifies real file I/O: creation, rename,
* wikilink updates, archive/trash filtering, and relationship display.
*/
import { test, expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import os from 'os'
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true })
for (const item of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, item.name)
const d = path.join(dest, item.name)
if (item.isDirectory()) copyDirSync(s, d)
else fs.copyFileSync(s, d)
}
}
let tempVaultDir: string
test.beforeEach(async ({ page }) => {
// Fresh vault copy for each test
tempVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'laputa-test-vault-'))
copyDirSync(FIXTURE_VAULT, tempVaultDir)
// Intercept window.__mockHandlers assignment to override vault path handlers.
// The Object.defineProperty setter fires when mock-tauri/index.ts sets
// window.__mockHandlers = mockHandlers, letting us patch the same object
// reference that mockInvoke reads from.
await page.addInitScript((vaultPath: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ref: any = null
Object.defineProperty(window, '__mockHandlers', {
set(val) {
ref = val
ref.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: vaultPath }],
active_vault: vaultPath,
})
ref.check_vault_exists = () => true
ref.get_last_vault_path = () => vaultPath
ref.get_default_vault_path = () => vaultPath
ref.save_vault_list = () => null
},
get() { return ref },
configurable: true,
})
}, tempVaultDir)
await page.goto('/')
// Wait until the real vault entries are loaded in the sidebar
await page.getByText('Alpha Project', { exact: true }).first().waitFor({ timeout: 10_000 })
})
test.afterEach(async () => {
fs.rmSync(tempVaultDir, { recursive: true, force: true })
})
/** Helper: locate the note-list container. */
function noteList(page: import('@playwright/test').Page) {
return page.locator('[data-testid="note-list-container"]')
}
/** Helper: click a note item by title text in the sidebar. */
async function openNote(page: import('@playwright/test').Page, title: string) {
await noteList(page).getByText(title, { exact: true }).click()
await page.waitForTimeout(300)
}
// ---------------------------------------------------------------------------
// 1. Vault loads entries from real fixture files
// ---------------------------------------------------------------------------
test('vault loads entries from fixture files', async ({ page }) => {
const list = noteList(page)
await expect(list.getByText('Alpha Project', { exact: true })).toBeVisible()
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
await expect(list.getByText('Note C', { exact: true })).toBeVisible()
await expect(list.getByText('Team Meeting', { exact: true })).toBeVisible()
// Open a note and verify editor shows its content from disk
await openNote(page, 'Alpha Project')
// The WYSIWYG editor renders the title as an H1 heading
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
})
// ---------------------------------------------------------------------------
// 2. Archived note hidden from main sidebar
// ---------------------------------------------------------------------------
test('archived note does not appear in All Notes', async ({ page }) => {
const list = noteList(page)
// "Archived Note" has Archived: Yes — should be hidden from default view
await expect(list.getByText('Archived Note', { exact: true })).not.toBeVisible()
// But regular notes are visible
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
})
// ---------------------------------------------------------------------------
// 3. Trashed note hidden from note list
// ---------------------------------------------------------------------------
test('trashed note does not appear in All Notes', async ({ page }) => {
const list = noteList(page)
// "Trashed Note" has Trashed: true — should be hidden
await expect(list.getByText('Trashed Note', { exact: true })).not.toBeVisible()
// Regular notes are visible
await expect(list.getByText('Note C', { exact: true })).toBeVisible()
})
// ---------------------------------------------------------------------------
// 4. Create note saves file to disk with correct slug
// ---------------------------------------------------------------------------
test('create note saves file to disk with correct slug', async ({ page }) => {
// "Create new note" instantly creates "Untitled note" and opens in editor
await page.locator('button[title="Create new note"]').click()
await page.waitForTimeout(500)
// Verify the new note opens — H1 heading should appear in the WYSIWYG editor
await expect(page.getByRole('heading', { name: /Untitled note/i, level: 1 })).toBeVisible({ timeout: 5_000 })
// Save to disk via Cmd+S
await page.keyboard.press('Meta+s')
// Poll for the file to appear on disk
const expectedPath = path.join(tempVaultDir, 'note', 'untitled-note.md')
await expect(async () => {
expect(fs.existsSync(expectedPath)).toBe(true)
}).toPass({ timeout: 5_000 })
const content = fs.readFileSync(expectedPath, 'utf-8')
expect(content).toContain('# Untitled note')
expect(content).toContain('type: Note')
})
// ---------------------------------------------------------------------------
// 5. Rename note updates filename on disk
// ---------------------------------------------------------------------------
test('rename note updates filename on disk', async ({ page }) => {
// Open Note B
await openNote(page, 'Note B')
// Double-click the tab title — scope to the draggable tab element to avoid
// matching the breadcrumb span that also has class "truncate".
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
await page.waitForTimeout(200)
// In rename mode, draggable becomes false and an <input> appears in the tab.
// It's the only <input> element on the page at this point.
const input = page.locator('input').first()
await expect(input).toBeVisible({ timeout: 2_000 })
await input.fill('Note B Renamed')
await input.press('Enter')
await page.waitForTimeout(500)
// Verify filesystem: old file gone, new file exists
const oldPath = path.join(tempVaultDir, 'note', 'note-b.md')
const newPath = path.join(tempVaultDir, 'note', 'note-b-renamed.md')
await expect(async () => {
expect(fs.existsSync(oldPath)).toBe(false)
expect(fs.existsSync(newPath)).toBe(true)
}).toPass({ timeout: 5_000 })
const newContent = fs.readFileSync(newPath, 'utf-8')
expect(newContent).toContain('# Note B Renamed')
})
// ---------------------------------------------------------------------------
// 6. Wikilink update on rename — other files' [[Note B]] updated
// ---------------------------------------------------------------------------
test('rename note updates wikilinks in other files', async ({ page }) => {
// Open Note B and rename it
await openNote(page, 'Note B')
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
await page.waitForTimeout(200)
const input = page.locator('input').first()
await expect(input).toBeVisible({ timeout: 2_000 })
await input.fill('Note B Updated')
await input.press('Enter')
// Wait for rename to complete (file to be moved)
const newPath = path.join(tempVaultDir, 'note', 'note-b-updated.md')
await expect(async () => {
expect(fs.existsSync(newPath)).toBe(true)
}).toPass({ timeout: 5_000 })
// Verify alpha-project.md now references [[Note B Updated]] instead of [[Note B]]
const alphaContent = fs.readFileSync(path.join(tempVaultDir, 'project', 'alpha-project.md'), 'utf-8')
expect(alphaContent).toContain('[[Note B Updated]]')
expect(alphaContent).not.toContain('[[Note B]]')
})
// ---------------------------------------------------------------------------
// 7. Relationship display in inspector
// ---------------------------------------------------------------------------
test('inspector shows relationships for note with wikilink fields', async ({ page }) => {
// Open Note C which has Related to: [[Alpha Project]] and Topics: [[design]]
await openNote(page, 'Note C')
// The DynamicRelationshipsPanel renders field labels like "Related to", "Topics"
// as <span> elements. Verify that the relationship labels are visible in the inspector.
await expect(page.getByText('Related to').first()).toBeVisible({ timeout: 5_000 })
})
// ---------------------------------------------------------------------------
// 8. Opening a note loads real file content from disk
// ---------------------------------------------------------------------------
test('editor shows real file content from disk', async ({ page }) => {
// Open Team Meeting
await openNote(page, 'Team Meeting')
// Verify editor shows the actual file content — H1 heading from the file
await expect(page.getByRole('heading', { name: 'Team Meeting', level: 1 })).toBeVisible({ timeout: 5_000 })
})

View File

@@ -0,0 +1,168 @@
import { test, expect } from '@playwright/test'
import { openCommandPalette, executeCommand } from './helpers'
test.describe('BlockNote serializer blank lines fix', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('tight lists are serialized without blank lines between items', async ({ page }) => {
// 1. Open the first note in the note list (mock content has bullet lists)
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)
// 2. Wait for the BlockNote editor to render
const editorContainer = page.locator('.bn-editor')
await expect(editorContainer).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(500)
// 3. Type a character in the editor to trigger serialization
await editorContainer.click()
// Move cursor to end of first paragraph and type a space
await page.keyboard.press('End')
await page.keyboard.type(' ')
await page.waitForTimeout(300)
// 4. Toggle to raw editor to see the serialized content
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await page.waitForTimeout(500)
// 5. Read the raw editor content (CodeMirror textarea)
const rawEditor = page.locator('[data-testid="raw-editor-codemirror"]')
await expect(rawEditor).toBeVisible({ timeout: 5000 })
// Get the raw markdown content via the CodeMirror view
const rawContent = await page.evaluate(() => {
const el = document.querySelector('[data-testid="raw-editor-codemirror"]')
if (!el) return ''
// CodeMirror stores its content in the view
const cm = el.querySelector('.cm-content')
return cm?.textContent ?? ''
})
// 6. Verify: bullet list items should NOT have blank lines between them
// The mock content has bullet list items like "- First level item"
// After BlockNote serialization, they should still be tight (no blank lines)
expect(rawContent).toBeTruthy()
// Check there are no patterns like "* item\n\n* item" or "- item\n\n- item"
// in the serialized output. We look for list markers since the raw editor
// shows the serialized content.
const lines = rawContent.split('\n')
for (let i = 0; i < lines.length - 2; i++) {
const line = lines[i]
const nextLine = lines[i + 1]
const lineAfter = lines[i + 2]
// If current line is a list item and line after blank is also a list item,
// that's the bug
if (/^[-*+]\s/.test(line.trim()) && nextLine?.trim() === '' && /^[-*+]\s/.test(lineAfter?.trim() ?? '')) {
throw new Error(
`Found blank line between list items at line ${i + 1}:\n` +
` "${line}"\n (blank)\n "${lineAfter}"\n` +
'This indicates the serializer is adding extra blank lines between list items.'
)
}
}
})
test('saving without editing does not add whitespace changes', async ({ page }) => {
// 1. Open the first note
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)
// 2. Wait for BlockNote to load the note
const editorContainer = page.locator('.bn-editor')
await expect(editorContainer).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(500)
// 3. Toggle to raw mode to read the note content BEFORE any edits
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await page.waitForTimeout(500)
const rawEditor = page.locator('[data-testid="raw-editor-codemirror"]')
await expect(rawEditor).toBeVisible({ timeout: 5000 })
const contentBefore = await page.evaluate(() => {
const el = document.querySelector('[data-testid="raw-editor-codemirror"]')
const cm = el?.querySelector('.cm-content')
return cm?.textContent ?? ''
})
// 4. Toggle back to WYSIWYG, make no edits, then toggle raw again
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await page.waitForTimeout(500)
// Just click the editor without typing
await page.locator('.bn-editor').click()
await page.waitForTimeout(300)
// Toggle raw again to compare
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await page.waitForTimeout(500)
const contentAfter = await page.evaluate(() => {
const el = document.querySelector('[data-testid="raw-editor-codemirror"]')
const cm = el?.querySelector('.cm-content')
return cm?.textContent ?? ''
})
// 5. Content should be identical (no extra blank lines added)
expect(contentAfter).toBe(contentBefore)
})
test('headings do not have extra blank lines added after them', async ({ page }) => {
// 1. Open the first note
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)
const editorContainer = page.locator('.bn-editor')
await expect(editorContainer).toBeVisible({ timeout: 5000 })
// 2. Type a character to trigger serialization
await editorContainer.click()
await page.keyboard.press('End')
await page.keyboard.type(' ')
await page.waitForTimeout(300)
// 3. Toggle raw editor
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await page.waitForTimeout(500)
const rawContent = await page.evaluate(() => {
const el = document.querySelector('[data-testid="raw-editor-codemirror"]')
const cm = el?.querySelector('.cm-content')
return cm?.textContent ?? ''
})
// 4. Verify: no heading is followed by more than one blank line
const lines = rawContent.split('\n')
for (let i = 0; i < lines.length - 2; i++) {
if (/^#{1,6}\s/.test(lines[i].trim())) {
// A heading followed by two consecutive blank lines is the bug
if (lines[i + 1]?.trim() === '' && lines[i + 2]?.trim() === '') {
throw new Error(
`Found multiple blank lines after heading at line ${i + 1}:\n` +
` "${lines[i]}"\n (blank)\n (blank)\n` +
'Headings should have at most one blank line after them.'
)
}
}
}
})
})

View File

@@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test'
test.describe('Changing note type preserves content (data corruption fix)', () => {
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 }) => {
// 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 })
const firstNote = noteListContainer.locator('.cursor-pointer').first()
await firstNote.click()
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()
expect(headingBefore).toBeTruthy()
// 3. The type selector should be visible in the inspector
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() ?? ''
// 4. Change the type to something different
const targetType = currentType === 'Project' ? 'Experiment' : 'Project'
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()
// 5. Wait for the move to complete (toast confirms it)
const toastSlug = targetType.toLowerCase()
const toast = page.getByText(`Note moved to ${toastSlug}/`)
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)
// 7. Restore original type to leave vault clean
await page.waitForTimeout(2500)
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')
}
})
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

@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
const QUICK_OPEN_INPUT = 'input[placeholder="Search notes..."]'
/** Known archived note titles from mock data */
const ARCHIVED_TITLES = ['Website Redesign', 'Twitter Thread Growth Experiment']
async function openQuickOpen(page: import('@playwright/test').Page) {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
await expect(page.locator(QUICK_OPEN_INPUT)).toBeVisible()
}
function quickOpenPanel(page: import('@playwright/test').Page) {
return page.locator('.fixed.inset-0').filter({ has: page.locator(QUICK_OPEN_INPUT) })
}
function getResultTitles(container: import('@playwright/test').Locator) {
return container.locator('span.truncate').allTextContents()
}
test.describe('Archived/Trashed Yes/No detection', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('archived notes are filtered out of the default sidebar', async ({ page }) => {
// In the default sidebar view (non-archived section), archived notes must not appear
const noteItems = page.locator('[data-testid="note-item"]')
const count = await noteItems.count()
for (let i = 0; i < count; i++) {
const text = await noteItems.nth(i).textContent()
for (const title of ARCHIVED_TITLES) {
expect(text, `archived note "${title}" should not be in default sidebar`).not.toContain(title)
}
}
})
test('archived note shows ArchivedNoteBanner in the editor', async ({ page }) => {
// Open quick open and navigate to an archived note
await openQuickOpen(page)
await page.locator(QUICK_OPEN_INPUT).fill('Website Redesign')
await page.waitForTimeout(400)
// The archived note might not appear in quick open (filtered), so try direct URL
const panel = quickOpenPanel(page)
const titles = await getResultTitles(panel)
if (titles.some(t => t.includes('Website Redesign'))) {
// If it appears in quick open, click it
const result = panel.locator('span.truncate').filter({ hasText: 'Website Redesign' }).first()
await result.click()
} else {
// Close quick open and use sidebar Archived section if available
await page.keyboard.press('Escape')
// Click the Archived section header to expand it
const archivedSection = page.locator('text=Archived').first()
if (await archivedSection.isVisible({ timeout: 1000 }).catch(() => false)) {
await archivedSection.click()
await page.waitForTimeout(300)
const archivedNote = page.locator('[data-testid="note-item"]').filter({ hasText: 'Website Redesign' })
if (await archivedNote.isVisible({ timeout: 1000 }).catch(() => false)) {
await archivedNote.click()
} else {
test.skip()
return
}
} else {
test.skip()
return
}
}
await page.waitForTimeout(500)
// Verify the archived banner is visible
const banner = page.locator('[data-testid="archived-note-banner"]')
await expect(banner).toBeVisible({ timeout: 3000 })
await expect(banner).toContainText('Archived')
})
test('archived note shows archived badge in the note list', async ({ page }) => {
// Navigate to the Archived section in the sidebar
const archivedSection = page.locator('button, [role="button"], span').filter({ hasText: /^Archived/ }).first()
if (!await archivedSection.isVisible({ timeout: 2000 }).catch(() => false)) {
test.skip()
return
}
await archivedSection.click()
await page.waitForTimeout(300)
// Look for archived badge on note items
const badges = page.locator('[data-testid="state-badge"]')
const badgeCount = await badges.count()
expect(badgeCount).toBeGreaterThan(0)
})
test('archived notes do not appear in Quick Open search', async ({ page }) => {
await openQuickOpen(page)
const panel = quickOpenPanel(page)
for (const title of ARCHIVED_TITLES) {
const query = title.split(' ')[0]
await page.locator(QUICK_OPEN_INPUT).fill(query)
await page.waitForTimeout(400)
const titles = await getResultTitles(panel)
expect(titles, `"${title}" should not appear in Quick Open`).not.toContain(title)
}
})
})

View File

@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
test.describe('Create note crash fix', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('clicking + next to a type section creates a note without crashing', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
// Hover over the Projects section to reveal the + button
const sectionHeader = page.getByText('Projects').first()
await sectionHeader.hover()
// Click the actual create button (exact match avoids the parent sortable div)
const createBtn = page.getByRole('button', { name: 'Create new Project', exact: true })
await createBtn.click({ force: true })
// The new note should appear — check for tab + heading
await expect(page.getByText('Untitled project').first()).toBeVisible({ timeout: 3000 })
expect(errors).toEqual([])
})
test('Cmd+N creates a note without crashing', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await page.waitForTimeout(500)
// An "Untitled note" tab should be visible
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
expect(errors).toEqual([])
})
test('creating note for custom type does not crash', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
// Hover over a custom type section (Areas exists in mock vault)
const sectionHeader = page.getByText('Areas').first()
await sectionHeader.hover()
const createBtn = page.getByRole('button', { name: 'Create new Area', exact: true })
await createBtn.click({ force: true })
await expect(page.getByText('Untitled area').first()).toBeVisible({ timeout: 3000 })
expect(errors).toEqual([])
})
test('rapid note creation does not crash', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
const sectionHeader = page.getByText('Experiments').first()
await sectionHeader.hover()
const createBtn = page.getByRole('button', { name: 'Create new Experiment', exact: true })
await createBtn.click({ force: true })
await createBtn.click({ force: true })
await page.waitForTimeout(500)
// At least one untitled experiment should exist
await expect(page.getByText('Untitled experiment').first()).toBeVisible({ timeout: 3000 })
expect(errors).toEqual([])
})
})

View File

@@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
/** Known BlockNote/ProseMirror error that fires during editor re-mount after rename. */
const KNOWN_EDITOR_ERRORS = ['isConnected']
function isKnownEditorError(msg: string): boolean {
return KNOWN_EDITOR_ERRORS.some(k => msg.includes(k))
}
/**
* Helper: create a new note, select the existing H1 heading text,
* replace it with a new title, then wait for the 500 ms title-sync debounce.
*/
async function createNoteWithTitle(page: import('@playwright/test').Page, title: string) {
// 1. Cmd+N → new "Untitled note" (creates heading with "Untitled note")
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
// 2. Wait for the heading to render in BlockNote
const heading = page.locator('[data-content-type="heading"] h1')
await heading.waitFor({ timeout: 3000 })
// 3. Triple-click the heading to select all its text, then type replacement
await heading.click({ clickCount: 3 })
await page.keyboard.type(title, { delay: 20 })
// 4. Wait for the 500 ms useHeadingTitleSync debounce to fire + React re-render
await page.waitForTimeout(800)
}
test.describe('Note filename updates on title change + save', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Wait for startup toast ("Laputa registered as MCP tool") to dismiss
await page.waitForTimeout(2500)
})
test('Cmd+N creates untitled note, typing new title triggers rename via title sync', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
await createNoteWithTitle(page, 'Test Note ABC')
// Title sync now triggers a full rename (save + rename + wikilink update).
// The toast should show "Renamed" after the debounce fires.
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Renamed', { timeout: 5000 })
// Breadcrumb should show the new title
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText('Test Note ABC', { timeout: 2000 })
// Cmd+S should NOT trigger another rename (filename already matches)
await sendShortcut(page, 's', ['Control'])
await page.waitForTimeout(500)
// No unexpected JS errors
expect(errors).toEqual([])
})
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()
await noteItem.click()
await page.waitForTimeout(500)
// Cmd+S — should show "Saved" or "Nothing to save", NOT "Renamed"
await sendShortcut(page, 's', ['Control'])
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toBeVisible({ timeout: 3000 })
const toastText = await toast.textContent()
expect(toastText).not.toContain('Renamed')
})
test('rapid title changes only rename to final title', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
await createNoteWithTitle(page, 'First Title')
// Select heading text again and replace with final title
const heading = page.locator('[data-content-type="heading"] h1')
await heading.click({ clickCount: 3 })
await page.keyboard.type('Final Title', { delay: 20 })
// Wait for debounce to fire — title sync triggers rename automatically
await page.waitForTimeout(800)
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Renamed', { timeout: 5000 })
expect(errors).toEqual([])
})
})

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test'
const DROP_OVERLAY = '.editor__drop-overlay'
const EDITOR_CONTAINER = '.editor__blocknote-container'
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('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 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')
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,
}))
}, 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')
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,
}))
}, EDITOR_CONTAINER)
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
})
})

View File

@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test'
test.describe('Renaming a note updates wikilinks across the vault', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('tab rename triggers rename flow and shows toast', async ({ page }) => {
// 1. Click the first note in the 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)
// 2. Capture the original tab title
const tabTitle = page.locator('.group span.truncate').first()
await expect(tabTitle).toBeVisible({ timeout: 5000 })
const originalTitle = await tabTitle.textContent()
expect(originalTitle).toBeTruthy()
// 3. Double-click the tab to enter rename mode
await tabTitle.dblclick()
await page.waitForTimeout(300)
// 4. Type a new name and press Enter
const editInput = page.locator('.group input')
await expect(editInput).toBeVisible({ timeout: 3000 })
const newTitle = `${originalTitle} Renamed`
await editInput.fill(newTitle)
await editInput.press('Enter')
await page.waitForTimeout(1000)
// 5. Verify the tab title updated (rename mock handler returns a new path)
const newTabTitle = page.locator('.group span.truncate').first()
await expect(newTabTitle).toHaveText(newTitle, { timeout: 5000 })
// 6. Verify the toast message appeared (confirms rename flow ran, not just in-memory update)
const toast = page.getByText('Renamed', { exact: true })
await expect(toast).toBeVisible({ timeout: 5000 })
})
})

View File

@@ -0,0 +1,121 @@
import { test, expect, type Page } from '@playwright/test'
import { openCommandPalette, executeCommand } from './helpers'
async function getCssVar(page: Page, name: string): Promise<string> {
return page.evaluate(
(n) => document.documentElement.style.getPropertyValue(n),
name,
)
}
async function switchToDefaultTheme(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Switch to Default Theme')
await expect(async () => {
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
}).toPass({ timeout: 5000 })
}
async function openThemeNoteInRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Edit Default Theme')
await page.waitForTimeout(500)
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw Editor')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 3000 })
}
/** Replace all text in the CodeMirror editor via its EditorView API. */
async function setCmContent(page: Page, newContent: string) {
await page.evaluate((text) => {
const cmContent = document.querySelector('.cm-content') as HTMLElement | null
if (!cmContent) throw new Error('No .cm-content found')
// Access EditorView via CodeMirror's internal DOM reference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (cmContent as any).cmTile?.root?.view
if (!view) throw new Error('No EditorView found on .cm-content')
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
})
}, newContent)
}
test.describe('Theme live reload on save', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('editing theme frontmatter and saving updates CSS vars immediately', async ({ page }) => {
// 1. Switch to the default theme
await switchToDefaultTheme(page)
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
expect(await getCssVar(page, '--sidebar')).toBe('#F7F6F3')
// 2. Open the theme note in raw editor mode
await openThemeNoteInRawMode(page)
// 3. Replace content via CodeMirror API with changed colors
const updatedContent = [
'---',
'type: Theme',
'title: Default',
'primary: "#155DFF"',
'background: "#1a1a2e"',
'foreground: "#37352F"',
'sidebar: "#2a2a3e"',
'border: "#E9E9E7"',
'muted: "#F0F0EF"',
'muted-foreground: "#9B9A97"',
'accent: "#F0F7FF"',
'accent-foreground: "#0A3B8F"',
'font-family: "\'Inter\', -apple-system, BlinkMacSystemFont, sans-serif"',
'font-size-base: 14',
'line-height-base: 1.6',
'---',
'',
'# Default',
'',
'Light theme with warm, paper-like tones.',
].join('\n')
await setCmContent(page, updatedContent)
// Wait for debounce to flush (RawEditorView has 500ms debounce)
await page.waitForTimeout(700)
// 4. Save with Ctrl+S
await page.keyboard.press('Control+s')
// 5. Verify CSS vars updated live
await expect(async () => {
expect(await getCssVar(page, '--background')).toBe('#1a1a2e')
}).toPass({ timeout: 5000 })
expect(await getCssVar(page, '--sidebar')).toBe('#2a2a3e')
})
test('saving a non-theme note does not affect active theme CSS', async ({ page }) => {
// 1. Switch to the default theme
await switchToDefaultTheme(page)
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
// 2. Open a regular note (first in list), switch to raw mode
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.waitFor({ timeout: 5000 })
await noteList.locator('.cursor-pointer').first().click()
await page.waitForTimeout(300)
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw Editor')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 3000 })
// 3. Type something and save
await page.locator('.cm-content').click()
await page.keyboard.type('test edit')
await page.keyboard.press('Control+s')
await page.waitForTimeout(500)
// 4. Theme CSS vars unchanged
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
})
})

View File

@@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test'
test.describe('Type icon, color, and sidebar label', () => {
test.beforeEach(async ({ page }) => {
// Block vault API so the app falls back to mock data (which contains our test fixtures)
await page.route('**/api/vault/**', (route) => route.abort())
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('Config section shows correct sidebar label from type entry', async ({ page }) => {
const sidebar = page.locator('aside')
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
// The Config type entry has sidebarLabel: "Config" — the section button should use that label
const configBtn = sidebar.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]')
await expect(configBtn).toBeVisible()
})
test('Config section icon is not the default FileText', async ({ page }) => {
const sidebar = page.locator('aside')
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
const configSection = sidebar.locator('div.group\\/section').filter({
has: page.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]'),
})
await expect(configSection).toBeVisible()
// The icon SVG should be present (GearSix, resolved from type entry icon: 'gear-six')
const icon = configSection.locator('svg').first()
await expect(icon).toBeVisible()
})
test('Config section icon has gray color applied via style', async ({ page }) => {
const sidebar = page.locator('aside')
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
const configSection = sidebar.locator('div.group\\/section').filter({
has: page.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]'),
})
await expect(configSection).toBeVisible()
// The icon should have gray color from the type entry's color: 'gray'
const icon = configSection.locator('svg').first()
const color = await icon.evaluate((el) => el.style.color)
expect(color).toContain('var(--accent-gray)')
})
test('custom type with icon/color reflects in sidebar (Recipe)', async ({ page }) => {
const sidebar = page.locator('aside')
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
// Recipe type has icon: cooking-pot, color: orange — check section has correct color
const recipeSection = sidebar.locator('div.group\\/section').filter({
has: page.locator('button[aria-label="Collapse Recipes"], button[aria-label="Expand Recipes"]'),
})
await expect(recipeSection).toBeVisible()
const icon = recipeSection.locator('svg').first()
const color = await icon.evaluate((el) => el.style.color)
expect(color).toContain('var(--accent-orange)')
})
})

View File

@@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test'
test.describe('Wikilink insertion and navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Select first note to open in editor
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(1000)
})
test('[[ autocomplete inserts wikilink that is not broken', async ({ page }) => {
// Focus editor and move to a new line
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
// Type [[ then a query (>= 2 chars for MIN_QUERY_LENGTH)
await page.keyboard.type('[[Ma')
await page.waitForTimeout(800)
// The wikilink suggestion menu should appear
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
// Select the first suggestion
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// A wikilink should have been inserted
const wikilinks = page.locator('.wikilink')
const count = await wikilinks.count()
expect(count).toBeGreaterThanOrEqual(1)
// The wikilink should NOT be broken
const lastWikilink = wikilinks.last()
const isBroken = await lastWikilink.evaluate(
el => el.classList.contains('wikilink--broken'),
)
expect(isBroken).toBe(false)
// The wikilink should have a data-target attribute
const target = await lastWikilink.getAttribute('data-target')
expect(target).toBeTruthy()
})
test('clicking an inserted wikilink navigates to the note', async ({ page }) => {
// Insert a wikilink via autocomplete
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.keyboard.type('[[Ma')
await page.waitForTimeout(800)
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// Get the wikilink that was just inserted
const wikilink = page.locator('.wikilink').last()
await expect(wikilink).toBeVisible()
const targetTitle = await wikilink.textContent()
// Click the wikilink to navigate
await wikilink.click()
await page.waitForTimeout(1000)
// The editor should now show the target note
const heading = page.locator('.bn-editor h1')
await expect(heading).toBeVisible({ timeout: 3000 })
const headingText = await heading.textContent()
// The heading should match the beginning of the target note's title
expect(headingText?.toLowerCase()).toContain(targetTitle?.toLowerCase().substring(0, 4) || '')
})
})

View File

@@ -129,12 +129,19 @@ function parseMarkdownFile(filePath: string): VaultEntry | null {
}
}
// Boolean field helper
// Boolean field helper — handles both real booleans and YAML 1.1 string
// variants ("Yes"/"yes"/"True"/"true") that js-yaml 4.x (YAML 1.2) leaves as strings.
const getBool = (...keys: string[]): boolean | null => {
for (const k of keys) {
for (const fk of Object.keys(fm)) {
if (fk.toLowerCase() === k.toLowerCase() && typeof fm[fk] === 'boolean') {
return fm[fk]
if (fk.toLowerCase() === k.toLowerCase()) {
const v = fm[fk]
if (typeof v === 'boolean') return v
if (typeof v === 'string') {
const lc = v.toLowerCase()
if (lc === 'true' || lc === 'yes') return true
if (lc === 'false' || lc === 'no') return false
}
}
}
}
@@ -201,7 +208,7 @@ function vaultApiPlugin(): Plugin {
return {
name: 'vault-api',
configureServer(server) {
server.middlewares.use((req, res, next) => {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (url.pathname === '/api/vault/ping') {
@@ -258,6 +265,122 @@ function vaultApiPlugin(): Plugin {
return
}
if (url.pathname === '/api/vault/entry') {
const filePath = url.searchParams.get('path')
if (!filePath || !fs.existsSync(filePath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const entry = parseMarkdownFile(filePath)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(entry))
return
}
if (url.pathname === '/api/vault/search') {
const vaultPath = url.searchParams.get('vault_path')
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const mode = url.searchParams.get('mode') ?? 'all'
if (!vaultPath || !query) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ results: [], elapsed_ms: 0, query, mode }))
return
}
const files = findMarkdownFiles(vaultPath)
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
for (const f of files) {
const entry = parseMarkdownFile(f)
if (!entry || entry.trashed) continue
const raw = fs.readFileSync(f, 'utf-8')
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ results: results.slice(0, 20), elapsed_ms: 1, query, mode }))
return
}
// --- POST endpoints for write operations ---
if (url.pathname === '/api/vault/save' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { path: filePath, content } = JSON.parse(body)
if (!filePath || content === undefined) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path or content' }))
return
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end('null')
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Save failed' }))
}
return
}
if (url.pathname === '/api/vault/rename' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body)
const oldContent = fs.readFileSync(oldPath, 'utf-8')
const h1Match = oldContent.match(/^# (.+)$/m)
const oldTitle = h1Match ? h1Match[1].trim() : ''
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const parentDir = path.dirname(oldPath)
const newPath = path.join(parentDir, `${slug}.md`)
const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`)
fs.writeFileSync(newPath, newContent, 'utf-8')
if (newPath !== oldPath) fs.unlinkSync(oldPath)
let updatedFiles = 0
if (oldTitle && vaultPath) {
const allFiles = findMarkdownFiles(vaultPath)
const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
for (const f of allFiles) {
if (f === newPath) continue
try {
const c = fs.readFileSync(f, 'utf-8')
const replaced = c.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]`
)
if (replaced !== c) { fs.writeFileSync(f, replaced, 'utf-8'); updatedFiles++ }
} catch { /* skip */ }
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ new_path: newPath, updated_files: updatedFiles }))
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Rename failed' }))
}
return
}
if (url.pathname === '/api/vault/delete' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { path: filePath } = JSON.parse(body)
if (!filePath) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path' }))
return
}
fs.unlinkSync(filePath)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(filePath))
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Delete failed' }))
}
return
}
next()
})
},