Compare commits
15 Commits
v0.2026033
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9960fd6139 | ||
|
|
e3923fe123 | ||
|
|
375baa4e1a | ||
|
|
321a518a1f | ||
|
|
ed549a160c | ||
|
|
3de211df96 | ||
|
|
f84faac7c3 | ||
|
|
ee69e30b6b | ||
|
|
e823243a3b | ||
|
|
85e8eb7041 | ||
|
|
6640e74a74 | ||
|
|
be98fd51e5 | ||
|
|
b1aaae82df | ||
|
|
2f658425df | ||
|
|
5ce1431522 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.84
|
||||
AVERAGE_THRESHOLD=9.38
|
||||
HOTSPOT_THRESHOLD=9.6
|
||||
AVERAGE_THRESHOLD=9.37
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0032"
|
||||
title: "Git actions (Changes, Pulse, Commit) in status bar, not sidebar"
|
||||
title: 0032 Status Bar For Git Actions
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
[[Subfolder scanning and folder tree navigation]]
|
||||
|
||||
## Context
|
||||
|
||||
@@ -16,14 +17,14 @@ The Laputa sidebar originally surfaced git-related affordances — a "Changes" n
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
|
||||
- **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
|
||||
- **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
|
||||
* **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
|
||||
* **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
|
||||
* **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
|
||||
- `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
|
||||
- Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
|
||||
- Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
|
||||
- Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar
|
||||
* Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
|
||||
* `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
|
||||
* Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
|
||||
* Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
|
||||
* Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar
|
||||
|
||||
@@ -5,29 +5,30 @@ title: "Subfolder scanning and folder tree navigation"
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
[[0032 Status Bar For Git Actions]]
|
||||
|
||||
Supersedes the scanning constraint in [ADR-0006](0006-flat-vault-structure.md) which limited vault indexing to root-level `.md` files plus protected folders (`attachments/`, `assets/`).
|
||||
|
||||
Users with folder-based workflows (PARA, Zettelkasten with folders, project directories) could not see or filter notes by directory. The vault scanner silently ignored all subdirectory `.md` files, making Laputa unsuitable for vaults with any folder structure.
|
||||
|
||||
## Decision
|
||||
|
||||
**Extend the Rust vault scanner to index `.md` files in all visible subdirectories, and expose the vault's folder tree via a new `list_vault_folders` Tauri command so the sidebar can render a collapsible FOLDERS section.**
|
||||
**Extend the Rust vault scanner to index **`.md`** files in all visible subdirectories, and expose the vault's folder tree via a new **`list_vault_folders`** Tauri command so the sidebar can render a collapsible FOLDERS section.**
|
||||
|
||||
Hidden directories (names starting with `.`, plus `.git` and `.laputa`) are excluded from both scanning and the folder tree.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache.
|
||||
- **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes.
|
||||
- **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories.
|
||||
* **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache.
|
||||
* **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes.
|
||||
* **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories.
|
||||
|
||||
## Consequences
|
||||
|
||||
- All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed.
|
||||
- The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching.
|
||||
- `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it.
|
||||
- ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root.
|
||||
- Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected).
|
||||
* All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed.
|
||||
* The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching.
|
||||
* `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it.
|
||||
* ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root.
|
||||
* Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected).
|
||||
|
||||
29
docs/adr/0034-git-repo-required-for-vault.md
Normal file
29
docs/adr/0034-git-repo-required-for-vault.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0034"
|
||||
title: "Git repo required — blocking modal enforces vault prerequisite"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0014 (git-based vault cache) and ADR-0021 (push-to-main workflow) both assume the vault is a git repository, but neither codified it as a hard enforcement. In practice, opening a non-git folder silently degraded: the cache couldn't compute a commit hash, Pulse/Changes were empty, and commit/push commands failed. The failure mode was invisible to users.
|
||||
|
||||
## Decision
|
||||
|
||||
**When the app opens a vault that has no `.git` directory, a blocking modal prevents all app use until the user either initialises a git repository (git init + initial commit, offered as a one-click action) or selects a different vault. The check is performed by a new `is_git_repo` Tauri command. In browser/dev mode, the check fails open (modal is skipped).**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Hard block via modal on vault open — unambiguous, prevents silent failures, surfaces the fix immediately. Downside: breaks existing workflows for users with non-git vaults; requires a clear escape hatch (choose different vault).
|
||||
- **Option B**: Soft warning banner, allow using the app without git — avoids blocking users, but silent failures persist for Pulse/Changes/commit features.
|
||||
- **Option C**: Auto-init git on vault open without asking — less friction, but surprising; user may not want their vault in git.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Git is now a first-class prerequisite for Laputa vaults, not just implied by the cache strategy.
|
||||
- The `is_git_repo` command is intentionally lightweight (checks for `.git` existence only; does not validate remote or commit history).
|
||||
- The modal offers `git init` + an initial commit as a one-click path, lowering the barrier for new users.
|
||||
- Browser mode bypasses the check so dev/Storybook workflows are unaffected.
|
||||
- Re-evaluate if Laputa needs to support non-git vaults (e.g., iCloud-only, shared network drive); at that point ADR-0014 would also need revisiting.
|
||||
31
docs/adr/0035-path-suffix-wikilink-resolution.md
Normal file
31
docs/adr/0035-path-suffix-wikilink-resolution.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0035"
|
||||
title: "Path-suffix wikilink resolution for subfolder vaults"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0006 stated that wikilink resolution was "simplified to multi-pass title/filename matching — no path-based matching needed" because the vault was flat. ADR-0033 relaxed the flat-vault constraint by adding subfolder scanning. As a result, wikilinks like `[[docs/adr/0031-foo]]` or `[[adr/0031-foo]]` could not resolve to entries in subdirectories: the resolver only matched on `title` and `filename` stem, never on the vault-relative path.
|
||||
|
||||
The backlink detection in the Inspector also used a hardcoded `/Laputa/` path regex, which was wrong for any vault that isn't named "Laputa".
|
||||
|
||||
## Decision
|
||||
|
||||
**Add path-suffix matching as Pass 1 of wikilink resolution: a link target resolves to a `VaultEntry` if the entry's vault-relative path ends with the link string (with or without `.md`). Filename-stem matching (the previous Pass 1) becomes Pass 2. Inspector backlinks replace the hardcoded `/Laputa/` regex with a generic `targetMatchesEntry` path-suffix helper. Autocomplete pre-filter also matches against the full vault-relative path so subfolder names surface results.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Path-suffix as Pass 1, then filename match as Pass 2 — consistent with how Obsidian resolves links in multi-folder vaults, zero config. Downside: if two notes share the same filename in different folders, only the first (path-suffix) match wins.
|
||||
- **Option B**: Strict full-path matching only (disable title-stem resolution) — unambiguous, but breaks the majority of existing short-form `[[note-title]]` links.
|
||||
- **Option C**: Keep title-only matching, require full paths for subfolder notes — backwards-compatible, but forces users to always type full paths for subfolders, defeating the purpose of wikilinks.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Supersedes the "no path-based matching needed" clause from ADR-0006 (that assumption was contingent on the flat vault invariant, which ADR-0033 relaxed).
|
||||
- `relativePathStem` utility added in `wikilink.ts` to extract the vault-relative path stem from a full `VaultEntry`.
|
||||
- The Inspector's `targetMatchesEntry` helper is now the canonical way to test if a link resolves to an entry — use it everywhere instead of ad-hoc regex.
|
||||
- Wikilink autocomplete suggestions now surface notes in subfolders when users type a folder prefix (e.g. `[[adr/`).
|
||||
- Re-evaluate if path-suffix ambiguity (two files with the same name in different folders) becomes a user complaint.
|
||||
31
docs/adr/0036-external-rename-detection-via-git-diff.md
Normal file
31
docs/adr/0036-external-rename-detection-via-git-diff.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0036"
|
||||
title: "External rename detection via git diff on focus regain"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa handles in-app renames (rename.rs) and propagates wikilink updates across the vault. But notes can also be renamed externally — from Finder, another editor, or a git operation (e.g., `git mv`). In those cases, the app had no way to detect that a rename had occurred, leaving wikilinks broken and the vault inconsistent.
|
||||
|
||||
The app already uses git for the cache (ADR-0014) and requires git as a vault prerequisite (ADR-0034), making git diff a natural and already-available detection mechanism.
|
||||
|
||||
## Decision
|
||||
|
||||
**When the app window regains focus, run `git diff --diff-filter=R --name-status HEAD` to detect file renames that occurred since the last committed HEAD. If any renamed `.md` files are found, show a non-blocking banner ("X file(s) renamed — update wikilinks?"). Accepting triggers the existing vault-wide wikilink replacement logic (reused from rename.rs). Ignoring dismisses the banner without changes. New Tauri commands: `detect_renames` and `update_wikilinks_for_renames`.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Git diff on focus regain, non-blocking banner — uses existing infrastructure, non-disruptive, user retains control. Downside: only detects renames that are staged/committed; uncommitted renames via `git mv` are captured, but renames done purely in Finder (no git involvement) are not.
|
||||
- **Option B**: `FSEvents` / file-system watcher for rename events — catches all renames regardless of git. Downside: significantly more complex, requires Rust async machinery, false positives from editor temp files, and this feature is already planned as a separate enhancement.
|
||||
- **Option C**: Scan for broken wikilinks on focus — correct but O(n) and noisy; doesn't tell us the new filename.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Git's rename detection (`--diff-filter=R`) requires the rename to be git-tracked (either staged or committed); renames that happen outside git knowledge are not detected by this mechanism.
|
||||
- The on-focus check runs `git diff HEAD` which is fast but adds a small shell invocation overhead each time the window activates. This is acceptable for typical vault sizes.
|
||||
- `rename.rs` is now shared between in-app renames and external rename recovery — the replacement logic is the canonical entry point for wikilink bulk updates.
|
||||
- The banner is non-blocking and "Ignore" is always available — the user never loses work.
|
||||
- Re-evaluate if FS-level rename detection (outside git) becomes a priority; at that point this mechanism would be a fallback, not the primary strategy.
|
||||
31
docs/adr/0037-codemirror-language-markdown-highlighting.md
Normal file
31
docs/adr/0037-codemirror-language-markdown-highlighting.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0037"
|
||||
title: "Language-based markdown syntax highlighting in raw editor"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The raw editor (CodeMirror 6, introduced in ADR-0022) initially had a custom `frontmatterHighlight` extension that used regex-based decoration for YAML frontmatter and headings. Markdown body content had no syntax highlighting at all, making the raw editor feel like a plain textarea despite being a full CodeMirror instance.
|
||||
|
||||
Extending the custom regex-based approach to cover all markdown syntax (bold, italic, links, lists, blockquotes, code) would have been brittle and hard to maintain.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace the custom heading decoration in `frontmatterHighlight.ts` with `@codemirror/lang-markdown` (the official CodeMirror language package). A custom `HighlightStyle` maps CodeMirror highlight tags to visual styles for headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code. The frontmatter YAML plugin is retained for YAML-specific colouring but its heading decoration is removed in favour of the language parser.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): `@codemirror/lang-markdown` with custom HighlightStyle — uses the official, maintained language parser; future highlight rules are one CSS declaration. Downside: adds a new npm dependency; the custom frontmatter plugin must be kept separately.
|
||||
- **Option B**: Extend the custom regex plugin to cover all markdown — no new dependency. Downside: regex-based tokenisation is fragile (e.g., nested formatting), already proving hard to maintain after the heading/frontmatter overlap bug.
|
||||
- **Option C**: Switch to a markdown-aware editor (e.g., Milkdown, Monaco) — full-featured. Downside: major migration, breaks the dual-editor architecture in ADR-0022, significant scope.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `@codemirror/lang-markdown` added to `package.json` — this is the only new runtime dependency introduced by this change.
|
||||
- `frontmatterHighlight.ts` is simplified (heading decoration removed); `markdownHighlight.ts` is the new extension responsible for body highlighting.
|
||||
- The two extensions are composed in `useCodeMirror.ts` — YAML frontmatter block is still styled by the custom plugin; everything else by the language parser.
|
||||
- Future syntax highlighting changes (e.g., task lists, tables) can be added by extending the `HighlightStyle` without modifying the parser.
|
||||
- Re-evaluate if `@codemirror/lang-markdown` conflicts with the custom frontmatter YAML handling as the editor evolves (e.g., if frontmatter block needs to be parsed as a code block rather than decorated text).
|
||||
@@ -89,3 +89,7 @@ proposed → active → superseded
|
||||
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
|
||||
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
|
||||
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
|
||||
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
|
||||
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
|
||||
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
|
||||
|
||||
@@ -5,26 +5,27 @@ title: "Canary release channel and local feature flags"
|
||||
status: active
|
||||
date: 2026-03-25
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Shipping new features directly to all users is risky. A mechanism was needed to let early adopters test pre-release builds and to gate experimental features behind flags that can be toggled without a new release.
|
||||
|
||||
[[Keyboard-first design principle]]
|
||||
|
||||
## Decision
|
||||
|
||||
**Add a canary release channel alongside stable, with builds from the `canary` branch. Feature flags are localStorage-based (`ff_<name>`) with compile-time defaults, checked via `useFeatureFlag(flag)` hook. The update channel is configurable in Settings.**
|
||||
**Add a canary release channel alongside stable, with builds from the **`canary`** branch. Feature flags are localStorage-based (**`ff_<name>`**) with compile-time defaults, checked via **`useFeatureFlag(flag)`** hook. The update channel is configurable in Settings.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
|
||||
- **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
|
||||
- **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
|
||||
* **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
|
||||
* **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
|
||||
* **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
|
||||
- Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
|
||||
- `useUpdater(channel)` checks the appropriate update manifest.
|
||||
- `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
- `update_channel` stored in Settings as `"stable"` or `"canary"`.
|
||||
- Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.
|
||||
* `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
|
||||
* Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
|
||||
* `useUpdater(channel)` checks the appropriate update manifest.
|
||||
* `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
* `update_channel` stored in Settings as `"stable"` or `"canary"`.
|
||||
* Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.
|
||||
@@ -89,15 +89,14 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn flatten_vault(vault_path: String) -> Result<usize, String> {
|
||||
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::flatten_vault(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn vault_health_check(vault_path: String) -> Result<vault::VaultHealthReport, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::vault_health_check(&vault_path)
|
||||
let folder_path = std::path::Path::new(vault_path.as_ref()).join(&folder_name);
|
||||
if folder_path.exists() {
|
||||
return Err(format!("Folder '{}' already exists", folder_name));
|
||||
}
|
||||
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
|
||||
Ok(folder_name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -227,7 +226,6 @@ pub async fn search_vault(
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::migrate_is_a_to_type(&vault_path)?;
|
||||
vault::flatten_vault(&vault_path)?;
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
git::ensure_gitignore(&vault_path)?;
|
||||
Ok("Vault repaired".to_string())
|
||||
@@ -362,7 +360,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_vault_flattens_type_folders() {
|
||||
fn test_repair_vault_migrates_is_a_to_type() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let note_dir = vault_path.join("note");
|
||||
@@ -371,9 +369,8 @@ mod tests {
|
||||
|
||||
let result = repair_vault(vault_path.to_str().unwrap().to_string());
|
||||
assert!(result.is_ok());
|
||||
assert!(vault_path.join("hello.md").exists());
|
||||
assert!(!note_dir.join("hello.md").exists());
|
||||
let content = std::fs::read_to_string(vault_path.join("hello.md")).unwrap();
|
||||
assert!(note_dir.join("hello.md").exists());
|
||||
let content = std::fs::read_to_string(note_dir.join("hello.md")).unwrap();
|
||||
assert!(content.contains("type: Note"));
|
||||
assert!(!content.contains("is_a:"));
|
||||
}
|
||||
|
||||
@@ -155,8 +155,7 @@ pub fn run() {
|
||||
commands::batch_delete_notes,
|
||||
commands::empty_trash,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::flatten_vault,
|
||||
commands::vault_health_check,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
commands::get_settings,
|
||||
|
||||
@@ -295,7 +295,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("CmdOrCtrl+I")
|
||||
.accelerator("CmdOrCtrl+Alt+I")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
|
||||
@@ -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 = 8;
|
||||
const CACHE_VERSION: u32 = 9;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
|
||||
@@ -284,20 +284,120 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip matching outer quotes (single or double) from a YAML scalar.
|
||||
fn unquote(s: &str) -> &str {
|
||||
s.strip_prefix('"')
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
.or_else(|| {
|
||||
s.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
})
|
||||
.unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Parse a scalar YAML value into a JSON value.
|
||||
fn parse_scalar(s: &str) -> serde_json::Value {
|
||||
let trimmed = unquote(s);
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"true" | "yes" => serde_json::Value::Bool(true),
|
||||
"false" | "no" => serde_json::Value::Bool(false),
|
||||
_ => trimmed
|
||||
.parse::<i64>()
|
||||
.map(|n| serde_json::json!(n))
|
||||
.unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the key from a top-level `key:` or `"key":` YAML line.
|
||||
/// Returns `None` for indented, blank, or non-key lines.
|
||||
fn extract_yaml_key(line: &str) -> Option<&str> {
|
||||
if line.is_empty() || line.starts_with(' ') || line.starts_with('\t') {
|
||||
return None;
|
||||
}
|
||||
let (k, _) = line.split_once(':')?;
|
||||
Some(k.trim().trim_matches('"'))
|
||||
}
|
||||
|
||||
/// Flush a pending list accumulator into the map.
|
||||
fn flush_list(
|
||||
map: &mut HashMap<String, serde_json::Value>,
|
||||
key: &mut Option<String>,
|
||||
items: &mut Vec<serde_json::Value>,
|
||||
) {
|
||||
if let Some(k) = key.take() {
|
||||
if !items.is_empty() {
|
||||
map.insert(k, serde_json::Value::Array(std::mem::take(items)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback parser for when gray_matter fails to parse YAML (returns raw string).
|
||||
/// Extracts simple `key: value` lines, handling booleans, numbers, quoted strings,
|
||||
/// and YAML lists.
|
||||
fn fallback_parse_yaml_string(raw: &str) -> HashMap<String, serde_json::Value> {
|
||||
let mut map = HashMap::new();
|
||||
let mut list_key: Option<String> = None;
|
||||
let mut list_items: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
// Accumulate list items under the current key
|
||||
if list_key.is_some() {
|
||||
if let Some(item) = line.strip_prefix(" - ") {
|
||||
list_items.push(parse_scalar(item.trim()));
|
||||
continue;
|
||||
}
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
}
|
||||
|
||||
let Some(key) = extract_yaml_key(line) else {
|
||||
continue;
|
||||
};
|
||||
let value_part = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
|
||||
if value_part.is_empty() {
|
||||
list_key = Some(key.to_string());
|
||||
} else {
|
||||
map.insert(key.to_string(), parse_scalar(value_part));
|
||||
}
|
||||
}
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
map
|
||||
}
|
||||
|
||||
/// Extract the raw YAML frontmatter string from between `---` delimiters.
|
||||
fn extract_raw_frontmatter(content: &str) -> Option<&str> {
|
||||
let rest = content.strip_prefix("---")?;
|
||||
let rest = rest
|
||||
.strip_prefix('\n')
|
||||
.or_else(|| rest.strip_prefix("\r\n"))?;
|
||||
let end = rest.find("\n---")?;
|
||||
Some(&rest[..end])
|
||||
}
|
||||
|
||||
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
|
||||
/// When gray_matter fails to parse YAML (e.g. malformed quotes from Notion exports),
|
||||
/// `raw_content` is used as a fallback: simple key:value pairs are extracted line-by-line
|
||||
/// so that critical fields like Trashed, Archived, type are not silently lost.
|
||||
pub(crate) fn extract_fm_and_rels(
|
||||
data: Option<gray_matter::Pod>,
|
||||
raw_content: &str,
|
||||
) -> (
|
||||
Frontmatter,
|
||||
HashMap<String, Vec<String>>,
|
||||
HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let hash = match data {
|
||||
Some(gray_matter::Pod::Hash(map)) => map,
|
||||
_ => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
|
||||
let json_map = match data {
|
||||
Some(gray_matter::Pod::Hash(map)) => {
|
||||
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect()
|
||||
}
|
||||
_ => {
|
||||
// gray_matter returned Null, String, or None — YAML parse failed.
|
||||
// Fall back to line-by-line extraction from the raw frontmatter block.
|
||||
match extract_raw_frontmatter(raw_content) {
|
||||
Some(raw) => fallback_parse_yaml_string(raw),
|
||||
None => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let json_map: HashMap<String, serde_json::Value> =
|
||||
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
|
||||
(
|
||||
parse_frontmatter(&json_map),
|
||||
extract_relationships(&json_map),
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
@@ -118,311 +115,6 @@ pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
/// Folders that are NOT flattened — they contain assets, not notes.
|
||||
const KEEP_FOLDERS: &[&str] = &["attachments", "assets"];
|
||||
|
||||
/// Determine a unique filename at `dest_dir`, appending -2, -3, etc. on collision.
|
||||
fn unique_filename(dest_dir: &Path, filename: &str, taken: &HashSet<String>) -> String {
|
||||
if !dest_dir.join(filename).exists() && !taken.contains(filename) {
|
||||
return filename.to_string();
|
||||
}
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let ext = Path::new(filename)
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let mut counter = 2;
|
||||
loop {
|
||||
let candidate = format!("{}-{}{}", stem, counter, ext);
|
||||
if !dest_dir.join(&candidate).exists() && !taken.contains(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten vault structure: move all notes from type-based subfolders to the vault root.
|
||||
/// Skips `type/`, `config/`, `attachments/`, and `_themes/` folders.
|
||||
/// Updates path-based wikilinks to title-based after moving.
|
||||
/// Returns the number of files moved.
|
||||
pub fn flatten_vault(vault_path: &str) -> Result<usize, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
// Collect all .md files in subfolders (not already at root, not in KEEP_FOLDERS)
|
||||
let mut to_move: Vec<(std::path::PathBuf, String)> = Vec::new();
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Skip files already at vault root
|
||||
if path.parent() == Some(vault) {
|
||||
continue;
|
||||
}
|
||||
// Check if this file is inside a KEEP_FOLDER
|
||||
let rel = path.strip_prefix(vault).unwrap_or(path);
|
||||
let top_folder = rel
|
||||
.components()
|
||||
.next()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if KEEP_FOLDERS.iter().any(|&k| k == top_folder) {
|
||||
continue;
|
||||
}
|
||||
// Hidden folders (e.g. .laputa, .git)
|
||||
if top_folder.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
to_move.push((path.to_path_buf(), filename));
|
||||
}
|
||||
|
||||
if to_move.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Build a map of old path → (new filename, old relative stem) for wikilink updates
|
||||
let mut taken: HashSet<String> = HashSet::new();
|
||||
// Pre-populate with files already at root
|
||||
if let Ok(entries) = fs::read_dir(vault) {
|
||||
for e in entries.flatten() {
|
||||
if e.path().is_file() {
|
||||
if let Some(name) = e.file_name().to_str() {
|
||||
taken.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let mut moves: Vec<(std::path::PathBuf, std::path::PathBuf, String)> = Vec::new(); // (old, new, old_rel_stem)
|
||||
for (old_path, filename) in &to_move {
|
||||
let new_name = unique_filename(vault, filename, &taken);
|
||||
taken.insert(new_name.clone());
|
||||
let new_path = vault.join(&new_name);
|
||||
let old_rel = old_path
|
||||
.to_string_lossy()
|
||||
.strip_prefix(&vault_prefix)
|
||||
.unwrap_or(&old_path.to_string_lossy())
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&old_path.to_string_lossy())
|
||||
.to_string();
|
||||
moves.push((old_path.clone(), new_path, old_rel));
|
||||
}
|
||||
|
||||
// Move all files
|
||||
let mut moved = 0;
|
||||
for (old, new, _) in &moves {
|
||||
match fs::rename(old, new) {
|
||||
Ok(()) => moved += 1,
|
||||
Err(e) => log::warn!(
|
||||
"Failed to move {} → {}: {}",
|
||||
old.display(),
|
||||
new.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Update path-based wikilinks across the vault.
|
||||
// Replace [[folder/slug]] with [[slug]] (title-based).
|
||||
// Build a single regex matching any old path stem.
|
||||
let path_stems: Vec<&str> = moves.iter().map(|(_, _, stem)| stem.as_str()).collect();
|
||||
if !path_stems.is_empty() {
|
||||
let escaped: Vec<String> = path_stems.iter().map(|s| regex::escape(s)).collect();
|
||||
let pattern_str = format!(r"\[\[({})\]\]", escaped.join("|"));
|
||||
if let Ok(re) = Regex::new(&pattern_str) {
|
||||
// Collect all .md files in vault (now at root + keep folders)
|
||||
let all_md: Vec<std::path::PathBuf> = WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md")
|
||||
})
|
||||
.map(|e| e.into_path())
|
||||
.collect();
|
||||
|
||||
for md_path in &all_md {
|
||||
let content = match fs::read_to_string(md_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !re.is_match(&content) {
|
||||
continue;
|
||||
}
|
||||
let replaced = re.replace_all(&content, |caps: ®ex::Captures| {
|
||||
let matched = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
// Extract just the filename stem (last segment)
|
||||
let slug = matched.rsplit('/').next().unwrap_or(matched);
|
||||
format!("[[{}]]", slug)
|
||||
});
|
||||
if replaced != content {
|
||||
let _ = fs::write(md_path, replaced.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty directories (only type-folder directories, not KEEP_FOLDERS)
|
||||
for (old, _, _) in &moves {
|
||||
if let Some(parent) = old.parent() {
|
||||
if parent != vault {
|
||||
let _ = fs::remove_dir(parent); // only succeeds if empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Result of a vault health check.
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct VaultHealthReport {
|
||||
/// Files in non-protected subfolders (won't be scanned by scan_vault).
|
||||
pub stray_files: Vec<String>,
|
||||
/// Files whose filename doesn't match slugify(title).
|
||||
pub title_mismatches: Vec<TitleMismatch>,
|
||||
}
|
||||
|
||||
/// A single filename-title mismatch.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TitleMismatch {
|
||||
pub path: String,
|
||||
pub filename: String,
|
||||
pub title: String,
|
||||
pub expected_filename: String,
|
||||
}
|
||||
|
||||
/// Slugify a title to produce the expected filename stem.
|
||||
fn slugify(text: &str) -> String {
|
||||
let result: String = text
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let trimmed = result.trim_matches('-').to_string();
|
||||
// Collapse consecutive dashes
|
||||
let mut prev_dash = false;
|
||||
let collapsed: String = trimmed
|
||||
.chars()
|
||||
.filter(|&c| {
|
||||
if c == '-' {
|
||||
if prev_dash {
|
||||
return false;
|
||||
}
|
||||
prev_dash = true;
|
||||
} else {
|
||||
prev_dash = false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
if collapsed.is_empty() {
|
||||
"untitled".to_string()
|
||||
} else {
|
||||
collapsed
|
||||
}
|
||||
}
|
||||
|
||||
/// Check vault health: detect stray files and filename-title mismatches.
|
||||
pub fn vault_health_check(vault_path: &str) -> Result<VaultHealthReport, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
let mut report = VaultHealthReport::default();
|
||||
|
||||
// 1. Detect stray files in non-protected subfolders
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Skip root files (they're fine)
|
||||
if path.parent() == Some(vault) {
|
||||
continue;
|
||||
}
|
||||
let rel = path.strip_prefix(vault).unwrap_or(path);
|
||||
let top_folder = rel
|
||||
.components()
|
||||
.next()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if KEEP_FOLDERS.iter().any(|&k| k == top_folder) || top_folder.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
report.stray_files.push(rel.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// 2. Detect filename-title mismatches (root .md files only)
|
||||
if let Ok(dir_entries) = fs::read_dir(vault) {
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
for dir_entry in dir_entries.flatten() {
|
||||
let path = dir_entry.path();
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let parsed = matter.parse(&content);
|
||||
let fm_title = parsed.data.as_ref().and_then(|pod| {
|
||||
if let gray_matter::Pod::Hash(ref map) = pod {
|
||||
if let Some(gray_matter::Pod::String(s)) = map.get("title") {
|
||||
return Some(s.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
let title = super::parsing::extract_title(fm_title, &content, &filename);
|
||||
let expected_stem = slugify(&title);
|
||||
let expected_filename = format!("{}.md", expected_stem);
|
||||
let current_stem = filename.strip_suffix(".md").unwrap_or(&filename);
|
||||
if current_stem != expected_stem {
|
||||
report.title_mismatches.push(TitleMismatch {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename: filename.clone(),
|
||||
title,
|
||||
expected_filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -561,226 +253,4 @@ mod tests {
|
||||
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 0, "non-markdown files should be ignored");
|
||||
}
|
||||
|
||||
// --- flatten_vault ---
|
||||
|
||||
fn write_nested_file(
|
||||
dir: &std::path::Path,
|
||||
rel_path: &str,
|
||||
content: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let path = dir.join(rel_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_moves_notes_to_root() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
write_nested_file(
|
||||
vault,
|
||||
"project/my-proj.md",
|
||||
"---\ntype: Project\n---\n# My Proj\n",
|
||||
);
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 2);
|
||||
assert!(vault.join("hello.md").exists());
|
||||
assert!(vault.join("my-proj.md").exists());
|
||||
assert!(!vault.join("note/hello.md").exists());
|
||||
assert!(!vault.join("project/my-proj.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_skips_protected_folders() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "attachments/image.md", "# Image note\n");
|
||||
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(vault.join("hello.md").exists());
|
||||
assert!(vault.join("attachments/image.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_handles_filename_collision() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "hello.md", "---\ntype: Note\n---\n# Root Hello\n");
|
||||
write_nested_file(
|
||||
vault,
|
||||
"note/hello.md",
|
||||
"---\ntype: Note\n---\n# Note Hello\n",
|
||||
);
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(vault.join("hello.md").exists());
|
||||
assert!(vault.join("hello-2.md").exists());
|
||||
// Root file unchanged
|
||||
let root_content = fs::read_to_string(vault.join("hello.md")).unwrap();
|
||||
assert!(root_content.contains("Root Hello"));
|
||||
// Moved file gets suffixed name
|
||||
let moved_content = fs::read_to_string(vault.join("hello-2.md")).unwrap();
|
||||
assert!(moved_content.contains("Note Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_updates_path_wikilinks() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(
|
||||
vault,
|
||||
"note/hello.md",
|
||||
"---\ntype: Note\n---\n# Hello\n\nSee [[project/my-proj]] for details.\n",
|
||||
);
|
||||
write_nested_file(
|
||||
vault,
|
||||
"project/my-proj.md",
|
||||
"---\ntype: Project\n---\n# My Proj\n",
|
||||
);
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 2);
|
||||
let content = fs::read_to_string(vault.join("hello.md")).unwrap();
|
||||
assert!(content.contains("[[my-proj]]"));
|
||||
assert!(!content.contains("[[project/my-proj]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_noop_when_already_flat() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
write_file(vault, "world.md", "---\ntype: Note\n---\n# World\n");
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_nested_subfolders() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "note/sub/deep.md", "---\ntype: Note\n---\n# Deep\n");
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(vault.join("deep.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_cleans_empty_directories() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
|
||||
flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert!(
|
||||
!vault.join("note").exists(),
|
||||
"empty folder should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
// --- slugify ---
|
||||
|
||||
#[test]
|
||||
fn test_slugify_basic() {
|
||||
assert_eq!(slugify("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify_special_chars() {
|
||||
assert_eq!(slugify("My Note (v2)!"), "my-note-v2");
|
||||
assert_eq!(slugify("Sprint Retrospective"), "sprint-retrospective");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify_empty() {
|
||||
assert_eq!(slugify(""), "untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify_unicode() {
|
||||
assert_eq!(slugify("Café Résumé"), "caf-r-sum");
|
||||
}
|
||||
|
||||
// --- vault_health_check ---
|
||||
|
||||
#[test]
|
||||
fn test_health_check_detects_stray_files() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "root-note.md", "---\ntype: Note\n---\n# Root Note\n");
|
||||
write_file(vault, "project.md", "---\ntype: Type\n---\n# Project\n");
|
||||
write_nested_file(
|
||||
vault,
|
||||
"old-folder/stray.md",
|
||||
"---\ntype: Note\n---\n# Stray\n",
|
||||
);
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(report.stray_files.len(), 1);
|
||||
assert!(report.stray_files[0].contains("stray.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_no_stray_when_flat() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "my-note.md", "# My Note\n");
|
||||
write_file(vault, "project.md", "---\ntype: Type\n---\n# Project\n");
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert!(report.stray_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_detects_title_mismatch() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
// Filename is "wrong-name.md" but title frontmatter says "My Actual Title"
|
||||
write_file(
|
||||
vault,
|
||||
"wrong-name.md",
|
||||
"---\ntitle: My Actual Title\ntype: Note\n---\n# My Actual Title\n",
|
||||
);
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(report.title_mismatches.len(), 1);
|
||||
assert_eq!(report.title_mismatches[0].filename, "wrong-name.md");
|
||||
assert_eq!(
|
||||
report.title_mismatches[0].expected_filename,
|
||||
"my-actual-title.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_no_mismatch_when_correct() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "my-note.md", "---\ntype: Note\n---\n# My Note\n");
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert!(report.title_mismatches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_skips_hidden_folders() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "root.md", "# Root\n");
|
||||
write_nested_file(vault, ".git/config.md", "# Git Config\n");
|
||||
write_nested_file(vault, ".laputa/cache.md", "# Cache\n");
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert!(report.stray_files.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ pub use entry::{FolderNode, VaultEntry};
|
||||
pub use file::{get_note_content, save_note_content};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::{flatten_vault, migrate_is_a_to_type, vault_health_check, VaultHealthReport};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
};
|
||||
@@ -45,7 +45,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data);
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
|
||||
|
||||
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
|
||||
let snippet = extract_snippet(&content);
|
||||
|
||||
@@ -1103,6 +1103,142 @@ fn test_parse_visible_missing_defaults_to_none() {
|
||||
assert_eq!(entry.visible, None);
|
||||
}
|
||||
|
||||
// --- Regression: trashed/archived must survive unquoted date in "Trashed at" ---
|
||||
|
||||
#[test]
|
||||
fn test_trashed_true_with_unquoted_date_in_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Reproduces the engineering-management.md scenario: Trashed at has an
|
||||
// unquoted YAML date (2026-03-11) which gray_matter may parse as a non-string.
|
||||
// The entire Frontmatter deserialization must NOT fail because of this.
|
||||
let content = "---\ntype: Topic\nTrashed: true\n\"Trashed at\": 2026-03-11\n---\n# Engineering Management\n";
|
||||
let entry = parse_test_entry(&dir, "engineering-management.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when 'Trashed at' contains an unquoted date"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archived_true_with_extra_non_string_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// If any StringOrList field gets a non-string value, archived must still parse.
|
||||
let content = "---\nArchived: true\norder: 5\n---\n# Archived Note\n";
|
||||
let entry = parse_test_entry(&dir, "archived-extra.md", content);
|
||||
assert!(
|
||||
entry.archived,
|
||||
"Archived must be true even with other fields present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_with_reviewed_false_field() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Topic\nReviewed: False\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "reviewed-test.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when frontmatter contains Reviewed: False"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
/// Regression: wikilinks containing curly braces + nested quotes in YAML arrays
|
||||
/// cause gray_matter to produce Hash values instead of strings for array elements.
|
||||
/// This must NOT make parse_frontmatter fall back to default (losing trashed/archived).
|
||||
#[test]
|
||||
fn test_trashed_survives_malformed_wikilinks_in_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// This YAML has curly braces inside a double-quoted string, producing nested
|
||||
// Hash values in some YAML parsers. The Frontmatter serde must not fail.
|
||||
let content = "---\ntype: Topic\nNotes:\n - \"[[foo|bar]]\"\n - \"[[slug|{'Title': 'Subtitle'}]]\"\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "malformed-links.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even with curly-brace wikilinks in frontmatter arrays"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: files with malformed YAML (e.g. Notion exports with unescaped quotes
|
||||
/// in wikilinks) cause gray_matter to return Null instead of a Hash. The fallback
|
||||
/// parser must still extract Trashed, type, and other simple key:value fields.
|
||||
#[test]
|
||||
fn test_parse_real_engineering_management_file() {
|
||||
let path = std::path::Path::new("/Users/luca/Laputa/engineering-management.md");
|
||||
if !path.exists() {
|
||||
return; // Skip when the Laputa vault is not available
|
||||
}
|
||||
let entry = parse_md_file(path).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Simulate malformed YAML that gray_matter can't parse: unescaped double
|
||||
// quotes inside a double-quoted YAML string cause the YAML parser to fail.
|
||||
// The fallback line-by-line parser must still extract simple key:value pairs.
|
||||
//
|
||||
// Write the file manually with literal unescaped quotes (can't use Rust string
|
||||
// escaping for this since the YAML itself is the malformed part).
|
||||
let fm = [
|
||||
"---",
|
||||
"type: Topic",
|
||||
"Status: Draft",
|
||||
"Belongs to:",
|
||||
" - \"[[engineering|Engineering]]\"",
|
||||
"aliases:",
|
||||
" - Engineering Management",
|
||||
"Notes:",
|
||||
// This line has unescaped " inside a "-quoted YAML string — malformed YAML
|
||||
" - \"[[slug|{\"Title\": 'Subtitle'}]]\"",
|
||||
"Trashed: true",
|
||||
"\"Trashed at\": 2026-03-11",
|
||||
"---",
|
||||
"",
|
||||
"# Engineering Management",
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "eng-mgmt.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md")).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when YAML is malformed (fallback parser)"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Topic".to_string()),
|
||||
"isA must be extracted by fallback parser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fm = [
|
||||
"---",
|
||||
"type: Essay",
|
||||
"Notes:",
|
||||
" - \"[[slug|{\"Broken\": 'quotes'}]]\"",
|
||||
"Archived: true",
|
||||
"---",
|
||||
"",
|
||||
"# Archived Essay",
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "archived-essay.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("archived-essay.md")).unwrap();
|
||||
assert!(
|
||||
entry.archived,
|
||||
"Archived must be true even when YAML is malformed"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Essay".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visible_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 1px var(--border-primary);
|
||||
}
|
||||
@@ -39,11 +39,13 @@
|
||||
|
||||
.app__editor {
|
||||
flex: 1;
|
||||
min-width: 800px;
|
||||
min-width: 400px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: inline-size;
|
||||
container-name: editor;
|
||||
}
|
||||
|
||||
.app__editor > * {
|
||||
|
||||
30
src/App.tsx
30
src/App.tsx
@@ -47,8 +47,6 @@ import { useVaultBridge } from './hooks/useVaultBridge'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner'
|
||||
import { useFlatVaultMigration } from './hooks/useFlatVaultMigration'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, InboxPeriod } from './types'
|
||||
@@ -119,7 +117,6 @@ function App() {
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault)
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
@@ -275,6 +272,20 @@ function App() {
|
||||
window.dispatchEvent(new CustomEvent('laputa:open-icon-picker'))
|
||||
}, [])
|
||||
|
||||
const handleCreateFolder = useCallback(async (name: string) => {
|
||||
try {
|
||||
if (isTauri()) {
|
||||
await invoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
||||
} else {
|
||||
await mockInvoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
||||
}
|
||||
await vault.reloadVault()
|
||||
setToastMessage(`Created folder "${name}"`)
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to create folder: ${e}`)
|
||||
}
|
||||
}, [resolvedPath, vault, setToastMessage])
|
||||
|
||||
const handleRemoveNoteIconCommand = useCallback(() => {
|
||||
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
|
||||
}, [notes.activeTabPath, handleRemoveNoteIcon])
|
||||
@@ -477,7 +488,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} inboxCount={inboxCount} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -547,17 +558,6 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{flatVaultMigration.needsMigration && (
|
||||
<FlatVaultMigrationBanner
|
||||
strayFileCount={flatVaultMigration.strayFiles.length}
|
||||
isMigrating={flatVaultMigration.isMigrating}
|
||||
onMigrate={async () => {
|
||||
const count = await flatVaultMigration.migrate()
|
||||
setToastMessage(`Migrated ${count} file${count !== 1 ? 's' : ''} to vault root`)
|
||||
}}
|
||||
onDismiss={flatVaultMigration.dismiss}
|
||||
/>
|
||||
)}
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
|
||||
@@ -359,3 +359,13 @@
|
||||
.editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reduce padding at narrow editor widths so content isn't cramped */
|
||||
@container editor (max-width: 600px) {
|
||||
.editor__blocknote-container .bn-editor {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.title-section {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
interface FlatVaultMigrationBannerProps {
|
||||
strayFileCount: number
|
||||
isMigrating: boolean
|
||||
onMigrate: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner shown when the vault has files in non-protected subfolders.
|
||||
* Offers to flatten them to the vault root.
|
||||
*/
|
||||
export function FlatVaultMigrationBanner({ strayFileCount, isMigrating, onMigrate, onDismiss }: FlatVaultMigrationBannerProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-950 border-b border-amber-200 dark:border-amber-800 text-sm" data-testid="migration-banner">
|
||||
<span className="flex-1 text-amber-800 dark:text-amber-200">
|
||||
{strayFileCount} note{strayFileCount !== 1 ? 's' : ''} found in subfolders.
|
||||
Flatten to vault root for consistent scanning.
|
||||
</span>
|
||||
<button
|
||||
className="px-3 py-1 text-xs font-medium rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-50"
|
||||
onClick={onMigrate}
|
||||
disabled={isMigrating}
|
||||
data-testid="migration-flatten-btn"
|
||||
>
|
||||
{isMigrating ? 'Migrating...' : 'Flatten Now'}
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 text-xs text-amber-600 dark:text-amber-400 hover:underline"
|
||||
onClick={onDismiss}
|
||||
disabled={isMigrating}
|
||||
data-testid="migration-dismiss-btn"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, memo } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect, memo } from 'react'
|
||||
import { Folder, FolderOpen, CaretDown, CaretRight, Plus } from '@phosphor-icons/react'
|
||||
import type { FolderNode, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -7,6 +7,7 @@ interface FolderTreeProps {
|
||||
folders: FolderNode[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
onCreateFolder?: (name: string) => void
|
||||
}
|
||||
|
||||
function FolderItem({
|
||||
@@ -71,15 +72,31 @@ function FolderItem({
|
||||
)
|
||||
}
|
||||
|
||||
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect }: FolderTreeProps) {
|
||||
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder }: FolderTreeProps) {
|
||||
const [sectionCollapsed, setSectionCollapsed] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
|
||||
}, [])
|
||||
|
||||
if (folders.length === 0) return null
|
||||
useEffect(() => {
|
||||
if (isCreating) inputRef.current?.focus()
|
||||
}, [isCreating])
|
||||
|
||||
const handleCreateFolder = () => {
|
||||
const name = newFolderName.trim()
|
||||
if (name && onCreateFolder) {
|
||||
onCreateFolder(name)
|
||||
}
|
||||
setIsCreating(false)
|
||||
setNewFolderName('')
|
||||
}
|
||||
|
||||
if (folders.length === 0 && !isCreating) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
@@ -93,7 +110,14 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec
|
||||
{sectionCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FOLDERS</span>
|
||||
</div>
|
||||
<Plus size={12} className="text-muted-foreground" />
|
||||
{onCreateFolder && (
|
||||
<Plus
|
||||
size={12}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); setIsCreating(true); setSectionCollapsed(false) }}
|
||||
data-testid="create-folder-btn"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tree */}
|
||||
@@ -110,6 +134,24 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
{isCreating && (
|
||||
<div className="flex items-center gap-2" style={{ padding: '4px 8px' }}>
|
||||
<Folder size={18} className="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 border border-border rounded bg-background px-1.5 py-0.5 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateFolder()
|
||||
if (e.key === 'Escape') { setIsCreating(false); setNewFolderName('') }
|
||||
}}
|
||||
onBlur={handleCreateFolder}
|
||||
placeholder="Folder name"
|
||||
data-testid="new-folder-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -129,6 +129,9 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
{entry.title}
|
||||
<StateBadge archived={entry.archived} trashed={entry.trashed} />
|
||||
</div>
|
||||
{entry.path.includes('/') && (
|
||||
<div className="truncate text-[10px] text-muted-foreground" data-testid="note-path">{entry.path}</div>
|
||||
)}
|
||||
</div>
|
||||
{entry.snippet && (
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
|
||||
@@ -208,6 +208,20 @@ describe('NoteList', () => {
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes selected type when creating note from type section', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={onCreate} />)
|
||||
fireEvent.click(screen.getByTitle('Create new note'))
|
||||
expect(onCreate).toHaveBeenCalledWith('Project')
|
||||
})
|
||||
|
||||
it('passes undefined type when creating note from All Notes', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={onCreate} />)
|
||||
fireEvent.click(screen.getByTitle('Create new note'))
|
||||
expect(onCreate).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('shows entity pinned at top with grouped children', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
|
||||
@@ -32,7 +32,7 @@ interface NoteListProps {
|
||||
sidebarCollapsed?: boolean
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onCreateNote: () => void
|
||||
onCreateNote: (type?: string) => void
|
||||
onBulkArchive?: (paths: string[]) => void
|
||||
onBulkTrash?: (paths: string[]) => void
|
||||
onBulkRestore?: (paths: string[]) => void
|
||||
@@ -97,12 +97,15 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
|
||||
|
||||
const handleCreateNote = useCallback(() => {
|
||||
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
|
||||
}, [onCreateNote, selection])
|
||||
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
|
||||
const title = resolveHeaderTitle(selection, typeDocument)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{entitySelection ? (
|
||||
|
||||
@@ -35,6 +35,7 @@ interface SidebarProps {
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
inboxCount?: number
|
||||
onCollapse?: () => void
|
||||
}
|
||||
@@ -207,7 +208,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility,
|
||||
folders = [], inboxCount = 0, onCollapse,
|
||||
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -317,7 +318,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
</DndContext>
|
||||
|
||||
{/* Folder tree */}
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} />
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} />
|
||||
</nav>
|
||||
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('StatusBar', () => {
|
||||
expect(onClickPulse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows Commit & Push button next to Changes badge', () => {
|
||||
it('shows Commit button in status bar', () => {
|
||||
const onCommitPush = vi.fn()
|
||||
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={onCommitPush} />)
|
||||
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
|
||||
@@ -342,8 +342,13 @@ describe('StatusBar', () => {
|
||||
expect(onCommitPush).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides Commit & Push button when no modified files', () => {
|
||||
it('shows Commit button even when no modified files', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={vi.fn()} />)
|
||||
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Commit button when no onCommitPush callback', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void
|
||||
)
|
||||
}
|
||||
|
||||
function ChangesBadge({ count, onClick, onCommitPush }: { count: number; onClick?: () => void; onCommitPush?: () => void }) {
|
||||
function ChangesBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
@@ -350,19 +350,27 @@ function ChangesBadge({ count, onClick, onCommitPush }: { count: number; onClick
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: 'var(--accent-orange)', color: '#fff', borderRadius: 9, padding: '0 5px', fontSize: 10, fontWeight: 600, minWidth: 16, lineHeight: '16px' }}>{count}</span>
|
||||
Changes
|
||||
</span>
|
||||
{onCommitPush && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onCommitPush}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Commit & Push"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-commit-push"
|
||||
>
|
||||
<GitCommitHorizontal size={13} style={{ color: 'var(--accent-orange)' }} />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CommitButton({ onClick }: { onClick?: () => void }) {
|
||||
if (!onClick) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Commit & Push"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-commit-push"
|
||||
>
|
||||
<GitCommitHorizontal size={13} />
|
||||
Commit
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -436,8 +444,8 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)', position: 'relative', zIndex: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
@@ -449,8 +457,8 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
onMouseEnter={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} onCommitPush={onCommitPush} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
|
||||
<CommitButton onClick={onCommitPush} />
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
|
||||
@@ -29,7 +29,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘⌥I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
|
||||
@@ -175,24 +175,32 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onZoomReset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+I triggers toggle AI chat', () => {
|
||||
it('Cmd+Option+I triggers toggle AI chat', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
fireKey('i', { metaKey: true })
|
||||
fireKey('i', { metaKey: true, altKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+I works when text input is focused', () => {
|
||||
it('Cmd+Option+I works when text input is focused', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
withFocusedInput(() => {
|
||||
fireKey('i', { metaKey: true })
|
||||
fireKey('i', { metaKey: true, altKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+I does not trigger AI chat (reserved for italic)', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
fireKey('i', { metaKey: true })
|
||||
expect(onToggleAIChat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+O triggers open in new window', () => {
|
||||
const actions = makeActions()
|
||||
const onOpenInNewWindow = vi.fn()
|
||||
@@ -218,4 +226,14 @@ describe('useAppKeyboard', () => {
|
||||
expect(onToggleInspector).toHaveBeenCalled()
|
||||
expect(onToggleAIChat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Option+I does not trigger inspector toggle', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
const onToggleInspector = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat, onToggleInspector }))
|
||||
fireKey('i', { metaKey: true, altKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
expect(onToggleInspector).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,11 +89,16 @@ export function useAppKeyboard({
|
||||
'+': onZoomIn,
|
||||
'-': onZoomOut,
|
||||
'0': onZoomReset,
|
||||
i: () => onToggleAIChat?.(),
|
||||
'\\': () => onToggleRawEditor?.(),
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd+Option+I: toggle AI panel
|
||||
if ((e.metaKey || e.ctrlKey) && e.altKey && !e.shiftKey && (e.key === 'i' || e.key === 'I' || e.key === 'ˆ')) {
|
||||
e.preventDefault()
|
||||
onToggleAIChat?.()
|
||||
return
|
||||
}
|
||||
// Cmd+Shift+F: full-text search (distinct from Cmd+F browser find)
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
interface HealthReport {
|
||||
stray_files: string[]
|
||||
title_mismatches: { path: string; filename: string; title: string; expected_filename: string }[]
|
||||
}
|
||||
|
||||
interface FlatVaultMigration {
|
||||
/** True if stray files were detected in non-protected subfolders. */
|
||||
needsMigration: boolean
|
||||
/** List of stray file paths (relative to vault root). */
|
||||
strayFiles: string[]
|
||||
/** Dismiss the migration prompt without migrating. */
|
||||
dismiss: () => void
|
||||
/** Run flatten_vault and reload. Returns the count of files moved. */
|
||||
migrate: () => Promise<number>
|
||||
/** True while migration is running. */
|
||||
isMigrating: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the vault has files in non-protected subfolders and offers
|
||||
* to flatten them to the vault root. Runs once on vault load.
|
||||
*/
|
||||
export function useFlatVaultMigration(
|
||||
vaultPath: string,
|
||||
entriesLoaded: boolean,
|
||||
reloadVault: () => Promise<unknown>,
|
||||
): FlatVaultMigration {
|
||||
const [strayFiles, setStrayFiles] = useState<string[]>([])
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [isMigrating, setIsMigrating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!entriesLoaded || !vaultPath || !isTauri()) return
|
||||
let cancelled = false
|
||||
invoke<HealthReport>('vault_health_check', { vaultPath })
|
||||
.then((report) => {
|
||||
if (!cancelled && report.stray_files.length > 0) {
|
||||
setStrayFiles(report.stray_files)
|
||||
}
|
||||
})
|
||||
.catch(() => { /* non-critical */ })
|
||||
return () => { cancelled = true }
|
||||
}, [vaultPath, entriesLoaded])
|
||||
|
||||
const dismiss = useCallback(() => setDismissed(true), [])
|
||||
|
||||
const migrate = useCallback(async () => {
|
||||
setIsMigrating(true)
|
||||
try {
|
||||
const count = await invoke<number>('flatten_vault', { vaultPath })
|
||||
setStrayFiles([])
|
||||
setDismissed(true)
|
||||
await reloadVault()
|
||||
return count
|
||||
} finally {
|
||||
setIsMigrating(false)
|
||||
}
|
||||
}, [vaultPath, reloadVault])
|
||||
|
||||
return {
|
||||
needsMigration: strayFiles.length > 0 && !dismissed,
|
||||
strayFiles,
|
||||
dismiss,
|
||||
migrate,
|
||||
isMigrating,
|
||||
}
|
||||
}
|
||||
@@ -169,21 +169,17 @@ interface ImmediateCreateDeps {
|
||||
|
||||
/** Create an untitled note without persisting to disk (deferred save). */
|
||||
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
|
||||
try {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(deps.entries, noteType, deps.pendingNames)
|
||||
deps.pendingNames.add(title)
|
||||
const template = resolveTemplate(deps.entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, deps.vaultPath, template)
|
||||
deps.openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
|
||||
deps.trackUnsaved?.(resolved.entry.path)
|
||||
deps.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => deps.pendingNames.delete(title), 500)
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
}
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(deps.entries, noteType, deps.pendingNames)
|
||||
deps.pendingNames.add(title)
|
||||
const template = resolveTemplate(deps.entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, deps.vaultPath, template)
|
||||
deps.openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
|
||||
deps.trackUnsaved?.(resolved.entry.path)
|
||||
deps.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => deps.pendingNames.delete(title), 500)
|
||||
}
|
||||
|
||||
interface RelationshipCreateDeps {
|
||||
@@ -261,7 +257,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
|
||||
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
|
||||
}, type)
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending, setToastMessage])
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
|
||||
createNoteForRelationship({
|
||||
|
||||
@@ -128,6 +128,15 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.entries[0].archived).toBe(true)
|
||||
expect(result.current.entries[0].status).toBe('Done')
|
||||
})
|
||||
|
||||
it('preserves entries reference when path does not exist (no-op)', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
const entriesBefore = result.current.entries
|
||||
|
||||
act(() => { result.current.updateEntry('/vault/note/nonexistent.md', { archived: true }) })
|
||||
|
||||
expect(result.current.entries).toBe(entriesBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNoteStatus', () => {
|
||||
|
||||
@@ -133,8 +133,16 @@ export function useVaultLoader(vaultPath: string) {
|
||||
})
|
||||
}, [tracker])
|
||||
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
||||
setEntries((prev) => {
|
||||
let changed = false
|
||||
const next = prev.map((e) => {
|
||||
if (e.path === path) { changed = true; return { ...e, ...patch } }
|
||||
return e
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeEntry = useCallback((path: string) => {
|
||||
setEntries((prev) => prev.filter((e) => e.path !== path))
|
||||
|
||||
@@ -143,9 +143,13 @@
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
#root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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')
|
||||
expect(compactMarkdown(input)).toBe('- Item one\n- Item two\n- Item three\n')
|
||||
})
|
||||
|
||||
it('collapses blank lines between dash list items', () => {
|
||||
@@ -19,7 +19,7 @@ describe('compactMarkdown', () => {
|
||||
|
||||
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')
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n- Back on track\n- Good health\n')
|
||||
})
|
||||
|
||||
it('preserves single blank line between heading and content', () => {
|
||||
@@ -54,17 +54,17 @@ describe('compactMarkdown', () => {
|
||||
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n- Item one\n- Item two\n\nConclusion.\n')
|
||||
})
|
||||
|
||||
it('preserves empty input', () => {
|
||||
@@ -82,21 +82,41 @@ describe('compactMarkdown', () => {
|
||||
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
})
|
||||
|
||||
it('does not normalize * inside code blocks', () => {
|
||||
const input = '```\n* not a list\n```\n'
|
||||
expect(compactMarkdown(input)).toBe('```\n* not a list\n```\n')
|
||||
})
|
||||
|
||||
it('decodes   HTML entities from BlockNote bold+code output', () => {
|
||||
const input = '**Remove **`NoteWindow`** and render the full **`App`** component.**\n'
|
||||
expect(compactMarkdown(input)).toBe('**Remove **`NoteWindow`** and render the full **`App`** component.**\n')
|
||||
})
|
||||
|
||||
it('decodes multiple HTML entity types', () => {
|
||||
const input = 'Use & for ampersand and < for less-than.\n'
|
||||
expect(compactMarkdown(input)).toBe('Use & for ampersand and < for less-than.\n')
|
||||
})
|
||||
|
||||
it('does not decode HTML entities inside code blocks', () => {
|
||||
const input = '```\n  should stay\n```\n'
|
||||
expect(compactMarkdown(input)).toBe('```\n  should stay\n```\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Post-process BlockNote's blocksToMarkdownLossy output to produce
|
||||
* standard-convention Markdown:
|
||||
* - Tight lists (no blank lines between consecutive list items)
|
||||
* - Bullet list markers normalized to `-` (BlockNote outputs `*`)
|
||||
* - HTML entities like ` ` decoded back to spaces
|
||||
* - No runs of 3+ blank lines (collapsed to one blank line)
|
||||
* - No trailing blank lines
|
||||
* - Code block content is never modified
|
||||
@@ -14,7 +16,7 @@ export function compactMarkdown(md: string): string {
|
||||
let inCodeBlock = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
let line = lines[i]
|
||||
|
||||
// Track fenced code blocks — never modify content inside them
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
@@ -28,6 +30,12 @@ export function compactMarkdown(md: string): string {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize bullet markers: BlockNote uses `*`, convention is `-`
|
||||
line = normalizeBulletMarker(line)
|
||||
|
||||
// Decode HTML entities that BlockNote inserts (e.g.   for spaces)
|
||||
line = decodeHtmlEntities(line)
|
||||
|
||||
// Skip blank lines that sit between two list items (tight list rule)
|
||||
if (line.trim() === '') {
|
||||
if (isBlankBetweenListItems(lines, i)) continue
|
||||
@@ -80,3 +88,15 @@ function findNextNonBlank(lines: string[], idx: number): number | null {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const BULLET_RE = /^(\s*)\*(\s)/
|
||||
/** Normalize `*` bullet markers to `-` (BlockNote default → standard convention) */
|
||||
function normalizeBulletMarker(line: string): string {
|
||||
return line.replace(BULLET_RE, '$1-$2')
|
||||
}
|
||||
|
||||
/** Decode HTML entities that BlockNote inserts (  & etc.) */
|
||||
function decodeHtmlEntities(line: string): string {
|
||||
if (!line.includes('&#x')) return line
|
||||
return line.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
}
|
||||
|
||||
@@ -715,7 +715,12 @@ describe('filterEntries — folder selection', () => {
|
||||
expect(result.find(e => e.title === 'Site')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('filters by parent folder (non-recursive — direct children only)', () => {
|
||||
it('filters recursively — includes notes from subfolders', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'projects' })
|
||||
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2', 'Site'])
|
||||
})
|
||||
|
||||
it('filters direct children', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'areas' })
|
||||
expect(result.map(e => e.title)).toEqual(['Health'])
|
||||
})
|
||||
|
||||
@@ -314,12 +314,8 @@ function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): Vault
|
||||
}
|
||||
|
||||
function isInFolder(entryPath: string, folderRelPath: string): boolean {
|
||||
const sep = '/'
|
||||
const suffix = sep + folderRelPath + sep
|
||||
const dirEnd = entryPath.lastIndexOf(sep)
|
||||
if (dirEnd < 0) return false
|
||||
const entryDir = entryPath.slice(0, dirEnd + 1)
|
||||
return entryDir.endsWith(suffix)
|
||||
const needle = '/' + folderRelPath + '/'
|
||||
return entryPath.includes(needle) || entryPath.startsWith(folderRelPath + '/')
|
||||
}
|
||||
|
||||
function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter): VaultEntry[] {
|
||||
|
||||
39
tests/smoke/create-note-crash.spec.ts
Normal file
39
tests/smoke/create-note-crash.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/** Errors that indicate the app has crashed (not just minor internal warnings). */
|
||||
function isCrashError(msg: string): boolean {
|
||||
return msg.includes('Maximum update depth') || msg.includes('Invalid hook call') || msg.includes('#185')
|
||||
}
|
||||
|
||||
test('create note via Cmd+N does not crash', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => { if (isCrashError(err.message)) errors.push(err.message) })
|
||||
|
||||
await page.goto(process.env.BASE_URL ?? 'http://localhost:5201')
|
||||
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.keyboard.press('Meta+n')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
expect(errors).toHaveLength(0)
|
||||
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('create note via sidebar + button does not crash', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => { if (isCrashError(err.message)) errors.push(err.message) })
|
||||
|
||||
await page.goto(process.env.BASE_URL ?? 'http://localhost:5201')
|
||||
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const plusButtons = page.locator('button[aria-label*="Create new"]')
|
||||
if (await plusButtons.count() > 0) {
|
||||
await plusButtons.first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
expect(errors).toHaveLength(0)
|
||||
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Flat vault structure', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('new note is created at vault root (no type folder in path)', async ({ page }) => {
|
||||
// Create a new note via Ctrl+N (mock Cmd+N)
|
||||
await page.locator('body').click()
|
||||
await page.keyboard.press('Control+n')
|
||||
|
||||
// Wait for the editor to appear (note was created)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check that no toast says "Note moved" — type change should not move file
|
||||
const movedToast = page.locator('text=Note moved')
|
||||
await expect(movedToast).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('changing type via frontmatter does NOT show move toast', async ({ page }) => {
|
||||
// Create a note first
|
||||
await page.locator('body').click()
|
||||
await page.keyboard.press('Control+n')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify no "Note moved" toast appears (since move_note_to_type_folder is removed)
|
||||
const movedToast = page.locator('text=Note moved')
|
||||
await expect(movedToast).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('app loads without errors', async ({ page }) => {
|
||||
// Verify the app loaded — check that the main container exists
|
||||
const main = page.locator('#root')
|
||||
await expect(main).toBeVisible()
|
||||
|
||||
// No console errors about move_note_to_type_folder
|
||||
const errors: string[] = []
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') errors.push(msg.text())
|
||||
})
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const moveErrors = errors.filter(e => e.includes('move_note_to_type_folder'))
|
||||
expect(moveErrors).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
56
tests/smoke/type-create-note.spec.ts
Normal file
56
tests/smoke/type-create-note.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('clicking + in type section creates note with that type', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Click on "Projects" in the sidebar to select that type section
|
||||
const projectsItem = page.locator('[data-testid="sidebar-section-Projects"]').or(page.locator('.app__sidebar').locator('text=Projects').first())
|
||||
await projectsItem.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Click the "+" button to create a new note
|
||||
await page.click('[title="Create new note"]')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// The new note should have type-based naming (e.g., "Untitled project N")
|
||||
const titleInput = page.locator('[data-testid="title-field-input"]')
|
||||
if (await titleInput.count() > 0) {
|
||||
const value = await titleInput.inputValue()
|
||||
console.log('New note title:', value)
|
||||
expect(value.toLowerCase()).toContain('project')
|
||||
}
|
||||
|
||||
// Toggle raw editor to see frontmatter
|
||||
await page.keyboard.press('Meta+Shift+m')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const rawEditor = page.locator('.cm-content')
|
||||
if (await rawEditor.count() > 0) {
|
||||
const content = await rawEditor.textContent()
|
||||
console.log('Raw content:', content?.substring(0, 200))
|
||||
expect(content).toContain('type: Project')
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking + in All Notes creates generic note', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Click on "All Notes" in sidebar
|
||||
await page.locator('text=All Notes').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Click the "+" button
|
||||
await page.click('[title="Create new note"]')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// The new note should be a generic "Note" type
|
||||
const titleInput = page.locator('[data-testid="title-field-input"]')
|
||||
if (await titleInput.count() > 0) {
|
||||
const value = await titleInput.inputValue()
|
||||
console.log('New note title:', value)
|
||||
expect(value.toLowerCase()).toContain('note')
|
||||
expect(value.toLowerCase()).not.toContain('project')
|
||||
}
|
||||
})
|
||||
32218
ui-design.pen
32218
ui-design.pen
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user